mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
dbc377854a
commit
782f67ba63
46 files changed
+2631
-73
No files matched your search
@@ -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) {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.`
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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 变化(用于检测暗色模式切换)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in new issue
Block a user