diff --git a/apps/cli/src/ai/agent-stream-runner.test.ts b/apps/cli/src/ai/agent-stream-runner.test.ts
new file mode 100644
index 00000000..1c6cc095
--- /dev/null
+++ b/apps/cli/src/ai/agent-stream-runner.test.ts
@@ -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'))
+ })
+})
diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts
index eb895c31..44090e79 100644
--- a/apps/cli/src/ai/agent-stream-runner.ts
+++ b/apps/cli/src/ai/agent-stream-runner.ts
@@ -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 | null) {
+ if (isChartCapability) {
+ const chartCoreTools = new Set(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(CHART_CAPABILITY_CORE_TOOLS),
locale,
- getSkillConfig: (id) => skillMgr.getSkillConfig(id),
+ getSkillConfig: (id) =>
+ getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => skillMgr.getSkillConfig(skillConfigId)),
})
agentTools.push(activateSkillTool as any)
}
diff --git a/apps/cli/src/ai/tool-adapter.test.ts b/apps/cli/src/ai/tool-adapter.test.ts
index 0dff4d2e..8a86f1c9 100644
--- a/apps/cli/src/ai/tool-adapter.test.ts
+++ b/apps/cli/src/ai/tool-adapter.test.ts
@@ -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.' }])
diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts
index 5807803e..2814ff94 100644
--- a/apps/cli/src/ai/tool-adapter.ts
+++ b/apps/cli/src/ai/tool-adapter.ts
@@ -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[] {
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> {
- const toolParams = (params && typeof params === 'object' ? params : {}) as Record
- 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> {
+ const toolParams = (params && typeof params === 'object' ? params : {}) as Record
+ 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,
- 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)
- : 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,
+ 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)
+ : 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
+ )
+ )
}
diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts
index 6fce8458..bd0d3c1b 100644
--- a/apps/desktop/main/ai/agent-stream-runner.ts
+++ b/apps/desktop/main/ai/agent-stream-runner.ts
@@ -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 }
}
diff --git a/apps/desktop/main/ai/tools/index.test.ts b/apps/desktop/main/ai/tools/index.test.ts
new file mode 100644
index 00000000..aade52a7
--- /dev/null
+++ b/apps/desktop/main/ai/tools/index.test.ts
@@ -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)
+ })
+})
diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts
index fdadc9ed..c39fb1bc 100644
--- a/apps/desktop/main/ai/tools/index.ts
+++ b/apps/desktop/main/ai/tools/index.ts
@@ -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(CHART_CAPABILITY_ANALYSIS_TOOLS)
const preprocessLogger = {
info: (category: string, message: string, extra?: Record) => aiLogger.info(category, message, extra),
warn: (category: string, message: string, extra?: Record) => aiLogger.warn(category, message, extra),
}
-import { getSkillConfig } from '../skills'
-import { createActivateSkillTool as sharedCreateActivateSkillTool } from '@openchatlab/node-runtime'
const TRUNCATION_STRATEGY_MAP = new Map(
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,
})
}
diff --git a/package.json b/package.json
index 9e905dda..241b0c0e 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/core/src/ai/chart-capability.test.ts b/packages/core/src/ai/chart-capability.test.ts
index 537bf682..3605dabe 100644
--- a/packages/core/src/ai/chart-capability.test.ts
+++ b/packages/core/src/ai/chart-capability.test.ts
@@ -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)
+ })
})
diff --git a/packages/core/src/ai/chart-capability.ts b/packages/core/src/ai/chart-capability.ts
index 104de825..6bdfa070 100644
--- a/packages/core/src/ai/chart-capability.ts
+++ b/packages/core/src/ai/chart-capability.ts
@@ -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
diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts
index a7de566b..3def6da7 100644
--- a/packages/core/src/ai/index.ts
+++ b/packages/core/src/ai/index.ts
@@ -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 模型系统类型
diff --git a/packages/core/src/ai/tool-catalog.ts b/packages/core/src/ai/tool-catalog.ts
index 6661c071..753e375a 100644
--- a/packages/core/src/ai/tool-catalog.ts
+++ b/packages/core/src/ai/tool-catalog.ts
@@ -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' },
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 3eceb51e..52434a34 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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,
diff --git a/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts
new file mode 100644
index 00000000..3e7eaece
--- /dev/null
+++ b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts
@@ -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/)
+ })
+})
diff --git a/packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts b/packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts
new file mode 100644
index 00000000..020c8066
--- /dev/null
+++ b/packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts
@@ -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 {
+ 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' }])
+ })
+})
diff --git a/packages/node-runtime/src/ai/builtin-chart-skill.ts b/packages/node-runtime/src/ai/builtin-chart-skill.ts
new file mode 100644
index 00000000..cb0d4aec
--- /dev/null
+++ b/packages/node-runtime/src/ai/builtin-chart-skill.ts
@@ -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)
+}
diff --git a/packages/node-runtime/src/ai/chart-schema-gate.ts b/packages/node-runtime/src/ai/chart-schema-gate.ts
new file mode 100644
index 00000000..0351ab60
--- /dev/null
+++ b/packages/node-runtime/src/ai/chart-schema-gate.ts
@@ -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, state: ChartSchemaGateState): AgentTool {
+ const originalExecute = tool.execute
+
+ return {
+ ...tool,
+ execute: async (
+ toolCallId: string,
+ params: any,
+ signal?: AbortSignal,
+ onUpdate?: unknown
+ ): Promise> => {
+ if (tool.name === 'render_chart' && !state.schemaSeen) {
+ return {
+ content: [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }],
+ details: null,
+ } as AgentToolResult
+ }
+
+ const result = await originalExecute(toolCallId, params, signal, onUpdate as never)
+
+ if (tool.name === 'get_schema') {
+ state.schemaSeen = true
+ }
+
+ return result
+ },
+ }
+}
diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts
index dc821b08..8aa897cf 100644
--- a/packages/node-runtime/src/ai/index.ts
+++ b/packages/node-runtime/src/ai/index.ts
@@ -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'
diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts
index 95da1517..041be957 100644
--- a/packages/node-runtime/src/index.ts
+++ b/packages/node-runtime/src/index.ts
@@ -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'
diff --git a/packages/tools/src/definitions/render-chart.test.ts b/packages/tools/src/definitions/render-chart.test.ts
index a60f10ba..92b28039 100644
--- a/packages/tools/src/definitions/render-chart.test.ts
+++ b/packages/tools/src/definitions/render-chart.test.ts
@@ -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 }> = []
+ 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 }> = []
const context = createContext([{ name: 'Alice', message_count: 4 }], calls)
diff --git a/packages/tools/src/definitions/render-chart.ts b/packages/tools/src/definitions/render-chart.ts
index e3adc045..68c6239b 100644
--- a/packages/tools/src/definitions/render-chart.ts
+++ b/packages/tools/src/definitions/render-chart.ts
@@ -9,6 +9,7 @@ import { buildChartPayload } from '@openchatlab/core'
import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types'
const DEFAULT_MAX_ROWS = 1000
+const 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}`
}
diff --git a/packages/tools/src/definitions/sql-query.ts b/packages/tools/src/definitions/sql-query.ts
index f587625c..eaa15c8f 100644
--- a/packages/tools/src/definitions/sql-query.ts
+++ b/packages/tools/src/definitions/sql-query.ts
@@ -51,6 +51,7 @@ export const sqlQueryTool: ToolDefinition = {
description: '对聊天数据库执行只读 SELECT 查询。使用前可先调用 get_schema 查看表结构。',
inputSchema,
handler,
+ category: 'analysis',
}
export const schemaTool: ToolDefinition = {
diff --git a/scripts/dev-serve.mjs b/scripts/dev-serve.mjs
new file mode 100644
index 00000000..6f95ad85
--- /dev/null
+++ b/scripts/dev-serve.mjs
@@ -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,
+])
diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs
new file mode 100644
index 00000000..3970f346
--- /dev/null
+++ b/scripts/dev-web.mjs
@@ -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',
+ },
+})
diff --git a/src/components/AIChat/chat/ChartBlockRenderer.vue b/src/components/AIChat/chat/ChartBlockRenderer.vue
index 6d9c2124..282a04b6 100644
--- a/src/components/AIChat/chat/ChartBlockRenderer.vue
+++ b/src/components/AIChat/chat/ChartBlockRenderer.vue
@@ -1,5 +1,6 @@
@@ -47,11 +88,90 @@ const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
{{ subtitle }}
-
- {{ chart.spec.type }}
-
+
+
+ {{ chart.spec.type }}
+
+
+
+
+
+
+
+
{{ title }}
+
+ {{ subtitle }}
+
+
{{ summary }}
+
+
+
+
+
+
+
+
{{ summary }}
@@ -63,17 +183,24 @@ const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
-
+
-
+
diff --git a/src/components/charts/EChart.vue b/src/components/charts/EChart.vue
index 5c4920ef..019b809c 100644
--- a/src/components/charts/EChart.vue
+++ b/src/components/charts/EChart.vue
@@ -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(), {
const chartRef = ref()
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
diff --git a/src/components/charts/EChartBar.vue b/src/components/charts/EChartBar.vue
index 1bc48b2a..0c5f7995 100644
--- a/src/components/charts/EChartBar.vue
+++ b/src/components/charts/EChartBar.vue
@@ -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(), {
height: 200,
+ mode: 'expanded',
horizontal: false,
gradient: true,
borderRadius: 4,
@@ -44,10 +46,14 @@ const gradientColor = {
const option = computed(() => {
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(() => {
},
},
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(() => {
axisLabel: {
fontSize: 11,
color: '#6b7280',
+ interval: 'auto',
+ rotate: !isHorizontal && labelCount > 8 ? 28 : 0,
+ hideOverlap: true,
},
},
yAxis: isHorizontal
@@ -95,6 +104,8 @@ const option = computed(() => {
axisLabel: {
fontSize: 11,
color: '#6b7280',
+ overflow: 'truncate',
+ width: isCompact ? 70 : 110,
},
}
: {
diff --git a/src/components/charts/EChartHeatmap.vue b/src/components/charts/EChartHeatmap.vue
index 00fc961f..f7dcdaa6 100644
--- a/src/components/charts/EChartHeatmap.vue
+++ b/src/components/charts/EChartHeatmap.vue
@@ -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(), {
height: 280,
+ mode: 'expanded',
minColor: '#fee5e8', // 项目主题 pink-100
maxColor: '#ee4567', // 项目主题 pink-500
})
@@ -41,9 +43,14 @@ const maxValue = computed(() => {
})
const option = computed(() => {
+ 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(() => {
},
},
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(() => {
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(() => {
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(() => {
orient: 'horizontal',
left: 'center',
bottom: 0,
- itemWidth: 10,
- itemHeight: 120,
+ itemWidth: isCompact ? 8 : 10,
+ itemHeight: isCompact ? 84 : 120,
inRange: {
color: [props.minColor, props.maxColor],
},
diff --git a/src/components/charts/EChartLine.vue b/src/components/charts/EChartLine.vue
index 27b2921d..5ba27ef4 100644
--- a/src/components/charts/EChartLine.vue
+++ b/src/components/charts/EChartLine.vue
@@ -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(), {
height: 288,
+ mode: 'expanded',
showArea: true,
smooth: true,
})
const option = computed(() => {
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(() => {
smooth: props.smooth,
symbol: 'circle',
symbolSize: 4,
- showSymbol: false,
+ showSymbol: !isCompact && labelCount <= 80,
lineStyle: {
width: 2,
},
@@ -53,7 +60,7 @@ const option = computed(() => {
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(() => {
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(() => {
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(() => {
color: '#6b7280',
// 自动间隔显示标签
interval: 'auto',
+ hideOverlap: true,
},
},
yAxis: {
@@ -132,6 +147,12 @@ const option = computed(() => {
},
},
},
+ dataZoom: useDataZoom
+ ? [
+ { type: 'inside', throttle: 50 },
+ { type: 'slider', height: 18, bottom: 18, showDetail: false },
+ ]
+ : undefined,
series,
}
})
diff --git a/src/components/charts/EChartPie.vue b/src/components/charts/EChartPie.vue
index e41c572a..f9c97a87 100644
--- a/src/components/charts/EChartPie.vue
+++ b/src/components/charts/EChartPie.vue
@@ -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(), {
height: 280,
+ mode: 'expanded',
doughnut: true,
innerRadius: '50%',
showLegend: true,
@@ -48,33 +50,57 @@ const option = computed(() => {
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(() => {
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,
diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json
index fe887608..bcf5e137 100644
--- a/src/i18n/locales/en-US/ai.json
+++ b/src/i18n/locales/en-US/ai.json
@@ -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",
diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json
index 7f9d237e..58987ffc 100644
--- a/src/i18n/locales/ja-JP/ai.json
+++ b/src/i18n/locales/ja-JP/ai.json
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "ここから会話をフォーク"
},
+ "chart": {
+ "expand": "大きなチャートを開く"
+ },
"tools": {
"get_chat_overview": "チャット概要を取得",
"search_messages": "チャット履歴を検索",
diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json
index ee2b8f79..e76f8bc7 100644
--- a/src/i18n/locales/zh-CN/ai.json
+++ b/src/i18n/locales/zh-CN/ai.json
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "从此处复刻对话"
},
+ "chart": {
+ "expand": "打开大图"
+ },
"tools": {
"get_chat_overview": "获取聊天概览",
"search_messages": "搜索聊天记录",
diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json
index 6c32a582..44db5a6f 100644
--- a/src/i18n/locales/zh-TW/ai.json
+++ b/src/i18n/locales/zh-TW/ai.json
@@ -65,6 +65,9 @@
"fork": {
"tooltip": "從此處複製對話"
},
+ "chart": {
+ "expand": "開啟大圖"
+ },
"tools": {
"get_chat_overview": "取得聊天概覽",
"search_messages": "搜尋聊天紀錄",
diff --git a/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts b/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts
index 00a58e16..39699644 100644
--- a/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts
+++ b/tests/chart-runtime/real-llm-chart-flow.e2e.test.ts
@@ -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
diff --git a/tests/e2e/helpers/app-launcher.js b/tests/e2e/helpers/app-launcher.js
index 771606b0..d8748757 100644
--- a/tests/e2e/helpers/app-launcher.js
+++ b/tests/e2e/helpers/app-launcher.js
@@ -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 {
diff --git a/tests/e2e/helpers/app-launcher.test.js b/tests/e2e/helpers/app-launcher.test.js
index 8cd3800c..5bd83466 100644
--- a/tests/e2e/helpers/app-launcher.test.js
+++ b/tests/e2e/helpers/app-launcher.test.js
@@ -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()
+})
diff --git a/tests/e2e/run-smoke.js b/tests/e2e/run-smoke.js
index f2bd9265..52a16f93 100644
--- a/tests/e2e/run-smoke.js
+++ b/tests/e2e/run-smoke.js
@@ -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)
diff --git a/tests/e2e/smoke/app-launcher.smoke.test.js b/tests/e2e/smoke/app-launcher.smoke.test.js
index 5161d63d..12cedb0a 100644
--- a/tests/e2e/smoke/app-launcher.smoke.test.js
+++ b/tests/e2e/smoke/app-launcher.smoke.test.js
@@ -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
diff --git a/tests/e2e/smoke/chart-runtime.smoke.test.js b/tests/e2e/smoke/chart-runtime.smoke.test.js
new file mode 100644
index 00000000..5efd5ece
--- /dev/null
+++ b/tests/e2e/smoke/chart-runtime.smoke.test.js
@@ -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 })
+ }
+ }
+)