feat(ai): improve chart runtime UX

This commit is contained in:
CEQ151 authored and digua committed 2026-06-05 11:12:42 +08:00
1 parent 782f67ba63
commit 15470444e5
40 files changed
+1547 -144

No files matched your search

@@ -0,0 +1,42 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { getChartCapabilityAllowedBuiltinTools } from '@openchatlab/core'
import { getAvailableToolDefs } from './agent-stream-runner'
describe('CLI chart capability tool filtering', () => {
it('does not expose uncategorized raw SQL in chart-only turns', () => {
const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools())
const toolNames = getAvailableToolDefs(true, allowedToolSet).map((tool) => tool.name)
assert.deepEqual(toolNames.sort(), ['get_schema', 'render_chart'])
assert.ok(!toolNames.includes('execute_sql'))
})
it('keeps only chart core tools plus explicitly allowed analysis tools', () => {
const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql']))
const toolNames = getAvailableToolDefs(true, allowedToolSet).map((tool) => tool.name)
assert.deepEqual(toolNames.sort(), ['get_schema', 'keyword_frequency', 'render_chart'])
assert.ok(!toolNames.includes('execute_sql'))
})
it('can expose render_chart for auto skill turns with restrictive assistant tools', () => {
const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools(['keyword_frequency']))
const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name)
assert.ok(toolNames.includes('keyword_frequency'))
assert.ok(toolNames.includes('render_chart'))
})
it('does not expose raw SQL when the assistant did not allow it', () => {
const allowedToolSet = new Set(['keyword_frequency'])
const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name)
assert.ok(toolNames.includes('keyword_frequency'))
assert.ok(!toolNames.includes('execute_sql'))
})
})
+38 -9
View File
@@ -4,12 +4,19 @@
*/
import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime'
import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime'
import {
SkillManager,
buildSkillMenuWithBuiltinChart,
createActivateSkillTool,
getSkillConfigWithBuiltinChart,
} from '@openchatlab/node-runtime'
import {
CHART_CAPABILITY_CORE_TOOLS,
CHART_CAPABILITY_SKILL_ID,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
getChatOverview,
shouldUseChartCapabilityForMessage,
} from '@openchatlab/core'
import type { DataSnapshot } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
@@ -27,6 +34,23 @@ function getAiDir(dbManager: DatabaseManager): string {
return pathProvider.getAiDataDir()
}
const RAW_SQL_TOOL_NAMES = new Set(['execute_sql'])
export function getAvailableToolDefs(isChartCapability: boolean, allowedToolSet: Set<string> | null) {
if (isChartCapability) {
const chartCoreTools = new Set<string>(CHART_CAPABILITY_CORE_TOOLS)
return AGENT_TOOL_REGISTRY.filter(
(tool) =>
chartCoreTools.has(tool.name) ||
(tool.category === 'analysis' && allowedToolSet?.has(tool.name) && !RAW_SQL_TOOL_NAMES.has(tool.name))
)
}
return allowedToolSet
? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name))
: AGENT_TOOL_REGISTRY
}
export function createCliRunAgentStream(
dbManager: DatabaseManager,
convManager: AIConversationManager
@@ -66,15 +90,18 @@ export function createCliRunAgentStream(
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
const db = (dbManager as any).open?.(sessionId)
const isChartCapability = skillId === CHART_CAPABILITY_SKILL_ID
const isChartCapability =
skillId === CHART_CAPABILITY_SKILL_ID || (!skillId && shouldUseChartCapabilityForMessage(userMessage))
const autoSkillAllowedTools =
!skillId && enableAutoSkill && assistantAllowedTools && assistantAllowedTools.length > 0
? getChartCapabilityAllowedBuiltinTools(assistantAllowedTools)
: assistantAllowedTools
const allowedToolSet = isChartCapability
? new Set(getChartCapabilityAllowedBuiltinTools(assistantAllowedTools))
: assistantAllowedTools && assistantAllowedTools.length > 0
? new Set(assistantAllowedTools)
: autoSkillAllowedTools && autoSkillAllowedTools.length > 0
? new Set(autoSkillAllowedTools)
: null
const availableToolDefs = allowedToolSet
? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name))
: AGENT_TOOL_REGISTRY
const availableToolDefs = getAvailableToolDefs(isChartCapability, allowedToolSet)
const agentTools = db
? adaptToolsForAgent(availableToolDefs, () => ({ db, sessionId, locale }), { maxToolResultTokens })
@@ -93,7 +120,7 @@ export function createCliRunAgentStream(
const def = skillMgr.getSkillConfig(skillId)
if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt }
} else if (enableAutoSkill) {
const menu = skillMgr.getSkillMenu(chatType ?? 'group', toolNames)
const menu = buildSkillMenuWithBuiltinChart(skillMgr.getSkillMenu(chatType ?? 'group', toolNames), locale)
if (menu) resolvedSkillMenu = menu
}
@@ -101,8 +128,10 @@ export function createCliRunAgentStream(
const activateSkillTool = createActivateSkillTool({
chatType: chatType ?? 'group',
allowedTools: toolNames,
coreToolNames: new Set<string>(CHART_CAPABILITY_CORE_TOOLS),
locale,
getSkillConfig: (id) => skillMgr.getSkillConfig(id),
getSkillConfig: (id) =>
getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => skillMgr.getSkillConfig(skillConfigId)),
})
agentTools.push(activateSkillTool as any)
}
+37 -2
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import { adaptToolsForAgent } from './tool-adapter'
import type { ToolDefinition } from '@openchatlab/tools'
import type { ChartPayload, DatabaseAdapter } from '@openchatlab/core'
import { CHART_SCHEMA_REQUIRED_MESSAGE } from '@openchatlab/node-runtime'
const chart: ChartPayload = {
version: 1,
@@ -30,7 +31,40 @@ const chart: ChartPayload = {
}
describe('adaptToolsForAgent', () => {
it('preserves chart payloads in tool result details', async () => {
it('requires get_schema before render_chart', async () => {
let chartCalls = 0
const tool: ToolDefinition = {
name: 'render_chart',
description: 'Render a chart',
inputSchema: { type: 'object', properties: {} },
async handler() {
chartCalls += 1
return { content: 'Generated chart.' }
},
}
const [agentTool] = adaptToolsForAgent([tool], () => ({
db: {} as DatabaseAdapter,
sessionId: 'session-1',
locale: 'en-US',
}))
const result = await agentTool.execute('call-1', {})
assert.equal(chartCalls, 0)
assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }])
assert.equal(result.details, null)
})
it('preserves chart payloads in tool result details after schema lookup', async () => {
const getSchemaTool: ToolDefinition = {
name: 'get_schema',
description: 'Get schema',
inputSchema: { type: 'object', properties: {} },
async handler() {
return { content: 'message(id, ts)' }
},
}
const tool: ToolDefinition = {
name: 'render_chart',
description: 'Render a chart',
@@ -44,13 +78,14 @@ describe('adaptToolsForAgent', () => {
},
}
const [agentTool] = adaptToolsForAgent([tool], () => ({
const [getSchema, agentTool] = adaptToolsForAgent([getSchemaTool, tool], () => ({
db: {} as DatabaseAdapter,
sessionId: 'session-1',
locale: 'en-US',
}))
assert.ok(agentTool)
await getSchema.execute('call-0', {})
const result = await agentTool.execute('call-1', {})
assert.deepEqual(result.content, [{ type: 'text', text: 'Generated chart.' }])
+61 -53
View File
@@ -15,6 +15,8 @@ import {
type AgentToolResult,
type PreprocessableMessage,
type TruncationStrategy,
createChartSchemaGateState,
wrapWithChartSchemaGate,
} from '@openchatlab/node-runtime'
import { getServerAiLogger } from './logger'
@@ -57,62 +59,68 @@ export function adaptToolsForAgent(
options?: AdaptToolsOptions
): AgentTool<any, any>[] {
const tokenBudget = options?.maxToolResultTokens ?? DEFAULT_MAX_TOOL_RESULT_TOKENS
const chartSchemaGateState = createChartSchemaGateState()
return tools.map((tool) => ({
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 toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
const ctx = getContext()
const execCtx: ToolExecutionContext = {
db: ctx.db,
dataProvider: new CoreDataProvider(ctx.db),
sessionId: ctx.sessionId,
locale: ctx.locale,
segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any),
}
try {
const result = await tool.handler(toolParams, execCtx)
const chartDetails =
result.chart || result.charts
? {
...(result.chart ? { chart: result.chart } : {}),
...(result.charts ? { charts: result.charts } : {}),
}
: {}
if (result.rawMessages && result.rawMessages.length > 0) {
const pipelineResult = applyPreprocessingPipeline({
rawMessages: result.rawMessages as PreprocessableMessage[],
return tools.map((tool) =>
wrapWithChartSchemaGate(
{
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 toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
const ctx = getContext()
const execCtx: ToolExecutionContext = {
db: ctx.db,
dataProvider: new CoreDataProvider(ctx.db),
sessionId: ctx.sessionId,
locale: ctx.locale,
maxToolResultTokens: tokenBudget,
truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last',
extraDetails: (result.data ?? {}) as Record<string, unknown>,
logger: getServerAiLogger() ?? undefined,
})
return {
content: [{ type: 'text', text: pipelineResult.text }],
details: Object.keys(chartDetails).length > 0 ? chartDetails : null,
segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any),
}
}
try {
const result = await tool.handler(toolParams, execCtx)
const chartDetails =
result.chart || result.charts
? {
...(result.chart ? { chart: result.chart } : {}),
...(result.charts ? { charts: result.charts } : {}),
}
: {}
const baseDetails =
typeof result.data === 'object' && result.data !== null
? (result.data as Record<string, unknown>)
: result.data === undefined
? null
: { value: result.data }
if (result.rawMessages && result.rawMessages.length > 0) {
const pipelineResult = applyPreprocessingPipeline({
rawMessages: result.rawMessages as PreprocessableMessage[],
locale: ctx.locale,
maxToolResultTokens: tokenBudget,
truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last',
extraDetails: (result.data ?? {}) as Record<string, unknown>,
logger: getServerAiLogger() ?? undefined,
})
return {
content: [{ type: 'text', text: pipelineResult.text }],
details: Object.keys(chartDetails).length > 0 ? chartDetails : null,
}
}
return {
content: [{ type: 'text', text: result.content }],
details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails,
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null }
}
},
}))
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) {
const msg = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null }
}
},
},
chartSchemaGateState
)
)
}
+23 -5
View File
@@ -8,10 +8,17 @@
import type { AgentStreamChunk as SharedAgentStreamChunk } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
import { checkAndCompress, createCompressionLlmAdapter, formatAIError, initTokenizer } from '@openchatlab/node-runtime'
import {
buildSkillMenuWithBuiltinChart,
checkAndCompress,
createCompressionLlmAdapter,
formatAIError,
initTokenizer,
} from '@openchatlab/node-runtime'
import type { CompressionConfig, CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime'
import { Agent, type AgentStreamChunk, type SkillContext } from './agent'
import type { ToolContext } from './tools/types'
import { getAllowedToolsForChartCapability } from './tools'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm'
import type { AIServiceConfig } from './llm/types'
import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral'
@@ -19,6 +26,7 @@ import {
CHART_CAPABILITY_SKILL_ID,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
shouldUseChartCapabilityForMessage,
} from '@openchatlab/core'
import * as assistantManager from './assistant'
import type { AssistantConfig } from './assistant/types'
@@ -152,7 +160,8 @@ export function createElectronRunAgentStream(): (
}
let skillCtx: SkillContext | undefined
const isChartCapability = skillId === CHART_CAPABILITY_SKILL_ID
const isChartCapability =
skillId === CHART_CAPABILITY_SKILL_ID || (!skillId && shouldUseChartCapabilityForMessage(userMessage))
if (isChartCapability) {
const chartSkill = getChartCapabilitySkill(locale ?? 'zh-CN')
skillCtx = { skillDef: { ...chartSkill, chatScope: 'all' } as SkillContext['skillDef'] }
@@ -163,7 +172,7 @@ export function createElectronRunAgentStream(): (
systemPrompt: '',
presetQuestions: [],
}),
allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(assistantConfig?.allowedBuiltinTools),
allowedBuiltinTools: getAllowedToolsForChartCapability(assistantConfig?.allowedBuiltinTools),
}
} else if (skillId) {
const skillDef = skillManager.getSkillConfig(skillId) ?? undefined
@@ -172,8 +181,17 @@ export function createElectronRunAgentStream(): (
}
} else if (enableAutoSkill) {
const effectiveChatType = chatType ?? 'group'
const allowedTools = assistantConfig?.allowedBuiltinTools
const menu = skillManager.getSkillMenu(effectiveChatType, allowedTools)
assistantConfig = {
...(assistantConfig ?? {
id: resolvedAssistantId,
name: resolvedAssistantId,
systemPrompt: '',
presetQuestions: [],
}),
allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(assistantConfig?.allowedBuiltinTools),
}
const allowedTools = assistantConfig.allowedBuiltinTools
const menu = buildSkillMenuWithBuiltinChart(skillManager.getSkillMenu(effectiveChatType, allowedTools), locale)
if (menu) {
skillCtx = { skillMenu: menu }
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core'
import { CHART_SCHEMA_REQUIRED_MESSAGE } from '@openchatlab/node-runtime'
import { createActivateSkillTool, getAllowedToolsForChartCapability, getAllTools } from './index'
describe('desktop chart capability tool filtering', () => {
it('does not expose raw SQL tools in chart-only turns', () => {
const toolNames = getAllowedToolsForChartCapability(['execute_sql'])
assert.deepEqual(toolNames, ['render_chart'])
assert.ok(!toolNames.includes('execute_sql'))
})
it('keeps explicitly allowed non-SQL analysis tools', () => {
const toolNames = getAllowedToolsForChartCapability(['keyword_frequency', 'execute_sql'])
assert.deepEqual(toolNames.sort(), ['keyword_frequency', 'render_chart'])
assert.ok(!toolNames.includes('execute_sql'))
})
it('activates the built-in chart skill without an imported skill file', async () => {
const tool = createActivateSkillTool('group', ['render_chart'], 'zh-CN')
const result = await tool.execute('call_1', { skill_id: CHART_CAPABILITY_SKILL_ID })
const block = result.content[0]
assert.equal(result.details.skillId, CHART_CAPABILITY_SKILL_ID)
assert.equal(result.details.applicable, true)
assert.equal(block.type, 'text')
if (block.type !== 'text') throw new Error('expected text content')
assert.match(block.text, /render_chart/)
})
it('requires get_schema before desktop render_chart execution', async () => {
const tools = getAllTools(
{
sessionId: 'session-1',
locale: 'en-US',
},
['render_chart']
)
const renderChart = tools.find((tool) => tool.name === 'render_chart')
assert.ok(renderChart)
const result = await renderChart.execute('call-1', {})
assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }])
assert.equal(result.details, null)
})
})
+26 -6
View File
@@ -6,20 +6,28 @@
*/
import type { AgentTool } from '@openchatlab/node-runtime'
import { CHART_CAPABILITY_ANALYSIS_TOOLS, getChartCapabilityAllowedBuiltinTools } from '@openchatlab/core'
import type { ToolContext, TruncationStrategy } from './types'
import { TOOL_REGISTRY } from './definitions'
const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name))
import { t as i18nT } from '../../i18n'
import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime'
import { aiLogger } from '../logger'
import { getSkillConfig } from '../skills'
import {
createActivateSkillTool as sharedCreateActivateSkillTool,
createChartSchemaGateState,
getSkillConfigWithBuiltinChart,
wrapWithChartSchemaGate,
} from '@openchatlab/node-runtime'
const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name))
const RAW_SQL_TOOL_NAME_SET = new Set(['execute_sql'])
const CHART_CAPABILITY_ANALYSIS_TOOL_SET = new Set<string>(CHART_CAPABILITY_ANALYSIS_TOOLS)
const preprocessLogger = {
info: (category: string, message: string, extra?: Record<string, unknown>) => aiLogger.info(category, message, extra),
warn: (category: string, message: string, extra?: Record<string, unknown>) => aiLogger.warn(category, message, extra),
}
import { getSkillConfig } from '../skills'
import { createActivateSkillTool as sharedCreateActivateSkillTool } from '@openchatlab/node-runtime'
const TRUNCATION_STRATEGY_MAP = new Map<string, TruncationStrategy>(
TOOL_REGISTRY.filter((e) => e.truncationStrategy).map((e) => [e.name, e.truncationStrategy!])
@@ -116,7 +124,18 @@ export function getAllTools(context: ToolContext, allowedTools?: string[]): Agen
)
}
return [...coreTools, ...analysisTools].map(translateTool).map((t) => wrapWithPreprocessing(t, context))
const chartSchemaGateState = createChartSchemaGateState()
return [...coreTools, ...analysisTools]
.map(translateTool)
.map((t) => wrapWithChartSchemaGate(t, chartSchemaGateState))
.map((t) => wrapWithPreprocessing(t, context))
}
export function getAllowedToolsForChartCapability(allowedTools?: readonly string[] | null): string[] {
return getChartCapabilityAllowedBuiltinTools(allowedTools).filter(
(toolName) => CHART_CAPABILITY_ANALYSIS_TOOL_SET.has(toolName) || !RAW_SQL_TOOL_NAME_SET.has(toolName)
)
}
/**
@@ -132,7 +151,8 @@ export function createActivateSkillTool(
chatType,
allowedTools,
locale,
getSkillConfig: (id) => getSkillConfig(id),
getSkillConfig: (id) =>
getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => getSkillConfig(skillConfigId)),
coreToolNames: CORE_TOOL_NAMES,
})
}
+2 -2
View File
@@ -26,8 +26,8 @@
"build:mac": "pnpm --filter @openchatlab/desktop build:mac",
"build:win": "pnpm --filter @openchatlab/desktop build:win",
"dev:app": "vite --config vite.web.config.mts",
"dev:serve": "bash scripts/dev-serve.sh",
"dev:web": "pnpm run ensure:server-native && CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts",
"dev:serve": "node scripts/dev-serve.mjs",
"dev:web": "node scripts/dev-web.mjs",
"ensure:server-native": "pnpm --filter chatlab-cli run ensure-native",
"build:web": "vite build --config vite.web.config.mts",
"docs:dev": "pnpm --filter @openchatlab/docs dev",
@@ -3,9 +3,11 @@ import assert from 'node:assert/strict'
import {
CHART_CAPABILITY_ANALYSIS_TOOLS,
CHART_CAPABILITY_CORE_TOOLS,
CHART_CAPABILITY_SKILL_ID,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
shouldUseChartCapabilityForMessage,
} from './chart-capability'
describe('chart capability skill', () => {
@@ -14,10 +16,43 @@ describe('chart capability skill', () => {
assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID)
assert.deepEqual(skill.tools, ['render_chart', 'get_schema'])
assert.deepEqual(CHART_CAPABILITY_CORE_TOOLS, ['get_schema'])
assert.match(skill.prompt, /render_chart/)
assert.match(skill.prompt, /get_schema/)
})
it('instructs common phrase charts to exclude non-human placeholders', () => {
const zhSkill = getChartCapabilitySkill('zh-CN')
const enSkill = getChartCapabilitySkill('en-US')
assert.match(zhSkill.prompt, /常用语、口头禅、高频短句/)
assert.match(zhSkill.prompt, /\[表情包\]/)
assert.match(zhSkill.prompt, /撤回\/删除提示/)
assert.match(enSkill.prompt, /catchphrase, common phrase, or frequent short-text charts/)
assert.match(enSkill.prompt, /\[Sticker\]/)
assert.match(enSkill.prompt, /recall\/deletion notices/)
})
it('tightens chart SQL and final-answer behavior', () => {
const zhSkill = getChartCapabilitySkill('zh-CN')
const enSkill = getChartCapabilitySkill('en-US')
assert.match(zhSkill.prompt, /必须先调用 get_schema/)
assert.match(zhSkill.prompt, /禁止猜测表名、字段名或时间字段/)
assert.match(zhSkill.prompt, /MAX\(ts\)/)
assert.match(zhSkill.prompt, /禁止写 ts\/1000/)
assert.match(zhSkill.prompt, /补齐日期 × series 的 0 值/)
assert.match(zhSkill.prompt, /工具失败、SQL 修正、schema 探索过程不要写进最终回答/)
assert.match(zhSkill.prompt, /禁止夸张拟人化/)
assert.match(enSkill.prompt, /Always call get_schema/)
assert.match(enSkill.prompt, /Do not guess table names/)
assert.match(enSkill.prompt, /never write ts\/1000/)
assert.match(enSkill.prompt, /fill the date x series grid with zero values/)
assert.match(enSkill.prompt, /Do not include failed SQL attempts/)
assert.match(enSkill.prompt, /Do not make exaggerated personality or relationship claims/)
})
it('adds only chart analysis tools to assistant builtin tools', () => {
assert.deepEqual(CHART_CAPABILITY_ANALYSIS_TOOLS, ['render_chart'])
assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart'])
@@ -33,4 +68,11 @@ describe('chart capability skill', () => {
'time_stats',
])
})
it('detects explicit chart requests', () => {
assert.equal(shouldUseChartCapabilityForMessage('画一个前五常用语占比饼图'), true)
assert.equal(shouldUseChartCapabilityForMessage('帮我做一下热力图'), true)
assert.equal(shouldUseChartCapabilityForMessage('visualize member trends as a line chart'), true)
assert.equal(shouldUseChartCapabilityForMessage('分析一下谁说话最多'), false)
})
})
+42 -8
View File
@@ -1,10 +1,20 @@
export const CHART_CAPABILITY_SKILL_ID = 'chart_runtime'
export const CHART_CAPABILITY_CORE_TOOLS = ['get_schema'] as const
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 shouldUseChartCapabilityForMessage(message: string): boolean {
const normalized = message.trim().toLowerCase()
if (!normalized) return false
return /(?:画|绘制|生成|做|出|展示).{0,8}(?:图|图表)|(?:图表|可视化|饼图|柱状图|条形图|折线图|热力图|趋势图|占比图|分布图|chart|charts|plot|visuali[sz]e|visuali[sz]ation|pie chart|bar chart|line chart|heatmap)/i.test(
normalized
)
}
export function getChartCapabilitySkill(locale: string = 'zh-CN'): {
id: string
name: string
@@ -30,11 +40,23 @@ const ZH_PROMPT = `你是 ChatLab 绘图助手。本轮用户希望你在回答
你必须通过 render_chart 工具生成图表。不要输出 HTML、JavaScript、SVG、Canvas、ECharts option 或任何渲染代码。
硬性规则:
1. 第一次写 SQL 前必须先调用 get_schema。禁止猜测表名、字段名或时间字段。
2. “最近”“到现在”“到最近”必须用数据中的 MAX(ts) 或等价字段确定结束日期;不要手算或臆测日期。
3. ChatLab 的 message.ts 是秒级 Unix 时间戳,使用 date(ts, 'unixepoch', 'localtime');禁止写 ts/1000。
4. “最近的 N 人/成员”必须先用数据定义并筛选出 N 个成员,再只统计这些成员。
5. 折线图有多条线时,每条线必须有明确 series 字段含义;按日期统计时应补齐日期 × series 的 0 值,避免缺失日期导致折线断裂或误连。
6. 工具失败、SQL 修正、schema 探索过程不要写进最终回答;最终只保留图表和简短结论。
7. 图表解释只能描述数据直接支持的趋势、峰值、低谷、差异和同步性,禁止夸张拟人化或无法从图中验证的关系判断。
工作流程:
1. 如不确定表结构,先调用 get_schema。
2. 根据用户要求确定统计对象、时间范围、维度、指标和图表类型。
3. 调用 render_chart,提交只读 SQL 和 ChartSpec v1。
4. 图表生成后,用简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart。
1. 调用 get_schema。
2. 根据 schema 和用户要求确定统计对象、时间范围、维度、指标和图表类型。
3. 必要时用只读 SQL 在 render_chart 中完成成员筛选、时间范围确认、聚合和补零。
4. 调用 render_chart,提交只读 SQL 和 ChartSpec v1。
5. 图表生成后,用 1-3 句简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart。
常用语、口头禅、高频短句类图表必须排除非真人文本:发送者为系统消息、媒体占位(如 [表情包]、[图片]、[语音]、[视频]、[文件]、[链接]、[红包]、[转账])以及撤回/删除提示都不能进入统计。
ChartSpec v1 支持:
- bar: encoding.x + encoding.y
@@ -57,11 +79,23 @@ const EN_PROMPT = `You are the ChatLab Chart Assistant. In this turn, the user w
You must generate charts through the render_chart tool. Do not output HTML, JavaScript, SVG, Canvas, ECharts options, or rendering code.
Hard rules:
1. Always call get_schema before writing the first SQL query. Do not guess table names, field names, or timestamp fields.
2. For "latest", "until now", or "through the latest date", derive the end date from MAX(ts) or the equivalent timestamp field in the data. Do not hand-calculate or assume dates.
3. ChatLab message.ts is a Unix timestamp in seconds. Use date(ts, 'unixepoch', 'localtime'); never write ts/1000.
4. For "latest N members" or "recent N people", first define and select those N members from the data, then count only those members.
5. For multi-series line charts, every line must have an explicit series field. For daily counts, fill the date x series grid with zero values so missing days do not break or mislead the line.
6. Do not include failed SQL attempts, schema exploration, or retry reasoning in the final answer. Final answer should contain only the chart and a short conclusion.
7. Explain only trends, peaks, lows, differences, and synchrony directly supported by chart data. Do not make exaggerated personality or relationship claims.
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.
1. Call get_schema.
2. Derive the target members, time range, dimensions, metrics, and chart type from schema and the user request.
3. When needed, use read-only SQL inside render_chart to select members, determine time range, aggregate data, and fill zero rows.
4. Call render_chart with read-only SQL and ChartSpec v1.
5. After the chart is generated, explain the key finding in 1-3 concise sentences. If the user asks for multiple charts, call render_chart multiple times.
For catchphrase, common phrase, or frequent short-text charts, exclude non-human text from the SQL: system-message senders, media placeholders such as [Sticker], [Image], [Photo], [Voice], [Video], [File], [Link], and recall/deletion notices must not be counted.
ChartSpec v1 supports:
- bar: encoding.x + encoding.y
+2
View File
@@ -7,9 +7,11 @@ export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog'
export { BUILTIN_TOOL_CATALOG } from './tool-catalog'
export {
CHART_CAPABILITY_SKILL_ID,
CHART_CAPABILITY_CORE_TOOLS,
CHART_CAPABILITY_ANALYSIS_TOOLS,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
shouldUseChartCapabilityForMessage,
} from './chart-capability'
// LLM 模型系统类型
+1
View File
@@ -33,6 +33,7 @@ export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [
{ name: 'response_time_analysis', category: 'analysis' },
{ name: 'keyword_frequency', category: 'analysis' },
{ name: 'render_chart', category: 'analysis' },
{ name: 'execute_sql', category: 'analysis' },
// SQL Analysis 工具
{ name: 'message_type_breakdown', category: 'analysis' },
+4
View File
@@ -80,6 +80,8 @@ export {
getClusterGraph,
getRelationshipStats,
getLanguagePreferenceAnalysis,
isHumanCatchphraseContent,
NON_HUMAN_CATCHPHRASE_PLACEHOLDERS,
FULL_MSG_COLUMNS,
FULL_MSG_FROM,
FULL_MSG_SELECT,
@@ -247,9 +249,11 @@ export {
getBuiltinModelsByProvider,
getBuiltinModelById,
CHART_CAPABILITY_SKILL_ID,
CHART_CAPABILITY_CORE_TOOLS,
CHART_CAPABILITY_ANALYSIS_TOOLS,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
shouldUseChartCapabilityForMessage,
THINK_TAGS,
extractThinkingContent,
stripToolCallTags,
@@ -0,0 +1,39 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core'
import { buildSkillMenuWithBuiltinChart, getSkillConfigWithBuiltinChart } from '../builtin-chart-skill'
describe('builtin chart skill helpers', () => {
it('adds chart_runtime to an empty auto skill menu', () => {
const menu = buildSkillMenuWithBuiltinChart(null, 'zh-CN')
assert.match(menu, /chart_runtime/)
assert.match(menu, /绘图助手/)
assert.match(menu, /不要输出 Python\/JS 绘图代码/)
})
it('appends chart_runtime to an existing auto skill menu', () => {
const baseMenu = `## 可用技能
以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时,
请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。
- existing: Existing Skill — Existing description
如果用户的问题不需要使用技能,直接回答即可。`
const menu = buildSkillMenuWithBuiltinChart(baseMenu, 'zh-CN')
assert.match(menu, /existing/)
assert.match(menu, /chart_runtime/)
assert.ok(menu.indexOf('existing') < menu.indexOf('chart_runtime'))
})
it('resolves chart_runtime without a user-imported skill file', () => {
const skill = getSkillConfigWithBuiltinChart(CHART_CAPABILITY_SKILL_ID, 'en-US', () => null)
assert.ok(skill)
assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID)
assert.match(skill.prompt, /render_chart/)
})
})
@@ -0,0 +1,48 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import type { AgentTool } from '@earendil-works/pi-agent-core'
import {
CHART_SCHEMA_REQUIRED_MESSAGE,
createChartSchemaGateState,
wrapWithChartSchemaGate,
} from '../chart-schema-gate'
function createTool(name: string, calls: string[]): AgentTool<any, any> {
return {
name,
label: name,
description: name,
parameters: { type: 'object', properties: {}, required: [] },
async execute() {
calls.push(name)
return { content: [{ type: 'text', text: `${name} ok` }], details: { name } }
},
}
}
describe('chart schema gate', () => {
it('blocks render_chart until get_schema has been called in the same tool set', async () => {
const calls: string[] = []
const state = createChartSchemaGateState()
const renderChart = wrapWithChartSchemaGate(createTool('render_chart', calls), state)
const result = await renderChart.execute('call-1', {})
assert.deepEqual(calls, [])
assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }])
assert.equal(result.details, null)
})
it('allows render_chart after get_schema has been called', async () => {
const calls: string[] = []
const state = createChartSchemaGateState()
const getSchema = wrapWithChartSchemaGate(createTool('get_schema', calls), state)
const renderChart = wrapWithChartSchemaGate(createTool('render_chart', calls), state)
await getSchema.execute('call-1', {})
const result = await renderChart.execute('call-2', {})
assert.deepEqual(calls, ['get_schema', 'render_chart'])
assert.deepEqual(result.content, [{ type: 'text', text: 'render_chart ok' }])
})
})
@@ -0,0 +1,46 @@
import { CHART_CAPABILITY_SKILL_ID, getChartCapabilitySkill } from '@openchatlab/core'
import type { SkillDef } from './types'
type SkillGetter = (id: string) => SkillDef | null
export function getBuiltinChartSkill(locale: string = 'zh-CN'): SkillDef {
return { ...getChartCapabilitySkill(locale), builtinId: CHART_CAPABILITY_SKILL_ID }
}
function getChartMenuLine(locale: string): string {
const skill = getBuiltinChartSkill(locale)
const isZh = locale.startsWith('zh')
const guidance = isZh
? '用户明确要求图表、画图、占比、趋势、分布、饼图、柱状图、折线图或热力图时优先激活;不要输出 Python/JS 绘图代码'
: 'Activate first when the user explicitly asks for charts, visualization, ratios, trends, distributions, pie, bar, line, or heatmap charts; do not output Python/JS chart code'
return `- ${skill.id}: ${skill.name} — ${skill.description}. ${guidance}`
}
export function buildSkillMenuWithBuiltinChart(baseMenu: string | null | undefined, locale: string = 'zh-CN'): string {
if (baseMenu?.includes(CHART_CAPABILITY_SKILL_ID)) return baseMenu
const chartLine = getChartMenuLine(locale)
if (!baseMenu) {
return `## 可用技能
以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时,
请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。
${chartLine}
如果用户的问题不需要使用技能,直接回答即可。`
}
const closing = '\n\n如果用户的问题不需要使用技能,直接回答即可。'
if (!baseMenu.includes(closing)) return `${baseMenu}\n${chartLine}`
return baseMenu.replace(closing, `\n${chartLine}${closing}`)
}
export function getSkillConfigWithBuiltinChart(
id: string,
locale: string = 'zh-CN',
getSkillConfig: SkillGetter
): SkillDef | null {
if (id === CHART_CAPABILITY_SKILL_ID) return getBuiltinChartSkill(locale)
return getSkillConfig(id)
}
@@ -0,0 +1,41 @@
import type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core'
export const CHART_SCHEMA_REQUIRED_MESSAGE =
'Error: Call get_schema before render_chart. Do not guess table names, fields, or timestamp units.'
export interface ChartSchemaGateState {
schemaSeen: boolean
}
export function createChartSchemaGateState(): ChartSchemaGateState {
return { schemaSeen: false }
}
export function wrapWithChartSchemaGate(tool: AgentTool<any>, state: ChartSchemaGateState): AgentTool<any> {
const originalExecute = tool.execute
return {
...tool,
execute: async (
toolCallId: string,
params: any,
signal?: AbortSignal,
onUpdate?: unknown
): Promise<AgentToolResult<any>> => {
if (tool.name === 'render_chart' && !state.schemaSeen) {
return {
content: [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }],
details: null,
} as AgentToolResult<any>
}
const result = await originalExecute(toolCallId, params, signal, onUpdate as never)
if (tool.name === 'get_schema') {
state.schemaSeen = true
}
return result
},
}
}
+7
View File
@@ -52,6 +52,13 @@ export type {
export type { SkillManagerLogger } from './skill-manager'
export { createActivateSkillTool } from './activate-skill-tool'
export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './activate-skill-tool'
export {
buildSkillMenuWithBuiltinChart,
getBuiltinChartSkill,
getSkillConfigWithBuiltinChart,
} from './builtin-chart-skill'
export { CHART_SCHEMA_REQUIRED_MESSAGE, createChartSchemaGateState, wrapWithChartSchemaGate } from './chart-schema-gate'
export type { ChartSchemaGateState } from './chart-schema-gate'
// Compression
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './compression'
+9
View File
@@ -126,6 +126,15 @@ export type { CreateCompressionLlmAdapterOptions } from './ai'
// SkillManager
export { SkillManager } from './ai'
export {
buildSkillMenuWithBuiltinChart,
getBuiltinChartSkill,
getSkillConfigWithBuiltinChart,
CHART_SCHEMA_REQUIRED_MESSAGE,
createChartSchemaGateState,
wrapWithChartSchemaGate,
} from './ai'
export type { ChartSchemaGateState } from './ai'
// SkillManagerCore
export { SkillManagerCore } from './ai'
@@ -108,6 +108,22 @@ describe('renderChartTool', () => {
assert.equal(calls.length, 0)
})
it('rejects dividing ChatLab second timestamps by 1000', async () => {
const calls: Array<{ query: string; params: Record<string, unknown> }> = []
const context = createContext([], calls)
await assert.rejects(async () => {
await renderChartTool.handler(
{
sql: "SELECT date(ts/1000, 'unixepoch') AS day, COUNT(*) AS message_count FROM message GROUP BY day",
chartSpec: barSpec,
},
context
)
}, /message\.ts is already a Unix timestamp in seconds/)
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)
@@ -9,6 +9,7 @@ import { buildChartPayload } from '@openchatlab/core'
import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types'
const DEFAULT_MAX_ROWS = 1000
const RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS = /\b(?:\w+\.)?ts\s*\/\s*1000\b/i
const inputSchema: JsonSchema = {
type: 'object',
@@ -55,6 +56,10 @@ function normalizeSql(sql: unknown, maxRows: number): string {
throw new Error('render_chart only accepts SELECT or WITH SELECT SQL')
}
if (RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS.test(trimmed)) {
throw new Error('message.ts is already a Unix timestamp in seconds; do not divide ts by 1000')
}
return `SELECT * FROM (\n${trimmed}\n) AS chart_query LIMIT ${maxRows + 1}`
}
@@ -51,6 +51,7 @@ export const sqlQueryTool: ToolDefinition = {
description: '对聊天数据库执行只读 SELECT 查询。使用前可先调用 get_schema 查看表结构。',
inputSchema,
handler,
category: 'analysis',
}
export const schemaTool: ToolDefinition = {
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Cross-platform API server dev launcher.
*/
import { spawnSync } from 'node:child_process'
const pnpm = 'pnpm'
const extraArgs = process.argv.slice(2)
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
shell: false,
...options,
})
if (result.error) {
console.error(result.error.message)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function runPnpm(args, options = {}) {
if (process.env.npm_execpath) {
run(process.execPath, [process.env.npm_execpath, ...args], options)
return
}
run(pnpm, args, { shell: process.platform === 'win32', ...options })
}
runPnpm(['run', 'ensure:server-native'])
runPnpm([
'exec',
'tsx',
'watch',
'--include',
'packages/core/src/**',
'--include',
'packages/node-runtime/src/**',
'apps/cli/src/cli.ts',
'start',
'--headless',
'--no-open',
...extraArgs,
])
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Cross-platform Web dev launcher.
*
* Windows shells do not support inline env syntax such as
* `CHATLAB_AUTO_SERVE=1 vite ...`, so set the env in Node before spawning Vite.
*/
import { spawnSync } from 'node:child_process'
const pnpm = 'pnpm'
const extraArgs = process.argv.slice(2)
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
shell: false,
...options,
})
if (result.error) {
console.error(result.error.message)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function runPnpm(args, options = {}) {
if (process.env.npm_execpath) {
run(process.execPath, [process.env.npm_execpath, ...args], options)
return
}
run(pnpm, args, { shell: process.platform === 'win32', ...options })
}
runPnpm(['run', 'ensure:server-native'])
runPnpm(['exec', 'vite', '--config', 'vite.web.config.mts', ...extraArgs], {
env: {
...process.env,
CHATLAB_AUTO_SERVE: '1',
},
})
+137 -10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
BarChartRenderData,
ChartPayload,
@@ -12,10 +13,13 @@ import EChartHeatmap from '@/components/charts/EChartHeatmap.vue'
import EChartLine from '@/components/charts/EChartLine.vue'
import EChartPie from '@/components/charts/EChartPie.vue'
const { t } = useI18n()
const props = defineProps<{
chart: ChartPayload
}>()
const isExpandedOpen = ref(false)
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))
@@ -33,6 +37,43 @@ 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)
const isDenseChart = computed(() => {
if (props.chart.spec.type === 'pie') return pieData.value.labels.length > 8
if (props.chart.spec.type === 'line') {
return lineData.value.labels.length > 28 || (lineData.value.series?.length ?? 0) > 4
}
if (props.chart.spec.type === 'bar') return barData.value.labels.length > 12
if (props.chart.spec.type === 'heatmap') {
return heatmapData.value.xLabels.length * heatmapData.value.yLabels.length > 180
}
return false
})
const compactHeight = computed(() => {
const maxHeight = isDenseChart.value ? 320 : 380
return Math.min(height.value, maxHeight)
})
const expandedHeight = computed(() => {
const requested = height.value
if (props.chart.spec.type === 'pie') return Math.max(requested, 560)
if (props.chart.spec.type === 'line') return Math.max(requested, lineData.value.labels.length > 40 ? 620 : 540)
if (props.chart.spec.type === 'bar') {
const dynamicHeight = props.chart.spec.display?.horizontal ? barData.value.labels.length * 28 + 120 : 520
return Math.min(Math.max(requested, dynamicHeight), 820)
}
if (props.chart.spec.type === 'heatmap') {
return Math.min(Math.max(requested, heatmapData.value.yLabels.length * 22 + 150, 560), 860)
}
return Math.max(requested, 520)
})
const expandedMinWidth = computed(() => {
if (props.chart.spec.type === 'pie') return '640px'
if (props.chart.spec.type === 'bar') return '720px'
return '780px'
})
</script>
<template>
@@ -47,11 +88,90 @@ const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
{{ 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 class="flex shrink-0 items-center gap-1.5">
<span class="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>
<UModal
v-model:open="isExpandedOpen"
:ui="{
overlay: 'z-[10040] backdrop-blur-sm',
content: 'z-[10050] w-[96vw] max-w-6xl max-h-[92vh] overflow-hidden',
}"
>
<UButton
icon="i-heroicons-arrows-pointing-out"
size="xs"
color="neutral"
variant="ghost"
class="no-capture"
:title="t('ai.chat.message.chart.expand')"
:aria-label="t('ai.chat.message.chart.expand')"
@click.stop
/>
<template #content>
<div class="flex max-h-[92vh] flex-col overflow-hidden bg-white dark:bg-gray-950">
<div
class="flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4 dark:border-gray-800"
>
<div class="min-w-0">
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-gray-100">{{ title }}</h3>
<p v-if="subtitle" class="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
{{ subtitle }}
</p>
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">{{ summary }}</p>
</div>
<UButton
icon="i-heroicons-x-mark"
size="sm"
color="neutral"
variant="ghost"
class="shrink-0"
:title="t('common.close')"
:aria-label="t('common.close')"
@click="isExpandedOpen = false"
/>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<div class="mx-auto w-full" :style="{ minWidth: expandedMinWidth }">
<div
v-if="isEmpty"
class="flex h-64 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="expandedHeight"
mode="expanded"
:horizontal="chart.spec.display?.horizontal"
/>
<EChartLine
v-else-if="chart.spec.type === 'line'"
:data="lineData"
:height="expandedHeight"
mode="expanded"
/>
<EChartPie
v-else-if="chart.spec.type === 'pie'"
:data="pieData"
:height="expandedHeight"
mode="expanded"
:show-legend="chart.spec.display?.showLegend ?? true"
/>
<EChartHeatmap
v-else-if="chart.spec.type === 'heatmap'"
:data="heatmapData"
:height="expandedHeight"
mode="expanded"
/>
</div>
</div>
</div>
</template>
</UModal>
</div>
</div>
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">{{ summary }}</p>
</div>
@@ -63,17 +183,24 @@ const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
<EChartBar
v-else-if="chart.spec.type === 'bar'"
:data="barData"
:height="height"
:height="compactHeight"
mode="compact"
:horizontal="chart.spec.display?.horizontal"
/>
<EChartLine v-else-if="chart.spec.type === 'line'" :data="lineData" :height="height" />
<EChartLine v-else-if="chart.spec.type === 'line'" :data="lineData" :height="compactHeight" mode="compact" />
<EChartPie
v-else-if="chart.spec.type === 'pie'"
:data="pieData"
:height="height"
:height="compactHeight"
mode="compact"
:show-legend="chart.spec.display?.showLegend ?? true"
/>
<EChartHeatmap v-else-if="chart.spec.type === 'heatmap'" :data="heatmapData" :height="height" />
<EChartHeatmap
v-else-if="chart.spec.type === 'heatmap'"
:data="heatmapData"
:height="compactHeight"
mode="compact"
/>
</div>
</div>
</template>
+8
View File
@@ -13,6 +13,7 @@ import {
LegendComponent,
GridComponent,
VisualMapComponent,
DataZoomComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
@@ -28,6 +29,7 @@ echarts.use([
LegendComponent,
GridComponent,
VisualMapComponent,
DataZoomComponent,
])
interface Props {
@@ -45,6 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
const chartRef = ref<HTMLDivElement>()
let chartInstance: echarts.ECharts | null = null
let resizeObserver: ResizeObserver | null = null
// 计算高度样式
const heightStyle = computed(() => {
@@ -134,6 +137,10 @@ onMounted(() => {
initChart()
})
window.addEventListener('resize', handleResize)
if (chartRef.value) {
resizeObserver = new ResizeObserver(() => handleResize())
resizeObserver.observe(chartRef.value)
}
// 监听 HTML 元素的 class 变化(用于检测暗色模式切换)
observer = new MutationObserver(() => {
@@ -149,6 +156,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
observer?.disconnect()
chartInstance?.dispose()
chartInstance = null
+15 -4
View File
@@ -14,6 +14,7 @@ export interface EChartBarData {
interface Props {
data: EChartBarData
height?: number
mode?: 'compact' | 'expanded'
/** 是否为横向柱状图 */
horizontal?: boolean
/** 是否显示渐变色 */
@@ -24,6 +25,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
height: 200,
mode: 'expanded',
horizontal: false,
gradient: true,
borderRadius: 4,
@@ -44,10 +46,14 @@ const gradientColor = {
const option = computed<EChartsOption>(() => {
const isHorizontal = props.horizontal
const isCompact = props.mode === 'compact'
const labelCount = props.data.labels.length
return {
tooltip: {
trigger: 'axis',
confine: true,
extraCssText: 'max-width: min(360px, 70vw); white-space: normal; word-break: break-word;',
axisPointer: {
type: 'shadow',
},
@@ -58,11 +64,11 @@ const option = computed<EChartsOption>(() => {
},
},
grid: {
left: isHorizontal ? 60 : 40,
right: 20,
left: isHorizontal ? (isCompact ? 72 : 96) : 36,
right: 16,
top: 20,
bottom: isHorizontal ? 20 : 30,
containLabel: false,
bottom: isHorizontal ? 20 : labelCount > 8 ? 48 : 30,
containLabel: true,
},
xAxis: isHorizontal
? {
@@ -84,6 +90,9 @@ const option = computed<EChartsOption>(() => {
axisLabel: {
fontSize: 11,
color: '#6b7280',
interval: 'auto',
rotate: !isHorizontal && labelCount > 8 ? 28 : 0,
hideOverlap: true,
},
},
yAxis: isHorizontal
@@ -95,6 +104,8 @@ const option = computed<EChartsOption>(() => {
axisLabel: {
fontSize: 11,
color: '#6b7280',
overflow: 'truncate',
width: isCompact ? 70 : 110,
},
}
: {
+19 -8
View File
@@ -19,6 +19,7 @@ export interface EChartHeatmapData {
interface Props {
data: EChartHeatmapData
height?: number
mode?: 'compact' | 'expanded'
/** 最小值颜色 */
minColor?: string
/** 最大值颜色 */
@@ -27,6 +28,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
height: 280,
mode: 'expanded',
minColor: '#fee5e8', // 项目主题 pink-100
maxColor: '#ee4567', // 项目主题 pink-500
})
@@ -41,9 +43,14 @@ const maxValue = computed(() => {
})
const option = computed<EChartsOption>(() => {
const isCompact = props.mode === 'compact'
const xCount = props.data.xLabels.length
const yCount = props.data.yLabels.length
return {
tooltip: {
position: 'top',
confine: true,
extraCssText: 'max-width: min(360px, 70vw); white-space: normal; word-break: break-word;',
formatter: (params: any) => {
const xLabel = props.data.xLabels[params.data[0]]
const yLabel = props.data.yLabels[params.data[1]]
@@ -57,10 +64,11 @@ const option = computed<EChartsOption>(() => {
},
},
grid: {
left: 60,
left: isCompact ? 52 : 72,
right: 20,
top: 20,
bottom: 60,
top: isCompact ? 12 : 24,
bottom: isCompact ? 52 : 70,
containLabel: true,
},
xAxis: {
type: 'category',
@@ -71,9 +79,10 @@ const option = computed<EChartsOption>(() => {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 11,
fontSize: isCompact ? 10 : 11,
color: '#6b7280',
interval: 0,
interval: isCompact && xCount > 16 ? 'auto' : 0,
hideOverlap: true,
},
},
yAxis: {
@@ -85,8 +94,10 @@ const option = computed<EChartsOption>(() => {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
fontSize: 11,
fontSize: isCompact ? 10 : 11,
color: '#6b7280',
interval: isCompact && yCount > 14 ? 'auto' : 0,
hideOverlap: true,
},
},
visualMap: {
@@ -96,8 +107,8 @@ const option = computed<EChartsOption>(() => {
orient: 'horizontal',
left: 'center',
bottom: 0,
itemWidth: 10,
itemHeight: 120,
itemWidth: isCompact ? 8 : 10,
itemHeight: isCompact ? 84 : 120,
inRange: {
color: [props.minColor, props.maxColor],
},
+25 -4
View File
@@ -15,6 +15,7 @@ export interface EChartLineData {
interface Props {
data: EChartLineData
height?: number
mode?: 'compact' | 'expanded'
/** 是否显示面积 */
showArea?: boolean
/** 是否平滑曲线 */
@@ -23,12 +24,18 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
height: 288,
mode: 'expanded',
showArea: true,
smooth: true,
})
const option = computed<EChartsOption>(() => {
const hasSeries = !!props.data.series?.length
const isCompact = props.mode === 'compact'
const seriesCount = props.data.series?.length ?? 0
const labelCount = props.data.labels.length
const useScrollableLegend = seriesCount > (isCompact ? 3 : 8)
const useDataZoom = !isCompact && labelCount > 30
const series = hasSeries
? props.data.series!.map((item) => ({
name: item.name,
@@ -37,7 +44,7 @@ const option = computed<EChartsOption>(() => {
smooth: props.smooth,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
showSymbol: !isCompact && labelCount <= 80,
lineStyle: {
width: 2,
},
@@ -53,7 +60,7 @@ const option = computed<EChartsOption>(() => {
smooth: props.smooth,
symbol: 'circle',
symbolSize: 4,
showSymbol: false,
showSymbol: !isCompact && labelCount <= 80,
lineStyle: {
width: 2,
color: '#ee4567',
@@ -90,6 +97,8 @@ const option = computed<EChartsOption>(() => {
return {
tooltip: {
trigger: 'axis',
confine: true,
extraCssText: 'max-width: min(420px, 76vw); white-space: normal; word-break: break-word;',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
@@ -99,12 +108,17 @@ const option = computed<EChartsOption>(() => {
grid: {
left: 50,
right: 20,
top: hasSeries ? 36 : 20,
bottom: 30,
top: hasSeries ? (isCompact ? 44 : 56) : 20,
bottom: useDataZoom ? 66 : 34,
containLabel: true,
},
legend: hasSeries
? {
type: useScrollableLegend ? 'scroll' : 'plain',
top: 0,
left: 8,
right: 8,
height: 32,
textStyle: { color: '#6b7280', fontSize: 11 },
}
: undefined,
@@ -119,6 +133,7 @@ const option = computed<EChartsOption>(() => {
color: '#6b7280',
// 自动间隔显示标签
interval: 'auto',
hideOverlap: true,
},
},
yAxis: {
@@ -132,6 +147,12 @@ const option = computed<EChartsOption>(() => {
},
},
},
dataZoom: useDataZoom
? [
{ type: 'inside', throttle: 50 },
{ type: 'slider', height: 18, bottom: 18, showDetail: false },
]
: undefined,
series,
}
})
+47 -14
View File
@@ -14,6 +14,7 @@ export interface EChartPieData {
interface Props {
data: EChartPieData
height?: number
mode?: 'compact' | 'expanded'
/** 是否为环形图 */
doughnut?: boolean
/** 内圈半径(环形图时生效) */
@@ -24,6 +25,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
height: 280,
mode: 'expanded',
doughnut: true,
innerRadius: '50%',
showLegend: true,
@@ -48,33 +50,57 @@ const option = computed<EChartsOption>(() => {
name: label,
value: props.data.values[index],
}))
const itemCount = props.data.labels.length
const isCompact = props.mode === 'compact'
const isDense = itemCount > (isCompact ? 8 : 14)
const useScrollableLegend = itemCount > (isCompact ? 6 : 12)
const legendPlacement = isCompact
? {
type: useScrollableLegend ? 'scroll' : 'plain',
orient: 'horizontal' as const,
left: 8,
right: 8,
bottom: 0,
height: 28,
textStyle: {
fontSize: 11,
overflow: 'truncate' as const,
width: 72,
},
}
: {
type: useScrollableLegend ? 'scroll' : 'plain',
orient: 'vertical' as const,
right: 10,
top: 24,
bottom: 24,
textStyle: {
fontSize: 12,
overflow: 'truncate' as const,
width: 120,
},
}
const showOuterLabel = !isCompact && !isDense
return {
color: colors,
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
confine: true,
extraCssText: 'max-width: min(360px, 70vw); white-space: normal; word-break: break-word;',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
color: '#fff',
},
},
legend: props.showLegend
? {
orient: 'vertical',
right: 10,
top: 'center',
textStyle: {
fontSize: 12,
},
}
: undefined,
legend: props.showLegend ? legendPlacement : undefined,
series: [
{
type: 'pie',
radius: props.doughnut ? [props.innerRadius, '70%'] : '70%',
center: props.showLegend ? ['35%', '50%'] : ['50%', '50%'],
radius: props.doughnut ? [props.innerRadius, isCompact ? '58%' : '68%'] : isCompact ? '58%' : '68%',
center: props.showLegend ? (isCompact ? ['50%', '42%'] : ['38%', '50%']) : ['50%', isCompact ? '46%' : '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 4,
@@ -82,13 +108,20 @@ const option = computed<EChartsOption>(() => {
borderWidth: 2,
},
label: {
show: false,
show: showOuterLabel,
formatter: '{b}',
overflow: 'truncate',
width: 110,
alignTo: 'edge',
edgeDistance: 12,
},
emphasis: {
label: {
show: true,
show: showOuterLabel,
fontSize: 14,
fontWeight: 'bold',
overflow: 'truncate',
width: 140,
},
itemStyle: {
shadowBlur: 10,
+3
View File
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "Fork conversation from here"
},
"chart": {
"expand": "Open large chart"
},
"tools": {
"get_chat_overview": "Get Chat Overview",
"search_messages": "Search Messages",
+3
View File
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "ここから会話をフォーク"
},
"chart": {
"expand": "大きなチャートを開く"
},
"tools": {
"get_chat_overview": "チャット概要を取得",
"search_messages": "チャット履歴を検索",
+3
View File
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "从此处复刻对话"
},
"chart": {
"expand": "打开大图"
},
"tools": {
"get_chat_overview": "获取聊天概览",
"search_messages": "搜索聊天记录",
+3
View File
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "從此處複製對話"
},
"chart": {
"expand": "開啟大圖"
},
"tools": {
"get_chat_overview": "取得聊天概覽",
"search_messages": "搜尋聊天紀錄",
@@ -151,9 +151,14 @@ describe('real external LLM chart flow', () => {
Test-specific requirements:
- Use get_schema if you need table names or columns.
- Produce exactly two render_chart calls.
- Do not make exploratory, retry, or debugging render_chart calls beyond those two charts.
- 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.`,
- Use the data from June 1-2, 2026 inclusive.
- The database stores Unix seconds in message.ts. Use this exact UTC range:
- start: 1780272000
- end: 1780444799
- Member display names in chat messages are stored as Alice, Bob, and Cara in message.sender_group_nickname.`,
tools,
history: [],
userMessage:
@@ -181,7 +186,8 @@ Test-specific requirements:
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')
const heatmapHours = new Set(heatmap.data.xLabels.map((label) => Number(label)))
assert.ok(heatmapHours.has(9), 'heatmap should include hour 9')
assert.ok(heatmap.data.data.length > 0, 'heatmap should include density cells')
const finalContent = events
+6 -6
View File
@@ -11,6 +11,7 @@ const path = require('node:path')
const fs = require('node:fs')
const os = require('node:os')
const net = require('node:net')
const electron = require('electron')
const DEFAULT_START_PORT = 9222
const DEFAULT_MAX_PORT_RETRIES = 100
@@ -142,6 +143,7 @@ async function launchApp(options = {}, deps = {}) {
const portProbeTimeoutMs = options.portProbeTimeoutMs ?? DEFAULT_PORT_PROBE_TIMEOUT_MS
const startupWaitTime = options.startupWaitTime ?? DEFAULT_STARTUP_WAIT_MS
const forceKillTimeoutMs = options.forceKillTimeoutMs ?? DEFAULT_FORCE_KILL_TIMEOUT_MS
const envOverrides = options.envOverrides || {}
if (!port) {
const reservation = await findPortFn(startPort, maxPortRetries, 0, {
@@ -165,17 +167,14 @@ async function launchApp(options = {}, deps = {}) {
fsImpl.mkdirSync(userDataDir, { recursive: true })
}
const appPath = path.resolve(__dirname, '../../..')
const appPath = path.resolve(__dirname, '../../../apps/desktop')
if (!fsImpl.existsSync(appPath)) {
throw new Error(`[AppLauncher] 应用目录不存在: ${appPath}`)
}
const electronExe =
process.platform === 'win32'
? path.resolve(appPath, 'node_modules/.bin/electron.cmd')
: path.resolve(appPath, 'node_modules/.bin/electron')
const electronExe = typeof electron === 'string' ? electron : ''
if (!fsImpl.existsSync(electronExe)) {
if (!electronExe || !fsImpl.existsSync(electronExe)) {
throw new Error(`Electron 可执行文件不存在: ${electronExe}`)
}
@@ -196,6 +195,7 @@ async function launchApp(options = {}, deps = {}) {
TEST_MODE: 'true',
CHATLAB_E2E_USER_DATA_DIR: userDataDir,
ELECTRON_ENABLE_LOGGING: '1',
...envOverrides,
},
})
} finally {
+47 -1
View File
@@ -130,7 +130,7 @@ test('launchApp 支持 startPort 并正确注入 TEST_MODE 环境', async () =>
assert.equal(captured.startPort, 9900)
assert.equal(captured.reservationClosed, true)
assert.match(captured.spawnCmd, /electron(\.cmd)?$/)
assert.match(captured.spawnCmd, /electron(\.cmd|\.exe)?$/)
assert.deepEqual(captured.spawnArgs, ['--remote-debugging-port=9901', captured.spawnArgs[1]])
assert.equal(captured.spawnEnv.TEST_MODE, 'true')
assert.match(captured.spawnEnv.CHATLAB_E2E_USER_DATA_DIR, /chatlab-e2e-9901$/)
@@ -138,3 +138,49 @@ test('launchApp 支持 startPort 并正确注入 TEST_MODE 环境', async () =>
await app.close()
assert.deepEqual(captured.killSignals, ['SIGTERM'])
})
test('launchApp 允许注入额外环境变量覆盖', async () => {
const captured = {
spawnEnv: null,
}
const proc = new EventEmitter()
proc.pid = 45678
proc.exitCode = null
proc.signalCode = null
proc.kill = () => {
proc.exitCode = 0
proc.emit('exit', 0, null)
return true
}
const app = await launchApp(
{
startupWaitTime: 1,
envOverrides: {
CHATLAB_DATA_DIR: 'C:\\temp\\chat-data',
USERPROFILE: 'C:\\temp\\profile',
},
},
{
fsImpl: {
existsSync: () => true,
mkdirSync: () => {},
},
sleepFn: async () => {},
findPortFn: async () => ({
port: 9910,
reservationServer: { close: () => {} },
}),
spawnFn: (_cmd, _args, options) => {
captured.spawnEnv = options.env
return proc
},
}
)
assert.equal(captured.spawnEnv.CHATLAB_DATA_DIR, 'C:\\temp\\chat-data')
assert.equal(captured.spawnEnv.USERPROFILE, 'C:\\temp\\profile')
await app.close()
})
+8 -4
View File
@@ -4,10 +4,14 @@ const { spawnSync } = require('node:child_process')
process.env.CHATLAB_RUN_E2E_SMOKE = '1'
const result = spawnSync(process.execPath, ['--test', 'tests/e2e/smoke/app-launcher.smoke.test.js'], {
stdio: 'inherit',
env: process.env,
})
const result = spawnSync(
process.execPath,
['--test', 'tests/e2e/smoke/app-launcher.smoke.test.js', 'tests/e2e/smoke/chart-runtime.smoke.test.js'],
{
stdio: 'inherit',
env: process.env,
}
)
if (typeof result.status === 'number') {
process.exit(result.status)
+27 -6
View File
@@ -2,6 +2,7 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const http = require('node:http')
const { launchApp } = require('../helpers/app-launcher')
@@ -14,12 +15,32 @@ async function waitForCdpReady(port, timeoutMs = 15000) {
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(endpoint)
if (response.ok) {
const payload = await response.json()
if (payload && payload.webSocketDebuggerUrl) {
return payload
}
const payload = await new Promise((resolve, reject) => {
const req = http.get(endpoint, (response) => {
let body = ''
response.setEncoding('utf8')
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`))
return
}
try {
resolve(JSON.parse(body))
} catch (error) {
reject(error)
}
})
})
req.on('error', reject)
req.setTimeout(2000, () => {
req.destroy(new Error('timeout'))
})
})
if (payload && payload.webSocketDebuggerUrl) {
return payload
}
} catch (error) {
lastError = error
+513
View File
@@ -0,0 +1,513 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const http = require('node:http')
const Database = require('better-sqlite3')
const { launchApp } = require('../helpers/app-launcher')
const shouldRunSmoke = process.env.CHATLAB_RUN_E2E_SMOKE === '1'
const CHAT_DB_SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL,
group_id TEXT,
group_avatar TEXT,
owner_id TEXT,
schema_version INTEGER DEFAULT 5,
session_gap_threshold INTEGER
);
CREATE TABLE IF NOT EXISTS member (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT,
group_nickname TEXT,
aliases TEXT DEFAULT '[]',
avatar TEXT,
roles TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS member_name_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER NOT NULL,
name_type TEXT NOT NULL,
name TEXT NOT NULL,
start_ts INTEGER NOT NULL,
end_ts INTEGER,
FOREIGN KEY(member_id) REFERENCES member(id)
);
CREATE TABLE IF NOT EXISTS message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL,
sender_account_name TEXT,
sender_group_nickname TEXT,
ts INTEGER NOT NULL,
type INTEGER NOT NULL,
content TEXT,
reply_to_message_id TEXT DEFAULT NULL,
platform_message_id TEXT DEFAULT NULL,
FOREIGN KEY(sender_id) REFERENCES member(id)
);
CREATE TABLE IF NOT EXISTS chat_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
is_manual INTEGER DEFAULT 0,
summary TEXT
);
CREATE TABLE IF NOT EXISTS message_context (
message_id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL,
topic_id INTEGER
);
`
const AI_DB_SCHEMA = `
CREATE TABLE IF NOT EXISTS ai_conversation (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT,
active_message_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
assistant_id TEXT DEFAULT 'general_cn'
);
CREATE TABLE IF NOT EXISTS ai_message (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data_keywords TEXT,
data_message_count INTEGER,
content_blocks TEXT,
token_usage TEXT,
debug_context TEXT,
parent_id TEXT,
sibling_group_id TEXT,
branch_index INTEGER DEFAULT 0,
FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ai_conversation_session ON ai_conversation(session_id);
CREATE INDEX IF NOT EXISTS idx_ai_message_conversation ON ai_message(conversation_id);
CREATE INDEX IF NOT EXISTS idx_ai_message_parent ON ai_message(parent_id);
CREATE INDEX IF NOT EXISTS idx_ai_message_sibling ON ai_message(sibling_group_id);
`
const SESSION_ID = 'chart-smoke-session'
const SESSION_NAME = 'Chart Smoke Group'
const CONVERSATION_ID = 'conv_chart_smoke'
const CONVERSATION_TITLE = 'Chart replay smoke'
const CHART_TITLE = 'Selected members smoke'
function resolveNativeBinding() {
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 fs.existsSync(repoNativeBinding) ? repoNativeBinding : undefined
}
function unixTs(iso) {
return Math.floor(Date.parse(iso) / 1000)
}
function createTempRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-chart-ui-smoke-'))
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true })
}
function seedChatSessionDb(chatDataDir) {
const dbDir = path.join(chatDataDir, 'databases')
ensureDir(dbDir)
const db = new Database(path.join(dbDir, `${SESSION_ID}.db`), {
nativeBinding: resolveNativeBinding(),
})
try {
db.exec(CHAT_DB_SCHEMA)
db.prepare(
`INSERT INTO meta (name, platform, type, imported_at, group_id, owner_id, schema_version)
VALUES (?, ?, ?, ?, ?, ?, 5)`
).run(SESSION_NAME, 'wechat', 'group', unixTs('2026-06-03T00:00:00Z'), 'g_chart', 'u_alice')
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 members = db.prepare('SELECT id, group_nickname FROM member').all()
const memberIds = new Map(members.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 addMessage = (name, ts, type, content) => {
const id = memberIds.get(name)
assert.ok(id, `missing member id for ${name}`)
insertMessage.run(id, `${name} Account`, name, unixTs(ts), type, content, `${name}-${ts}`)
}
addMessage('Alice', '2026-06-01T09:00:00Z', 0, 'morning hello')
addMessage('Alice', '2026-06-01T10:00:00Z', 0, 'standup note')
addMessage('Alice', '2026-06-02T09:00:00Z', 1, '[图片]')
addMessage('Alice', '2026-06-02T10:00:00Z', 0, 'follow up')
addMessage('Bob', '2026-06-01T09:00:00Z', 0, 'first reply')
addMessage('Bob', '2026-06-02T10:00:00Z', 0, 'second reply')
addMessage('Bob', '2026-06-02T11:00:00Z', 0, 'third reply')
addMessage('Cara', '2026-06-01T09:00:00Z', 0, 'other member')
} finally {
db.close()
}
}
function seedAiConversationDb(homeDir) {
const aiDir = path.join(homeDir, '.chatlab', 'ai')
ensureDir(aiDir)
const db = new Database(path.join(aiDir, 'conversations.db'), {
nativeBinding: resolveNativeBinding(),
})
const now = Math.floor(Date.now() / 1000)
const chartBlock = {
type: 'chart',
chart: {
spec: {
version: 1,
type: 'pie',
title: CHART_TITLE,
encoding: { label: 'name', value: 'message_count' },
},
rowCount: 2,
truncated: false,
source: {
sql: 'SELECT name, message_count FROM smoke_data',
params: {},
},
data: {
labels: ['Alice', 'Bob'],
values: [4, 3],
},
},
}
try {
db.exec(AI_DB_SCHEMA)
db.prepare(
`INSERT INTO ai_conversation (id, session_id, title, active_message_id, created_at, updated_at, assistant_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(CONVERSATION_ID, SESSION_ID, CONVERSATION_TITLE, 'msg_ai_chart', now - 120, now - 60, 'general_cn')
db.prepare(
`INSERT INTO ai_message (
id, conversation_id, role, content, timestamp, data_keywords, data_message_count,
content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index
) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, ?, 0)`
).run('msg_user_chart', CONVERSATION_ID, 'user', 'Show me Alice and Bob share.', now - 110, 'msg_user_chart')
db.prepare(
`INSERT INTO ai_message (
id, conversation_id, role, content, timestamp, data_keywords, data_message_count,
content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index
) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, NULL, NULL, ?, ?, 0)`
).run(
'msg_ai_chart',
CONVERSATION_ID,
'assistant',
'Here is the chart.',
now - 100,
JSON.stringify([chartBlock]),
'msg_user_chart',
'msg_ai_chart'
)
} finally {
db.close()
}
}
function httpGetJson(url) {
return new Promise((resolve, reject) => {
const req = http.get(url, (response) => {
let body = ''
response.setEncoding('utf8')
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`))
return
}
try {
resolve(JSON.parse(body))
} catch (error) {
reject(error)
}
})
})
req.on('error', reject)
req.setTimeout(2000, () => {
req.destroy(new Error('timeout'))
})
})
}
async function waitFor(fn, timeoutMs, label) {
const start = Date.now()
let lastError = null
while (Date.now() - start < timeoutMs) {
try {
const result = await fn()
if (result) return result
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
const suffix = lastError ? ` Last error: ${lastError.message}` : ''
throw new Error(`${label} not ready within ${timeoutMs}ms.${suffix}`)
}
async function getRendererDebuggerUrl(port) {
return waitFor(
async () => {
const targets = await httpGetJson(`http://127.0.0.1:${port}/json/list`)
const pageTarget = targets.find(
(target) =>
target &&
target.type === 'page' &&
typeof target.webSocketDebuggerUrl === 'string' &&
!String(target.url || '').startsWith('devtools://')
)
return pageTarget?.webSocketDebuggerUrl || null
},
15000,
'Renderer target'
)
}
async function createCdpClient(wsUrl) {
return new Promise((resolve, reject) => {
const pending = new Map()
let nextId = 0
const socket = new WebSocket(wsUrl)
socket.addEventListener('open', () => {
resolve({
async send(method, params) {
const id = ++nextId
const payload = { id, method, params }
const response = await new Promise((resolveSend, rejectSend) => {
pending.set(id, { resolve: resolveSend, reject: rejectSend })
socket.send(JSON.stringify(payload))
})
if (response.error) {
throw new Error(response.error.message || `CDP ${method} failed`)
}
return response.result
},
close() {
socket.close()
},
})
})
socket.addEventListener('message', (event) => {
const payload = JSON.parse(String(event.data))
if (!payload.id) return
const handlers = pending.get(payload.id)
if (!handlers) return
pending.delete(payload.id)
handlers.resolve(payload)
})
socket.addEventListener('error', (error) => {
reject(error.error || error)
})
socket.addEventListener('close', () => {
for (const [, handlers] of pending) {
handlers.reject(new Error('CDP socket closed'))
}
pending.clear()
})
})
}
async function evaluate(client, expression) {
const result = await client.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (result.exceptionDetails) {
throw new Error(`Runtime.evaluate failed: ${JSON.stringify(result.exceptionDetails)}`)
}
return result.result?.value
}
async function getRendererSnapshot(client) {
return evaluate(
client,
`
(() => ({
hash: location.hash,
bodyId: document.body?.id || null,
title: document.title,
hasSessionName: document.body?.innerText.includes(${JSON.stringify(SESSION_NAME)}) || false,
hasConversationTitle: document.body?.innerText.includes(${JSON.stringify(CONVERSATION_TITLE)}) || false,
hasChartTitle: document.body?.innerText.includes(${JSON.stringify(CHART_TITLE)}) || false,
canvasCount: document.querySelectorAll('canvas').length,
textExcerpt: (document.body?.innerText || '').slice(0, 800),
}))()
`
)
}
test(
'E2E smoke: seeded chart conversation renders inside Electron AI chat',
{ skip: !shouldRunSmoke, timeout: 120000 },
async () => {
const tempRoot = createTempRoot()
const homeDir = path.join(tempRoot, 'home')
const userDataDir = path.join(tempRoot, 'electron-user-data')
const chatDataDir = path.join(tempRoot, 'chat-data')
ensureDir(homeDir)
ensureDir(userDataDir)
ensureDir(chatDataDir)
seedChatSessionDb(chatDataDir)
seedAiConversationDb(homeDir)
let app = null
let client = null
try {
app = await launchApp({
startPort: 9232,
startupWaitTime: 3000,
userDataDir,
envOverrides: {
HOME: homeDir,
USERPROFILE: homeDir,
CHATLAB_DATA_DIR: chatDataDir,
},
})
const wsUrl = await getRendererDebuggerUrl(app.port)
client = await createCdpClient(wsUrl)
await client.send('Runtime.enable')
await client.send('Page.enable')
await waitFor(
async () =>
evaluate(client, "document.readyState === 'complete' && !!document.body && !!document.querySelector('#app')"),
15000,
'Renderer document'
)
await evaluate(client, `location.hash = '#/group-chat/${SESSION_ID}?tab=ai-chat'`)
await waitFor(
async () =>
evaluate(
client,
`document.body.id === 'page-group-chat' && document.body.innerText.includes(${JSON.stringify(SESSION_NAME)})`
),
20000,
'Group chat route'
)
await waitFor(
async () => evaluate(client, `document.body.innerText.includes(${JSON.stringify(CONVERSATION_TITLE)})`),
15000,
'Conversation title'
)
await waitFor(
async () =>
evaluate(
client,
`
(() => {
const rows = [...document.querySelectorAll('div.group.relative.rounded-lg.cursor-pointer')]
const target = rows.find((el) => el.innerText && el.innerText.includes(${JSON.stringify(CONVERSATION_TITLE)}))
if (!target) return false
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
return true
})()
`
),
10000,
'Conversation list item'
)
let chartState = null
try {
chartState = await waitFor(
async () =>
evaluate(
client,
`
(() => {
if (!document.body.innerText.includes(${JSON.stringify(CHART_TITLE)})) return null
const canvas = document.querySelector('canvas')
if (!canvas) return null
const rect = canvas.getBoundingClientRect()
if (rect.width < 200 || rect.height < 150) return null
return {
bodyId: document.body.id,
chartTitlePresent: true,
canvasCount: document.querySelectorAll('canvas').length,
width: Math.round(rect.width),
height: Math.round(rect.height),
}
})()
`
),
20000,
'Chart render'
)
} catch (error) {
const snapshot = await getRendererSnapshot(client)
throw new Error(`${error.message} Snapshot: ${JSON.stringify(snapshot)}`)
}
assert.equal(chartState.bodyId, 'page-group-chat')
assert.equal(chartState.chartTitlePresent, true)
assert.ok(chartState.canvasCount >= 1, 'expected at least one chart canvas')
assert.ok(chartState.width >= 200, `expected chart width >= 200, got ${chartState.width}`)
assert.ok(chartState.height >= 150, `expected chart height >= 150, got ${chartState.height}`)
} finally {
if (client) {
client.close()
}
if (app) {
await app.close()
}
fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
}
}
)