mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(ai): stabilize chart rendering during streaming
This commit is contained in:
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> {
|
||||
|
||||
@@ -178,7 +178,7 @@ export const SQL_TOOL_DEFS: SqlToolDef[] = [
|
||||
},
|
||||
{
|
||||
name: 'conversation_initiator_stats',
|
||||
description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。需要已生成会话索引。',
|
||||
description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user