From 56693685fa8d639103c38f751ba9758fa7bdfbae Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 11 Jun 2026 20:10:22 +0800 Subject: [PATCH] fix(ai): stabilize chart rendering during streaming --- .../src/definitions/render-chart.test.ts | 2 + .../tools/src/definitions/render-chart.ts | 9 ++-- packages/tools/src/sql/definitions.ts | 2 +- src/components/AIChat/chat/ChatMessage.vue | 2 +- src/stores/aiChatChartBlocks.test.ts | 42 +++++++++++++++++ src/stores/aiChatChartBlocks.ts | 47 ++++++++++++++++++- 6 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/definitions/render-chart.test.ts b/packages/tools/src/definitions/render-chart.test.ts index 7129432f..89b7523f 100644 --- a/packages/tools/src/definitions/render-chart.test.ts +++ b/packages/tools/src/definitions/render-chart.test.ts @@ -83,6 +83,8 @@ describe('renderChartTool', () => { assert.match(result.content, /Generated chart "Messages by member"/) assert.match(result.content, /Data preview/) + assert.match(result.content, /already uses all 2 rows/i) + assert.match(result.content, /Do not call render_chart again/i) assert.match(result.content, /Alice/) assert.match(result.content, /message_count/) assert.match(result.content, /4/) diff --git a/packages/tools/src/definitions/render-chart.ts b/packages/tools/src/definitions/render-chart.ts index 4bb0487d..bb74d132 100644 --- a/packages/tools/src/definitions/render-chart.ts +++ b/packages/tools/src/definitions/render-chart.ts @@ -107,13 +107,16 @@ function summarizeChartForModel( ): string { const summary = summarizeChart(type, title, rows.length, truncated, locale) const previewRows = rows.slice(0, DEFAULT_PREVIEW_ROWS) - if (previewRows.length === 0) return summary + const coverage = locale?.startsWith('zh') + ? `图表已使用${truncated ? '截断后的' : '全部'} ${rows.length} 行数据生成;下面只是数据预览,不要为了查看预览外的行重复调用 render_chart。` + : `The chart already uses ${truncated ? 'the truncated' : 'all'} ${rows.length} rows; the rows below are only a preview. Do not call render_chart again just to inspect rows outside the preview.` + if (previewRows.length === 0) return `${summary}\n${coverage}` const preview = JSON.stringify(previewRows) if (locale?.startsWith('zh')) { - return `${summary}\n数据预览(前 ${previewRows.length} 行,用于分析峰值、低谷和差异):${preview}` + return `${summary}\n${coverage}\n数据预览(前 ${previewRows.length} 行,用于分析峰值、低谷和差异):${preview}` } - return `${summary}\nData preview (first ${previewRows.length} rows; use this to identify peaks, lows, and differences): ${preview}` + return `${summary}\n${coverage}\nData preview (first ${previewRows.length} rows; use this to identify peaks, lows, and differences): ${preview}` } async function handler(params: Record, context: ToolExecutionContext): Promise { diff --git a/packages/tools/src/sql/definitions.ts b/packages/tools/src/sql/definitions.ts index 198d11f6..1ba8d5f2 100644 --- a/packages/tools/src/sql/definitions.ts +++ b/packages/tools/src/sql/definitions.ts @@ -178,7 +178,7 @@ export const SQL_TOOL_DEFS: SqlToolDef[] = [ }, { name: 'conversation_initiator_stats', - description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。需要已生成会话索引。', + description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。', parameters: { type: 'object', properties: { diff --git a/src/components/AIChat/chat/ChatMessage.vue b/src/components/AIChat/chat/ChatMessage.vue index 74088cd2..7854e6a7 100644 --- a/src/components/AIChat/chat/ChatMessage.vue +++ b/src/components/AIChat/chat/ChatMessage.vue @@ -153,7 +153,7 @@ const visibleBlocks = computed(() => { return block.text.trim().length > 0 } if (block.type === 'error') { - return !shouldHideRecoverableChartError(blocks, index) + return !shouldHideRecoverableChartError(blocks, index, { isStreaming: props.isStreaming }) } return true }) diff --git a/src/stores/aiChatChartBlocks.test.ts b/src/stores/aiChatChartBlocks.test.ts index e32087df..5c687435 100644 --- a/src/stores/aiChatChartBlocks.test.ts +++ b/src/stores/aiChatChartBlocks.test.ts @@ -119,6 +119,36 @@ describe('aiChat chart block helpers', () => { assert.equal(nextBlocks[1]?.type, 'chart') }) + it('does not append duplicate render-only charts already present in the message', () => { + const reorderedSpecChart: ChartPayload = { + ...chart, + spec: { + encoding: { series: 'member_name', y: 'message_count', x: 'day' }, + title: chart.spec.title, + type: chart.spec.type, + version: chart.spec.version, + }, + dataset: { + ...chart.dataset, + rows: [{ day: '2026-06-01', member_name: 'Alice', message_count: 4 }], + }, + } + const blocks = [ + { type: 'chart', chart: { ...chart, dataset: { ...chart.dataset, rows: [] } } }, + { type: 'think', tag: 'thinking', text: '再看剩余月份' }, + createRenderOnlyToolPendingBlock('render_chart', { title: '趋势图' }, 'call_chart_2'), + ].filter((block): block is NonNullable => block !== null) + + const nextBlocks = replaceRenderOnlyToolPendingBlockWithCharts(blocks, 'render_chart', 'call_chart_2', [ + reorderedSpecChart, + ]) + + assert.deepEqual( + nextBlocks.map((block) => block.type), + ['chart', 'think'] + ) + }) + it('converts render-only chart tool failures to visible error blocks', () => { const errorBlock = toRenderOnlyToolErrorBlock('render_chart', { content: [{ type: 'text', text: 'Error: Field "missing_count" does not exist in SQL result' }], @@ -176,4 +206,16 @@ describe('aiChat chart block helpers', () => { false ) }) + + it('hides chart render errors while the assistant message is still streaming', () => { + const blocks = [ + { + type: 'error', + error: { name: 'ChartRenderError', message: 'encoding.x must be a non-empty field name' }, + }, + ] + + assert.equal(shouldHideRecoverableChartError(blocks, 0, { isStreaming: true }), true) + assert.equal(shouldHideRecoverableChartError(blocks, 0, { isStreaming: false }), false) + }) }) diff --git a/src/stores/aiChatChartBlocks.ts b/src/stores/aiChatChartBlocks.ts index 97ce6446..e00dfcc2 100644 --- a/src/stores/aiChatChartBlocks.ts +++ b/src/stores/aiChatChartBlocks.ts @@ -27,6 +27,11 @@ type BlockWithTool = { } } +type BlockWithChart = { + type?: unknown + chart?: ChartPayload +} + export function isRenderOnlyTool(toolName?: string): boolean { return toolName === 'render_chart' } @@ -139,19 +144,57 @@ export function toChartContentBlocks(charts: ChartPayload[]): ChartContentBlock[ return charts.map((chart) => ({ type: 'chart', chart: toPersistedChartPayload(chart) })) } +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +function chartSignature(chart: ChartPayload): string { + const persisted = toPersistedChartPayload(chart) + return stableStringify({ + spec: persisted.spec, + columns: persisted.dataset.columns, + data: persisted.data, + rowCount: persisted.rowCount, + truncated: persisted.truncated, + }) +} + +function hasDuplicateChart(blocks: readonly unknown[], chart: ChartPayload): boolean { + const signature = chartSignature(chart) + return blocks.some((block) => { + if (!isRecord(block)) return false + const candidate = block as BlockWithChart + return candidate.type === 'chart' && candidate.chart ? chartSignature(candidate.chart) === signature : false + }) +} + export function replaceRenderOnlyToolPendingBlockWithCharts( blocks: readonly T[], toolName: string | undefined, toolCallId: string | undefined, charts: ChartPayload[] ): Array { - return [...removeRenderOnlyToolPendingBlock(blocks, toolName, toolCallId), ...toChartContentBlocks(charts)] + const withoutPending = removeRenderOnlyToolPendingBlock(blocks, toolName, toolCallId) + const uniqueCharts = charts.filter((chart) => !hasDuplicateChart(withoutPending, chart)) + return [...withoutPending, ...toChartContentBlocks(uniqueCharts)] } -export function shouldHideRecoverableChartError(blocks: readonly unknown[], index: number): boolean { +export function shouldHideRecoverableChartError( + blocks: readonly unknown[], + index: number, + options?: { isStreaming?: boolean } +): boolean { const block = blocks[index] if (!isRecord(block) || block.type !== 'error' || !isRecord(block.error)) return false if (block.error.name !== 'ChartRenderError') return false + if (options?.isStreaming) return true return blocks.slice(index + 1).some((nextBlock) => isRecord(nextBlock) && nextBlock.type === 'chart') }