feat(ai): add slash-activated chart runtime with EChart init fix

- Add ChartSpec validation and chart data normalization (bar/line/pie/heatmap)
- Add render_chart analysis tool with SQL safety (readonly check, outer LIMIT wrapper, CTE write rejection)
- Add chart content block type and ChartBlockRenderer for inline chart rendering
- Add slash menu chart capability activation (single-use per message)
- Add chart payload transport through Electron and CLI Web tool adapters
- Add error block handling for render-only chart tool failures
- Add chart payload persistence through content_blocks JSON storage
- Fix EChart.vue init timing: defer initChart() to nextTick so container
  layout completes before ECharts reads width (was rendering 92px canvases)
- Add comprehensive test coverage: core, integration, agent flow, frontend, smoke
- Add i18n for render_chart and get_schema in 4 locales
- Add project handoff documentation
This commit is contained in:
CEQ151 authored and digua committed 2026-06-05 11:12:42 +08:00
1 parent dbc377854a
commit 782f67ba63
46 files changed
+2631 -73

No files matched your search

+23 -3
View File
@@ -5,7 +5,12 @@
import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime' import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime'
import { SkillManager, createActivateSkillTool } 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 { DataSnapshot } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes' import type { AgentStreamRequest } from '@openchatlab/http-routes'
import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools'
@@ -46,11 +51,13 @@ export function createCliRunAgentStream(
const aiDataDir = getAiDir(dbManager) const aiDataDir = getAiDir(dbManager)
let assistantSystemPrompt: string | undefined let assistantSystemPrompt: string | undefined
let assistantAllowedTools: string[] | undefined
if (assistantId) { if (assistantId) {
const assistantConfig = loadAssistantConfig(aiDataDir, assistantId) const assistantConfig = loadAssistantConfig(aiDataDir, assistantId)
if (assistantConfig?.systemPrompt) { if (assistantConfig?.systemPrompt) {
assistantSystemPrompt = assistantConfig.systemPrompt assistantSystemPrompt = assistantConfig.systemPrompt
} }
assistantAllowedTools = assistantConfig?.allowedBuiltinTools
} }
const llmConfig = getDefaultAssistantConfig(aiDataDir) const llmConfig = getDefaultAssistantConfig(aiDataDir)
@@ -59,8 +66,18 @@ export function createCliRunAgentStream(
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
const db = (dbManager as any).open?.(sessionId) 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 const agentTools = db
? adaptToolsForAgent(AGENT_TOOL_REGISTRY, () => ({ db, sessionId, locale }), { maxToolResultTokens }) ? adaptToolsForAgent(availableToolDefs, () => ({ db, sessionId, locale }), { maxToolResultTokens })
: [] : []
const skillMgr = new SkillManager(aiDataDir) const skillMgr = new SkillManager(aiDataDir)
@@ -69,7 +86,10 @@ export function createCliRunAgentStream(
let resolvedSkillDef: { name: string; prompt: string } | undefined let resolvedSkillDef: { name: string; prompt: string } | undefined
let resolvedSkillMenu: 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) const def = skillMgr.getSkillConfig(skillId)
if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt } if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt }
} else if (enableAutoSkill) { } else if (enableAutoSkill) {
+59
View File
@@ -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 })
})
})
+22 -2
View File
@@ -75,6 +75,13 @@ export function adaptToolsForAgent(
} }
try { try {
const result = await tool.handler(toolParams, execCtx) 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) { if (result.rawMessages && result.rawMessages.length > 0) {
const pipelineResult = applyPreprocessingPipeline({ const pipelineResult = applyPreprocessingPipeline({
@@ -85,10 +92,23 @@ export function adaptToolsForAgent(
extraDetails: (result.data ?? {}) as Record<string, unknown>, extraDetails: (result.data ?? {}) as Record<string, unknown>,
logger: getServerAiLogger() ?? undefined, 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<string, unknown>)
: 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) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error) const msg = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null }
+19 -1
View File
@@ -15,6 +15,11 @@ import type { ToolContext } from './tools/types'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm' import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm'
import type { AIServiceConfig } from './llm/types' import type { AIServiceConfig } from './llm/types'
import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral' import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral'
import {
CHART_CAPABILITY_SKILL_ID,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
} from '@openchatlab/core'
import * as assistantManager from './assistant' import * as assistantManager from './assistant'
import type { AssistantConfig } from './assistant/types' import type { AssistantConfig } from './assistant/types'
import * as skillManager from './skills' import * as skillManager from './skills'
@@ -147,7 +152,20 @@ export function createElectronRunAgentStream(): (
} }
let skillCtx: SkillContext | undefined 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 const skillDef = skillManager.getSkillConfig(skillId) ?? undefined
if (skillDef) { if (skillDef) {
skillCtx = { skillDef } skillCtx = { skillDef }
@@ -16,6 +16,7 @@ import {
searchSessionsTool, searchSessionsTool,
getSessionMessagesTool, getSessionMessagesTool,
getMembersTool, getMembersTool,
schemaTool,
memberStatsTool, memberStatsTool,
timeStatsTool, timeStatsTool,
getMemberNameHistoryTool, getMemberNameHistoryTool,
@@ -23,6 +24,7 @@ import {
getSessionSummariesTool, getSessionSummariesTool,
responseTimeAnalysisTool, responseTimeAnalysisTool,
keywordFrequencyTool, keywordFrequencyTool,
renderChartTool,
SQL_TOOL_DEFS, SQL_TOOL_DEFS,
createSqlToolDefinition, createSqlToolDefinition,
} from '@openchatlab/tools' } from '@openchatlab/tools'
@@ -48,6 +50,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [
adaptSharedTool(searchSessionsTool, { category: 'core' }), adaptSharedTool(searchSessionsTool, { category: 'core' }),
adaptSharedTool(getSessionMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }), adaptSharedTool(getSessionMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }),
adaptSharedTool(getMembersTool, { category: 'core' }), adaptSharedTool(getMembersTool, { category: 'core' }),
adaptSharedTool(schemaTool, { category: 'core' }),
// ==================== Analysis 工具(按需加载) ==================== // ==================== Analysis 工具(按需加载) ====================
adaptSharedTool(memberStatsTool, { category: 'analysis' }), adaptSharedTool(memberStatsTool, { category: 'analysis' }),
@@ -57,6 +60,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [
adaptSharedTool(getSessionSummariesTool, { category: 'analysis' }), adaptSharedTool(getSessionSummariesTool, { category: 'analysis' }),
adaptSharedTool(responseTimeAnalysisTool, { category: 'analysis' }), adaptSharedTool(responseTimeAnalysisTool, { category: 'analysis' }),
adaptSharedTool(keywordFrequencyTool, { category: 'analysis' }), adaptSharedTool(keywordFrequencyTool, { category: 'analysis' }),
adaptSharedTool(renderChartTool, { category: 'analysis' }),
// ==================== SQL 分析工具 ==================== // ==================== SQL 分析工具 ====================
...sqlToolEntries, ...sqlToolEntries,
@@ -56,6 +56,13 @@ export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): To
const execCtx = buildExecutionContext(context) const execCtx = buildExecutionContext(context)
try { try {
const result = await tool.handler(toolParams, execCtx) 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) { if (result.rawMessages && result.rawMessages.length > 0) {
const baseData = (typeof result.data === 'object' && result.data !== null ? result.data : {}) as Record< 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 { return {
content: [{ type: 'text', text: result.content }], 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<string, unknown>)
: result.data === undefined
? null
: { value: result.data }
return { return {
content: [{ type: 'text', text: result.content }], content: [{ type: 'text', text: result.content }],
details: result.data ?? null, details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails,
} }
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error) const msg = error instanceof Error ? error.message : String(error)
@@ -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',
])
})
})
+81
View File
@@ -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.`
+6
View File
@@ -5,6 +5,12 @@
// 内置工具目录 // 内置工具目录
export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog' export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog'
export { BUILTIN_TOOL_CATALOG } 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 模型系统类型 // LLM 模型系统类型
export type { export type {
+2
View File
@@ -22,6 +22,7 @@ export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [
{ name: 'search_sessions', category: 'core' }, { name: 'search_sessions', category: 'core' },
{ name: 'get_session_messages', category: 'core' }, { name: 'get_session_messages', category: 'core' },
{ name: 'get_members', category: 'core' }, { name: 'get_members', category: 'core' },
{ name: 'get_schema', category: 'core' },
// Analysis 工具 // Analysis 工具
{ name: 'get_member_stats', category: 'analysis' }, { name: 'get_member_stats', category: 'analysis' },
@@ -31,6 +32,7 @@ export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [
{ name: 'get_session_summaries', category: 'analysis' }, { name: 'get_session_summaries', category: 'analysis' },
{ name: 'response_time_analysis', category: 'analysis' }, { name: 'response_time_analysis', category: 'analysis' },
{ name: 'keyword_frequency', category: 'analysis' }, { name: 'keyword_frequency', category: 'analysis' },
{ name: 'render_chart', category: 'analysis' },
// SQL Analysis 工具 // SQL Analysis 工具
{ name: 'message_type_breakdown', category: 'analysis' }, { name: 'message_type_breakdown', category: 'analysis' },
+85
View File
@@ -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
)
})
+336
View File
@@ -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<string, unknown>
}
display?: {
horizontal?: boolean
stacked?: boolean
showLegend?: boolean
showDataZoom?: boolean
height?: number
}
}
export interface ChartDataset {
columns: ChartField[]
rows: Record<string, unknown>[]
}
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<ChartType>(['bar', 'line', 'pie', 'heatmap'])
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>[], 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<string, unknown>[], 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<string, unknown>[],
labelField: string,
valueField: string
): {
labels: string[]
values: number[]
} {
const labels: string[] = []
const indexByLabel = new Map<string, number>()
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<string, unknown>[], 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<string, number>()
const seriesNames: string[] = []
const seriesIndex = new Map<string, number>()
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<string, unknown>[], 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<string, number>()
const yIndex = new Map<string, number>()
const values = new Map<string, number>()
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<string, unknown>[],
rawSpec: unknown,
options?: { truncated?: boolean }
): ChartPayload {
const spec = normalizeChartSpec(rawSpec)
const dataset = inferChartDataset(rows, spec.fields)
const requiredFields = new Set<string>()
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,
}
}
+21
View File
@@ -184,6 +184,23 @@ export type {
export { isNewerStableVersion, isStableVersion } from './version' export { isNewerStableVersion, isStableVersion } from './version'
export type { ParsedStableVersion } 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(平台无关的类型、数据和工具函数) // NLP(平台无关的类型、数据和工具函数)
export { export {
POS_TAG_DEFINITIONS, POS_TAG_DEFINITIONS,
@@ -229,6 +246,10 @@ export {
BUILTIN_MODELS, BUILTIN_MODELS,
getBuiltinModelsByProvider, getBuiltinModelsByProvider,
getBuiltinModelById, getBuiltinModelById,
CHART_CAPABILITY_SKILL_ID,
CHART_CAPABILITY_ANALYSIS_TOOLS,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
THINK_TAGS, THINK_TAGS,
extractThinkingContent, extractThinkingContent,
stripToolCallTags, stripToolCallTags,
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import { AIConversationManager } from '../conversations' import { AIConversationManager } from '../conversations'
import type { ChartPayload } from '@openchatlab/core'
const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING 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', () => { describe('AIConversationManager deleteMessagesFrom', () => {
it('deletes the target message and all subsequent messages', () => { it('deletes the target message and all subsequent messages', () => {
const dir = createTempDir() const dir = createTempDir()
@@ -97,4 +97,31 @@ describe('AgentEventHandler', () => {
assert.equal(contentChunks.length, 1) assert.equal(contentChunks.length, 1)
assert.equal(contentChunks[0].content, 'Hello') 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)
})
}) })
@@ -8,6 +8,7 @@
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import type { ChartPayload } from '@openchatlab/core'
const DEFAULT_GENERAL_ID = 'general_cn' const DEFAULT_GENERAL_ID = 'general_cn'
@@ -26,6 +27,7 @@ export interface AIConversation {
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| { | {
type: 'tool' type: 'tool'
tool: { tool: {
@@ -252,6 +252,19 @@ Returned summaries are brief descriptions of each session, helping quickly locat
top_n: 'Number of top keywords to return', 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 ===== // ===== AI Agent system prompts =====
@@ -252,6 +252,19 @@ export default {
top_n: '上位何個のキーワードを返却するか', 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: { agent: {
@@ -243,6 +243,19 @@ export default {
top_n: '返回前多少个关键词', 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: { agent: {
@@ -243,6 +243,19 @@ export default {
top_n: '回傳前多少個關鍵詞', 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: { agent: {
@@ -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<string, unknown>[],
calls: Array<{ query: string; params: Record<string, unknown> }>
): ToolExecutionContext {
const dataProvider = {
async executeParameterizedSql(query: string, params: Record<string, unknown>) {
calls.push({ query, params })
return rows
},
} as Partial<ToolDataProvider> 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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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<string, unknown> }> = []
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'
)
})
})
@@ -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<string, unknown> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
return raw as Record<string, unknown>
}
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<string, unknown>, context: ToolExecutionContext): Promise<ToolResult> {
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<Record<string, unknown>>(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',
}
+1
View File
@@ -29,6 +29,7 @@ export { getConversationBetweenTool } from './definitions/get-conversation-betwe
export { getSessionSummariesTool } from './definitions/get-session-summaries' export { getSessionSummariesTool } from './definitions/get-session-summaries'
export { responseTimeAnalysisTool } from './definitions/response-time-analysis' export { responseTimeAnalysisTool } from './definitions/response-time-analysis'
export { keywordFrequencyTool } from './definitions/keyword-frequency' export { keywordFrequencyTool } from './definitions/keyword-frequency'
export { renderChartTool } from './definitions/render-chart'
// === SQL Tools === // === SQL Tools ===
export { SQL_TOOL_DEFS, createSqlToolDefinition, createAllSqlToolDefinitions } from './sql' export { SQL_TOOL_DEFS, createSqlToolDefinition, createAllSqlToolDefinitions } from './sql'
+3 -1
View File
@@ -11,6 +11,7 @@
import type { ToolDefinition } from './types' import type { ToolDefinition } from './types'
import { sqlQueryTool, schemaTool } from './definitions/sql-query' import { sqlQueryTool, schemaTool } from './definitions/sql-query'
import { renderChartTool } from './definitions/render-chart'
import { sessionInfoTool } from './definitions/session-info' import { sessionInfoTool } from './definitions/session-info'
import { sessionsListTool } from './definitions/sessions' import { sessionsListTool } from './definitions/sessions'
import { chatOverviewTool } from './definitions/chat-overview' import { chatOverviewTool } from './definitions/chat-overview'
@@ -44,6 +45,7 @@ const SHARED_TOOLS: ToolDefinition[] = [
searchSessionsTool, searchSessionsTool,
getSessionMessagesTool, getSessionMessagesTool,
getMembersTool, getMembersTool,
schemaTool,
// Analysis // Analysis
memberStatsTool, memberStatsTool,
@@ -53,6 +55,7 @@ const SHARED_TOOLS: ToolDefinition[] = [
getSessionSummariesTool, getSessionSummariesTool,
responseTimeAnalysisTool, responseTimeAnalysisTool,
keywordFrequencyTool, keywordFrequencyTool,
renderChartTool,
// Raw SQL // Raw SQL
sqlQueryTool, sqlQueryTool,
@@ -75,7 +78,6 @@ export const MCP_TOOL_REGISTRY: ToolDefinition[] = [
// MCP-specific: session discovery & schema // MCP-specific: session discovery & schema
sessionsListTool, sessionsListTool,
sessionInfoTool, sessionInfoTool,
schemaTool,
// Shared core + analysis + raw SQL // Shared core + analysis + raw SQL
...SHARED_TOOLS, ...SHARED_TOOLS,
] ]
+5 -1
View File
@@ -4,7 +4,7 @@
* 平台无关的工具注册表类型,同时服务于 MCP Server、HTTP API 和 Electron Agent。 * 平台无关的工具注册表类型,同时服务于 MCP Server、HTTP API 和 Electron Agent。
*/ */
import type { DatabaseAdapter } from '@openchatlab/core' import type { ChartPayload, DatabaseAdapter } from '@openchatlab/core'
// ==================== Schema ==================== // ==================== Schema ====================
@@ -19,6 +19,8 @@ export interface JsonSchema {
type: string type: string
description?: string description?: string
items?: { type: string } items?: { type: string }
properties?: Record<string, unknown>
additionalProperties?: boolean | Record<string, unknown>
default?: unknown default?: unknown
enum?: unknown[] enum?: unknown[]
minimum?: number minimum?: number
@@ -268,6 +270,8 @@ export interface RawMessage {
export interface ToolResult { export interface ToolResult {
content: string content: string
data?: unknown data?: unknown
chart?: ChartPayload
charts?: ChartPayload[]
/** 消息类工具可透传原始消息数据,供预处理管道消费 */ /** 消息类工具可透传原始消息数据,供预处理管道消费 */
rawMessages?: RawMessage[] rawMessages?: RawMessage[]
} }
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import type {
BarChartRenderData,
ChartPayload,
HeatmapChartRenderData,
LineChartRenderData,
PieChartRenderData,
} from '@openchatlab/core'
import EChartBar from '@/components/charts/EChartBar.vue'
import EChartHeatmap from '@/components/charts/EChartHeatmap.vue'
import EChartLine from '@/components/charts/EChartLine.vue'
import EChartPie from '@/components/charts/EChartPie.vue'
const props = defineProps<{
chart: ChartPayload
}>()
const title = computed(() => props.chart.spec.title)
const subtitle = computed(() => props.chart.spec.subtitle || props.chart.spec.description || '')
const height = computed(() => props.chart.spec.display?.height ?? (props.chart.spec.type === 'heatmap' ? 300 : 260))
const isEmpty = computed(() => props.chart.rowCount === 0)
const summary = computed(() => {
const parts: string[] = []
if (props.chart.spec.unit) parts.push(props.chart.spec.unit)
if (props.chart.truncated) parts.push('truncated')
parts.push(`${props.chart.rowCount} rows`)
return parts.join(' · ')
})
const barData = computed(() => props.chart.data as BarChartRenderData)
const lineData = computed(() => props.chart.data as LineChartRenderData)
const pieData = computed(() => props.chart.data as PieChartRenderData)
const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
</script>
<template>
<div
class="my-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white/80 shadow-sm dark:border-gray-700/70 dark:bg-gray-900/70"
>
<div class="border-b border-gray-100 px-3 py-2 dark:border-gray-800">
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{{ title }}</h3>
<p v-if="subtitle" class="mt-0.5 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
{{ subtitle }}
</p>
</div>
<span
class="shrink-0 rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase text-gray-500 dark:bg-gray-800"
>
{{ chart.spec.type }}
</span>
</div>
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">{{ summary }}</p>
</div>
<div class="px-2 py-3">
<div v-if="isEmpty" class="flex h-32 items-center justify-center text-xs text-gray-400 dark:text-gray-500">
No data
</div>
<EChartBar
v-else-if="chart.spec.type === 'bar'"
:data="barData"
:height="height"
:horizontal="chart.spec.display?.horizontal"
/>
<EChartLine v-else-if="chart.spec.type === 'line'" :data="lineData" :height="height" />
<EChartPie
v-else-if="chart.spec.type === 'pie'"
:data="pieData"
:height="height"
:show-legend="chart.spec.display?.showLegend ?? true"
/>
<EChartHeatmap v-else-if="chart.spec.type === 'heatmap'" :data="heatmapData" :height="height" />
</div>
</div>
</template>
@@ -6,6 +6,7 @@ import MarkdownIt from 'markdown-it'
import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat' import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat'
import CaptureButton from '@/components/common/CaptureButton.vue' import CaptureButton from '@/components/common/CaptureButton.vue'
import ErrorBlock from './ErrorBlock.vue' import ErrorBlock from './ErrorBlock.vue'
import ChartBlockRenderer from './ChartBlockRenderer.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const { t, te, locale } = useI18n() const { t, te, locale } = useI18n()
@@ -330,6 +331,10 @@ const copyMarkdownText = computed(() => {
return `> ${t('ai.skill.active.label', { name: block.skillName })}` return `> ${t('ai.skill.active.label', { name: block.skillName })}`
} }
if (block.type === 'chart') {
return `> Chart: ${block.chart.spec.title}`
}
if (block.type === 'tool') { if (block.type === 'tool') {
const toolName = getToolDisplayName(block.tool) const toolName = getToolDisplayName(block.tool)
const toolParams = formatToolParams(block.tool) const toolParams = formatToolParams(block.tool)
@@ -484,6 +489,9 @@ async function handleCopyMarkdown() {
<span>{{ t('ai.skill.active.label', { name: block.skillName }) }}</span> <span>{{ t('ai.skill.active.label', { name: block.skillName }) }}</span>
</div> </div>
<!-- 图表块 -->
<ChartBlockRenderer v-else-if="block.type === 'chart'" :chart="block.chart" />
<!-- 工具块 --> <!-- 工具块 -->
<div <div
v-else-if="block.type === 'tool'" v-else-if="block.type === 'tool'"
+5 -2
View File
@@ -3,7 +3,7 @@
* ECharts 基础封装组件 * ECharts 基础封装组件
* 提供自动响应式、主题适配、加载状态等通用功能 * 提供自动响应式、主题适配、加载状态等通用功能
*/ */
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import * as echarts from 'echarts/core' import * as echarts from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart, LineChart, HeatmapChart } from 'echarts/charts' import { PieChart, BarChart, LineChart, HeatmapChart } from 'echarts/charts'
@@ -129,7 +129,10 @@ watch(
let observer: MutationObserver | null = null let observer: MutationObserver | null = null
onMounted(() => { onMounted(() => {
initChart() // Defer init to next tick so the container has completed layout before ECharts reads its width
nextTick(() => {
initChart()
})
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
// 监听 HTML 元素的 class 变化(用于检测暗色模式切换) // 监听 HTML 元素的 class 变化(用于检测暗色模式切换)
+68 -41
View File
@@ -9,6 +9,7 @@ import EChart from './EChart.vue'
export interface EChartLineData { export interface EChartLineData {
labels: string[] labels: string[]
values: number[] values: number[]
series?: Array<{ name: string; values: number[] }>
} }
interface Props { interface Props {
@@ -27,6 +28,65 @@ const props = withDefaults(defineProps<Props>(), {
}) })
const option = computed<EChartsOption>(() => { const option = computed<EChartsOption>(() => {
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 { return {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
@@ -39,9 +99,15 @@ const option = computed<EChartsOption>(() => {
grid: { grid: {
left: 50, left: 50,
right: 20, right: 20,
top: 20, top: hasSeries ? 36 : 20,
bottom: 30, bottom: 30,
}, },
legend: hasSeries
? {
top: 0,
textStyle: { color: '#6b7280', fontSize: 11 },
}
: undefined,
xAxis: { xAxis: {
type: 'category', type: 'category',
data: props.data.labels, data: props.data.labels,
@@ -66,46 +132,7 @@ const option = computed<EChartsOption>(() => {
}, },
}, },
}, },
series: [ 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,
},
},
},
],
} }
}) })
</script> </script>
+6 -2
View File
@@ -76,6 +76,7 @@
"get_member_name_history": "Get Nickname History", "get_member_name_history": "Get Nickname History",
"get_conversation_between": "Get Conversation", "get_conversation_between": "Get Conversation",
"get_message_context": "Get Message Context", "get_message_context": "Get Message Context",
"get_schema": "Inspect Schema",
"search_sessions": "Search Sessions", "search_sessions": "Search Sessions",
"get_session_messages": "Get Session Messages", "get_session_messages": "Get Session Messages",
"get_session_summaries": "Get Session Summaries", "get_session_summaries": "Get Session Summaries",
@@ -91,7 +92,8 @@
"daily_active_members": "Daily Active Members", "daily_active_members": "Daily Active Members",
"conversation_initiator_stats": "Conversation Initiators", "conversation_initiator_stats": "Conversation Initiators",
"activity_heatmap": "Activity Heatmap", "activity_heatmap": "Activity Heatmap",
"unanswered_messages": "Unanswered Messages" "unanswered_messages": "Unanswered Messages",
"render_chart": "Generate Chart"
}, },
"generating": "Generating response...", "generating": "Generating response...",
"summary": { "summary": {
@@ -414,6 +416,7 @@
"get_message_context": "Get message context", "get_message_context": "Get message context",
"get_conversation_between": "Get conversation between two members", "get_conversation_between": "Get conversation between two members",
"get_members": "Get members", "get_members": "Get members",
"get_schema": "Inspect database schema",
"get_member_stats": "Get member statistics", "get_member_stats": "Get member statistics",
"get_member_name_history": "Get member name history", "get_member_name_history": "Get member name history",
"get_time_stats": "Get time statistics", "get_time_stats": "Get time statistics",
@@ -432,7 +435,8 @@
"conversation_initiator_stats": "Conversation initiators", "conversation_initiator_stats": "Conversation initiators",
"activity_heatmap": "Activity heatmap", "activity_heatmap": "Activity heatmap",
"response_time_analysis": "Response time analysis", "response_time_analysis": "Response time analysis",
"keyword_frequency": "Keyword frequency" "keyword_frequency": "Keyword frequency",
"render_chart": "Generate dynamic chart"
} }
}, },
"skill": { "skill": {
+6 -2
View File
@@ -76,6 +76,7 @@
"get_member_name_history": "ニックネーム履歴を取得", "get_member_name_history": "ニックネーム履歴を取得",
"get_conversation_between": "やり取り履歴を取得", "get_conversation_between": "やり取り履歴を取得",
"get_message_context": "コンテキストを取得", "get_message_context": "コンテキストを取得",
"get_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_initiator_stats": "話題開始者統計",
"activity_heatmap": "活動ヒートマップ", "activity_heatmap": "活動ヒートマップ",
"unanswered_messages": "未返信メッセージ" "unanswered_messages": "未返信メッセージ",
"render_chart": "チャート生成"
}, },
"generating": "回答を作成中...", "generating": "回答を作成中...",
"summary": { "summary": {
@@ -414,6 +416,7 @@
"get_message_context": "メッセージのコンテキストを取得", "get_message_context": "メッセージのコンテキストを取得",
"get_conversation_between": "2人のやり取りを取得", "get_conversation_between": "2人のやり取りを取得",
"get_members": "メンバー一覧を取得", "get_members": "メンバー一覧を取得",
"get_schema": "データベーススキーマを確認",
"get_member_stats": "メンバー統計を取得", "get_member_stats": "メンバー統計を取得",
"get_member_name_history": "メンバーの名前変更履歴を取得", "get_member_name_history": "メンバーの名前変更履歴を取得",
"get_time_stats": "時間統計を取得", "get_time_stats": "時間統計を取得",
@@ -432,7 +435,8 @@
"conversation_initiator_stats": "話題開始者統計", "conversation_initiator_stats": "話題開始者統計",
"activity_heatmap": "活性度ヒートマップ", "activity_heatmap": "活性度ヒートマップ",
"response_time_analysis": "応答時間分析", "response_time_analysis": "応答時間分析",
"keyword_frequency": "高頻度キーワード統計" "keyword_frequency": "高頻度キーワード統計",
"render_chart": "動的チャート生成"
} }
}, },
"skill": { "skill": {
+6 -2
View File
@@ -76,6 +76,7 @@
"get_member_name_history": "获取昵称历史", "get_member_name_history": "获取昵称历史",
"get_conversation_between": "获取对话记录", "get_conversation_between": "获取对话记录",
"get_message_context": "获取上下文", "get_message_context": "获取上下文",
"get_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_initiator_stats": "话题发起者统计",
"activity_heatmap": "活跃度热力图", "activity_heatmap": "活跃度热力图",
"unanswered_messages": "未回复消息" "unanswered_messages": "未回复消息",
"render_chart": "生成图表"
}, },
"generating": "正在生成回复...", "generating": "正在生成回复...",
"summary": { "summary": {
@@ -414,6 +416,7 @@
"get_message_context": "获取消息上下文", "get_message_context": "获取消息上下文",
"get_conversation_between": "获取两人对话", "get_conversation_between": "获取两人对话",
"get_members": "获取成员列表", "get_members": "获取成员列表",
"get_schema": "查看数据表结构",
"get_member_stats": "获取成员统计", "get_member_stats": "获取成员统计",
"get_member_name_history": "获取成员改名历史", "get_member_name_history": "获取成员改名历史",
"get_time_stats": "获取时间统计", "get_time_stats": "获取时间统计",
@@ -432,7 +435,8 @@
"conversation_initiator_stats": "话题发起者统计", "conversation_initiator_stats": "话题发起者统计",
"activity_heatmap": "活跃度热力图", "activity_heatmap": "活跃度热力图",
"response_time_analysis": "响应时间分析", "response_time_analysis": "响应时间分析",
"keyword_frequency": "高频词统计" "keyword_frequency": "高频词统计",
"render_chart": "生成动态图表"
} }
}, },
"skill": { "skill": {
+6 -2
View File
@@ -76,6 +76,7 @@
"get_member_name_history": "取得暱稱歷史", "get_member_name_history": "取得暱稱歷史",
"get_conversation_between": "取得對話紀錄", "get_conversation_between": "取得對話紀錄",
"get_message_context": "取得上下文", "get_message_context": "取得上下文",
"get_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_initiator_stats": "話題發起者統計",
"activity_heatmap": "活躍度熱力圖", "activity_heatmap": "活躍度熱力圖",
"unanswered_messages": "未回覆訊息" "unanswered_messages": "未回覆訊息",
"render_chart": "生成圖表"
}, },
"generating": "正在產生回覆...", "generating": "正在產生回覆...",
"summary": { "summary": {
@@ -414,6 +416,7 @@
"get_message_context": "取得訊息上下文", "get_message_context": "取得訊息上下文",
"get_conversation_between": "取得兩人對話", "get_conversation_between": "取得兩人對話",
"get_members": "取得成員列表", "get_members": "取得成員列表",
"get_schema": "查看資料表結構",
"get_member_stats": "取得成員統計", "get_member_stats": "取得成員統計",
"get_member_name_history": "取得成員改名歷史", "get_member_name_history": "取得成員改名歷史",
"get_time_stats": "取得時間統計", "get_time_stats": "取得時間統計",
@@ -432,7 +435,8 @@
"conversation_initiator_stats": "話題發起者統計", "conversation_initiator_stats": "話題發起者統計",
"activity_heatmap": "活躍度熱力圖", "activity_heatmap": "活躍度熱力圖",
"response_time_analysis": "回應時間分析", "response_time_analysis": "回應時間分析",
"keyword_frequency": "高頻詞統計" "keyword_frequency": "高頻詞統計",
"render_chart": "生成動態圖表"
} }
}, },
"skill": { "skill": {
+2
View File
@@ -1,4 +1,5 @@
import type { TimeFilter } from '@/types/base' import type { TimeFilter } from '@/types/base'
import type { ChartPayload } from '@openchatlab/core'
export interface AIConversation { export interface AIConversation {
id: string id: string
@@ -13,6 +14,7 @@ export interface AIConversation {
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| { | {
type: 'tool' type: 'tool'
tool: { tool: {
+79 -10
View File
@@ -18,6 +18,13 @@ import { useLLMStore } from '@/stores/llm'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '@electron/shared/types' import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '@electron/shared/types'
import { useAgentStreamService } from '@/services/ai-stream/service' import { useAgentStreamService } from '@/services/ai-stream/service'
import { buildSerializablePreprocessConfig, shouldEnsureDesensitizeRulesBeforeSerialize } from './aiPreprocessConfig' import { buildSerializablePreprocessConfig, shouldEnsureDesensitizeRulesBeforeSerialize } from './aiPreprocessConfig'
import type { ChartPayload } from '@openchatlab/core'
import {
extractChartPayloads,
isRenderOnlyTool,
toChartContentBlocks,
toRenderOnlyToolErrorBlock,
} from './aiChatChartBlocks'
// 工具调用记录 // 工具调用记录
export interface ToolCallRecord { export interface ToolCallRecord {
@@ -49,6 +56,7 @@ export interface MentionedMemberContext {
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| { | {
type: 'tool' type: 'tool'
tool: ToolBlockContent tool: ToolBlockContent
@@ -596,6 +604,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateAIMessage: (updates: Partial<ChatMessage>) => void updateAIMessage: (updates: Partial<ChatMessage>) => void
appendTextToBlocks: (text: string) => void appendTextToBlocks: (text: string) => void
appendThinkToBlocks: (text: string, tag?: string, durationMs?: number) => void appendThinkToBlocks: (text: string, tag?: string, durationMs?: number) => void
appendChartsToBlocks: (charts: ChartPayload[]) => void
appendErrorToBlocks: (error: SerializedErrorInfo) => void
addToolBlock: (toolName: string, params?: Record<string, unknown>) => void addToolBlock: (toolName: string, params?: Record<string, unknown>) => void
updateToolBlockStatus: (toolName: string, status: 'done' | 'error') => void updateToolBlockStatus: (toolName: string, status: 'done' | 'error') => void
} }
@@ -650,6 +660,21 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateAIMessage({ contentBlocks: [...blocks] }) 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<string, unknown>) => { const addToolBlock = (toolName: string, params?: Record<string, unknown>) => {
const idx = getAiMessageIndex() const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || [] const blocks = targetBuffer.messages[idx].contentBlocks || []
@@ -670,7 +695,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateAIMessage({ contentBlocks: [...blocks] }) updateAIMessage({ contentBlocks: [...blocks] })
} }
return { updateAIMessage, appendTextToBlocks, appendThinkToBlocks, addToolBlock, updateToolBlockStatus } return {
updateAIMessage,
appendTextToBlocks,
appendThinkToBlocks,
appendChartsToBlocks,
appendErrorToBlocks,
addToolBlock,
updateToolBlockStatus,
}
} }
async function sendMessage( async function sendMessage(
@@ -778,8 +811,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
let aiMessageIndex = targetBuffer.messages.length - 1 let aiMessageIndex = targetBuffer.messages.length - 1
let hasStreamError = false let hasStreamError = false
const { updateAIMessage, appendTextToBlocks, appendThinkToBlocks, addToolBlock, updateToolBlockStatus } = const {
createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) updateAIMessage,
appendTextToBlocks,
appendThinkToBlocks,
appendChartsToBlocks,
appendErrorToBlocks,
addToolBlock,
updateToolBlockStatus,
} = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex)
const currentAssistantId = targetBuffer.assistantId ?? getDefaultGeneralAssistantId(state.locale) const currentAssistantId = targetBuffer.assistantId ?? getDefaultGeneralAssistantId(state.locale)
if (!resolvedConversationId) { if (!resolvedConversationId) {
@@ -874,19 +914,29 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
status: 'running', status: 'running',
} }
state.toolsUsedInCurrentRound.push(chunk.toolName) state.toolsUsedInCurrentRound.push(chunk.toolName)
addToolBlock(chunk.toolName, toolParams) if (!isRenderOnlyTool(chunk.toolName)) {
addToolBlock(chunk.toolName, toolParams)
}
} }
break break
case 'tool_result': case 'tool_result':
if (chunk.toolName) { 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) { if (state.currentToolStatus?.name === chunk.toolName) {
state.currentToolStatus = { state.currentToolStatus = {
...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 state.isLoadingSource = false
break break
@@ -1310,8 +1360,15 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
targetBuffer.messages.push(...subsequentMessages) targetBuffer.messages.push(...subsequentMessages)
} }
const { updateAIMessage, appendTextToBlocks, appendThinkToBlocks, addToolBlock, updateToolBlockStatus } = const {
createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) updateAIMessage,
appendTextToBlocks,
appendThinkToBlocks,
appendChartsToBlocks,
appendErrorToBlocks,
addToolBlock,
updateToolBlockStatus,
} = createStreamBlockHelpers(targetBuffer, () => aiMessageIndex)
try { try {
const hasConfig = await useLLMService().hasConfig() const hasConfig = await useLLMService().hasConfig()
@@ -1393,11 +1450,23 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
if (chunk.toolName) { if (chunk.toolName) {
state.currentToolStatus = { name: chunk.toolName, displayName: chunk.toolName, status: 'running' } state.currentToolStatus = { name: chunk.toolName, displayName: chunk.toolName, status: 'running' }
state.toolsUsedInCurrentRound.push(chunk.toolName) state.toolsUsedInCurrentRound.push(chunk.toolName)
addToolBlock(chunk.toolName, chunk.toolParams as Record<string, unknown> | undefined) if (!isRenderOnlyTool(chunk.toolName)) {
addToolBlock(chunk.toolName, chunk.toolParams as Record<string, unknown> | undefined)
}
} }
break break
case 'tool_result': 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.currentToolStatus = null
state.isLoadingSource = false state.isLoadingSource = false
break break
+111
View File
@@ -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
)
})
})
+75
View File
@@ -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<string, unknown> {
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,
},
}
}
+49
View File
@@ -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)
})
})
+16 -2
View File
@@ -7,6 +7,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useAssistantStore } from './assistant' import { useAssistantStore } from './assistant'
import { usePlatformService, useSkillService } from '@/services' import { usePlatformService, useSkillService } from '@/services'
import { CHART_CAPABILITY_SKILL_ID, getChartCapabilitySkill } from '@openchatlab/core'
import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale' import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale'
const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE
@@ -65,9 +66,14 @@ export const useSkillStore = defineStore('skill', () => {
const activeSkill = computed(() => { const activeSkill = computed(() => {
if (!activeSkillId.value) return null 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 return skills.value.find((s) => s.id === activeSkillId.value) ?? null
}) })
const chartCapabilitySkill = computed(() => getChartCapabilitySkill(currentLocale.value))
const scopedSkills = computed(() => { const scopedSkills = computed(() => {
return skills.value.filter((s) => s.chatScope === 'all' || s.chatScope === currentChatType.value) 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 compatibleSkills = computed(() => {
const assistantStore = useAssistantStore() const assistantStore = useAssistantStore()
const config = assistantStore.selectedAssistant 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 if (!s.tools.length) return true
return true return true
}) })
@@ -188,6 +195,13 @@ export const useSkillStore = defineStore('skill', () => {
} }
async function getSkillConfig(id: string): Promise<SkillConfigFull | null> { async function getSkillConfig(id: string): Promise<SkillConfigFull | null> {
if (id === CHART_CAPABILITY_SKILL_ID) {
const skill = getChartCapabilitySkill(currentLocale.value)
return {
...skill,
builtinId: CHART_CAPABILITY_SKILL_ID,
}
}
try { try {
return await useSkillService().getConfig(id) return await useSkillService().getConfig(id)
} catch (error) { } catch (error) {
@@ -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<string, unknown> = {}
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<any, unknown> {
return {
name: renderChartTool.name,
label: renderChartTool.name,
description: renderChartTool.description,
parameters: convertJsonSchemaToParameters(renderChartTool.inputSchema) as any,
async execute(_toolCallId: string, params: unknown): Promise<AgentToolResult<unknown>> {
const context: ToolExecutionContext = {
sessionId: 'offline-agent-chart-flow',
locale: 'en-US',
dataProvider,
}
const result = await renderChartTool.handler((params ?? {}) as Record<string, unknown>, 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<ToolDataProvider> as ToolDataProvider
const tools = [adaptRenderChartTool(dataProvider)]
const streamFn = (_model: Model<any>, 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<any>,
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<ToolDataProvider> as ToolDataProvider
const tools = [adaptRenderChartTool(dataProvider)]
const streamFn = (_model: Model<any>, 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<any>,
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/)
})
})
@@ -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<typeof openBetterSqliteDatabase>): 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<string, unknown> = {}
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<any, unknown> {
return {
name: tool.name,
label: tool.name,
description: tool.description,
parameters: convertJsonSchemaToParameters(tool.inputSchema) as any,
async execute(_toolCallId: string, params: unknown): Promise<AgentToolResult<unknown>> {
const context: ToolExecutionContext = {
sessionId: 'real-llm-chart-flow',
locale: 'en-US',
dataProvider,
}
const result = await tool.handler((params ?? {}) as Record<string, unknown>, 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 })
}
}
)
})
@@ -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<ReturnType<typeof renderChartTool.handler>>): ChartPayload {
assert.ok(result.chart, 'expected render_chart to return a chart payload')
return result.chart
}
function seedChatDb(db: ReturnType<typeof openBetterSqliteDatabase>): 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 })
}
})
})
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chart Smoke</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+122
View File
@@ -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')
+28
View File
@@ -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;
}
+22
View File
@@ -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,
},
})