From 9ad012680d8d7d3b4c03ecc58580c72291857751 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 8 Jun 2026 21:40:50 +0800 Subject: [PATCH] feat(ai): integrate chart planning support --- apps/cli/src/ai/agent-stream-runner.ts | 2 + apps/cli/src/ai/agent.ts | 8 ++ apps/cli/src/ai/chat-command.test.ts | 84 +++++++++++++++++++ apps/cli/src/ai/chat-command.ts | 46 +++++++++- apps/desktop/main/ai/agent-stream-runner.ts | 2 + apps/desktop/main/ai/agent/index.ts | 25 ++++-- .../src/ai/__tests__/chart-runtime.test.ts | 41 +++++++++ .../src/ai/agent/__tests__/planner.test.ts | 40 +++++++++ packages/node-runtime/src/ai/agent/index.ts | 1 + packages/node-runtime/src/ai/agent/planner.ts | 12 +++ .../src/ai/agent/planning-types.ts | 8 ++ packages/node-runtime/src/ai/chart-runtime.ts | 59 ++++++++++--- packages/node-runtime/src/ai/index.ts | 3 + packages/node-runtime/src/index.ts | 3 + .../src/definitions/render-chart.test.ts | 25 ++++++ .../tools/src/definitions/render-chart.ts | 21 ++++- src/components/AIChat/chat/ChatMessage.vue | 20 +++-- .../ai/chartMarkdownPlaceholders.test.ts | 22 +++++ src/services/ai/chartMarkdownPlaceholders.ts | 10 +++ 19 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 src/services/ai/chartMarkdownPlaceholders.test.ts create mode 100644 src/services/ai/chartMarkdownPlaceholders.ts diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index ed9a0cfb..a7293ff0 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -103,6 +103,8 @@ export function createCliRunAgentStream( userMessage, locale, assistantAllowedTools, + enableAutoDetection: enableAutoSkill === true, + enableAnalyticalAutoDetection: enableAutoSkill === true, }) const isChartCapability = chartRuntime.isChartCapability const autoSkillAllowedTools = diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index 0e47398d..95ec18bb 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -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 { 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() diff --git a/apps/cli/src/ai/chat-command.test.ts b/apps/cli/src/ai/chat-command.test.ts index a41a8fd7..1730548f 100644 --- a/apps/cli/src/ai/chat-command.test.ts +++ b/apps/cli/src/ai/chat-command.test.ts @@ -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) }) }) diff --git a/apps/cli/src/ai/chat-command.ts b/apps/cli/src/ai/chat-command.ts index 10ed1c0f..b274df7d 100644 --- a/apps/cli/src/ai/chat-command.ts +++ b/apps/cli/src/ai/chat-command.ts @@ -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 { + 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) diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 98a52b18..bc65ebe4 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -162,6 +162,8 @@ export function createElectronRunAgentStream(): ( userMessage, locale, assistantAllowedTools: assistantConfig?.allowedBuiltinTools, + enableAutoDetection: enableAutoSkill === true, + enableAnalyticalAutoDetection: enableAutoSkill === true, }) if (chartRuntime.isChartCapability) { const chartSkill = chartRuntime.skillDef ?? getChartCapabilitySkill(locale ?? 'zh-CN') diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts index b4f67948..1eb342ff 100644 --- a/apps/desktop/main/ai/agent/index.ts +++ b/apps/desktop/main/ai/agent/index.ts @@ -16,6 +16,7 @@ import { createLlmRouteDecider, createPlanContentBlock, decideRequestRoute, + getChartPlannerCapabilityForMessage, runAgentCore, streamSimple, type PiMessage, @@ -132,12 +133,27 @@ export class Agent { piTools.push(createActivateSkillTool(this.chatType, allowedTools, this.locale)) } + let cachedMessages: PiMessage[] = [] + + handler.emitStatus('preparing', cachedMessages, { + pendingUserMessage: userMessage, + force: true, + }) + + const availableToolNames = piTools.map((tool) => tool.name) const routeInput = { userMessage, chatType: this.chatType, locale: this.locale, dataSnapshot: this.context.dataSnapshot, - availableTools: piTools.map((tool) => tool.name), + availableTools: availableToolNames, + availableCapabilities: [ + getChartPlannerCapabilityForMessage({ + userMessage, + locale: this.locale, + availableTools: availableToolNames, + }), + ].filter((capability) => capability !== null), assistantSummary: this.assistantConfig?.name, skillSummary: this.skillCtx?.skillDef?.name ?? (this.skillCtx?.skillMenu ? 'auto_skill_menu' : undefined), } @@ -183,13 +199,6 @@ export class Agent { const historyMessages = this.loadHistory() - let cachedMessages: PiMessage[] = [] - - handler.emitStatus('preparing', cachedMessages, { - pendingUserMessage: userMessage, - force: true, - }) - try { const result = await runAgentCore({ piModel: this.piModel, diff --git a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts index b603d143..5afa6995 100644 --- a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts +++ b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { CHART_CAPABILITY_SKILL_ID, + getChartPlannerCapabilityForMessage, getAllowedBuiltinToolsForChartAutoSkill, getChartCapabilityAllowedBuiltinTools, getChartCapabilitySkill, @@ -17,6 +18,8 @@ describe('chart runtime policy', () => { assert.equal(skill.name, 'Chart Assistant') assert.deepEqual(skill.tools, ['render_chart', 'get_schema']) assert.match(skill.prompt, /render_chart/) + assert.match(skill.prompt, /chart1\.png/) + assert.match(skill.prompt, /Data preview/) }) it('enables chart runtime only for explicit chart skill by default', () => { @@ -39,6 +42,20 @@ describe('chart runtime policy', () => { ) }) + it('can auto-enable chart runtime for analytical trend questions', () => { + const runtime = resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '分析过去一年群里每季度消息量变化趋势,指出峰值和低谷。', + locale: 'zh-CN', + enableAutoDetection: true, + enableAnalyticalAutoDetection: true, + }) + + assert.equal(runtime.isChartCapability, true) + assert.equal(runtime.skillDef?.id, CHART_CAPABILITY_SKILL_ID) + assert.deepEqual(runtime.allowedBuiltinTools, ['render_chart']) + }) + it('keeps chart tool allowlists free of raw SQL', () => { assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart']) assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql']), [ @@ -63,4 +80,28 @@ describe('chart runtime policy', () => { 'render_chart', ]) }) + + it('offers chart planner capability for analytical trend questions without explicit chart wording', () => { + const capability = getChartPlannerCapabilityForMessage({ + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化。', + locale: 'zh-CN', + availableTools: ['get_schema', 'search_messages', 'render_chart'], + }) + + assert.equal(capability?.id, 'chart_generation') + assert.deepEqual(capability?.tools, ['get_schema', 'render_chart']) + assert.match(capability?.guidance ?? '', /趋势/) + assert.match(capability?.guidance ?? '', /get_schema/) + assert.match(capability?.guidance ?? '', /render_chart/) + }) + + it('does not offer chart planner capability when render_chart is unavailable', () => { + const capability = getChartPlannerCapabilityForMessage({ + userMessage: '分析过去一年群里话题的变化趋势。', + locale: 'zh-CN', + availableTools: ['get_schema', 'search_messages'], + }) + + assert.equal(capability, null) + }) }) diff --git a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts index 2ffd54d6..3c129904 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts @@ -100,4 +100,44 @@ describe('createAnalysisPlanner', () => { assert.match(guidance, /search_messages/) assert.match(guidance, /覆盖全年/) }) + + it('includes available capability summaries in the planner prompt', async () => { + let capturedPrompt = '' + const planner = createAnalysisPlanner({ + complete: async (prompt) => { + capturedPrompt = prompt + return JSON.stringify({ + title: '趋势和图表分析', + intent: 'trend', + steps: [ + { + goal: '确认 schema 后生成趋势图', + suggestedTools: ['get_schema', 'render_chart'], + evidenceNeeded: '按季度聚合后的趋势数据', + }, + ], + successCriteria: ['给出趋势证据'], + }) + }, + }) + + const plan = await planner({ + ...baseInput, + availableTools: ['search_messages', 'get_schema', 'render_chart'], + availableCapabilities: [ + { + id: 'chart_generation', + label: '图表生成', + tools: ['get_schema', 'render_chart'], + guidance: '趋势、排名、分布、占比用图更清楚时,可先调用 get_schema,再用 render_chart 生成一张图。', + }, + ], + }) + + assert.equal(plan?.steps[0]?.suggestedTools.includes('render_chart'), true) + assert.match(capturedPrompt, /availableCapabilities/) + assert.match(capturedPrompt, /chart_generation/) + assert.match(capturedPrompt, /get_schema/) + assert.match(capturedPrompt, /render_chart/) + }) }) diff --git a/packages/node-runtime/src/ai/agent/index.ts b/packages/node-runtime/src/ai/agent/index.ts index 90d6814c..13cb9422 100644 --- a/packages/node-runtime/src/ai/agent/index.ts +++ b/packages/node-runtime/src/ai/agent/index.ts @@ -9,6 +9,7 @@ export type { AnalysisPlanner, AnalysisPlanStep, AnalysisPlanSummary, + PlannerCapabilitySummary, PlannerInput, PlanContentBlock, } from './planning-types' diff --git a/packages/node-runtime/src/ai/agent/planner.ts b/packages/node-runtime/src/ai/agent/planner.ts index 95e4e3d2..a3b19bff 100644 --- a/packages/node-runtime/src/ai/agent/planner.ts +++ b/packages/node-runtime/src/ai/agent/planner.ts @@ -108,6 +108,15 @@ function buildPlannerPrompt(input: PlannerInput): string { const dataSnapshot = input.dataSnapshot ? `${input.dataSnapshot.name}, ${input.dataSnapshot.totalMessages} messages, ${input.dataSnapshot.totalMembers} members` : '(none)' + const availableCapabilities = + input.availableCapabilities && input.availableCapabilities.length > 0 + ? input.availableCapabilities + .map((capability) => { + const tools = capability.tools.join(', ') || 'none' + return ` - ${capability.id} (${capability.label}; tools: ${tools}): ${capability.guidance}` + }) + .join('\n') + : '(none)' return `Create a concise user-visible analysis plan for a ChatLab planned_execution request. @@ -125,6 +134,7 @@ Limits: - Max 5 steps. - Max 5 successCriteria. - suggestedTools must come only from availableTools. +- If availableCapabilities are listed, you may plan steps that use their tools when the capability helps the user. - Do not reveal hidden chain-of-thought; write a user-readable plan summary. - The plan is soft guidance, not a mandatory execution script. @@ -132,6 +142,8 @@ Context: - locale: ${input.locale} - chatType: ${input.chatType} - availableTools: ${input.availableTools.join(', ') || '(none)'} +- availableCapabilities: +${availableCapabilities} - dataSnapshot: ${dataSnapshot} - assistantSummary: ${input.assistantSummary ?? '(none)'} - skillSummary: ${input.skillSummary ?? '(none)'} diff --git a/packages/node-runtime/src/ai/agent/planning-types.ts b/packages/node-runtime/src/ai/agent/planning-types.ts index 77250ccb..27d41dd3 100644 --- a/packages/node-runtime/src/ai/agent/planning-types.ts +++ b/packages/node-runtime/src/ai/agent/planning-types.ts @@ -17,6 +17,13 @@ export interface AnalysisPlanSummary { successCriteria: string[] } +export interface PlannerCapabilitySummary { + id: string + label: string + tools: string[] + guidance: string +} + export interface PlanContentBlock { type: 'plan' version: 1 @@ -30,6 +37,7 @@ export interface PlannerInput { locale: string dataSnapshot?: DataSnapshot availableTools: string[] + availableCapabilities?: PlannerCapabilitySummary[] assistantSummary?: string skillSummary?: string recentIntentSummary?: string diff --git a/packages/node-runtime/src/ai/chart-runtime.ts b/packages/node-runtime/src/ai/chart-runtime.ts index ccd4ed2c..6bac93e6 100644 --- a/packages/node-runtime/src/ai/chart-runtime.ts +++ b/packages/node-runtime/src/ai/chart-runtime.ts @@ -1,6 +1,7 @@ import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' import { appendSkillMenuLines, formatSkillMenuLine } from './skill-menu' import type { SkillDef } from './types' +import type { PlannerCapabilitySummary } from './agent/planning-types' export { CHART_CAPABILITY_SKILL_ID } @@ -15,6 +16,7 @@ export interface ResolveChartRuntimeOptions { locale?: string assistantAllowedTools?: readonly string[] | null enableAutoDetection?: boolean + enableAnalyticalAutoDetection?: boolean } export interface ResolvedChartRuntime { @@ -43,13 +45,48 @@ export function shouldUseChartCapabilityForMessage(message: string): boolean { ) } +export function shouldOfferChartCapabilityForAnalyticalMessage(message: string): boolean { + const normalized = message.trim().toLowerCase() + if (!normalized) return false + if (shouldUseChartCapabilityForMessage(normalized)) return true + + return /(?:趋势|变化|走势|排名|排行|前\s*\d+|top\s*\d*|最多|最少|高频|分布|占比|比例|构成|份额|活跃度|热度|按.{0,8}统计|统计.{0,8}按|trend|ranking|rank|top\s*\d*|distribution|ratio|share|percentage|breakdown)/i.test( + normalized + ) +} + +export function getChartPlannerCapabilityForMessage(options: { + userMessage: string + locale?: string + availableTools: readonly string[] +}): PlannerCapabilitySummary | null { + const availableToolSet = new Set(options.availableTools) + if (!availableToolSet.has('get_schema') || !availableToolSet.has('render_chart')) { + return null + } + if (!shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage)) { + return null + } + + const isZh = (options.locale ?? 'zh-CN').startsWith('zh') + return { + id: 'chart_generation', + label: isZh ? '图表生成' : 'Chart generation', + tools: ['get_schema', 'render_chart'], + guidance: isZh + ? '用户明确要求图表,或趋势、排名、分布、占比等分析用图更清楚时,可以在计划中加入最多一张图表步骤;必须先用 get_schema 确认 schema,再用 render_chart 生成图表;不要输出绘图代码。' + : 'When the user explicitly asks for charts, or when trends, rankings, distributions, or ratios are clearer visually, you may add at most one chart step to the plan; call get_schema before render_chart; do not output chart-rendering code.', + } +} + export function resolveChartRuntimeForRequest(options: ResolveChartRuntimeOptions): ResolvedChartRuntime { const locale = options.locale ?? 'zh-CN' const isExplicitChartSkill = options.skillId === CHART_CAPABILITY_SKILL_ID - const isAutoChartSkill = - options.enableAutoDetection === true && - !options.skillId && - shouldUseChartCapabilityForMessage(options.userMessage ?? '') + const shouldAutoDetectChart = + shouldUseChartCapabilityForMessage(options.userMessage ?? '') || + (options.enableAnalyticalAutoDetection === true && + shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage ?? '')) + const isAutoChartSkill = options.enableAutoDetection === true && !options.skillId && shouldAutoDetectChart if (!isExplicitChartSkill && !isAutoChartSkill) { return { isChartCapability: false } @@ -113,7 +150,7 @@ export function getSkillConfigWithBuiltinChart( const ZH_PROMPT = `你是 ChatLab 绘图助手。本轮用户希望你在回答里自然嵌入一张或多张图表。 -你必须通过 render_chart 工具生成图表。不要输出 HTML、JavaScript、SVG、Canvas、ECharts option 或任何渲染代码。 +你必须通过 render_chart 工具生成图表。不要输出 HTML、JavaScript、SVG、Canvas、ECharts option、Markdown 图片链接(如 ![图表](chart1.png))或任何渲染代码。 硬性规则: 1. 第一次写 SQL 前必须先调用 get_schema。禁止猜测表名、字段名或时间字段。 @@ -121,8 +158,9 @@ const ZH_PROMPT = `你是 ChatLab 绘图助手。本轮用户希望你在回答 3. ChatLab 的 message.ts 是秒级 Unix 时间戳,使用 date(ts, 'unixepoch', 'localtime');禁止写 ts/1000。 4. “最近的 N 人/成员”必须先用数据定义并筛选出 N 个成员,再只统计这些成员。 5. 折线图有多条线时,每条线必须有明确 series 字段含义;按日期统计时应补齐日期 × series 的 0 值,避免缺失日期导致折线断裂或误连。 -6. 工具失败、SQL 修正、schema 探索过程不要写进最终回答;最终只保留图表和简短结论。 -7. 图表解释只能描述数据直接支持的趋势、峰值、低谷、差异和同步性,禁止夸张拟人化或无法从图中验证的关系判断。 +6. 工具失败、SQL 修正、schema 探索过程不要写进最终回答;最终只保留原生图表和简短结论,禁止用 chart1.png、chart2.png 等文件名引用图表。 +7. render_chart 的工具结果会包含数据预览(Data preview);用它分析峰值、低谷和差异,不要为了读取数值重复生成等价图表。 +8. 图表解释只能描述数据直接支持的趋势、峰值、低谷、差异和同步性,禁止夸张拟人化或无法从图中验证的关系判断。 工作流程: 1. 调用 get_schema。 @@ -152,7 +190,7 @@ ChartSpec 示例: const EN_PROMPT = `You are the ChatLab Chart Assistant. In this turn, the user wants one or more charts embedded naturally in the answer. -You must generate charts through the render_chart tool. Do not output HTML, JavaScript, SVG, Canvas, ECharts options, or rendering code. +You must generate charts through the render_chart tool. Do not output HTML, JavaScript, SVG, Canvas, ECharts options, Markdown image links such as ![Chart](chart1.png), or rendering code. Hard rules: 1. Always call get_schema before writing the first SQL query. Do not guess table names, field names, or timestamp fields. @@ -160,8 +198,9 @@ Hard rules: 3. ChatLab message.ts is a Unix timestamp in seconds. Use date(ts, 'unixepoch', 'localtime'); never write ts/1000. 4. For "latest N members" or "recent N people", first define and select those N members from the data, then count only those members. 5. For multi-series line charts, every line must have an explicit series field. For daily counts, fill the date x series grid with zero values so missing days do not break or mislead the line. -6. Do not include failed SQL attempts, schema exploration, or retry reasoning in the final answer. Final answer should contain only the chart and a short conclusion. -7. Explain only trends, peaks, lows, differences, and synchrony directly supported by chart data. Do not make exaggerated personality or relationship claims. +6. Do not include failed SQL attempts, schema exploration, or retry reasoning in the final answer. Final answer should contain only native charts and a short conclusion; never reference charts with filenames like chart1.png or chart2.png. +7. render_chart tool results include a Data preview; use it to identify peaks, lows, and differences. Do not generate equivalent charts again just to read values. +8. Explain only trends, peaks, lows, differences, and synchrony directly supported by chart data. Do not make exaggerated personality or relationship claims. Workflow: 1. Call get_schema. diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 9302a321..287006fd 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -54,8 +54,10 @@ export { getChartCapabilityAllowedBuiltinTools, getChartCapabilitySkill, getChartCapabilitySkill as getBuiltinChartSkill, + getChartPlannerCapabilityForMessage, getSkillConfigWithBuiltinChart, resolveChartRuntimeForRequest, + shouldOfferChartCapabilityForAnalyticalMessage, shouldUseChartCapabilityForMessage, } from './chart-runtime' export { CHART_SCHEMA_REQUIRED_MESSAGE, createChartSchemaGateState, wrapWithChartSchemaGate } from './chart-schema-gate' @@ -105,6 +107,7 @@ export type { AnalysisPlanner, AnalysisPlanStep, AnalysisPlanSummary, + PlannerCapabilitySummary, PlannerInput, PlanContentBlock, } from './agent' diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index ecda4fe1..b81b8495 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -148,9 +148,11 @@ export { getAllowedBuiltinToolsForChartAutoSkill, getChartCapabilityAllowedBuiltinTools, getChartCapabilitySkill, + getChartPlannerCapabilityForMessage, getBuiltinChartSkill, getSkillConfigWithBuiltinChart, resolveChartRuntimeForRequest, + shouldOfferChartCapabilityForAnalyticalMessage, shouldUseChartCapabilityForMessage, CHART_SCHEMA_REQUIRED_MESSAGE, createChartSchemaGateState, @@ -211,6 +213,7 @@ export type { AnalysisPlanner, AnalysisPlanStep, AnalysisPlanSummary, + PlannerCapabilitySummary, PlannerInput, PlanContentBlock, } from './ai' diff --git a/packages/tools/src/definitions/render-chart.test.ts b/packages/tools/src/definitions/render-chart.test.ts index 92b28039..7129432f 100644 --- a/packages/tools/src/definitions/render-chart.test.ts +++ b/packages/tools/src/definitions/render-chart.test.ts @@ -63,6 +63,31 @@ describe('renderChartTool', () => { ]) }) + it('includes a compact data preview in the tool text for model reasoning', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext( + [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + calls + ) + + const result = await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats', + chartSpec: barSpec, + }, + context + ) + + assert.match(result.content, /Generated chart "Messages by member"/) + assert.match(result.content, /Data preview/) + assert.match(result.content, /Alice/) + assert.match(result.content, /message_count/) + assert.match(result.content, /4/) + }) + it('truncates rows after fetching one more than maxRows', async () => { const calls: Array<{ query: string; params: Record }> = [] const context = createContext( diff --git a/packages/tools/src/definitions/render-chart.ts b/packages/tools/src/definitions/render-chart.ts index 68c6239b..7b46e9f4 100644 --- a/packages/tools/src/definitions/render-chart.ts +++ b/packages/tools/src/definitions/render-chart.ts @@ -9,6 +9,7 @@ import { buildChartPayload } from '@openchatlab/core' import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' const DEFAULT_MAX_ROWS = 1000 +const DEFAULT_PREVIEW_ROWS = 20 const RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS = /\b(?:\w+\.)?ts\s*\/\s*1000\b/i const inputSchema: JsonSchema = { @@ -81,6 +82,24 @@ function summarizeChart(type: string, title: string, rowCount: number, truncated return `Generated chart "${title}" (${type}, ${rowCount} rows${truncated ? ', truncated' : ''}).` } +function summarizeChartForModel( + type: string, + title: string, + rows: Record[], + truncated: boolean, + locale?: string +): 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 preview = JSON.stringify(previewRows) + if (locale?.startsWith('zh')) { + return `${summary}\n数据预览(前 ${previewRows.length} 行,用于分析峰值、低谷和差异):${preview}` + } + return `${summary}\nData preview (first ${previewRows.length} rows; use this to identify peaks, lows, and differences): ${preview}` +} + async function handler(params: Record, context: ToolExecutionContext): Promise { if (!context.dataProvider) throw new Error('render_chart requires a data provider') @@ -94,7 +113,7 @@ async function handler(params: Record, context: ToolExecutionCo const chart = buildChartPayload(rows, params.chartSpec, { truncated }) return { - content: summarizeChart(chart.spec.type, chart.spec.title, rows.length, truncated, context.locale), + content: summarizeChartForModel(chart.spec.type, chart.spec.title, rows, truncated, context.locale), data: { rowCount: rows.length, truncated, diff --git a/src/components/AIChat/chat/ChatMessage.vue b/src/components/AIChat/chat/ChatMessage.vue index 3bd9995a..58ac70f4 100644 --- a/src/components/AIChat/chat/ChatMessage.vue +++ b/src/components/AIChat/chat/ChatMessage.vue @@ -8,6 +8,7 @@ import CaptureButton from '@/components/common/CaptureButton.vue' import ErrorBlock from './ErrorBlock.vue' import ChartBlockRenderer from './ChartBlockRenderer.vue' import { useToast } from '@/composables/useToast' +import { stripChartImagePlaceholders } from '@/services/ai/chartMarkdownPlaceholders' const { t, te, locale } = useI18n() const toast = useToast() @@ -88,7 +89,7 @@ function formatThinkDuration(durationMs?: number): string { // 渲染后的 HTML(用于用户消息或纯文本 AI 消息) const renderedContent = computed(() => { if (!props.content) return '' - return md.render(props.content) + return md.render(getDisplayText(props.content)) }) watch( @@ -135,11 +136,20 @@ function submitEditing() { overwriteSubsequent.value = false } +const hasChartBlocks = computed(() => (props.contentBlocks || []).some((block) => block.type === 'chart')) + +function getDisplayText(text: string): string { + return hasChartBlocks.value ? stripChartImagePlaceholders(text) : text +} + // 过滤无内容的文本/思考块,避免显示空气泡 const visibleBlocks = computed(() => { const blocks = props.contentBlocks || [] return blocks.filter((block) => { - if (block.type === 'text' || block.type === 'think') { + if (block.type === 'text') { + return getDisplayText(block.text).trim().length > 0 + } + if (block.type === 'think') { return block.text.trim().length > 0 } return true @@ -318,13 +328,13 @@ function formatToolParams(tool: ToolBlockContent): string { } const copyMarkdownText = computed(() => { - if (props.content.trim()) return props.content + if (!useBlocksRendering.value && props.content.trim()) return getDisplayText(props.content) if (!useBlocksRendering.value) return '' const lines = visibleBlocks.value .map((block) => { if (block.type === 'text') { - return block.text + return getDisplayText(block.text) } if (block.type === 'think') { @@ -462,7 +472,7 @@ async function handleCopyMarkdown() {
{ + it('removes generated chart image placeholders while keeping surrounding text', () => { + const result = stripChartImagePlaceholders(`## 月度消息量趋势 + +![月度消息量趋势](chart1.png) +![阅读消息量分布](chart2.png) + +下面是趋势分析。`) + + assert.equal(result, '## 月度消息量趋势\n\n下面是趋势分析。') + }) + + it('keeps non-chart markdown images', () => { + const input = '![真实截图](screenshots/report.png)' + + assert.equal(stripChartImagePlaceholders(input), input) + }) +}) diff --git a/src/services/ai/chartMarkdownPlaceholders.ts b/src/services/ai/chartMarkdownPlaceholders.ts new file mode 100644 index 00000000..bac5cbf9 --- /dev/null +++ b/src/services/ai/chartMarkdownPlaceholders.ts @@ -0,0 +1,10 @@ +const CHART_IMAGE_PLACEHOLDER_RE = + /^\s*!\[[^\]\n]*\]\(\s*(?:\.\/)?chart[\w-]*\.(?:png|jpg|jpeg|webp|svg)(?:\?[^)\s]*)?\s*(?:"[^"]*")?\s*\)\s*$/gim + +export function stripChartImagePlaceholders(text: string): string { + if (!text) return text + return text + .replace(CHART_IMAGE_PLACEHOLDER_RE, '') + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, '') +}