mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +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
|
## 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:
|
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` 返回值可同时提供文字摘要和下面的界面数据:
|
MCP 的 `tools/call` 返回值可同时提供文字摘要和下面的界面数据:
|
||||||
|
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-for="seg in iter.items" :key="seg.id">
|
<template v-for="seg in iter.items" :key="seg.id">
|
||||||
<ThinkingSegment v-if="seg.type === 'thinking' && showThinking" :segment="seg" />
|
<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'">
|
<template v-else-if="seg.type === 'content'">
|
||||||
<div v-if="seg.repetitionWarning" class="repetition-warning">
|
<div v-if="seg.repetitionWarning" class="repetition-warning">
|
||||||
<el-icon><WarningFilled /></el-icon>
|
<el-icon><WarningFilled /></el-icon>
|
||||||
@ -222,6 +222,14 @@
|
|||||||
|
|
||||||
</template><!-- /传统合并渲染模式 -->
|
</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
|
<!-- Stopped/interrupted status lives outside the rendering fork so
|
||||||
segmented history turns with only thinking/tool output still make
|
segmented history turns with only thinking/tool output still make
|
||||||
the manual stop visible. -->
|
the manual stop visible. -->
|
||||||
@ -594,6 +602,7 @@ import { previewKindOf } from './preview/previewKind'
|
|||||||
import { openFilePreview } from './preview/previewBus'
|
import { openFilePreview } from './preview/previewBus'
|
||||||
import BrowserTimeline from './BrowserTimeline.vue'
|
import BrowserTimeline from './BrowserTimeline.vue'
|
||||||
import ToolCallSegment from './ToolCallSegment.vue'
|
import ToolCallSegment from './ToolCallSegment.vue'
|
||||||
|
import ToolResultView from './tool-results/ToolResultView.vue'
|
||||||
import ThinkingSegment from './ThinkingSegment.vue'
|
import ThinkingSegment from './ThinkingSegment.vue'
|
||||||
import ContentSegment from './ContentSegment.vue'
|
import ContentSegment from './ContentSegment.vue'
|
||||||
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
|
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
|
||||||
@ -1195,7 +1204,9 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
const toolCalls = meta?.toolCalls || []
|
const toolCalls = meta?.toolCalls || []
|
||||||
toolCalls.forEach((tc: ToolCallMeta, i: number) => {
|
toolCalls.forEach((tc: ToolCallMeta, i: number) => {
|
||||||
segs.push({
|
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,
|
toolName: tc.name, toolArgs: tc.arguments,
|
||||||
toolResult: tc.result, toolSuccess: tc.success,
|
toolResult: tc.result, toolSuccess: tc.success,
|
||||||
toolCallId: tc.toolCallId, structuredContent: tc.structuredContent,
|
toolCallId: tc.toolCallId, structuredContent: tc.structuredContent,
|
||||||
@ -1207,6 +1218,20 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
return segs
|
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
|
* Use segmented rendering when there are multiple segments, OR when the turn
|
||||||
* contains structured output or a delegation segment. Structured output must
|
* 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 ExecutionDetailDialog from './ExecutionDetailDialog.vue'
|
||||||
import ToolResultView from './tool-results/ToolResultView.vue'
|
import ToolResultView from './tool-results/ToolResultView.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
segment: MessageSegment
|
segment: MessageSegment
|
||||||
}>()
|
/** MessageBubble presents rich output in the answer instead of the timeline. */
|
||||||
|
showStructuredResult?: boolean
|
||||||
|
}>(), { showStructuredResult: true })
|
||||||
|
|
||||||
const { getToolLabel } = useToolLabel()
|
const { getToolLabel } = useToolLabel()
|
||||||
|
|
||||||
@ -200,7 +202,7 @@ const detailStatus = computed<'running' | 'completed' | 'error'>(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<ToolResultView v-if="isSuccess && segment.structuredContent" :structured-content="segment.structuredContent" />
|
<ToolResultView v-if="showStructuredResult && isSuccess && segment.structuredContent" :structured-content="segment.structuredContent" />
|
||||||
|
|
||||||
<ExecutionDetailDialog
|
<ExecutionDetailDialog
|
||||||
v-if="canViewDetail"
|
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 { createI18n } from 'vue-i18n'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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)
|
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('td')?.textContent).toBe('Saved result')
|
||||||
expect(host.querySelector('.seg-tool__body')).toBeNull()
|
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