mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): integrate chart planning support
This commit is contained in:
1 parent
6f54dcc62b
commit
9ad012680d
19 files changed
+407
-25
No files matched your search
@@ -103,6 +103,8 @@ export function createCliRunAgentStream(
|
||||
userMessage,
|
||||
locale,
|
||||
assistantAllowedTools,
|
||||
enableAutoDetection: enableAutoSkill === true,
|
||||
enableAnalyticalAutoDetection: enableAutoSkill === true,
|
||||
})
|
||||
const isChartCapability = chartRuntime.isChartCapability
|
||||
const autoSkillAllowedTools =
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createCompressionLlmAdapter,
|
||||
AgentEventHandler,
|
||||
formatAIError,
|
||||
getChartPlannerCapabilityForMessage,
|
||||
initTokenizer,
|
||||
type AgentStreamChunk,
|
||||
type PiMessage,
|
||||
@@ -175,6 +176,13 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
|
||||
locale,
|
||||
dataSnapshot,
|
||||
availableTools: tools.map((tool) => tool.name),
|
||||
availableCapabilities: [
|
||||
getChartPlannerCapabilityForMessage({
|
||||
userMessage,
|
||||
locale,
|
||||
availableTools: tools.map((tool) => tool.name),
|
||||
}),
|
||||
].filter((capability) => capability !== null),
|
||||
skillSummary: skillDef?.name ?? (skillMenu ? 'auto_skill_menu' : undefined),
|
||||
}
|
||||
const routeStartedAt = Date.now()
|
||||
|
||||
@@ -316,6 +316,87 @@ describe('runChatTurn', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('persists render_chart results as chart content blocks for CLI replay', async () => {
|
||||
const stdout = new MemoryWritable()
|
||||
const aiChatManager = createAIChatManager()
|
||||
const chart = {
|
||||
version: 1,
|
||||
spec: {
|
||||
version: 1,
|
||||
type: 'bar',
|
||||
title: '季度消息量趋势',
|
||||
encoding: { x: 'quarter', y: 'count' },
|
||||
},
|
||||
dataset: {
|
||||
columns: [
|
||||
{ name: 'quarter', type: 'category' },
|
||||
{ name: 'count', type: 'integer' },
|
||||
],
|
||||
rows: [
|
||||
{ quarter: '2025-Q4', count: 1607 },
|
||||
{ quarter: '2026-Q1', count: 4284 },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
labels: ['2025-Q4', '2026-Q1'],
|
||||
values: [1607, 4284],
|
||||
},
|
||||
rowCount: 2,
|
||||
} as const
|
||||
|
||||
const result = await runChatTurn(
|
||||
{ sessionId: 'session-1', question: '分析季度消息量变化趋势', json: true, includeEvents: true },
|
||||
{
|
||||
dbManager: createDbManager(['session-1']),
|
||||
pathProvider: {} as never,
|
||||
aiChatManager,
|
||||
stdout,
|
||||
createRunAgentStream: () => async (_params, onEvent) => {
|
||||
onEvent({
|
||||
type: 'plan',
|
||||
plan: {
|
||||
type: 'plan',
|
||||
version: 1,
|
||||
status: 'created',
|
||||
plan: {
|
||||
version: 1,
|
||||
title: '季度消息量趋势',
|
||||
route: 'planned_execution',
|
||||
intent: 'trend',
|
||||
steps: [
|
||||
{ goal: '生成趋势图', suggestedTools: ['get_schema', 'render_chart'], evidenceNeeded: '季度数据' },
|
||||
],
|
||||
successCriteria: ['展示趋势'],
|
||||
},
|
||||
},
|
||||
})
|
||||
onEvent({ type: 'tool_result', toolName: 'render_chart', toolResult: { details: { chart } } })
|
||||
onEvent({ type: 'content', content: '2026-Q1 是峰值。' })
|
||||
onEvent({
|
||||
type: 'done',
|
||||
isFinished: true,
|
||||
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
result.contentBlocks?.map((block) => block.type),
|
||||
['plan', 'chart', 'text']
|
||||
)
|
||||
assert.equal(result.contentBlocks?.[1]?.type, 'chart')
|
||||
assert.deepEqual(result.contentBlocks?.[1]?.chart.dataset.rows, [])
|
||||
assert.deepEqual(result.contentBlocks?.[1]?.chart.data, chart.data)
|
||||
assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages.at(-1), {
|
||||
aiChatId: 'ai_chat_1',
|
||||
role: 'assistant',
|
||||
content: '2026-Q1 是峰值。',
|
||||
contentBlocks: result.contentBlocks,
|
||||
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails the turn and does not persist messages when the agent stream reports an error', async () => {
|
||||
const stdout = new MemoryWritable()
|
||||
const aiChatManager = createAIChatManager()
|
||||
@@ -348,6 +429,7 @@ describe('runChatTurn', () => {
|
||||
{ id: 'ai-chat-1', sessionId: 'session-1', assistantId: 'custom_assistant' },
|
||||
])
|
||||
let streamedAssistantId: string | undefined
|
||||
let streamedEnableAutoSkill: boolean | undefined
|
||||
|
||||
await runChatTurn(
|
||||
{ aiChatId: 'ai-chat-1', question: 'hello', json: true },
|
||||
@@ -358,6 +440,7 @@ describe('runChatTurn', () => {
|
||||
stdout,
|
||||
createRunAgentStream: () => async (params, onEvent) => {
|
||||
streamedAssistantId = params.assistantId
|
||||
streamedEnableAutoSkill = params.enableAutoSkill
|
||||
onEvent({ type: 'content', content: 'hi' })
|
||||
onEvent({ type: 'done', isFinished: true })
|
||||
},
|
||||
@@ -365,6 +448,7 @@ describe('runChatTurn', () => {
|
||||
)
|
||||
|
||||
assert.equal(streamedAssistantId, 'custom_assistant')
|
||||
assert.equal(streamedEnableAutoSkill, true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
PlanContentBlock,
|
||||
TokenUsageData,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import type { PathProvider } from '@openchatlab/core'
|
||||
import type { ChartPayload, PathProvider } from '@openchatlab/core'
|
||||
import { createCliRunAgentStream } from './agent-stream-runner'
|
||||
|
||||
export interface ChatCommandOptions {
|
||||
@@ -76,6 +76,37 @@ function createAgentStreamError(error: unknown): Error {
|
||||
return new Error('Agent stream failed')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isChartPayload(value: unknown): value is ChartPayload {
|
||||
return isRecord(value) && value.version === 1 && isRecord(value.spec) && isRecord(value.dataset)
|
||||
}
|
||||
|
||||
function extractChartPayloads(toolResult: unknown): ChartPayload[] {
|
||||
if (!isRecord(toolResult)) return []
|
||||
const details = isRecord(toolResult.details) ? toolResult.details : toolResult
|
||||
const charts: ChartPayload[] = []
|
||||
if (isChartPayload(details.chart)) charts.push(details.chart)
|
||||
if (Array.isArray(details.charts)) {
|
||||
for (const chart of details.charts) {
|
||||
if (isChartPayload(chart)) charts.push(chart)
|
||||
}
|
||||
}
|
||||
return charts
|
||||
}
|
||||
|
||||
function toPersistedChartPayload(chart: ChartPayload): ChartPayload {
|
||||
return {
|
||||
...chart,
|
||||
dataset: {
|
||||
...chart.dataset,
|
||||
rows: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSingleSessionId(dbManager: DatabaseManager): string {
|
||||
const sessionIds = dbManager.listSessionIds()
|
||||
if (sessionIds.length === 1) return sessionIds[0]!
|
||||
@@ -172,6 +203,14 @@ export async function runChatTurn(
|
||||
}
|
||||
}
|
||||
|
||||
const appendChartBlocks = (charts: ChartPayload[]) => {
|
||||
if (charts.length === 0) return
|
||||
// 中文注释:图表 block 需要持久化给 CLI 生成的 AI 对话回放;
|
||||
// 原始 SQL 行数据可能较大,保存时只保留渲染数据和字段元信息。
|
||||
contentBlocks.push(...charts.map((chart) => ({ type: 'chart' as const, chart: toPersistedChartPayload(chart) })))
|
||||
hasReplayContentBlocks = true
|
||||
}
|
||||
|
||||
await runAgentStream(
|
||||
{
|
||||
userMessage: options.question,
|
||||
@@ -180,6 +219,7 @@ export async function runChatTurn(
|
||||
assistantId: target.assistantId,
|
||||
chatType: 'group',
|
||||
locale: options.locale ?? 'zh-CN',
|
||||
enableAutoSkill: true,
|
||||
},
|
||||
(chunk: AgentStreamChunk) => {
|
||||
if (options.includeEvents) {
|
||||
@@ -198,6 +238,10 @@ export async function runChatTurn(
|
||||
appendThinkBlock(chunk.content ?? '', chunk.thinkTag, chunk.thinkDurationMs)
|
||||
return
|
||||
}
|
||||
if (chunk.type === 'tool_result' && chunk.toolName === 'render_chart') {
|
||||
appendChartBlocks(extractChartPayloads(chunk.toolResult))
|
||||
return
|
||||
}
|
||||
if (chunk.type === 'content' && chunk.content) {
|
||||
answer += chunk.content
|
||||
appendTextBlock(chunk.content)
|
||||
|
||||
Reference in new issue
Block a user