fix(ai): stabilize chart rendering during streaming

This commit is contained in:
digua committed 2026-06-12 11:36:46 +08:00
1 parent eea7ed88b9
commit 56693685fa
6 files changed
+97 -7

No files matched your search

@@ -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/)
@@ -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<string, unknown>, context: ToolExecutionContext): Promise<ToolResult> {
+1 -1
View File
@@ -178,7 +178,7 @@ export const SQL_TOOL_DEFS: SqlToolDef[] = [
},
{
name: 'conversation_initiator_stats',
description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。需要已生成会话索引。',
description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。',
parameters: {
type: 'object',
properties: {
+1 -1
View File
@@ -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
})
+42
View File
@@ -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<typeof block> => 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)
})
})
+45 -2
View File
@@ -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<T>(
blocks: readonly T[],
toolName: string | undefined,
toolCallId: string | undefined,
charts: ChartPayload[]
): Array<T | ChartContentBlock> {
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')
}