mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
fix(chat): display structured tool results below final answers (#656)
This commit is contained in:
parent
a504ae1687
commit
3b5f7de119
@ -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:
|
||||
|
||||
|
||||
@ -543,7 +543,7 @@ user = claims["sub"] # 验签通过才相信
|
||||
|
||||
## 工具结果直接展示图表、表格与商品卡片
|
||||
|
||||
Web 控制台支持直接渲染 MCP 工具成功返回的 `structuredContent`,无需模型重新包装为 Markdown。展示内容绑定到对应工具调用,重新打开会话时也能恢复;原有文本回复继续兼容。
|
||||
Web 控制台支持直接渲染 MCP 工具成功返回的 `structuredContent`,无需模型重新包装为 Markdown。展示数据绑定到对应工具调用;本轮回复结束后,图表、表格和商品卡片统一显示在回复正文下方,不占用思考及工具执行过程。中止或失败的回复仍展示已经成功取得的结果;失败或未完成工具的结果不展示。重新打开会话可恢复,原有文本回复继续兼容。
|
||||
|
||||
MCP 的 `tools/call` 返回值可同时提供文字摘要和下面的界面数据:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user