mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
fix(chat): keep ordinary MCP results out of final answers (#661)
Contributed by czhcc <cui.zihui@trs-dsj.com.cn> (@czhcc). Display only explicit rich UI payloads below final answers while retaining ordinary MCP output in tool details. Verified with 21 related frontend tests and a full production build.
This commit is contained in:
parent
61b7f56bca
commit
10aad3be6c
@ -603,6 +603,7 @@ 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 ToolResultView from './tool-results/ToolResultView.vue'
|
||||||
|
import { hasToolResultUi } from './tool-results/registry'
|
||||||
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'
|
||||||
@ -1224,7 +1225,7 @@ const answerToolResults = computed(() => {
|
|||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
return segments.value.filter(seg => {
|
return segments.value.filter(seg => {
|
||||||
if (seg.type !== 'tool_call' || seg.status !== 'completed'
|
if (seg.type !== 'tool_call' || seg.status !== 'completed'
|
||||||
|| seg.toolSuccess === false || !seg.structuredContent) return false
|
|| seg.toolSuccess === false || !hasToolResultUi(seg.structuredContent)) return false
|
||||||
const key = seg.toolCallId || seg.id
|
const key = seg.toolCallId || seg.id
|
||||||
if (seen.has(key)) return false
|
if (seen.has(key)) return false
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { createPinia } from 'pinia'
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { Message } from '@/types'
|
import type { Message } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('/logo/mateclaw_logo_s.png', () => ({ default: '/logo/mateclaw_logo_s.png' }))
|
||||||
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
||||||
useStreamingMarkdown: (content: unknown) => ({
|
useStreamingMarkdown: (content: unknown) => ({
|
||||||
renderedContent: content,
|
renderedContent: content,
|
||||||
@ -123,6 +124,42 @@ const answerMessage = (status = 'completed', metadata: unknown = {}) => ({
|
|||||||
contentParts: [], status, metadata,
|
contentParts: [], status, metadata,
|
||||||
}) as Message
|
}) as Message
|
||||||
|
|
||||||
|
const weatherResult = {
|
||||||
|
success: true,
|
||||||
|
result: { city: '广州', weather: '阵雨', temperature: 28, humidity: 85 },
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not append ordinary MCP JSON when a live turn completes, while retaining tool details', async () => {
|
||||||
|
const result = JSON.stringify(weatherResult)
|
||||||
|
const tool = { ...successfulTool('weather'), toolName: 'get_weather',
|
||||||
|
toolResult: result, structuredContent: weatherResult }
|
||||||
|
const message = reactive(answerMessage('generating', { segments: [tool,
|
||||||
|
{ id: 'text', type: 'content', status: 'running', text: '广州有阵雨,28°C。' }],
|
||||||
|
}))
|
||||||
|
const host = mountMessage(message)
|
||||||
|
message.status = 'completed'
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector('.segments-view')?.textContent).toContain('广州有阵雨,28°C。')
|
||||||
|
expect(host.querySelector('.answer-tool-results')).toBeNull()
|
||||||
|
host.querySelector<HTMLElement>('.seg-tool__header')!.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector('.seg-tool pre')?.textContent).toBe(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['segments', 'toolCalls'])('does not append ordinary MCP JSON from saved %s alongside rich output', (source) => {
|
||||||
|
const metadata = source === 'segments'
|
||||||
|
? { segments: [{ ...successfulTool('weather'), structuredContent: weatherResult }, successfulTool('rich')] }
|
||||||
|
: { toolCalls: [
|
||||||
|
{ name: 'get_weather', toolCallId: 'weather', status: 'completed', success: true, structuredContent: weatherResult },
|
||||||
|
{ name: 'sales', toolCallId: 'rich', status: 'completed', success: true, structuredContent: richResult },
|
||||||
|
] }
|
||||||
|
const host = mountMessage(answerMessage('completed', JSON.stringify(metadata)))
|
||||||
|
expect(host.querySelectorAll('.answer-tool-results .tool-result-view')).toHaveLength(1)
|
||||||
|
expect(host.querySelector('.answer-tool-results td')?.textContent).toBe('Quarterly sales')
|
||||||
|
expect(host.querySelector('.answer-tool-results pre')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('shows rich results once after the final content when a live turn completes', async () => {
|
it('shows rich results once after the final content when a live turn completes', async () => {
|
||||||
const message = reactive(answerMessage('generating', { segments: [successfulTool('a'),
|
const message = reactive(answerMessage('generating', { segments: [successfulTool('a'),
|
||||||
{ id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }],
|
{ id: 'text', type: 'content', status: 'running', text: 'Sales grew 50%' }],
|
||||||
|
|||||||
@ -12,6 +12,12 @@ export interface ResolvedToolBlock { title?: string; component?: Component; data
|
|||||||
const registry = new Map<string, Renderer>()
|
const registry = new Map<string, Renderer>()
|
||||||
const record = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
const record = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
|
/** Only explicit UI payloads belong below the assistant answer; ordinary MCP data stays in tool details. */
|
||||||
|
export function hasToolResultUi(input: unknown): boolean {
|
||||||
|
const ui = record(input) ? input.mateclawUi : undefined
|
||||||
|
return record(ui) && ui.version === 1 && Array.isArray(ui.blocks) && ui.blocks.length <= 8
|
||||||
|
}
|
||||||
|
|
||||||
/** Registration is limited to trusted application code; payloads only name a renderer. */
|
/** Registration is limited to trusted application code; payloads only name a renderer. */
|
||||||
export function registerToolResultRenderer(type: string, renderer: Renderer): void {
|
export function registerToolResultRenderer(type: string, renderer: Renderer): void {
|
||||||
registry.set(type, { ...renderer, component: markRaw(renderer.component) })
|
registry.set(type, { ...renderer, component: markRaw(renderer.component) })
|
||||||
@ -24,7 +30,7 @@ export function resolveToolResult(input: unknown): ResolvedToolBlock[] {
|
|||||||
const fallback = () => [{ fallback: bounded(json) }]
|
const fallback = () => [{ fallback: bounded(json) }]
|
||||||
if (new TextEncoder().encode(JSON.stringify(input)).length > MAX_TOOL_UI_BYTES) return fallback()
|
if (new TextEncoder().encode(JSON.stringify(input)).length > MAX_TOOL_UI_BYTES) return fallback()
|
||||||
const ui = record(input) ? input.mateclawUi : undefined
|
const ui = record(input) ? input.mateclawUi : undefined
|
||||||
if (!record(ui) || ui.version !== 1 || !Array.isArray(ui.blocks) || ui.blocks.length > 8) return fallback()
|
if (!hasToolResultUi(input)) return fallback()
|
||||||
return ui.blocks.map((block: unknown) => {
|
return ui.blocks.map((block: unknown) => {
|
||||||
if (!record(block)) return { fallback: bounded(JSON.stringify(block, null, 2)) }
|
if (!record(block)) return { fallback: bounded(JSON.stringify(block, null, 2)) }
|
||||||
const title = typeof block.title === 'string' ? block.title : undefined
|
const title = typeof block.title === 'string' ? block.title : undefined
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user