fix(chat): display structured tool results below final answers (#656)

This commit is contained in:
mateaix 2026-10-05 17:20:30 +08:00
parent a504ae1687
commit 3b5f7de119
5 changed files with 90 additions and 8 deletions

View File

@ -576,7 +576,7 @@ Subprocesses are cleaned up on normal shutdown. If MateClaw was force-killed (`k
## Structured charts, tables and product cards
The web console can render a successful MCP tool's `structuredContent` directly, without asking the model to rewrite it into Markdown. Results stay attached to the tool call and are restored when reopening the conversation. Existing text responses remain supported.
The web console can render a successful MCP tool's `structuredContent` directly, without asking the model to rewrite it into Markdown. Structured tool results appear below the answer after the turn ends, rather than inside the reasoning/tool timeline. Stopped or failed turns retain results from successful tool calls; failed or unfinished calls are excluded. The same metadata restores these results when reopening a conversation. Existing text responses remain supported.
Return a normal MCP `tools/call` result with a text summary and the following application-specific UI envelope:

View File

@ -543,7 +543,7 @@ user = claims["sub"] # 验签通过才相信
## 工具结果直接展示图表、表格与商品卡片
Web 控制台支持直接渲染 MCP 工具成功返回的 `structuredContent`,无需模型重新包装为 Markdown。展示内容绑定到对应工具调用,重新打开会话时也能恢复;原有文本回复继续兼容。
Web 控制台支持直接渲染 MCP 工具成功返回的 `structuredContent`,无需模型重新包装为 Markdown。展示数据绑定到对应工具调用;本轮回复结束后,图表、表格和商品卡片统一显示在回复正文下方,不占用思考及工具执行过程。中止或失败的回复仍展示已经成功取得的结果;失败或未完成工具的结果不展示。重新打开会话可恢复,原有文本回复继续兼容。
MCP 的 `tools/call` 返回值可同时提供文字摘要和下面的界面数据:

View File

@ -73,7 +73,7 @@
<template v-else>
<template v-for="seg in iter.items" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking' && showThinking" :segment="seg" />
<ToolCallSegment v-else-if="seg.type === 'tool_call'" :segment="seg" />
<ToolCallSegment v-else-if="seg.type === 'tool_call'" :segment="seg" :show-structured-result="false" />
<template v-else-if="seg.type === 'content'">
<div v-if="seg.repetitionWarning" class="repetition-warning">
<el-icon><WarningFilled /></el-icon>
@ -222,6 +222,14 @@
</template><!-- /传统合并渲染模式 -->
<!-- Tool payloads remain in metadata; only their presentation moves.
Wait for this turn to finish, including stop/error, so results do
not interrupt reasoning and do not disappear on interrupted turns. -->
<div v-if="!isGenerating && answerToolResults.length" class="answer-tool-results">
<ToolResultView v-for="result in answerToolResults" :key="result.toolCallId || result.id"
:structured-content="result.structuredContent" />
</div>
<!-- Stopped/interrupted status lives outside the rendering fork so
segmented history turns with only thinking/tool output still make
the manual stop visible. -->
@ -594,6 +602,7 @@ import { previewKindOf } from './preview/previewKind'
import { openFilePreview } from './preview/previewBus'
import BrowserTimeline from './BrowserTimeline.vue'
import ToolCallSegment from './ToolCallSegment.vue'
import ToolResultView from './tool-results/ToolResultView.vue'
import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue'
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
@ -1195,7 +1204,9 @@ const segments = computed<MessageSegment[]>(() => {
const toolCalls = meta?.toolCalls || []
toolCalls.forEach((tc: ToolCallMeta, i: number) => {
segs.push({
id: `tc-${i}`, type: 'tool_call', status: 'completed',
id: `tc-${i}`, type: 'tool_call',
status: tc.success === false ? 'error'
: (!tc.status || tc.status === 'completed' ? 'completed' : 'running'),
toolName: tc.name, toolArgs: tc.arguments,
toolResult: tc.result, toolSuccess: tc.success,
toolCallId: tc.toolCallId, structuredContent: tc.structuredContent,
@ -1207,6 +1218,20 @@ const segments = computed<MessageSegment[]>(() => {
return segs
})
// Use the same deduplicated source for live and reloaded messages. Never
// concatenate toolCalls with segments: they are two copies of the same calls.
const answerToolResults = computed(() => {
const seen = new Set<string>()
return segments.value.filter(seg => {
if (seg.type !== 'tool_call' || seg.status !== 'completed'
|| seg.toolSuccess === false || !seg.structuredContent) return false
const key = seg.toolCallId || seg.id
if (seen.has(key)) return false
seen.add(key)
return true
})
})
/**
* Use segmented rendering when there are multiple segments, OR when the turn
* contains structured output or a delegation segment. Structured output must

View File

@ -7,9 +7,11 @@ import DelegationNodeView from './DelegationNodeView.vue'
import ExecutionDetailDialog from './ExecutionDetailDialog.vue'
import ToolResultView from './tool-results/ToolResultView.vue'
const props = defineProps<{
const props = withDefaults(defineProps<{
segment: MessageSegment
}>()
/** MessageBubble presents rich output in the answer instead of the timeline. */
showStructuredResult?: boolean
}>(), { showStructuredResult: true })
const { getToolLabel } = useToolLabel()
@ -200,7 +202,7 @@ const detailStatus = computed<'running' | 'completed' | 'error'>(() => {
</div>
</Transition>
<ToolResultView v-if="isSuccess && segment.structuredContent" :structured-content="segment.structuredContent" />
<ToolResultView v-if="showStructuredResult && isSuccess && segment.structuredContent" :structured-content="segment.structuredContent" />
<ExecutionDetailDialog
v-if="canViewDetail"

View File

@ -1,4 +1,4 @@
import { createApp, defineComponent, h } from 'vue'
import { createApp, defineComponent, h, reactive, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
@ -108,5 +108,60 @@ describe('structured tool history', () => {
const host = mountMessage({ id: 'saved', conversationId: 'conv', role: 'assistant', content: '', contentParts: [], status: 'completed', metadata } as Message)
expect(host.querySelector('td')?.textContent).toBe('Saved result')
expect(host.querySelector('.seg-tool__body')).toBeNull()
expect(host.querySelector('.seg-tool .tool-result-view')).toBeNull()
expect(host.querySelector('.answer-tool-results td')?.textContent).toBe('Saved result')
})
})
const richResult = { mateclawUi: { version: 1, blocks: [{ type: 'table', data: {
columns: [{ key: 'a', label: 'A' }], rows: [{ a: 'Quarterly sales' }],
} }] } }
const successfulTool = (id: string) => ({ id, toolCallId: id, type: 'tool_call', status: 'completed',
toolName: 'sales', toolSuccess: true, structuredContent: richResult })
const answerMessage = (status = 'completed', metadata: unknown = {}) => ({
id: 'answer', conversationId: 'conv', role: 'assistant', content: 'Sales grew 50%',
contentParts: [], status, metadata,
}) as Message
it('shows rich results once after the final content when a live turn completes', async () => {
const message = reactive(answerMessage('generating', { segments: [successfulTool('a'),
{ id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }],
toolCalls: [{ name: 'sales', toolCallId: 'a', status: 'completed', structuredContent: richResult }],
}))
const host = mountMessage(message)
expect(host.querySelector('.tool-result-view')).toBeNull()
message.status = 'completed'
await nextTick()
expect(host.querySelectorAll('.tool-result-view')).toHaveLength(1)
const results = host.querySelector('.answer-tool-results')!
const timeline = host.querySelector('.segments-view')!
expect(timeline.textContent).toContain('Sales grew 50%')
expect(timeline.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(host.querySelector('.seg-tool .tool-result-view')).toBeNull()
})
it.each(['completed', 'stopped', 'interrupted', 'failed'])('retains successful output for %s turns', (status) => {
const metadata = { segments: [successfulTool('a'),
{ ...successfulTool('b'), status: 'error', toolSuccess: false },
{ ...successfulTool('c'), status: 'running' }],
}
const host = mountMessage(answerMessage(status, JSON.stringify(metadata)))
expect(host.querySelectorAll('.answer-tool-results table')).toHaveLength(1)
})
it('deduplicates repeated IDs but preserves separate calls of the same tool', () => {
const host = mountMessage(answerMessage('completed', { segments: [
successfulTool('a'), successfulTool('a'), successfulTool('b'),
] }))
expect(host.querySelectorAll('.answer-tool-results table')).toHaveLength(2)
})
it('does not promote unfinished or failed legacy toolCalls to successful results', () => {
const host = mountMessage(answerMessage('stopped', { toolCalls: [
{ name: 'running', status: 'running', structuredContent: richResult },
{ name: 'approval', status: 'awaiting_approval', structuredContent: richResult },
{ name: 'failed', status: 'completed', success: false, structuredContent: richResult },
{ name: 'done', status: 'completed', success: true, structuredContent: richResult },
] }))
expect(host.querySelectorAll('.answer-tool-results table')).toHaveLength(1)
})