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 @@ 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 }) + } + } +)