diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 78aec675..eb895c31 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -5,7 +5,12 @@ import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime' import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime' -import { getChatOverview } from '@openchatlab/core' +import { + CHART_CAPABILITY_SKILL_ID, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, + getChatOverview, +} from '@openchatlab/core' import type { DataSnapshot } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' @@ -46,11 +51,13 @@ export function createCliRunAgentStream( const aiDataDir = getAiDir(dbManager) let assistantSystemPrompt: string | undefined + let assistantAllowedTools: string[] | undefined if (assistantId) { const assistantConfig = loadAssistantConfig(aiDataDir, assistantId) if (assistantConfig?.systemPrompt) { assistantSystemPrompt = assistantConfig.systemPrompt } + assistantAllowedTools = assistantConfig?.allowedBuiltinTools } const llmConfig = getDefaultAssistantConfig(aiDataDir) @@ -59,8 +66,18 @@ export function createCliRunAgentStream( const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) const db = (dbManager as any).open?.(sessionId) + const isChartCapability = skillId === CHART_CAPABILITY_SKILL_ID + const allowedToolSet = isChartCapability + ? new Set(getChartCapabilityAllowedBuiltinTools(assistantAllowedTools)) + : assistantAllowedTools && assistantAllowedTools.length > 0 + ? new Set(assistantAllowedTools) + : null + const availableToolDefs = allowedToolSet + ? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name)) + : AGENT_TOOL_REGISTRY + const agentTools = db - ? adaptToolsForAgent(AGENT_TOOL_REGISTRY, () => ({ db, sessionId, locale }), { maxToolResultTokens }) + ? adaptToolsForAgent(availableToolDefs, () => ({ db, sessionId, locale }), { maxToolResultTokens }) : [] const skillMgr = new SkillManager(aiDataDir) @@ -69,7 +86,10 @@ export function createCliRunAgentStream( let resolvedSkillDef: { name: string; prompt: string } | undefined let resolvedSkillMenu: string | undefined - if (skillId) { + if (isChartCapability) { + const def = getChartCapabilitySkill(locale ?? 'zh-CN') + resolvedSkillDef = { name: def.name, prompt: def.prompt } + } else if (skillId) { const def = skillMgr.getSkillConfig(skillId) if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt } } else if (enableAutoSkill) { diff --git a/apps/cli/src/ai/tool-adapter.test.ts b/apps/cli/src/ai/tool-adapter.test.ts new file mode 100644 index 00000000..0dff4d2e --- /dev/null +++ b/apps/cli/src/ai/tool-adapter.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { adaptToolsForAgent } from './tool-adapter' +import type { ToolDefinition } from '@openchatlab/tools' +import type { ChartPayload, DatabaseAdapter } from '@openchatlab/core' + +const chart: ChartPayload = { + version: 1, + spec: { + version: 1, + type: 'pie', + title: 'Selected members', + encoding: { label: 'name', value: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + }, + data: { + labels: ['Alice', 'Bob'], + values: [4, 3], + }, + rowCount: 2, +} + +describe('adaptToolsForAgent', () => { + it('preserves chart payloads in tool result details', async () => { + const tool: ToolDefinition = { + name: 'render_chart', + description: 'Render a chart', + inputSchema: { type: 'object', properties: {} }, + async handler() { + return { + content: 'Generated chart.', + data: { rowCount: 2 }, + chart, + } + }, + } + + const [agentTool] = adaptToolsForAgent([tool], () => ({ + db: {} as DatabaseAdapter, + sessionId: 'session-1', + locale: 'en-US', + })) + + assert.ok(agentTool) + const result = await agentTool.execute('call-1', {}) + + assert.deepEqual(result.content, [{ type: 'text', text: 'Generated chart.' }]) + assert.deepEqual(result.details, { rowCount: 2, chart }) + }) +}) diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index 77c0a355..5807803e 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -75,6 +75,13 @@ export function adaptToolsForAgent( } try { const result = await tool.handler(toolParams, execCtx) + const chartDetails = + result.chart || result.charts + ? { + ...(result.chart ? { chart: result.chart } : {}), + ...(result.charts ? { charts: result.charts } : {}), + } + : {} if (result.rawMessages && result.rawMessages.length > 0) { const pipelineResult = applyPreprocessingPipeline({ @@ -85,10 +92,23 @@ export function adaptToolsForAgent( extraDetails: (result.data ?? {}) as Record, logger: getServerAiLogger() ?? undefined, }) - return { content: [{ type: 'text', text: pipelineResult.text }], details: null } + return { + content: [{ type: 'text', text: pipelineResult.text }], + details: Object.keys(chartDetails).length > 0 ? chartDetails : null, + } } - return { content: [{ type: 'text', text: result.content }], details: null } + const baseDetails = + typeof result.data === 'object' && result.data !== null + ? (result.data as Record) + : result.data === undefined + ? null + : { value: result.data } + + return { + content: [{ type: 'text', text: result.content }], + details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails, + } } catch (error) { const msg = error instanceof Error ? error.message : String(error) return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index e03dc0ba..6fce8458 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -15,6 +15,11 @@ import type { ToolContext } from './tools/types' import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm' import type { AIServiceConfig } from './llm/types' import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral' +import { + CHART_CAPABILITY_SKILL_ID, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, +} from '@openchatlab/core' import * as assistantManager from './assistant' import type { AssistantConfig } from './assistant/types' import * as skillManager from './skills' @@ -147,7 +152,20 @@ export function createElectronRunAgentStream(): ( } let skillCtx: SkillContext | undefined - if (skillId) { + const isChartCapability = skillId === CHART_CAPABILITY_SKILL_ID + if (isChartCapability) { + const chartSkill = getChartCapabilitySkill(locale ?? 'zh-CN') + skillCtx = { skillDef: { ...chartSkill, chatScope: 'all' } as SkillContext['skillDef'] } + assistantConfig = { + ...(assistantConfig ?? { + id: resolvedAssistantId, + name: resolvedAssistantId, + systemPrompt: '', + presetQuestions: [], + }), + allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(assistantConfig?.allowedBuiltinTools), + } + } else if (skillId) { const skillDef = skillManager.getSkillConfig(skillId) ?? undefined if (skillDef) { skillCtx = { skillDef } diff --git a/apps/desktop/main/ai/tools/definitions/index.ts b/apps/desktop/main/ai/tools/definitions/index.ts index 8865cb08..10a21e44 100644 --- a/apps/desktop/main/ai/tools/definitions/index.ts +++ b/apps/desktop/main/ai/tools/definitions/index.ts @@ -16,6 +16,7 @@ import { searchSessionsTool, getSessionMessagesTool, getMembersTool, + schemaTool, memberStatsTool, timeStatsTool, getMemberNameHistoryTool, @@ -23,6 +24,7 @@ import { getSessionSummariesTool, responseTimeAnalysisTool, keywordFrequencyTool, + renderChartTool, SQL_TOOL_DEFS, createSqlToolDefinition, } from '@openchatlab/tools' @@ -48,6 +50,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [ adaptSharedTool(searchSessionsTool, { category: 'core' }), adaptSharedTool(getSessionMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }), adaptSharedTool(getMembersTool, { category: 'core' }), + adaptSharedTool(schemaTool, { category: 'core' }), // ==================== Analysis 工具(按需加载) ==================== adaptSharedTool(memberStatsTool, { category: 'analysis' }), @@ -57,6 +60,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [ adaptSharedTool(getSessionSummariesTool, { category: 'analysis' }), adaptSharedTool(responseTimeAnalysisTool, { category: 'analysis' }), adaptSharedTool(keywordFrequencyTool, { category: 'analysis' }), + adaptSharedTool(renderChartTool, { category: 'analysis' }), // ==================== SQL 分析工具 ==================== ...sqlToolEntries, diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts index f56ee099..e70cbfd9 100644 --- a/apps/desktop/main/ai/tools/shared-tool-adapter.ts +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -56,6 +56,13 @@ export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): To const execCtx = buildExecutionContext(context) try { const result = await tool.handler(toolParams, execCtx) + const chartDetails = + result.chart || result.charts + ? { + ...(result.chart ? { chart: result.chart } : {}), + ...(result.charts ? { charts: result.charts } : {}), + } + : {} if (result.rawMessages && result.rawMessages.length > 0) { const baseData = (typeof result.data === 'object' && result.data !== null ? result.data : {}) as Record< @@ -64,13 +71,20 @@ export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): To > return { content: [{ type: 'text', text: result.content }], - details: { ...baseData, rawMessages: result.rawMessages }, + details: { ...baseData, ...chartDetails, rawMessages: result.rawMessages }, } } + const baseDetails = + typeof result.data === 'object' && result.data !== null + ? (result.data as Record) + : result.data === undefined + ? null + : { value: result.data } + return { content: [{ type: 'text', text: result.content }], - details: result.data ?? null, + details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails, } } catch (error) { const msg = error instanceof Error ? error.message : String(error) diff --git a/packages/core/src/ai/chart-capability.test.ts b/packages/core/src/ai/chart-capability.test.ts new file mode 100644 index 00000000..537bf682 --- /dev/null +++ b/packages/core/src/ai/chart-capability.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + CHART_CAPABILITY_ANALYSIS_TOOLS, + CHART_CAPABILITY_SKILL_ID, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, +} from './chart-capability' + +describe('chart capability skill', () => { + it('advertises render_chart and schema lookup tools', () => { + const skill = getChartCapabilitySkill('en-US') + + assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID) + assert.deepEqual(skill.tools, ['render_chart', 'get_schema']) + assert.match(skill.prompt, /render_chart/) + assert.match(skill.prompt, /get_schema/) + }) + + it('adds only chart analysis tools to assistant builtin tools', () => { + assert.deepEqual(CHART_CAPABILITY_ANALYSIS_TOOLS, ['render_chart']) + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart']) + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['keyword_frequency']), [ + 'keyword_frequency', + 'render_chart', + ]) + }) + + it('does not duplicate render_chart when it is already allowed', () => { + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['render_chart', 'time_stats']), [ + 'render_chart', + 'time_stats', + ]) + }) +}) diff --git a/packages/core/src/ai/chart-capability.ts b/packages/core/src/ai/chart-capability.ts new file mode 100644 index 00000000..104de825 --- /dev/null +++ b/packages/core/src/ai/chart-capability.ts @@ -0,0 +1,81 @@ +export const CHART_CAPABILITY_SKILL_ID = 'chart_runtime' +export const CHART_CAPABILITY_ANALYSIS_TOOLS = ['render_chart'] as const + +export function getChartCapabilityAllowedBuiltinTools(allowedTools?: readonly string[] | null): string[] { + return Array.from(new Set([...(allowedTools ?? []), ...CHART_CAPABILITY_ANALYSIS_TOOLS])) +} + +export function getChartCapabilitySkill(locale: string = 'zh-CN'): { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' + tools: string[] + prompt: string +} { + const isZh = locale.startsWith('zh') + return { + id: CHART_CAPABILITY_SKILL_ID, + name: isZh ? '绘图助手' : 'Chart Assistant', + description: isZh ? '按本轮问题生成灵活的聊天数据图表' : 'Generate flexible charts for this chat question', + tags: [isZh ? '图表' : 'chart'], + chatScope: 'all', + tools: ['render_chart', 'get_schema'], + prompt: isZh ? ZH_PROMPT : EN_PROMPT, + } +} + +const ZH_PROMPT = `你是 ChatLab 绘图助手。本轮用户希望你在回答里自然嵌入一张或多张图表。 + +你必须通过 render_chart 工具生成图表。不要输出 HTML、JavaScript、SVG、Canvas、ECharts option 或任何渲染代码。 + +工作流程: +1. 如不确定表结构,先调用 get_schema。 +2. 根据用户要求确定统计对象、时间范围、维度、指标和图表类型。 +3. 调用 render_chart,提交只读 SQL 和 ChartSpec v1。 +4. 图表生成后,用简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart。 + +ChartSpec v1 支持: +- bar: encoding.x + encoding.y +- line: encoding.x + encoding.y,可选 encoding.series 表示每条线的含义 +- pie: encoding.label + encoding.value +- heatmap: encoding.x + encoding.y + encoding.value + +ChartSpec 示例: +{ + "version": 1, + "type": "line", + "title": "最近 30 天成员发言趋势", + "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, + "unit": "条" +} + +用户明确要求图表时必须尝试生成。用户没有明确要求时,只有排名、趋势、分布、占比或二维密度明显更清楚时才主动生成,且默认最多一张。` + +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. + +Workflow: +1. If the database schema is uncertain, call get_schema first. +2. Derive the target members, time range, dimensions, metrics, and chart type from the user request. +3. Call render_chart with read-only SQL and ChartSpec v1. +4. After the chart is generated, explain the key finding briefly. If the user asks for multiple charts, call render_chart multiple times. + +ChartSpec v1 supports: +- bar: encoding.x + encoding.y +- line: encoding.x + encoding.y, optional encoding.series for the meaning of each line +- pie: encoding.label + encoding.value +- heatmap: encoding.x + encoding.y + encoding.value + +ChartSpec example: +{ + "version": 1, + "type": "line", + "title": "Member message trend in the last 30 days", + "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, + "unit": "messages" +} + +When the user explicitly asks for charts, you must try to generate them. If the user does not explicitly ask, add at most one chart only when ranking, trend, distribution, ratio, or two-dimensional density is clearly easier to understand visually.` diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts index f7852571..a7de566b 100644 --- a/packages/core/src/ai/index.ts +++ b/packages/core/src/ai/index.ts @@ -5,6 +5,12 @@ // 内置工具目录 export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog' export { BUILTIN_TOOL_CATALOG } from './tool-catalog' +export { + CHART_CAPABILITY_SKILL_ID, + CHART_CAPABILITY_ANALYSIS_TOOLS, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, +} from './chart-capability' // LLM 模型系统类型 export type { diff --git a/packages/core/src/ai/tool-catalog.ts b/packages/core/src/ai/tool-catalog.ts index 49523262..6661c071 100644 --- a/packages/core/src/ai/tool-catalog.ts +++ b/packages/core/src/ai/tool-catalog.ts @@ -22,6 +22,7 @@ export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [ { name: 'search_sessions', category: 'core' }, { name: 'get_session_messages', category: 'core' }, { name: 'get_members', category: 'core' }, + { name: 'get_schema', category: 'core' }, // Analysis 工具 { name: 'get_member_stats', category: 'analysis' }, @@ -31,6 +32,7 @@ export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [ { name: 'get_session_summaries', category: 'analysis' }, { name: 'response_time_analysis', category: 'analysis' }, { name: 'keyword_frequency', category: 'analysis' }, + { name: 'render_chart', category: 'analysis' }, // SQL Analysis 工具 { name: 'message_type_breakdown', category: 'analysis' }, diff --git a/packages/core/src/chart/index.test.ts b/packages/core/src/chart/index.test.ts new file mode 100644 index 00000000..7af43954 --- /dev/null +++ b/packages/core/src/chart/index.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildChartPayload, ChartValidationError } from './index' + +test('builds a pie chart from dynamic label/value fields', () => { + const chart = buildChartPayload( + [ + { member_name: 'Alice', msg_count: 3 }, + { member_name: 'Bob', msg_count: 5 }, + ], + { + version: 1, + type: 'pie', + title: 'Selected members', + encoding: { label: 'member_name', value: 'msg_count' }, + } + ) + + assert.equal(chart.spec.type, 'pie') + assert.deepEqual(chart.data, { labels: ['Alice', 'Bob'], values: [3, 5] }) +}) + +test('builds line series when encoding.series is provided', () => { + const chart = buildChartPayload( + [ + { day: '2026-06-01', member_name: 'Alice', msg_count: 2 }, + { day: '2026-06-01', member_name: 'Bob', msg_count: 4 }, + { day: '2026-06-02', member_name: 'Alice', msg_count: 1 }, + ], + { + version: 1, + type: 'line', + title: 'Member trend', + encoding: { x: 'day', y: 'msg_count', series: 'member_name' }, + } + ) + + assert.deepEqual(chart.data, { + labels: ['2026-06-01', '2026-06-02'], + values: [2, 1], + series: [ + { name: 'Alice', values: [2, 1] }, + { name: 'Bob', values: [4, 0] }, + ], + }) +}) + +test('builds heatmap indices from x/y/value fields', () => { + const chart = buildChartPayload( + [ + { hour: 9, weekday: 'Mon', msg_count: 2 }, + { hour: 10, weekday: 'Mon', msg_count: 3 }, + { hour: 9, weekday: 'Tue', msg_count: 4 }, + ], + { + version: 1, + type: 'heatmap', + title: 'Activity heatmap', + encoding: { x: 'hour', y: 'weekday', value: 'msg_count' }, + } + ) + + assert.deepEqual(chart.data, { + xLabels: ['9', '10'], + yLabels: ['Mon', 'Tue'], + data: [ + [0, 0, 2], + [1, 0, 3], + [0, 1, 4], + ], + }) +}) + +test('rejects missing encoding fields', () => { + assert.throws( + () => + buildChartPayload([{ label: 'Alice', count: 1 }], { + version: 1, + type: 'bar', + title: 'Broken chart', + encoding: { x: 'label', y: 'missing_count' }, + }), + ChartValidationError + ) +}) diff --git a/packages/core/src/chart/index.ts b/packages/core/src/chart/index.ts new file mode 100644 index 00000000..7268b2ec --- /dev/null +++ b/packages/core/src/chart/index.ts @@ -0,0 +1,336 @@ +export type ChartType = 'bar' | 'line' | 'pie' | 'heatmap' + +export type ChartFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'datetime' | 'category' + +export interface ChartField { + name: string + type: ChartFieldType + label?: string + unit?: string +} + +export interface ChartEncoding { + x?: string + y?: string + value?: string + label?: string + series?: string + color?: string +} + +export interface ChartSpec { + version: 1 + type: ChartType + title: string + subtitle?: string + description?: string + encoding: ChartEncoding + fields?: ChartField[] + unit?: string + filters?: { + timeRange?: { startTs?: number; endTs?: number; label?: string } + members?: Array<{ id?: number; name: string }> + keywords?: string[] + messageTypes?: number[] + custom?: Record + } + display?: { + horizontal?: boolean + stacked?: boolean + showLegend?: boolean + showDataZoom?: boolean + height?: number + } +} + +export interface ChartDataset { + columns: ChartField[] + rows: Record[] +} + +export interface BarChartRenderData { + labels: string[] + values: number[] +} + +export interface LineChartRenderData { + labels: string[] + values: number[] + series?: Array<{ name: string; values: number[] }> +} + +export interface PieChartRenderData { + labels: string[] + values: number[] +} + +export interface HeatmapChartRenderData { + xLabels: string[] + yLabels: string[] + data: Array<[number, number, number]> +} + +export type ChartRenderData = BarChartRenderData | LineChartRenderData | PieChartRenderData | HeatmapChartRenderData + +export interface ChartPayload { + version: 1 + spec: ChartSpec + dataset: ChartDataset + data: ChartRenderData + rowCount: number + truncated?: boolean +} + +export class ChartValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ChartValidationError' + } +} + +const CHART_TYPES = new Set(['bar', 'line', 'pie', 'heatmap']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asFieldName(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ChartValidationError(`${label} must be a non-empty field name`) + } + return value +} + +function assertFieldExists(rows: Record[], field: string): void { + if (rows.length === 0) return + if (!Object.prototype.hasOwnProperty.call(rows[0], field)) { + throw new ChartValidationError(`Field "${field}" does not exist in SQL result`) + } +} + +function toLabel(value: unknown): string { + if (value === null || value === undefined || value === '') return '(empty)' + return String(value) +} + +function toNumber(value: unknown, field: string): number { + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) { + throw new ChartValidationError(`Field "${field}" contains non-numeric value "${String(value)}"`) + } + return num +} + +function inferFieldType(values: unknown[]): ChartFieldType { + const present = values.filter((v) => v !== null && v !== undefined) + if (present.length === 0) return 'string' + if (present.every((v) => typeof v === 'number' && Number.isInteger(v))) return 'integer' + if ( + present.every( + (v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) + ) + ) { + return 'number' + } + if (present.every((v) => typeof v === 'boolean')) return 'boolean' + return 'category' +} + +export function normalizeChartSpec(raw: unknown): ChartSpec { + if (!isRecord(raw)) throw new ChartValidationError('chartSpec must be an object') + if (raw.version !== 1) throw new ChartValidationError('chartSpec.version must be 1') + if (typeof raw.type !== 'string' || !CHART_TYPES.has(raw.type as ChartType)) { + throw new ChartValidationError('chartSpec.type must be one of: bar, line, pie, heatmap') + } + if (typeof raw.title !== 'string' || raw.title.trim().length === 0) { + throw new ChartValidationError('chartSpec.title must be a non-empty string') + } + if (!isRecord(raw.encoding)) throw new ChartValidationError('chartSpec.encoding must be an object') + + const spec = raw as unknown as ChartSpec + switch (spec.type) { + case 'bar': + case 'line': + asFieldName(spec.encoding.x, 'encoding.x') + asFieldName(spec.encoding.y, 'encoding.y') + break + case 'pie': + asFieldName(spec.encoding.label, 'encoding.label') + asFieldName(spec.encoding.value, 'encoding.value') + break + case 'heatmap': + asFieldName(spec.encoding.x, 'encoding.x') + asFieldName(spec.encoding.y, 'encoding.y') + asFieldName(spec.encoding.value, 'encoding.value') + break + } + + return { + ...spec, + title: spec.title.trim(), + } +} + +export function inferChartDataset(rows: Record[], declaredFields?: ChartField[]): ChartDataset { + const declared = new Map((declaredFields ?? []).map((field) => [field.name, field])) + const columnNames = rows.length > 0 ? Object.keys(rows[0]) : [...declared.keys()] + const columns = columnNames.map((name) => { + const declaredField = declared.get(name) + if (declaredField) return declaredField + return { + name, + type: inferFieldType(rows.map((row) => row[name])), + } + }) + + return { columns, rows } +} + +function aggregateByLabel( + rows: Record[], + labelField: string, + valueField: string +): { + labels: string[] + values: number[] +} { + const labels: string[] = [] + const indexByLabel = new Map() + const values: number[] = [] + + for (const row of rows) { + const label = toLabel(row[labelField]) + let index = indexByLabel.get(label) + if (index === undefined) { + index = labels.length + indexByLabel.set(label, index) + labels.push(label) + values.push(0) + } + values[index] += toNumber(row[valueField], valueField) + } + + return { labels, values } +} + +function buildLineData(rows: Record[], spec: ChartSpec): LineChartRenderData { + const xField = spec.encoding.x! + const yField = spec.encoding.y! + const seriesField = spec.encoding.series + + if (!seriesField) return aggregateByLabel(rows, xField, yField) + + assertFieldExists(rows, seriesField) + const labels: string[] = [] + const labelIndex = new Map() + const seriesNames: string[] = [] + const seriesIndex = new Map() + const matrix: number[][] = [] + + for (const row of rows) { + const label = toLabel(row[xField]) + const seriesName = toLabel(row[seriesField]) + let xIndex = labelIndex.get(label) + if (xIndex === undefined) { + xIndex = labels.length + labelIndex.set(label, xIndex) + labels.push(label) + for (const values of matrix) values.push(0) + } + + let sIndex = seriesIndex.get(seriesName) + if (sIndex === undefined) { + sIndex = seriesNames.length + seriesIndex.set(seriesName, sIndex) + seriesNames.push(seriesName) + matrix.push(Array(labels.length).fill(0)) + } + + matrix[sIndex][xIndex] += toNumber(row[yField], yField) + } + + const series = seriesNames.map((name, index) => ({ name, values: matrix[index] })) + return { + labels, + values: series[0]?.values ?? [], + series, + } +} + +function buildHeatmapData(rows: Record[], spec: ChartSpec): HeatmapChartRenderData { + const xField = spec.encoding.x! + const yField = spec.encoding.y! + const valueField = spec.encoding.value! + const xLabels: string[] = [] + const yLabels: string[] = [] + const xIndex = new Map() + const yIndex = new Map() + const values = new Map() + + for (const row of rows) { + const xLabel = toLabel(row[xField]) + const yLabel = toLabel(row[yField]) + let xi = xIndex.get(xLabel) + if (xi === undefined) { + xi = xLabels.length + xIndex.set(xLabel, xi) + xLabels.push(xLabel) + } + let yi = yIndex.get(yLabel) + if (yi === undefined) { + yi = yLabels.length + yIndex.set(yLabel, yi) + yLabels.push(yLabel) + } + const key = `${xi}:${yi}` + values.set(key, (values.get(key) ?? 0) + toNumber(row[valueField], valueField)) + } + + const data: Array<[number, number, number]> = [] + for (const [key, value] of values.entries()) { + const [x, y] = key.split(':').map(Number) + data.push([x, y, value]) + } + + return { xLabels, yLabels, data } +} + +export function buildChartPayload( + rows: Record[], + rawSpec: unknown, + options?: { truncated?: boolean } +): ChartPayload { + const spec = normalizeChartSpec(rawSpec) + const dataset = inferChartDataset(rows, spec.fields) + + const requiredFields = new Set() + for (const field of Object.values(spec.encoding)) { + if (typeof field === 'string' && field) requiredFields.add(field) + } + for (const field of requiredFields) assertFieldExists(rows, field) + + let data: ChartRenderData + switch (spec.type) { + case 'bar': + data = aggregateByLabel(rows, spec.encoding.x!, spec.encoding.y!) + break + case 'line': + data = buildLineData(rows, spec) + break + case 'pie': + data = aggregateByLabel(rows, spec.encoding.label!, spec.encoding.value!) + break + case 'heatmap': + data = buildHeatmapData(rows, spec) + break + } + + return { + version: 1, + spec, + dataset, + data, + rowCount: rows.length, + truncated: options?.truncated, + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31b4b54d..3eceb51e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -184,6 +184,23 @@ export type { export { isNewerStableVersion, isStableVersion } from './version' export type { ParsedStableVersion } from './version' +// Chart runtime +export { ChartValidationError, normalizeChartSpec, inferChartDataset, buildChartPayload } from './chart' +export type { + ChartType, + ChartFieldType, + ChartField, + ChartEncoding, + ChartSpec, + ChartDataset, + ChartPayload, + ChartRenderData, + BarChartRenderData, + LineChartRenderData, + PieChartRenderData, + HeatmapChartRenderData, +} from './chart' + // NLP(平台无关的类型、数据和工具函数) export { POS_TAG_DEFINITIONS, @@ -229,6 +246,10 @@ export { BUILTIN_MODELS, getBuiltinModelsByProvider, getBuiltinModelById, + CHART_CAPABILITY_SKILL_ID, + CHART_CAPABILITY_ANALYSIS_TOOLS, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, THINK_TAGS, extractThinkingContent, stripToolCallTags, diff --git a/packages/node-runtime/src/ai/__tests__/conversations.test.ts b/packages/node-runtime/src/ai/__tests__/conversations.test.ts index 6c9bf594..f868d37b 100644 --- a/packages/node-runtime/src/ai/__tests__/conversations.test.ts +++ b/packages/node-runtime/src/ai/__tests__/conversations.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import Database from 'better-sqlite3' import { AIConversationManager } from '../conversations' +import type { ChartPayload } from '@openchatlab/core' const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING @@ -285,6 +286,56 @@ describe('AIConversationManager message editing', () => { }) }) +describe('AIConversationManager chart content blocks', () => { + it('persists normalized chart payloads for stable replay', () => { + const dir = createTempDir() + try { + const chart: ChartPayload = { + version: 1, + spec: { + version: 1, + type: 'bar', + title: 'Messages by member', + encoding: { x: 'name', y: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + }, + data: { + labels: ['Alice', 'Bob'], + values: [4, 3], + }, + rowCount: 2, + } + + const manager = createManager(dir) + const conv = manager.createConversation('s1', 'Chart Replay', 'general_cn') + manager.addMessage(conv.id, 'user', 'draw a chart') + manager.addMessage(conv.id, 'assistant', 'Here is the chart.', undefined, undefined, [{ type: 'chart', chart }]) + manager.close() + + const reloaded = createManager(dir) + const messages = reloaded.getMessages(conv.id) + const block = messages[1]?.contentBlocks?.[0] + + if (!block || block.type !== 'chart') { + assert.fail('Expected a chart content block') + } + assert.deepEqual(block?.chart, chart) + reloaded.close() + } finally { + cleanup(dir) + } + }) +}) + describe('AIConversationManager deleteMessagesFrom', () => { it('deletes the target message and all subsequent messages', () => { const dir = createTempDir() diff --git a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts index a0c3b404..61ee2cc2 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts @@ -97,4 +97,31 @@ describe('AgentEventHandler', () => { assert.equal(contentChunks.length, 1) assert.equal(contentChunks[0].content, 'Hello') }) + + it('passes chart tool results through unchanged', () => { + const chunks: AgentStreamChunk[] = [] + const handler = new AgentEventHandler({ + onChunk: (c) => chunks.push(c), + context: {}, + systemPrompt: 'test', + }) + const toolResult = { + content: 'Chart generated.', + details: { + chart: { + version: 1, + type: 'pie', + title: 'Selected members', + data: [{ label: 'Alice', value: 3 }], + source: { rowCount: 1, truncated: false }, + }, + }, + } + + handler.handleCoreEvent({ type: 'tool_end', toolName: 'render_chart', toolResult }, []) + + const resultChunk = chunks.find((c) => c.type === 'tool_result') + assert.equal(resultChunk?.toolName, 'render_chart') + assert.deepEqual(resultChunk?.toolResult, toolResult) + }) }) diff --git a/packages/node-runtime/src/ai/conversations.ts b/packages/node-runtime/src/ai/conversations.ts index 98dd8b48..c00d8716 100644 --- a/packages/node-runtime/src/ai/conversations.ts +++ b/packages/node-runtime/src/ai/conversations.ts @@ -8,6 +8,7 @@ import Database from 'better-sqlite3' import * as fs from 'fs' import * as path from 'path' +import type { ChartPayload } from '@openchatlab/core' const DEFAULT_GENERAL_ID = 'general_cn' @@ -26,6 +27,7 @@ export interface AIConversation { export type ContentBlock = | { type: 'text'; text: string } | { type: 'think'; tag: string; text: string; durationMs?: number } + | { type: 'chart'; chart: ChartPayload } | { type: 'tool' tool: { diff --git a/packages/node-runtime/src/ai/i18n/locales/en-US.ts b/packages/node-runtime/src/ai/i18n/locales/en-US.ts index c8b38611..ef116de4 100644 --- a/packages/node-runtime/src/ai/i18n/locales/en-US.ts +++ b/packages/node-runtime/src/ai/i18n/locales/en-US.ts @@ -252,6 +252,19 @@ Returned summaries are brief descriptions of each session, helping quickly locat top_n: 'Number of top keywords to return', }, }, + get_schema: { + desc: 'Inspect the chat database schema. Use it before charting or custom SQL when table or field names are uncertain.', + params: {}, + }, + render_chart: { + desc: 'Generate a native ChatLab chart from read-only SQL and ChartSpec v1. Supports bar, line, pie, and heatmap. Do not output HTML, JavaScript, SVG, ECharts options, or rendering code.', + params: { + sql: 'Read-only SELECT or WITH SELECT SQL. It must return fields referenced by ChartSpec encoding.', + params: 'Named SQL parameters. Use an empty object when no parameters are needed.', + chartSpec: 'ChartSpec v1 with version, type, title, and encoding.', + maxRows: 'Maximum chart query rows. Default 1000.', + }, + }, }, // ===== AI Agent system prompts ===== diff --git a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts index 274aa65f..56c7fac8 100644 --- a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts +++ b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts @@ -252,6 +252,19 @@ export default { top_n: '上位何個のキーワードを返却するか', }, }, + get_schema: { + desc: 'チャットデータベースのスキーマを確認します。チャート作成やカスタム SQL の前にテーブル名とフィールド名を確認してください。', + params: {}, + }, + render_chart: { + desc: '読み取り専用 SQL と ChartSpec v1 から ChatLab ネイティブチャートを生成します。bar、line、pie、heatmap をサポートします。HTML、JavaScript、SVG、ECharts option、描画コードは出力しないでください。', + params: { + sql: '読み取り専用の SELECT または WITH SELECT SQL。ChartSpec encoding で参照するフィールドを返す必要があります。', + params: 'SQL の名前付きパラメータ。不要な場合は空オブジェクトを渡します。', + chartSpec: 'version、type、title、encoding を含む ChartSpec v1。', + maxRows: 'チャートクエリの最大行数。デフォルトは 1000。', + }, + }, }, agent: { diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts index a0a06fc3..d746ccc2 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts @@ -243,6 +243,19 @@ export default { top_n: '返回前多少个关键词', }, }, + get_schema: { + desc: '查看聊天数据库表结构。绘图或自定义 SQL 前应先用它确认表名和字段名。', + params: {}, + }, + render_chart: { + desc: '根据只读 SQL 和 ChartSpec v1 生成 ChatLab 原生图表。支持 bar、line、pie、heatmap。禁止输出 HTML、JavaScript、SVG、ECharts option 或渲染代码。', + params: { + sql: '只读 SELECT 或 WITH SELECT SQL。必须返回 ChartSpec encoding 中引用的字段。', + params: 'SQL 命名参数对象;不需要参数时传空对象。', + chartSpec: 'ChartSpec v1,包含 version、type、title、encoding。', + maxRows: '图表查询最大行数,默认 1000。', + }, + }, }, agent: { diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts index 8fa0cbbb..5719a20e 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts @@ -243,6 +243,19 @@ export default { top_n: '回傳前多少個關鍵詞', }, }, + get_schema: { + desc: '查看聊天資料庫表結構。繪圖或自訂 SQL 前應先用它確認表名和欄位名。', + params: {}, + }, + render_chart: { + desc: '根據唯讀 SQL 和 ChartSpec v1 生成 ChatLab 原生圖表。支援 bar、line、pie、heatmap。禁止輸出 HTML、JavaScript、SVG、ECharts option 或渲染程式碼。', + params: { + sql: '唯讀 SELECT 或 WITH SELECT SQL。必須返回 ChartSpec encoding 中引用的欄位。', + params: 'SQL 命名參數物件;不需要參數時傳空物件。', + chartSpec: 'ChartSpec v1,包含 version、type、title、encoding。', + maxRows: '圖表查詢最大行數,預設 1000。', + }, + }, }, agent: { diff --git a/packages/tools/src/definitions/render-chart.test.ts b/packages/tools/src/definitions/render-chart.test.ts new file mode 100644 index 00000000..a60f10ba --- /dev/null +++ b/packages/tools/src/definitions/render-chart.test.ts @@ -0,0 +1,148 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { renderChartTool } from './render-chart' +import type { ToolDataProvider, ToolExecutionContext } from '../types' + +const barSpec = { + version: 1, + type: 'bar', + title: 'Messages by member', + encoding: { x: 'name', y: 'message_count' }, +} as const + +function createContext( + rows: Record[], + calls: Array<{ query: string; params: Record }> +): ToolExecutionContext { + const dataProvider = { + async executeParameterizedSql(query: string, params: Record) { + calls.push({ query, params }) + return rows + }, + } as Partial as ToolDataProvider + + return { + sessionId: 'session-1', + locale: 'en-US', + dataProvider, + } +} + +describe('renderChartTool', () => { + it('runs parameterized read-only SQL and returns a normalized chart payload', 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 WHERE days = @days', + params: { days: 7 }, + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal(calls.length, 1) + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nSELECT name, message_count FROM member_stats WHERE days = @days\n) AS chart_query LIMIT 3' + ) + assert.deepEqual(calls[0]?.params, { days: 7 }) + assert.equal(result.chart?.spec.type, 'bar') + assert.deepEqual(result.chart?.data, { labels: ['Alice', 'Bob'], values: [4, 3] }) + assert.deepEqual(result.chart?.dataset.rows, [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ]) + }) + + it('truncates rows after fetching one more than maxRows', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext( + [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + { name: 'Cara', message_count: 2 }, + ], + calls + ) + + const result = await renderChartTool.handler( + { + sql: 'WITH ranked AS (SELECT name, message_count FROM member_stats) SELECT name, message_count FROM ranked', + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nWITH ranked AS (SELECT name, message_count FROM member_stats) SELECT name, message_count FROM ranked\n) AS chart_query LIMIT 3' + ) + assert.equal(result.chart?.rowCount, 2) + assert.equal(result.chart?.truncated, true) + assert.deepEqual(result.chart?.data, { labels: ['Alice', 'Bob'], values: [4, 3] }) + }) + + it('rejects direct write statements before reaching the data provider', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([], calls) + + await assert.rejects(async () => { + await renderChartTool.handler( + { + sql: 'DELETE FROM message', + chartSpec: barSpec, + }, + context + ) + }, /only accepts SELECT or WITH SELECT SQL/) + assert.equal(calls.length, 0) + }) + + it('enforces an outer row limit even when SQL already has a LIMIT', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([{ name: 'Alice', message_count: 4 }], calls) + + await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats LIMIT 100000', + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nSELECT name, message_count FROM member_stats LIMIT 100000\n) AS chart_query LIMIT 3' + ) + }) + + it('does not let a trailing line comment swallow the enforced row limit', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([{ name: 'Alice', message_count: 4 }], calls) + + await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats -- model note', + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nSELECT name, message_count FROM member_stats -- model note\n) AS chart_query LIMIT 3' + ) + }) +}) diff --git a/packages/tools/src/definitions/render-chart.ts b/packages/tools/src/definitions/render-chart.ts new file mode 100644 index 00000000..e3adc045 --- /dev/null +++ b/packages/tools/src/definitions/render-chart.ts @@ -0,0 +1,110 @@ +/** + * Dynamic chart rendering tool. + * + * The model provides read-only SQL plus a ChartSpec. ChatLab executes and + * validates the result before producing a chart payload for the chat UI. + */ + +import { buildChartPayload } from '@openchatlab/core' +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' + +const DEFAULT_MAX_ROWS = 1000 + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + sql: { + type: 'string', + description: 'Read-only SELECT or WITH SELECT SQL used to produce chart rows.', + }, + params: { + type: 'object', + description: 'Named SQL parameters. Use an empty object when no parameters are needed.', + additionalProperties: true, + default: {}, + }, + chartSpec: { + type: 'object', + description: + 'ChartSpec v1. Required fields: version, type, title, encoding. Supported types: bar, line, pie, heatmap.', + additionalProperties: true, + }, + maxRows: { + type: 'number', + description: 'Maximum rows to fetch before chart normalization.', + default: DEFAULT_MAX_ROWS, + minimum: 1, + maximum: 5000, + }, + }, + required: ['sql', 'chartSpec'], +} + +function normalizeSql(sql: unknown, maxRows: number): string { + if (typeof sql !== 'string' || sql.trim().length === 0) { + throw new Error('sql must be a non-empty string') + } + + const trimmed = sql.trim().replace(/;+\s*$/, '') + if (trimmed.includes(';')) { + throw new Error('Only a single read-only SQL statement is allowed') + } + + const statementStart = trimmed.replace(/^(\s|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/, '') + if (!/^(SELECT|WITH)\b/i.test(statementStart)) { + throw new Error('render_chart only accepts SELECT or WITH SELECT SQL') + } + + return `SELECT * FROM (\n${trimmed}\n) AS chart_query LIMIT ${maxRows + 1}` +} + +function normalizeParams(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + return raw as Record +} + +function normalizeMaxRows(raw: unknown): number { + const value = typeof raw === 'number' ? raw : DEFAULT_MAX_ROWS + if (!Number.isFinite(value)) return DEFAULT_MAX_ROWS + return Math.min(5000, Math.max(1, Math.floor(value))) +} + +function summarizeChart(type: string, title: string, rowCount: number, truncated: boolean, locale?: string): string { + if (locale?.startsWith('zh')) { + return `已生成图表「${title}」(${type},${rowCount} 行数据${truncated ? ',已截断' : ''})。` + } + return `Generated chart "${title}" (${type}, ${rowCount} rows${truncated ? ', truncated' : ''}).` +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + if (!context.dataProvider) throw new Error('render_chart requires a data provider') + + const maxRows = normalizeMaxRows(params.maxRows) + const sql = normalizeSql(params.sql, maxRows) + const sqlParams = normalizeParams(params.params) + + const fetchedRows = await context.dataProvider.executeParameterizedSql>(sql, sqlParams) + const truncated = fetchedRows.length > maxRows + const rows = truncated ? fetchedRows.slice(0, maxRows) : fetchedRows + const chart = buildChartPayload(rows, params.chartSpec, { truncated }) + + return { + content: summarizeChart(chart.spec.type, chart.spec.title, rows.length, truncated, context.locale), + data: { + rowCount: rows.length, + truncated, + chartType: chart.spec.type, + title: chart.spec.title, + }, + chart, + } +} + +export const renderChartTool: ToolDefinition = { + name: 'render_chart', + description: + 'Generate a native ChatLab chart from read-only SQL plus ChartSpec v1. Use this for flexible bar, line, pie, and heatmap charts. Never output HTML, JavaScript, SVG, ECharts options, or rendering code.', + inputSchema, + handler, + category: 'analysis', +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index d6651cae..cc3463f2 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -29,6 +29,7 @@ export { getConversationBetweenTool } from './definitions/get-conversation-betwe export { getSessionSummariesTool } from './definitions/get-session-summaries' export { responseTimeAnalysisTool } from './definitions/response-time-analysis' export { keywordFrequencyTool } from './definitions/keyword-frequency' +export { renderChartTool } from './definitions/render-chart' // === SQL Tools === export { SQL_TOOL_DEFS, createSqlToolDefinition, createAllSqlToolDefinitions } from './sql' diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts index f5250bed..2e3d054f 100644 --- a/packages/tools/src/registry.ts +++ b/packages/tools/src/registry.ts @@ -11,6 +11,7 @@ import type { ToolDefinition } from './types' import { sqlQueryTool, schemaTool } from './definitions/sql-query' +import { renderChartTool } from './definitions/render-chart' import { sessionInfoTool } from './definitions/session-info' import { sessionsListTool } from './definitions/sessions' import { chatOverviewTool } from './definitions/chat-overview' @@ -44,6 +45,7 @@ const SHARED_TOOLS: ToolDefinition[] = [ searchSessionsTool, getSessionMessagesTool, getMembersTool, + schemaTool, // Analysis memberStatsTool, @@ -53,6 +55,7 @@ const SHARED_TOOLS: ToolDefinition[] = [ getSessionSummariesTool, responseTimeAnalysisTool, keywordFrequencyTool, + renderChartTool, // Raw SQL sqlQueryTool, @@ -75,7 +78,6 @@ export const MCP_TOOL_REGISTRY: ToolDefinition[] = [ // MCP-specific: session discovery & schema sessionsListTool, sessionInfoTool, - schemaTool, // Shared core + analysis + raw SQL ...SHARED_TOOLS, ] diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 56e25fce..c3cfe76d 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -4,7 +4,7 @@ * 平台无关的工具注册表类型,同时服务于 MCP Server、HTTP API 和 Electron Agent。 */ -import type { DatabaseAdapter } from '@openchatlab/core' +import type { ChartPayload, DatabaseAdapter } from '@openchatlab/core' // ==================== Schema ==================== @@ -19,6 +19,8 @@ export interface JsonSchema { type: string description?: string items?: { type: string } + properties?: Record + additionalProperties?: boolean | Record default?: unknown enum?: unknown[] minimum?: number @@ -268,6 +270,8 @@ export interface RawMessage { export interface ToolResult { content: string data?: unknown + chart?: ChartPayload + charts?: ChartPayload[] /** 消息类工具可透传原始消息数据,供预处理管道消费 */ rawMessages?: RawMessage[] } diff --git a/src/components/AIChat/chat/ChartBlockRenderer.vue b/src/components/AIChat/chat/ChartBlockRenderer.vue new file mode 100644 index 00000000..6d9c2124 --- /dev/null +++ b/src/components/AIChat/chat/ChartBlockRenderer.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/AIChat/chat/ChatMessage.vue b/src/components/AIChat/chat/ChatMessage.vue index 4f425c35..a489810c 100644 --- a/src/components/AIChat/chat/ChatMessage.vue +++ b/src/components/AIChat/chat/ChatMessage.vue @@ -6,6 +6,7 @@ import MarkdownIt from 'markdown-it' import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat' import CaptureButton from '@/components/common/CaptureButton.vue' import ErrorBlock from './ErrorBlock.vue' +import ChartBlockRenderer from './ChartBlockRenderer.vue' import { useToast } from '@/composables/useToast' const { t, te, locale } = useI18n() @@ -330,6 +331,10 @@ const copyMarkdownText = computed(() => { return `> ${t('ai.skill.active.label', { name: block.skillName })}` } + if (block.type === 'chart') { + return `> Chart: ${block.chart.spec.title}` + } + if (block.type === 'tool') { const toolName = getToolDisplayName(block.tool) const toolParams = formatToolParams(block.tool) @@ -484,6 +489,9 @@ async function handleCopyMarkdown() { {{ t('ai.skill.active.label', { name: block.skillName }) }} + + +
{ - initChart() + // Defer init to next tick so the container has completed layout before ECharts reads its width + nextTick(() => { + initChart() + }) window.addEventListener('resize', handleResize) // 监听 HTML 元素的 class 变化(用于检测暗色模式切换) diff --git a/src/components/charts/EChartLine.vue b/src/components/charts/EChartLine.vue index 03f2c488..27b2921d 100644 --- a/src/components/charts/EChartLine.vue +++ b/src/components/charts/EChartLine.vue @@ -9,6 +9,7 @@ import EChart from './EChart.vue' export interface EChartLineData { labels: string[] values: number[] + series?: Array<{ name: string; values: number[] }> } interface Props { @@ -27,6 +28,65 @@ const props = withDefaults(defineProps(), { }) const option = computed(() => { + const hasSeries = !!props.data.series?.length + const series = hasSeries + ? props.data.series!.map((item) => ({ + name: item.name, + type: 'line' as const, + data: item.values, + smooth: props.smooth, + symbol: 'circle', + symbolSize: 4, + showSymbol: false, + lineStyle: { + width: 2, + }, + areaStyle: props.showArea ? { opacity: 0.08 } : undefined, + emphasis: { + focus: 'series' as const, + }, + })) + : [ + { + type: 'line' as const, + data: props.data.values, + smooth: props.smooth, + symbol: 'circle', + symbolSize: 4, + showSymbol: false, + lineStyle: { + width: 2, + color: '#ee4567', + }, + itemStyle: { + color: '#ee4567', + }, + areaStyle: props.showArea + ? { + color: { + type: 'linear' as const, + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(238, 69, 103, 0.3)' }, + { offset: 1, color: 'rgba(238, 69, 103, 0.05)' }, + ], + }, + } + : undefined, + emphasis: { + focus: 'series' as const, + itemStyle: { + color: '#ee4567', + borderColor: '#fff', + borderWidth: 2, + }, + }, + }, + ] + return { tooltip: { trigger: 'axis', @@ -39,9 +99,15 @@ const option = computed(() => { grid: { left: 50, right: 20, - top: 20, + top: hasSeries ? 36 : 20, bottom: 30, }, + legend: hasSeries + ? { + top: 0, + textStyle: { color: '#6b7280', fontSize: 11 }, + } + : undefined, xAxis: { type: 'category', data: props.data.labels, @@ -66,46 +132,7 @@ const option = computed(() => { }, }, }, - series: [ - { - type: 'line', - data: props.data.values, - smooth: props.smooth, - symbol: 'circle', - symbolSize: 4, - showSymbol: false, - lineStyle: { - width: 2, - color: '#ee4567', // 项目主题 pink-500 - }, - itemStyle: { - color: '#ee4567', - }, - areaStyle: props.showArea - ? { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [ - { offset: 0, color: 'rgba(238, 69, 103, 0.3)' }, // 项目主题粉色 - { offset: 1, color: 'rgba(238, 69, 103, 0.05)' }, - ], - }, - } - : undefined, - emphasis: { - focus: 'series', - itemStyle: { - color: '#ee4567', - borderColor: '#fff', - borderWidth: 2, - }, - }, - }, - ], + series, } }) diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json index d08c2b38..fe887608 100644 --- a/src/i18n/locales/en-US/ai.json +++ b/src/i18n/locales/en-US/ai.json @@ -76,6 +76,7 @@ "get_member_name_history": "Get Nickname History", "get_conversation_between": "Get Conversation", "get_message_context": "Get Message Context", + "get_schema": "Inspect Schema", "search_sessions": "Search Sessions", "get_session_messages": "Get Session Messages", "get_session_summaries": "Get Session Summaries", @@ -91,7 +92,8 @@ "daily_active_members": "Daily Active Members", "conversation_initiator_stats": "Conversation Initiators", "activity_heatmap": "Activity Heatmap", - "unanswered_messages": "Unanswered Messages" + "unanswered_messages": "Unanswered Messages", + "render_chart": "Generate Chart" }, "generating": "Generating response...", "summary": { @@ -414,6 +416,7 @@ "get_message_context": "Get message context", "get_conversation_between": "Get conversation between two members", "get_members": "Get members", + "get_schema": "Inspect database schema", "get_member_stats": "Get member statistics", "get_member_name_history": "Get member name history", "get_time_stats": "Get time statistics", @@ -432,7 +435,8 @@ "conversation_initiator_stats": "Conversation initiators", "activity_heatmap": "Activity heatmap", "response_time_analysis": "Response time analysis", - "keyword_frequency": "Keyword frequency" + "keyword_frequency": "Keyword frequency", + "render_chart": "Generate dynamic chart" } }, "skill": { diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json index ade4ae01..7f9d237e 100644 --- a/src/i18n/locales/ja-JP/ai.json +++ b/src/i18n/locales/ja-JP/ai.json @@ -76,6 +76,7 @@ "get_member_name_history": "ニックネーム履歴を取得", "get_conversation_between": "やり取り履歴を取得", "get_message_context": "コンテキストを取得", + "get_schema": "スキーマ確認", "search_sessions": "セッションを検索", "get_session_messages": "セッションメッセージを取得", "get_session_summaries": "セッション要約を取得", @@ -91,7 +92,8 @@ "daily_active_members": "日別アクティブ人数", "conversation_initiator_stats": "話題開始者統計", "activity_heatmap": "活動ヒートマップ", - "unanswered_messages": "未返信メッセージ" + "unanswered_messages": "未返信メッセージ", + "render_chart": "チャート生成" }, "generating": "回答を作成中...", "summary": { @@ -414,6 +416,7 @@ "get_message_context": "メッセージのコンテキストを取得", "get_conversation_between": "2人のやり取りを取得", "get_members": "メンバー一覧を取得", + "get_schema": "データベーススキーマを確認", "get_member_stats": "メンバー統計を取得", "get_member_name_history": "メンバーの名前変更履歴を取得", "get_time_stats": "時間統計を取得", @@ -432,7 +435,8 @@ "conversation_initiator_stats": "話題開始者統計", "activity_heatmap": "活性度ヒートマップ", "response_time_analysis": "応答時間分析", - "keyword_frequency": "高頻度キーワード統計" + "keyword_frequency": "高頻度キーワード統計", + "render_chart": "動的チャート生成" } }, "skill": { diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json index cd3d578c..ee2b8f79 100644 --- a/src/i18n/locales/zh-CN/ai.json +++ b/src/i18n/locales/zh-CN/ai.json @@ -76,6 +76,7 @@ "get_member_name_history": "获取昵称历史", "get_conversation_between": "获取对话记录", "get_message_context": "获取上下文", + "get_schema": "查看表结构", "search_sessions": "搜索会话", "get_session_messages": "获取会话消息", "get_session_summaries": "获取会话摘要", @@ -91,7 +92,8 @@ "daily_active_members": "每日活跃人数", "conversation_initiator_stats": "话题发起者统计", "activity_heatmap": "活跃度热力图", - "unanswered_messages": "未回复消息" + "unanswered_messages": "未回复消息", + "render_chart": "生成图表" }, "generating": "正在生成回复...", "summary": { @@ -414,6 +416,7 @@ "get_message_context": "获取消息上下文", "get_conversation_between": "获取两人对话", "get_members": "获取成员列表", + "get_schema": "查看数据表结构", "get_member_stats": "获取成员统计", "get_member_name_history": "获取成员改名历史", "get_time_stats": "获取时间统计", @@ -432,7 +435,8 @@ "conversation_initiator_stats": "话题发起者统计", "activity_heatmap": "活跃度热力图", "response_time_analysis": "响应时间分析", - "keyword_frequency": "高频词统计" + "keyword_frequency": "高频词统计", + "render_chart": "生成动态图表" } }, "skill": { diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json index eb0315fc..6c32a582 100644 --- a/src/i18n/locales/zh-TW/ai.json +++ b/src/i18n/locales/zh-TW/ai.json @@ -76,6 +76,7 @@ "get_member_name_history": "取得暱稱歷史", "get_conversation_between": "取得對話紀錄", "get_message_context": "取得上下文", + "get_schema": "查看表結構", "search_sessions": "搜尋會話", "get_session_messages": "取得會話訊息", "get_session_summaries": "取得會話摘要", @@ -91,7 +92,8 @@ "daily_active_members": "每日活躍人數", "conversation_initiator_stats": "話題發起者統計", "activity_heatmap": "活躍度熱力圖", - "unanswered_messages": "未回覆訊息" + "unanswered_messages": "未回覆訊息", + "render_chart": "生成圖表" }, "generating": "正在產生回覆...", "summary": { @@ -414,6 +416,7 @@ "get_message_context": "取得訊息上下文", "get_conversation_between": "取得兩人對話", "get_members": "取得成員列表", + "get_schema": "查看資料表結構", "get_member_stats": "取得成員統計", "get_member_name_history": "取得成員改名歷史", "get_time_stats": "取得時間統計", @@ -432,7 +435,8 @@ "conversation_initiator_stats": "話題發起者統計", "activity_heatmap": "活躍度熱力圖", "response_time_analysis": "回應時間分析", - "keyword_frequency": "高頻詞統計" + "keyword_frequency": "高頻詞統計", + "render_chart": "生成動態圖表" } }, "skill": { diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 06c06256..db31525a 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -1,4 +1,5 @@ import type { TimeFilter } from '@/types/base' +import type { ChartPayload } from '@openchatlab/core' export interface AIConversation { id: string @@ -13,6 +14,7 @@ export interface AIConversation { export type ContentBlock = | { type: 'text'; text: string } | { type: 'think'; tag: string; text: string; durationMs?: number } + | { type: 'chart'; chart: ChartPayload } | { type: 'tool' tool: { diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index 67f0709f..bf9806c0 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -18,6 +18,13 @@ import { useLLMStore } from '@/stores/llm' import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '@electron/shared/types' import { useAgentStreamService } from '@/services/ai-stream/service' import { buildSerializablePreprocessConfig, shouldEnsureDesensitizeRulesBeforeSerialize } from './aiPreprocessConfig' +import type { ChartPayload } from '@openchatlab/core' +import { + extractChartPayloads, + isRenderOnlyTool, + toChartContentBlocks, + toRenderOnlyToolErrorBlock, +} from './aiChatChartBlocks' // 工具调用记录 export interface ToolCallRecord { @@ -49,6 +56,7 @@ export interface MentionedMemberContext { export type ContentBlock = | { type: 'text'; text: string } | { type: 'think'; tag: string; text: string; durationMs?: number } + | { type: 'chart'; chart: ChartPayload } | { type: 'tool' tool: ToolBlockContent @@ -596,6 +604,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { updateAIMessage: (updates: Partial) => void appendTextToBlocks: (text: string) => void appendThinkToBlocks: (text: string, tag?: string, durationMs?: number) => void + appendChartsToBlocks: (charts: ChartPayload[]) => void + appendErrorToBlocks: (error: SerializedErrorInfo) => void addToolBlock: (toolName: string, params?: Record) => void updateToolBlockStatus: (toolName: string, status: 'done' | 'error') => void } @@ -650,6 +660,21 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { updateAIMessage({ contentBlocks: [...blocks] }) } + const appendChartsToBlocks = (charts: ChartPayload[]) => { + if (charts.length === 0) return + const idx = getAiMessageIndex() + const blocks = targetBuffer.messages[idx].contentBlocks || [] + blocks.push(...toChartContentBlocks(charts)) + updateAIMessage({ contentBlocks: [...blocks] }) + } + + const appendErrorToBlocks = (error: SerializedErrorInfo) => { + const idx = getAiMessageIndex() + const blocks = targetBuffer.messages[idx].contentBlocks || [] + blocks.push({ type: 'error', error }) + updateAIMessage({ contentBlocks: [...blocks] }) + } + const addToolBlock = (toolName: string, params?: Record) => { const idx = getAiMessageIndex() const blocks = targetBuffer.messages[idx].contentBlocks || [] @@ -670,7 +695,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { updateAIMessage({ contentBlocks: [...blocks] }) } - return { updateAIMessage, appendTextToBlocks, appendThinkToBlocks, addToolBlock, updateToolBlockStatus } + return { + updateAIMessage, + appendTextToBlocks, + appendThinkToBlocks, + appendChartsToBlocks, + appendErrorToBlocks, + addToolBlock, + updateToolBlockStatus, + } } async function sendMessage( @@ -778,8 +811,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { let aiMessageIndex = targetBuffer.messages.length - 1 let hasStreamError = false - const { updateAIMessage, appendTextToBlocks, appendThinkToBlocks, addToolBlock, updateToolBlockStatus } = - createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) + const { + updateAIMessage, + appendTextToBlocks, + appendThinkToBlocks, + appendChartsToBlocks, + appendErrorToBlocks, + addToolBlock, + updateToolBlockStatus, + } = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) const currentAssistantId = targetBuffer.assistantId ?? getDefaultGeneralAssistantId(state.locale) if (!resolvedConversationId) { @@ -874,19 +914,29 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { status: 'running', } state.toolsUsedInCurrentRound.push(chunk.toolName) - addToolBlock(chunk.toolName, toolParams) + if (!isRenderOnlyTool(chunk.toolName)) { + addToolBlock(chunk.toolName, toolParams) + } } break case 'tool_result': if (chunk.toolName) { + const charts = extractChartPayloads(chunk.toolResult) + appendChartsToBlocks(charts) + const renderOnlyError = toRenderOnlyToolErrorBlock(chunk.toolName, chunk.toolResult) + if (renderOnlyError) { + appendErrorToBlocks(renderOnlyError.error) + } if (state.currentToolStatus?.name === chunk.toolName) { state.currentToolStatus = { ...state.currentToolStatus, - status: 'done', + status: renderOnlyError ? 'error' : 'done', } } - updateToolBlockStatus(chunk.toolName, 'done') + if (!isRenderOnlyTool(chunk.toolName)) { + updateToolBlockStatus(chunk.toolName, renderOnlyError ? 'error' : 'done') + } } state.isLoadingSource = false break @@ -1310,8 +1360,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { targetBuffer.messages.push(...subsequentMessages) } - const { updateAIMessage, appendTextToBlocks, appendThinkToBlocks, addToolBlock, updateToolBlockStatus } = - createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) + const { + updateAIMessage, + appendTextToBlocks, + appendThinkToBlocks, + appendChartsToBlocks, + appendErrorToBlocks, + addToolBlock, + updateToolBlockStatus, + } = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) try { const hasConfig = await useLLMService().hasConfig() @@ -1393,11 +1450,23 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { if (chunk.toolName) { state.currentToolStatus = { name: chunk.toolName, displayName: chunk.toolName, status: 'running' } state.toolsUsedInCurrentRound.push(chunk.toolName) - addToolBlock(chunk.toolName, chunk.toolParams as Record | undefined) + if (!isRenderOnlyTool(chunk.toolName)) { + addToolBlock(chunk.toolName, chunk.toolParams as Record | undefined) + } } break case 'tool_result': - if (chunk.toolName) updateToolBlockStatus(chunk.toolName, 'done') + if (chunk.toolName) { + const charts = extractChartPayloads(chunk.toolResult) + appendChartsToBlocks(charts) + const renderOnlyError = toRenderOnlyToolErrorBlock(chunk.toolName, chunk.toolResult) + if (renderOnlyError) { + appendErrorToBlocks(renderOnlyError.error) + } + if (!isRenderOnlyTool(chunk.toolName)) { + updateToolBlockStatus(chunk.toolName, renderOnlyError ? 'error' : 'done') + } + } state.currentToolStatus = null state.isLoadingSource = false break diff --git a/src/stores/aiChatChartBlocks.test.ts b/src/stores/aiChatChartBlocks.test.ts new file mode 100644 index 00000000..d1338e28 --- /dev/null +++ b/src/stores/aiChatChartBlocks.test.ts @@ -0,0 +1,111 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { ChartPayload } from '@openchatlab/core' +import { + extractChartPayloads, + isRenderOnlyTool, + toChartContentBlocks, + toRenderOnlyToolErrorBlock, +} from './aiChatChartBlocks' + +const chart: ChartPayload = { + version: 1, + spec: { + version: 1, + type: 'line', + title: 'Daily messages', + encoding: { x: 'day', y: 'message_count', series: 'member_name' }, + }, + dataset: { + columns: [ + { name: 'day', type: 'date' }, + { name: 'member_name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [{ day: '2026-06-01', member_name: 'Alice', message_count: 4 }], + }, + data: { + labels: ['2026-06-01'], + values: [4], + series: [{ name: 'Alice', values: [4] }], + }, + rowCount: 1, +} + +const secondChart: ChartPayload = { + ...chart, + spec: { + ...chart.spec, + type: 'pie', + title: 'Selected members', + encoding: { label: 'member_name', value: 'message_count' }, + }, + data: { + labels: ['Alice'], + values: [4], + }, +} + +describe('aiChat chart block helpers', () => { + it('treats render_chart as render-only', () => { + assert.equal(isRenderOnlyTool('render_chart'), true) + assert.equal(isRenderOnlyTool('search_messages'), false) + assert.equal(isRenderOnlyTool(undefined), false) + }) + + it('extracts chart payloads from agent result details', () => { + const result = { + content: [{ type: 'text', text: 'Generated chart.' }], + details: { rowCount: 1, chart }, + } + + assert.deepEqual(extractChartPayloads(result), [chart]) + }) + + it('extracts multiple chart payloads and filters invalid entries', () => { + const result = { + details: { + charts: [chart, { version: 1, spec: null }, secondChart], + }, + } + + assert.deepEqual(extractChartPayloads(result), [chart, secondChart]) + }) + + it('supports top-level chart payload fallback', () => { + assert.deepEqual(extractChartPayloads({ chart }), [chart]) + }) + + it('converts chart payloads to content blocks', () => { + assert.deepEqual(toChartContentBlocks([chart, secondChart]), [ + { type: 'chart', chart }, + { type: 'chart', chart: secondChart }, + ]) + }) + + 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' }], + details: null, + }) + + assert.deepEqual(errorBlock, { + type: 'error', + error: { + name: 'ChartRenderError', + message: 'Field "missing_count" does not exist in SQL result', + stack: null, + }, + }) + }) + + it('does not create render-only error blocks for successful charts or normal tools', () => { + assert.equal(toRenderOnlyToolErrorBlock('render_chart', { details: { chart } }), null) + assert.equal( + toRenderOnlyToolErrorBlock('search_messages', { + content: [{ type: 'text', text: 'Error: search failed' }], + }), + null + ) + }) +}) diff --git a/src/stores/aiChatChartBlocks.ts b/src/stores/aiChatChartBlocks.ts new file mode 100644 index 00000000..180d98ab --- /dev/null +++ b/src/stores/aiChatChartBlocks.ts @@ -0,0 +1,75 @@ +import type { ChartPayload } from '@openchatlab/core' + +export type ChartContentBlock = { type: 'chart'; chart: ChartPayload } +export type RenderOnlyToolErrorBlock = { + type: 'error' + error: { name: string | null; message: string; stack: string | null } +} + +export function isRenderOnlyTool(toolName?: string): boolean { + return toolName === 'render_chart' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isChartPayload(value: unknown): value is ChartPayload { + return isRecord(value) && value.version === 1 && isRecord(value.spec) && isRecord(value.dataset) +} + +export 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 +} + +export function toChartContentBlocks(charts: ChartPayload[]): ChartContentBlock[] { + return charts.map((chart) => ({ type: 'chart', chart })) +} + +function extractToolResultText(toolResult: unknown): string | null { + if (!isRecord(toolResult)) return null + if (typeof toolResult.error === 'string') return toolResult.error + if (isRecord(toolResult.error) && typeof toolResult.error.message === 'string') return toolResult.error.message + if (Array.isArray(toolResult.content)) { + return toolResult.content + .map((part) => (isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '')) + .join('\n') + .trim() + } + return null +} + +export function toRenderOnlyToolErrorBlock( + toolName: string | undefined, + toolResult: unknown +): RenderOnlyToolErrorBlock | null { + if (!isRenderOnlyTool(toolName)) return null + if (extractChartPayloads(toolResult).length > 0) return null + + const text = extractToolResultText(toolResult) + if (!text) return null + + const match = /^Error:\s*(.+)$/is.exec(text) + const message = match?.[1]?.trim() + if (!message) return null + + return { + type: 'error', + error: { + name: 'ChartRenderError', + message, + stack: null, + }, + } +} diff --git a/src/stores/skill.test.ts b/src/stores/skill.test.ts new file mode 100644 index 00000000..7a7fbb77 --- /dev/null +++ b/src/stores/skill.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createPinia, setActivePinia } from 'pinia' +import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' +import { useSkillStore, type SkillSummary } from './skill' + +const localSkill: SkillSummary = { + id: 'local_summary', + name: 'Local Summary', + description: 'Summarize the current chat', + tags: ['summary'], + chatScope: 'all', + tools: [], +} + +describe('skill store chart capability', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('exposes chart capability as a compatible slash skill without import', () => { + const store = useSkillStore() + store.skills = [localSkill] + store.setFilterContext('group', 'en-US') + + const ids = store.compatibleSkills.map((skill) => skill.id) + assert.equal(ids[0], CHART_CAPABILITY_SKILL_ID) + assert.ok(ids.includes(localSkill.id)) + assert.equal(store.compatibleSkills[0].name, 'Chart Assistant') + }) + + it('activates and clears the chart capability through the same single-message toggle', async () => { + const store = useSkillStore() + store.setFilterContext('group', 'zh-CN') + + store.activateSkill(CHART_CAPABILITY_SKILL_ID) + assert.equal(store.activeSkillId, CHART_CAPABILITY_SKILL_ID) + assert.equal(store.activeSkill?.name, '绘图助手') + + const config = await store.getSkillConfig(CHART_CAPABILITY_SKILL_ID) + assert.equal(config?.id, CHART_CAPABILITY_SKILL_ID) + assert.deepEqual(config?.tools, ['render_chart', 'get_schema']) + assert.match(config?.prompt ?? '', /render_chart/) + + store.activateSkill(null) + assert.equal(store.activeSkillId, null) + assert.equal(store.activeSkill, null) + }) +}) diff --git a/src/stores/skill.ts b/src/stores/skill.ts index 85489e2a..a189d2ed 100644 --- a/src/stores/skill.ts +++ b/src/stores/skill.ts @@ -7,6 +7,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useAssistantStore } from './assistant' import { usePlatformService, useSkillService } from '@/services' +import { CHART_CAPABILITY_SKILL_ID, getChartCapabilitySkill } from '@openchatlab/core' import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale' const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE @@ -65,9 +66,14 @@ export const useSkillStore = defineStore('skill', () => { const activeSkill = computed(() => { if (!activeSkillId.value) return null + if (activeSkillId.value === CHART_CAPABILITY_SKILL_ID) { + return getChartCapabilitySkill(currentLocale.value) + } return skills.value.find((s) => s.id === activeSkillId.value) ?? null }) + const chartCapabilitySkill = computed(() => getChartCapabilitySkill(currentLocale.value)) + const scopedSkills = computed(() => { return skills.value.filter((s) => s.chatScope === 'all' || s.chatScope === currentChatType.value) }) @@ -75,9 +81,10 @@ export const useSkillStore = defineStore('skill', () => { const compatibleSkills = computed(() => { const assistantStore = useAssistantStore() const config = assistantStore.selectedAssistant - if (!config) return scopedSkills.value + const baseSkills = [chartCapabilitySkill.value, ...scopedSkills.value] + if (!config) return baseSkills - return scopedSkills.value.filter((s) => { + return baseSkills.filter((s) => { if (!s.tools.length) return true return true }) @@ -188,6 +195,13 @@ export const useSkillStore = defineStore('skill', () => { } async function getSkillConfig(id: string): Promise { + if (id === CHART_CAPABILITY_SKILL_ID) { + const skill = getChartCapabilitySkill(currentLocale.value) + return { + ...skill, + builtinId: CHART_CAPABILITY_SKILL_ID, + } + } try { return await useSkillService().getConfig(id) } catch (error) { diff --git a/tests/chart-runtime/agent-chart-flow.test.ts b/tests/chart-runtime/agent-chart-flow.test.ts new file mode 100644 index 00000000..f6b938d5 --- /dev/null +++ b/tests/chart-runtime/agent-chart-flow.test.ts @@ -0,0 +1,381 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + createAssistantMessageEventStream, + type AssistantMessage, + type Context, + type Message, + type Model, + type ToolCall, + type Usage, +} from '@earendil-works/pi-ai' +import type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core' +import { + renderChartTool, + type ToolDataProvider, + type ToolDefinition, + type ToolExecutionContext, +} from '@openchatlab/tools' +import type { ChartPayload } from '@openchatlab/core' +import { runAgentCore, type AgentCoreEvent } from '../../packages/node-runtime/src/ai/agent' + +function emptyUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } +} + +function assistantMessage( + content: AssistantMessage['content'], + stopReason: AssistantMessage['stopReason'] +): AssistantMessage { + return { + role: 'assistant', + content, + api: 'openai-completions', + provider: 'chatlab-test', + model: 'fake-chart-model', + usage: emptyUsage(), + stopReason, + timestamp: Date.now(), + } +} + +function createToolCallsStream(toolCalls: ToolCall[]) { + const stream = createAssistantMessageEventStream() + const partialStart = assistantMessage([], 'toolUse') + const finalMessage = assistantMessage(toolCalls, 'toolUse') + stream.push({ type: 'start', partial: partialStart }) + toolCalls.forEach((toolCall, contentIndex) => { + stream.push({ type: 'toolcall_start', contentIndex, partial: finalMessage }) + stream.push({ + type: 'toolcall_delta', + contentIndex, + delta: JSON.stringify(toolCall.arguments), + partial: finalMessage, + }) + stream.push({ type: 'toolcall_end', contentIndex, toolCall, partial: finalMessage }) + }) + stream.push({ type: 'done', reason: 'toolUse', message: finalMessage }) + return stream +} + +function createToolCallStream(toolCall: ToolCall) { + return createToolCallsStream([toolCall]) +} + +function createTextStream(text: string) { + const stream = createAssistantMessageEventStream() + const partialStart = assistantMessage([], 'stop') + const finalMessage = assistantMessage([{ type: 'text', text }], 'stop') + stream.push({ type: 'start', partial: partialStart }) + stream.push({ type: 'text_start', contentIndex: 0, partial: partialStart }) + stream.push({ type: 'text_delta', contentIndex: 0, delta: text, partial: finalMessage }) + stream.push({ type: 'text_end', contentIndex: 0, content: text, partial: finalMessage }) + stream.push({ type: 'done', reason: 'stop', message: finalMessage }) + return stream +} + +function hasToolResult(messages: Message[]): boolean { + return messages.some((message) => message.role === 'toolResult' && message.toolName === 'render_chart') +} + +function countToolResults(messages: Message[]): number { + return messages.filter((message) => message.role === 'toolResult' && message.toolName === 'render_chart').length +} + +function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']) { + const properties: Record = {} + for (const [key, prop] of Object.entries(schema.properties)) { + properties[key] = { ...prop } + } + return { + type: 'object' as const, + properties, + required: schema.required || [], + } +} + +function adaptRenderChartTool(dataProvider: ToolDataProvider): AgentTool { + return { + name: renderChartTool.name, + label: renderChartTool.name, + description: renderChartTool.description, + parameters: convertJsonSchemaToParameters(renderChartTool.inputSchema) as any, + async execute(_toolCallId: string, params: unknown): Promise> { + const context: ToolExecutionContext = { + sessionId: 'offline-agent-chart-flow', + locale: 'en-US', + dataProvider, + } + const result = await renderChartTool.handler((params ?? {}) as Record, context) + return { + content: [{ type: 'text', text: result.content }], + details: { + ...(typeof result.data === 'object' && result.data !== null ? result.data : {}), + ...(result.chart ? { chart: result.chart } : {}), + }, + } + }, + } +} + +describe('offline Agent chart flow', () => { + it('turns a natural-language chart request into a render_chart tool result and final answer', async () => { + const requestedSql: string[] = [] + const dataProvider: ToolDataProvider = { + async executeParameterizedSql(sql, params) { + requestedSql.push(sql) + assert.match(sql, /JOIN member/i) + assert.match(sql, /@memberA/) + assert.deepEqual(params, { memberA: 'Alice', memberB: 'Bob', startTs: 1780272000, endTs: 1780444800 }) + return [ + { day: '2026-06-01', member_name: 'Alice', message_count: 2 }, + { day: '2026-06-01', member_name: 'Bob', message_count: 1 }, + { day: '2026-06-02', member_name: 'Alice', message_count: 2 }, + { day: '2026-06-02', member_name: 'Bob', message_count: 2 }, + ] + }, + async getSchema() { + return [] + }, + } as Partial as ToolDataProvider + const tools = [adaptRenderChartTool(dataProvider)] + const streamFn = (_model: Model, context: Context) => { + const userMessages = context.messages.filter((message) => message.role === 'user') + const latestUser = userMessages[userMessages.length - 1] + const userText = Array.isArray(latestUser?.content) + ? latestUser.content.map((part) => (part.type === 'text' ? part.text : '')).join('\n') + : latestUser?.content || '' + assert.match(userText, /Alice/) + assert.match(userText, /Bob/) + assert.match(userText, /line/i) + + if (!hasToolResult(context.messages)) { + return createToolCallStream({ + type: 'toolCall', + id: 'call_chart_1', + name: 'render_chart', + arguments: { + sql: ` + SELECT date(msg.ts, 'unixepoch') AS day, + COALESCE(m.group_nickname, m.account_name, m.platform_id) AS member_name, + COUNT(*) AS message_count + FROM message msg + JOIN member m ON m.id = msg.sender_id + WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) IN (@memberA, @memberB) + AND msg.ts >= @startTs AND msg.ts < @endTs + GROUP BY day, m.id + ORDER BY day ASC, member_name ASC + `, + params: { memberA: 'Alice', memberB: 'Bob', startTs: 1780272000, endTs: 1780444800 }, + chartSpec: { + version: 1, + type: 'line', + title: 'Alice vs Bob daily message trend', + encoding: { x: 'day', y: 'message_count', series: 'member_name' }, + filters: { members: [{ name: 'Alice' }, { name: 'Bob' }] }, + unit: 'messages', + }, + }, + }) + } + + return createTextStream('Alice stayed ahead on both days, while Bob caught up on June 2.') + } + const events: AgentCoreEvent[] = [] + + const result = await runAgentCore({ + piModel: { + id: 'fake-chart-model', + provider: 'chatlab-test', + api: 'openai-completions', + } as Model, + apiKey: 'test-key', + systemPrompt: 'Use render_chart for chart requests.', + tools, + history: [], + userMessage: 'Please draw a line chart comparing Alice and Bob message counts by day.', + maxToolRounds: 3, + steerMessage: 'Please explain the chart briefly.', + streamFn, + onEvent: (event) => events.push(event), + }) + + assert.equal(result.error, undefined) + assert.deepEqual(result.toolsUsed, ['render_chart']) + assert.equal(result.toolRounds, 1) + assert.equal(requestedSql.length, 1) + + const toolEnd = events.find((event) => event.type === 'tool_end' && event.toolName === 'render_chart') + assert.ok(toolEnd && 'toolResult' in toolEnd, 'expected render_chart tool_end event') + const chart = (toolEnd.toolResult as { details?: { chart?: ChartPayload } }).details?.chart + assert.ok(chart, 'expected chart payload in render_chart tool result details') + assert.equal(chart.spec.type, 'line') + assert.deepEqual(chart.data, { + labels: ['2026-06-01', '2026-06-02'], + values: [2, 2], + series: [ + { name: 'Alice', values: [2, 2] }, + { name: 'Bob', values: [1, 2] }, + ], + }) + + const content = events + .filter((event) => event.type === 'content') + .map((event) => event.content) + .join('') + assert.match(content, /Bob caught up/) + }) + + it('runs multiple render_chart calls when the user asks for two charts', async () => { + const requestedSql: string[] = [] + const dataProvider: ToolDataProvider = { + async executeParameterizedSql(sql, params) { + requestedSql.push(sql) + if (sql.includes('selected_member_share_source')) { + assert.deepEqual(params, { memberA: 'Alice', memberB: 'Bob' }) + return [ + { member_name: 'Alice', message_count: 4 }, + { member_name: 'Bob', message_count: 3 }, + ] + } + if (sql.includes('member_hour_density_source')) { + assert.deepEqual(params, { memberA: 'Alice', memberB: 'Bob' }) + return [ + { hour: '09', member_name: 'Alice', message_count: 2 }, + { hour: '10', member_name: 'Alice', message_count: 2 }, + { hour: '09', member_name: 'Bob', message_count: 1 }, + { hour: '10', member_name: 'Bob', message_count: 2 }, + ] + } + throw new Error(`unexpected SQL: ${sql}`) + }, + async getSchema() { + return [] + }, + } as Partial as ToolDataProvider + const tools = [adaptRenderChartTool(dataProvider)] + const streamFn = (_model: Model, context: Context) => { + const userMessages = context.messages.filter((message) => message.role === 'user') + const latestUser = userMessages[userMessages.length - 1] + const userText = Array.isArray(latestUser?.content) + ? latestUser.content.map((part) => (part.type === 'text' ? part.text : '')).join('\n') + : latestUser?.content || '' + assert.match(userText, /two charts/i) + assert.match(userText, /pie/i) + assert.match(userText, /heatmap/i) + + if (countToolResults(context.messages) < 2) { + return createToolCallsStream([ + { + type: 'toolCall', + id: 'call_chart_pie', + name: 'render_chart', + arguments: { + sql: ` + WITH selected_member_share_source AS ( + SELECT @memberA AS member_name, 4 AS message_count + UNION ALL + SELECT @memberB AS member_name, 3 AS message_count + ) + SELECT member_name, message_count + FROM selected_member_share_source + `, + params: { memberA: 'Alice', memberB: 'Bob' }, + chartSpec: { + version: 1, + type: 'pie', + title: 'Selected members message share', + encoding: { label: 'member_name', value: 'message_count' }, + }, + }, + }, + { + type: 'toolCall', + id: 'call_chart_heatmap', + name: 'render_chart', + arguments: { + sql: ` + WITH member_hour_density_source AS ( + SELECT '09' AS hour, @memberA AS member_name, 2 AS message_count + UNION ALL + SELECT '10' AS hour, @memberA AS member_name, 2 AS message_count + UNION ALL + SELECT '09' AS hour, @memberB AS member_name, 1 AS message_count + UNION ALL + SELECT '10' AS hour, @memberB AS member_name, 2 AS message_count + ) + SELECT hour, member_name, message_count + FROM member_hour_density_source + `, + params: { memberA: 'Alice', memberB: 'Bob' }, + chartSpec: { + version: 1, + type: 'heatmap', + title: 'Selected members hour density', + encoding: { x: 'hour', y: 'member_name', value: 'message_count' }, + }, + }, + }, + ]) + } + + return createTextStream('Here are the requested pie chart and heatmap.') + } + const events: AgentCoreEvent[] = [] + + const result = await runAgentCore({ + piModel: { + id: 'fake-chart-model', + provider: 'chatlab-test', + api: 'openai-completions', + } as Model, + apiKey: 'test-key', + systemPrompt: 'Use render_chart for chart requests.', + tools, + history: [], + userMessage: 'Please generate two charts: a pie chart for Alice and Bob, plus a heatmap by hour.', + maxToolRounds: 3, + steerMessage: 'Please explain both charts briefly.', + streamFn, + onEvent: (event) => events.push(event), + }) + + assert.equal(result.error, undefined) + assert.deepEqual(result.toolsUsed, ['render_chart', 'render_chart']) + assert.equal(result.toolRounds, 1) + assert.equal(requestedSql.length, 2) + + const chartPayloads = events + .filter((event) => event.type === 'tool_end' && event.toolName === 'render_chart') + .map((event) => (event.toolResult as { details?: { chart?: ChartPayload } }).details?.chart) + .filter((chart): chart is ChartPayload => !!chart) + + assert.equal(chartPayloads.length, 2) + const chartsByType = new Map(chartPayloads.map((chart) => [chart.spec.type, chart])) + assert.deepEqual([...chartsByType.keys()].sort(), ['heatmap', 'pie']) + assert.deepEqual(chartsByType.get('pie')?.data, { labels: ['Alice', 'Bob'], values: [4, 3] }) + assert.deepEqual(chartsByType.get('heatmap')?.data, { + xLabels: ['09', '10'], + yLabels: ['Alice', 'Bob'], + data: [ + [0, 0, 2], + [1, 0, 2], + [0, 1, 1], + [1, 1, 2], + ], + }) + + const content = events + .filter((event) => event.type === 'content') + .map((event) => event.content) + .join('') + assert.match(content, /pie chart and heatmap/) + }) +}) diff --git a/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts b/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts new file mode 100644 index 00000000..00a58e16 --- /dev/null +++ b/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts @@ -0,0 +1,198 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core' +import { CHAT_DB_SCHEMA, getChartCapabilitySkill, type ChartPayload } from '@openchatlab/core' +import { + CoreDataProvider, + renderChartTool, + schemaTool, + type ToolDataProvider, + type ToolDefinition, + type ToolExecutionContext, +} from '@openchatlab/tools' +import { buildPiModel } from '../../packages/node-runtime/src/ai/llm-builder' +import { runAgentCore, type AgentCoreEvent } from '../../packages/node-runtime/src/ai/agent' +import { openBetterSqliteDatabase } from '../../packages/node-runtime/src/better-sqlite3-adapter' + +const shouldRunRealLlmE2E = process.env.CHATLAB_RUN_REAL_LLM_CHART_E2E === '1' + +function resolveNativeBinding(): string | undefined { + if (process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING) return process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + const repoNativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + return existsSync(repoNativeBinding) ? repoNativeBinding : undefined +} + +function unixTs(iso: string): number { + return Math.floor(Date.parse(iso) / 1000) +} + +function createTempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'chatlab-real-llm-chart-e2e-')) +} + +function seedChatDb(db: ReturnType): void { + db.exec(CHAT_DB_SCHEMA) + db.prepare( + `INSERT INTO meta (name, platform, type, imported_at, group_id, owner_id) + VALUES ('Chart E2E Group', 'wechat', 'group', ?, 'g1', 'u_alice')` + ).run(unixTs('2026-06-03T00:00:00Z')) + + const insertMember = db.prepare( + `INSERT INTO member (platform_id, account_name, group_nickname) + VALUES (?, ?, ?)` + ) + insertMember.run('u_alice', 'Alice Account', 'Alice') + insertMember.run('u_bob', 'Bob Account', 'Bob') + insertMember.run('u_cara', 'Cara Account', 'Cara') + + const memberRows = db.prepare('SELECT id, group_nickname FROM member').all() as Array<{ + id: number + group_nickname: string + }> + const memberId = new Map(memberRows.map((row) => [row.group_nickname, row.id])) + const insertMessage = db.prepare( + `INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, platform_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + const add = (name: 'Alice' | 'Bob' | 'Cara', ts: string, type: number, content: string) => { + const id = memberId.get(name) + assert.ok(id, `missing member id for ${name}`) + insertMessage.run(id, `${name} Account`, name, unixTs(ts), type, content, `${name}-${ts}`) + } + + add('Alice', '2026-06-01T09:00:00Z', 0, 'morning hello') + add('Alice', '2026-06-01T10:00:00Z', 0, 'standup note') + add('Alice', '2026-06-02T09:00:00Z', 1, '[图片]') + add('Alice', '2026-06-02T10:00:00Z', 0, 'follow up') + add('Bob', '2026-06-01T09:00:00Z', 0, 'first reply') + add('Bob', '2026-06-02T10:00:00Z', 0, 'second reply') + add('Bob', '2026-06-02T11:00:00Z', 0, 'third reply') + add('Cara', '2026-06-01T09:00:00Z', 0, 'other member') + add('Cara', '2026-06-03T12:00:00Z', 0, 'outside selected members') +} + +function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']) { + const properties: Record = {} + for (const [key, prop] of Object.entries(schema.properties)) { + properties[key] = { ...prop } + } + return { + type: 'object' as const, + properties, + required: schema.required || [], + } +} + +function adaptTool(tool: ToolDefinition, dataProvider: ToolDataProvider): AgentTool { + return { + name: tool.name, + label: tool.name, + description: tool.description, + parameters: convertJsonSchemaToParameters(tool.inputSchema) as any, + async execute(_toolCallId: string, params: unknown): Promise> { + const context: ToolExecutionContext = { + sessionId: 'real-llm-chart-flow', + locale: 'en-US', + dataProvider, + } + const result = await tool.handler((params ?? {}) as Record, context) + return { + content: [{ type: 'text', text: result.content }], + details: { + ...(typeof result.data === 'object' && result.data !== null && !Array.isArray(result.data) + ? result.data + : {}), + ...(result.chart ? { chart: result.chart } : {}), + }, + } + }, + } +} + +function getChartPayloads(events: AgentCoreEvent[]): ChartPayload[] { + return events + .filter((event) => event.type === 'tool_end' && event.toolName === 'render_chart') + .map((event) => (event.toolResult as { details?: { chart?: ChartPayload } }).details?.chart) + .filter((chart): chart is ChartPayload => !!chart) +} + +describe('real external LLM chart flow', () => { + it( + 'asks a real model to generate two chart payloads from real chat data', + { skip: !shouldRunRealLlmE2E }, + async () => { + const apiKey = process.env.CHATLAB_REAL_LLM_API_KEY || process.env.OPENAI_API_KEY + assert.ok(apiKey, 'Set CHATLAB_REAL_LLM_API_KEY or OPENAI_API_KEY to run the real LLM chart E2E test') + + const provider = process.env.CHATLAB_REAL_LLM_PROVIDER || 'openai' + const model = process.env.CHATLAB_REAL_LLM_MODEL || 'gpt-4.1-mini' + const baseUrl = process.env.CHATLAB_REAL_LLM_BASE_URL || undefined + const piModel = buildPiModel({ provider, model, baseUrl }) + const dir = createTempDir() + const dbPath = path.join(dir, 'chat.db') + const nativeBinding = resolveNativeBinding() + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + + try { + seedChatDb(db) + const dataProvider = new CoreDataProvider(db) + const tools = [adaptTool(schemaTool, dataProvider), adaptTool(renderChartTool, dataProvider)] + const events: AgentCoreEvent[] = [] + const chartPrompt = getChartCapabilitySkill('en-US').prompt + + const result = await runAgentCore({ + piModel, + apiKey, + systemPrompt: `${chartPrompt} + +Test-specific requirements: +- Use get_schema if you need table names or columns. +- Produce exactly two render_chart calls. +- The pie chart must include only Alice and Bob and must exclude Cara. +- The heatmap must include only Alice and Bob and use hour on x, member name on y, and message count as value. +- Use the data from June 1-2, 2026 inclusive. The database stores Unix seconds in message.ts.`, + tools, + history: [], + userMessage: + 'Create exactly two charts from this chat data: first a pie chart comparing Alice and Bob message share, then a heatmap showing Alice and Bob message density by hour. Briefly explain both.', + maxToolRounds: 4, + steerMessage: 'Explain the generated charts briefly. Do not call more tools.', + thinkingLevel: 'off', + onEvent: (event) => events.push(event), + }) + + assert.equal(result.error, undefined) + const charts = getChartPayloads(events) + assert.equal(charts.length, 2, `expected exactly two chart payloads, got ${charts.length}`) + + const chartsByType = new Map(charts.map((chart) => [chart.spec.type, chart])) + const pie = chartsByType.get('pie') + const heatmap = chartsByType.get('heatmap') + assert.ok(pie, 'expected a pie chart payload') + assert.ok(heatmap, 'expected a heatmap chart payload') + assert.deepEqual(new Set(pie.data.labels), new Set(['Alice', 'Bob'])) + assert.equal( + pie.data.values.reduce((sum, value) => sum + value, 0), + 7 + ) + assert.ok(!heatmap.data.yLabels.includes('Cara'), 'heatmap should exclude Cara') + assert.ok(heatmap.data.yLabels.includes('Alice'), 'heatmap should include Alice') + assert.ok(heatmap.data.yLabels.includes('Bob'), 'heatmap should include Bob') + assert.ok(heatmap.data.xLabels.includes('09'), 'heatmap should include hour 09') + assert.ok(heatmap.data.data.length > 0, 'heatmap should include density cells') + + const finalContent = events + .filter((event) => event.type === 'content') + .map((event) => event.content) + .join('') + assert.match(finalContent, /Alice|Bob|pie|heatmap/i) + } finally { + db.close() + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + } + ) +}) diff --git a/tests/chart-runtime/render-chart.integration.test.ts b/tests/chart-runtime/render-chart.integration.test.ts new file mode 100644 index 00000000..f4369c05 --- /dev/null +++ b/tests/chart-runtime/render-chart.integration.test.ts @@ -0,0 +1,243 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { CHAT_DB_SCHEMA, type ChartPayload } from '@openchatlab/core' +import { CoreDataProvider, renderChartTool } from '@openchatlab/tools' +import { openBetterSqliteDatabase } from '../../packages/node-runtime/src/better-sqlite3-adapter' + +function resolveNativeBinding(): string | undefined { + if (process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING) return process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + const repoNativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + return existsSync(repoNativeBinding) ? repoNativeBinding : undefined +} + +function unixTs(iso: string): number { + return Math.floor(Date.parse(iso) / 1000) +} + +function createTempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'chatlab-chart-runtime-')) +} + +function getChart(result: Awaited>): ChartPayload { + assert.ok(result.chart, 'expected render_chart to return a chart payload') + return result.chart +} + +function seedChatDb(db: ReturnType): void { + db.exec(CHAT_DB_SCHEMA) + db.prepare( + `INSERT INTO meta (name, platform, type, imported_at, group_id, owner_id) + VALUES ('Chart Test Group', 'wechat', 'group', ?, 'g1', 'u_alice')` + ).run(unixTs('2026-06-03T00:00:00Z')) + + const insertMember = db.prepare( + `INSERT INTO member (platform_id, account_name, group_nickname) + VALUES (?, ?, ?)` + ) + insertMember.run('u_alice', 'Alice Account', 'Alice') + insertMember.run('u_bob', 'Bob Account', 'Bob') + insertMember.run('u_cara', 'Cara Account', 'Cara') + + const memberRows = db.prepare('SELECT id, group_nickname FROM member').all() as Array<{ + id: number + group_nickname: string + }> + const memberId = new Map(memberRows.map((row) => [row.group_nickname, row.id])) + const insertMessage = db.prepare( + `INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, platform_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + const add = (name: 'Alice' | 'Bob' | 'Cara', ts: string, type: number, content: string) => { + const id = memberId.get(name) + assert.ok(id, `missing member id for ${name}`) + insertMessage.run(id, `${name} Account`, name, unixTs(ts), type, content, `${name}-${ts}`) + } + + add('Alice', '2026-06-01T09:00:00Z', 0, 'morning hello') + add('Alice', '2026-06-01T10:00:00Z', 0, 'standup note') + add('Alice', '2026-06-02T09:00:00Z', 1, '[图片]') + add('Alice', '2026-06-02T10:00:00Z', 0, 'follow up') + add('Bob', '2026-06-01T09:00:00Z', 0, 'first reply') + add('Bob', '2026-06-02T10:00:00Z', 0, 'second reply') + add('Bob', '2026-06-02T11:00:00Z', 0, 'third reply') + add('Cara', '2026-06-01T09:00:00Z', 0, 'other member') + add('Cara', '2026-06-03T12:00:00Z', 0, 'outside selected members') +} + +describe('render_chart integration with a real chat SQLite database', () => { + it('executes read-only SQL against chat tables and returns flexible chart payloads', async () => { + const dir = createTempDir() + const dbPath = path.join(dir, 'chat.db') + const nativeBinding = resolveNativeBinding() + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + + try { + seedChatDb(db) + const dataProvider = new CoreDataProvider(db) + const context = { + sessionId: 'chart-test-session', + locale: 'en-US', + dataProvider, + } + const range = { + startTs: unixTs('2026-06-01T00:00:00Z'), + endTs: unixTs('2026-06-03T00:00:00Z'), + } + + const pie = getChart( + await renderChartTool.handler( + { + sql: ` + SELECT COALESCE(m.group_nickname, m.account_name, m.platform_id) AS name, + COUNT(*) AS message_count + FROM message msg + JOIN member m ON m.id = msg.sender_id + WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) IN (@memberA, @memberB) + AND msg.ts >= @startTs AND msg.ts < @endTs + GROUP BY m.id + ORDER BY message_count DESC, name ASC + `, + params: { memberA: 'Alice', memberB: 'Bob', ...range }, + chartSpec: { + version: 1, + type: 'pie', + title: 'Selected members message share', + encoding: { label: 'name', value: 'message_count' }, + filters: { members: [{ name: 'Alice' }, { name: 'Bob' }] }, + }, + }, + context + ) + ) + assert.equal(pie.spec.type, 'pie') + assert.deepEqual(pie.data, { labels: ['Alice', 'Bob'], values: [4, 3] }) + assert.deepEqual( + pie.spec.filters?.members?.map((member) => member.name), + ['Alice', 'Bob'] + ) + + const line = getChart( + await renderChartTool.handler( + { + sql: ` + SELECT date(msg.ts, 'unixepoch') AS day, + COALESCE(m.group_nickname, m.account_name, m.platform_id) AS member_name, + COUNT(*) AS message_count + FROM message msg + JOIN member m ON m.id = msg.sender_id + WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) IN (@memberA, @memberB) + AND msg.ts >= @startTs AND msg.ts < @endTs + GROUP BY day, m.id + ORDER BY day ASC, member_name ASC + `, + params: { memberA: 'Alice', memberB: 'Bob', ...range }, + chartSpec: { + version: 1, + type: 'line', + title: 'Selected members daily trend', + encoding: { x: 'day', y: 'message_count', series: 'member_name' }, + }, + }, + context + ) + ) + assert.equal(line.spec.type, 'line') + assert.deepEqual(line.data, { + labels: ['2026-06-01', '2026-06-02'], + values: [2, 2], + series: [ + { name: 'Alice', values: [2, 2] }, + { name: 'Bob', values: [1, 2] }, + ], + }) + + const heatmap = getChart( + await renderChartTool.handler( + { + sql: ` + SELECT strftime('%H', msg.ts, 'unixepoch') AS hour, + COALESCE(m.group_nickname, m.account_name, m.platform_id) AS member_name, + COUNT(*) AS message_count + FROM message msg + JOIN member m ON m.id = msg.sender_id + WHERE msg.ts >= @startTs AND msg.ts < @endTs + GROUP BY hour, m.id + ORDER BY member_name ASC, hour ASC + `, + params: range, + chartSpec: { + version: 1, + type: 'heatmap', + title: 'Member hour density', + encoding: { x: 'hour', y: 'member_name', value: 'message_count' }, + }, + }, + context + ) + ) + assert.equal(heatmap.spec.type, 'heatmap') + assert.deepEqual(heatmap.data, { + xLabels: ['09', '10', '11'], + yLabels: ['Alice', 'Bob', 'Cara'], + data: [ + [0, 0, 2], + [1, 0, 2], + [0, 1, 1], + [1, 1, 1], + [2, 1, 1], + [0, 2, 1], + ], + }) + } finally { + db.close() + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + }) + + it('does not mutate chat data when a WITH-prefixed write is attempted', async () => { + const dir = createTempDir() + const dbPath = path.join(dir, 'chat.db') + const nativeBinding = resolveNativeBinding() + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + + try { + seedChatDb(db) + const dataProvider = new CoreDataProvider(db) + const context = { + sessionId: 'chart-test-session', + locale: 'en-US', + dataProvider, + } + const beforeCount = (db.prepare('SELECT COUNT(*) AS count FROM message').get() as { count: number }).count + + await assert.rejects( + async () => + renderChartTool.handler( + { + sql: ` + WITH victim AS (SELECT id FROM message LIMIT 1) + DELETE FROM message WHERE id IN (SELECT id FROM victim) + `, + chartSpec: { + version: 1, + type: 'bar', + title: 'Should not render', + encoding: { x: 'name', y: 'message_count' }, + }, + }, + context + ), + /READ-ONLY|read-only|not authorized|readonly|SELECT|syntax/i + ) + + const afterCount = (db.prepare('SELECT COUNT(*) AS count FROM message').get() as { count: number }).count + assert.equal(afterCount, beforeCount) + } finally { + db.close() + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + }) +}) diff --git a/tests/chart-smoke/index.html b/tests/chart-smoke/index.html new file mode 100644 index 00000000..e758044c --- /dev/null +++ b/tests/chart-smoke/index.html @@ -0,0 +1,12 @@ + + + + + + Chart Smoke + + +
+ + + diff --git a/tests/chart-smoke/src/main.ts b/tests/chart-smoke/src/main.ts new file mode 100644 index 00000000..d1f92211 --- /dev/null +++ b/tests/chart-smoke/src/main.ts @@ -0,0 +1,122 @@ +import { createApp, h } from 'vue' +import type { ChartPayload } from '@openchatlab/core' +import ChartBlockRenderer from '../../../src/components/AIChat/chat/ChartBlockRenderer.vue' +import './style.css' + +const charts: ChartPayload[] = [ + { + version: 1, + spec: { + version: 1, + type: 'bar', + title: 'Messages by member', + encoding: { x: 'name', y: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { name: 'Alice', message_count: 8 }, + { name: 'Bob', message_count: 5 }, + { name: 'Cara', message_count: 3 }, + ], + }, + data: { labels: ['Alice', 'Bob', 'Cara'], values: [8, 5, 3] }, + rowCount: 3, + }, + { + version: 1, + spec: { + version: 1, + type: 'line', + title: 'Daily member trend', + encoding: { x: 'day', y: 'message_count', series: 'member_name' }, + }, + dataset: { + columns: [ + { name: 'day', type: 'date' }, + { name: 'member_name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { day: '2026-06-01', member_name: 'Alice', message_count: 3 }, + { day: '2026-06-02', member_name: 'Alice', message_count: 5 }, + { day: '2026-06-01', member_name: 'Bob', message_count: 2 }, + { day: '2026-06-02', member_name: 'Bob', message_count: 4 }, + ], + }, + data: { + labels: ['2026-06-01', '2026-06-02'], + values: [3, 5], + series: [ + { name: 'Alice', values: [3, 5] }, + { name: 'Bob', values: [2, 4] }, + ], + }, + rowCount: 4, + }, + { + version: 1, + spec: { + version: 1, + type: 'pie', + title: 'Selected members', + encoding: { label: 'name', value: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { name: 'Alice', message_count: 8 }, + { name: 'Bob', message_count: 5 }, + ], + }, + data: { labels: ['Alice', 'Bob'], values: [8, 5] }, + rowCount: 2, + }, + { + version: 1, + spec: { + version: 1, + type: 'heatmap', + title: 'Weekday hour density', + encoding: { x: 'hour', y: 'weekday', value: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'hour', type: 'integer' }, + { name: 'weekday', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { hour: '09', weekday: 'Mon', message_count: 3 }, + { hour: '10', weekday: 'Mon', message_count: 5 }, + { hour: '09', weekday: 'Tue', message_count: 2 }, + ], + }, + data: { + xLabels: ['09', '10'], + yLabels: ['Mon', 'Tue'], + data: [ + [0, 0, 3], + [1, 0, 5], + [0, 1, 2], + ], + }, + rowCount: 3, + }, +] + +createApp({ + render() { + return h( + 'main', + { class: 'chart-smoke-shell' }, + charts.map((chart) => h(ChartBlockRenderer, { key: chart.spec.title, chart })) + ) + }, +}).mount('#app') diff --git a/tests/chart-smoke/src/style.css b/tests/chart-smoke/src/style.css new file mode 100644 index 00000000..25cce3c2 --- /dev/null +++ b/tests/chart-smoke/src/style.css @@ -0,0 +1,28 @@ +html, +body, +#app { + min-height: 100%; + margin: 0; +} + +body { + background: #f7f8fb; + color: #111827; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +.chart-smoke-shell { + box-sizing: border-box; + display: grid; + gap: 16px; + width: min(920px, calc(100vw - 32px)); + margin: 0 auto; + padding: 24px 0; +} diff --git a/tests/chart-smoke/vite.config.mts b/tests/chart-smoke/vite.config.mts new file mode 100644 index 00000000..f250a97a --- /dev/null +++ b/tests/chart-smoke/vite.config.mts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const root = fileURLToPath(new URL('.', import.meta.url)) +const projectRoot = fileURLToPath(new URL('../..', import.meta.url)) + +export default defineConfig({ + root, + plugins: [vue()], + resolve: { + alias: { + '@': path.join(projectRoot, 'src'), + '@openchatlab/core': path.join(projectRoot, 'packages/core/src'), + }, + }, + build: { + outDir: path.join(projectRoot, 'tmp/chart-smoke-build'), + emptyOutDir: true, + }, +})