mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): improve chart runtime UX
This commit is contained in:
1 parent
782f67ba63
commit
15470444e5
40 files changed
+1547
-144
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { getChartCapabilityAllowedBuiltinTools } from '@openchatlab/core'
|
||||
import { getAvailableToolDefs } from './agent-stream-runner'
|
||||
|
||||
describe('CLI chart capability tool filtering', () => {
|
||||
it('does not expose uncategorized raw SQL in chart-only turns', () => {
|
||||
const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools())
|
||||
|
||||
const toolNames = getAvailableToolDefs(true, allowedToolSet).map((tool) => tool.name)
|
||||
|
||||
assert.deepEqual(toolNames.sort(), ['get_schema', 'render_chart'])
|
||||
assert.ok(!toolNames.includes('execute_sql'))
|
||||
})
|
||||
|
||||
it('keeps only chart core tools plus explicitly allowed analysis tools', () => {
|
||||
const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql']))
|
||||
|
||||
const toolNames = getAvailableToolDefs(true, allowedToolSet).map((tool) => tool.name)
|
||||
|
||||
assert.deepEqual(toolNames.sort(), ['get_schema', 'keyword_frequency', 'render_chart'])
|
||||
assert.ok(!toolNames.includes('execute_sql'))
|
||||
})
|
||||
|
||||
it('can expose render_chart for auto skill turns with restrictive assistant tools', () => {
|
||||
const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools(['keyword_frequency']))
|
||||
|
||||
const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name)
|
||||
|
||||
assert.ok(toolNames.includes('keyword_frequency'))
|
||||
assert.ok(toolNames.includes('render_chart'))
|
||||
})
|
||||
|
||||
it('does not expose raw SQL when the assistant did not allow it', () => {
|
||||
const allowedToolSet = new Set(['keyword_frequency'])
|
||||
|
||||
const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name)
|
||||
|
||||
assert.ok(toolNames.includes('keyword_frequency'))
|
||||
assert.ok(!toolNames.includes('execute_sql'))
|
||||
})
|
||||
})
|
||||
@@ -4,12 +4,19 @@
|
||||
*/
|
||||
|
||||
import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime'
|
||||
import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime'
|
||||
import {
|
||||
SkillManager,
|
||||
buildSkillMenuWithBuiltinChart,
|
||||
createActivateSkillTool,
|
||||
getSkillConfigWithBuiltinChart,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import {
|
||||
CHART_CAPABILITY_CORE_TOOLS,
|
||||
CHART_CAPABILITY_SKILL_ID,
|
||||
getChartCapabilityAllowedBuiltinTools,
|
||||
getChartCapabilitySkill,
|
||||
getChatOverview,
|
||||
shouldUseChartCapabilityForMessage,
|
||||
} from '@openchatlab/core'
|
||||
import type { DataSnapshot } from '@openchatlab/node-runtime'
|
||||
import type { AgentStreamRequest } from '@openchatlab/http-routes'
|
||||
@@ -27,6 +34,23 @@ function getAiDir(dbManager: DatabaseManager): string {
|
||||
return pathProvider.getAiDataDir()
|
||||
}
|
||||
|
||||
const RAW_SQL_TOOL_NAMES = new Set(['execute_sql'])
|
||||
|
||||
export function getAvailableToolDefs(isChartCapability: boolean, allowedToolSet: Set<string> | null) {
|
||||
if (isChartCapability) {
|
||||
const chartCoreTools = new Set<string>(CHART_CAPABILITY_CORE_TOOLS)
|
||||
return AGENT_TOOL_REGISTRY.filter(
|
||||
(tool) =>
|
||||
chartCoreTools.has(tool.name) ||
|
||||
(tool.category === 'analysis' && allowedToolSet?.has(tool.name) && !RAW_SQL_TOOL_NAMES.has(tool.name))
|
||||
)
|
||||
}
|
||||
|
||||
return allowedToolSet
|
||||
? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name))
|
||||
: AGENT_TOOL_REGISTRY
|
||||
}
|
||||
|
||||
export function createCliRunAgentStream(
|
||||
dbManager: DatabaseManager,
|
||||
convManager: AIConversationManager
|
||||
@@ -66,15 +90,18 @@ export function createCliRunAgentStream(
|
||||
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
|
||||
|
||||
const db = (dbManager as any).open?.(sessionId)
|
||||
const isChartCapability = skillId === CHART_CAPABILITY_SKILL_ID
|
||||
const isChartCapability =
|
||||
skillId === CHART_CAPABILITY_SKILL_ID || (!skillId && shouldUseChartCapabilityForMessage(userMessage))
|
||||
const autoSkillAllowedTools =
|
||||
!skillId && enableAutoSkill && assistantAllowedTools && assistantAllowedTools.length > 0
|
||||
? getChartCapabilityAllowedBuiltinTools(assistantAllowedTools)
|
||||
: assistantAllowedTools
|
||||
const allowedToolSet = isChartCapability
|
||||
? new Set(getChartCapabilityAllowedBuiltinTools(assistantAllowedTools))
|
||||
: assistantAllowedTools && assistantAllowedTools.length > 0
|
||||
? new Set(assistantAllowedTools)
|
||||
: autoSkillAllowedTools && autoSkillAllowedTools.length > 0
|
||||
? new Set(autoSkillAllowedTools)
|
||||
: null
|
||||
const availableToolDefs = allowedToolSet
|
||||
? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name))
|
||||
: AGENT_TOOL_REGISTRY
|
||||
const availableToolDefs = getAvailableToolDefs(isChartCapability, allowedToolSet)
|
||||
|
||||
const agentTools = db
|
||||
? adaptToolsForAgent(availableToolDefs, () => ({ db, sessionId, locale }), { maxToolResultTokens })
|
||||
@@ -93,7 +120,7 @@ export function createCliRunAgentStream(
|
||||
const def = skillMgr.getSkillConfig(skillId)
|
||||
if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt }
|
||||
} else if (enableAutoSkill) {
|
||||
const menu = skillMgr.getSkillMenu(chatType ?? 'group', toolNames)
|
||||
const menu = buildSkillMenuWithBuiltinChart(skillMgr.getSkillMenu(chatType ?? 'group', toolNames), locale)
|
||||
if (menu) resolvedSkillMenu = menu
|
||||
}
|
||||
|
||||
@@ -101,8 +128,10 @@ export function createCliRunAgentStream(
|
||||
const activateSkillTool = createActivateSkillTool({
|
||||
chatType: chatType ?? 'group',
|
||||
allowedTools: toolNames,
|
||||
coreToolNames: new Set<string>(CHART_CAPABILITY_CORE_TOOLS),
|
||||
locale,
|
||||
getSkillConfig: (id) => skillMgr.getSkillConfig(id),
|
||||
getSkillConfig: (id) =>
|
||||
getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => skillMgr.getSkillConfig(skillConfigId)),
|
||||
})
|
||||
agentTools.push(activateSkillTool as any)
|
||||
}
|
||||
|
||||
@@ -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.' }])
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type AgentToolResult,
|
||||
type PreprocessableMessage,
|
||||
type TruncationStrategy,
|
||||
createChartSchemaGateState,
|
||||
wrapWithChartSchemaGate,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import { getServerAiLogger } from './logger'
|
||||
|
||||
@@ -57,62 +59,68 @@ export function adaptToolsForAgent(
|
||||
options?: AdaptToolsOptions
|
||||
): AgentTool<any, any>[] {
|
||||
const tokenBudget = options?.maxToolResultTokens ?? DEFAULT_MAX_TOOL_RESULT_TOKENS
|
||||
const chartSchemaGateState = createChartSchemaGateState()
|
||||
|
||||
return tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
label: tool.name,
|
||||
description: tool.description,
|
||||
parameters: convertJsonSchemaToParameters(tool.inputSchema) as any,
|
||||
async execute(_toolCallId: string, params: unknown): Promise<AgentToolResult<unknown>> {
|
||||
const toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
|
||||
const ctx = getContext()
|
||||
const execCtx: ToolExecutionContext = {
|
||||
db: ctx.db,
|
||||
dataProvider: new CoreDataProvider(ctx.db),
|
||||
sessionId: ctx.sessionId,
|
||||
locale: ctx.locale,
|
||||
segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any),
|
||||
}
|
||||
try {
|
||||
const result = await tool.handler(toolParams, execCtx)
|
||||
const chartDetails =
|
||||
result.chart || result.charts
|
||||
? {
|
||||
...(result.chart ? { chart: result.chart } : {}),
|
||||
...(result.charts ? { charts: result.charts } : {}),
|
||||
}
|
||||
: {}
|
||||
|
||||
if (result.rawMessages && result.rawMessages.length > 0) {
|
||||
const pipelineResult = applyPreprocessingPipeline({
|
||||
rawMessages: result.rawMessages as PreprocessableMessage[],
|
||||
return tools.map((tool) =>
|
||||
wrapWithChartSchemaGate(
|
||||
{
|
||||
name: tool.name,
|
||||
label: tool.name,
|
||||
description: tool.description,
|
||||
parameters: convertJsonSchemaToParameters(tool.inputSchema) as any,
|
||||
async execute(_toolCallId: string, params: unknown): Promise<AgentToolResult<unknown>> {
|
||||
const toolParams = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>
|
||||
const ctx = getContext()
|
||||
const execCtx: ToolExecutionContext = {
|
||||
db: ctx.db,
|
||||
dataProvider: new CoreDataProvider(ctx.db),
|
||||
sessionId: ctx.sessionId,
|
||||
locale: ctx.locale,
|
||||
maxToolResultTokens: tokenBudget,
|
||||
truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last',
|
||||
extraDetails: (result.data ?? {}) as Record<string, unknown>,
|
||||
logger: getServerAiLogger() ?? undefined,
|
||||
})
|
||||
return {
|
||||
content: [{ type: 'text', text: pipelineResult.text }],
|
||||
details: Object.keys(chartDetails).length > 0 ? chartDetails : null,
|
||||
segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any),
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await tool.handler(toolParams, execCtx)
|
||||
const chartDetails =
|
||||
result.chart || result.charts
|
||||
? {
|
||||
...(result.chart ? { chart: result.chart } : {}),
|
||||
...(result.charts ? { charts: result.charts } : {}),
|
||||
}
|
||||
: {}
|
||||
|
||||
const baseDetails =
|
||||
typeof result.data === 'object' && result.data !== null
|
||||
? (result.data as Record<string, unknown>)
|
||||
: result.data === undefined
|
||||
? null
|
||||
: { value: result.data }
|
||||
if (result.rawMessages && result.rawMessages.length > 0) {
|
||||
const pipelineResult = applyPreprocessingPipeline({
|
||||
rawMessages: result.rawMessages as PreprocessableMessage[],
|
||||
locale: ctx.locale,
|
||||
maxToolResultTokens: tokenBudget,
|
||||
truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last',
|
||||
extraDetails: (result.data ?? {}) as Record<string, unknown>,
|
||||
logger: getServerAiLogger() ?? undefined,
|
||||
})
|
||||
return {
|
||||
content: [{ type: 'text', text: pipelineResult.text }],
|
||||
details: Object.keys(chartDetails).length > 0 ? chartDetails : null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: result.content }],
|
||||
details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails,
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null }
|
||||
}
|
||||
},
|
||||
}))
|
||||
const baseDetails =
|
||||
typeof result.data === 'object' && result.data !== null
|
||||
? (result.data as Record<string, unknown>)
|
||||
: result.data === undefined
|
||||
? null
|
||||
: { value: result.data }
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: result.content }],
|
||||
details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails,
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null }
|
||||
}
|
||||
},
|
||||
},
|
||||
chartSchemaGateState
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -6,20 +6,28 @@
|
||||
*/
|
||||
|
||||
import type { AgentTool } from '@openchatlab/node-runtime'
|
||||
import { CHART_CAPABILITY_ANALYSIS_TOOLS, getChartCapabilityAllowedBuiltinTools } from '@openchatlab/core'
|
||||
import type { ToolContext, TruncationStrategy } from './types'
|
||||
import { TOOL_REGISTRY } from './definitions'
|
||||
|
||||
const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name))
|
||||
import { t as i18nT } from '../../i18n'
|
||||
import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime'
|
||||
import { aiLogger } from '../logger'
|
||||
import { getSkillConfig } from '../skills'
|
||||
import {
|
||||
createActivateSkillTool as sharedCreateActivateSkillTool,
|
||||
createChartSchemaGateState,
|
||||
getSkillConfigWithBuiltinChart,
|
||||
wrapWithChartSchemaGate,
|
||||
} from '@openchatlab/node-runtime'
|
||||
|
||||
const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name))
|
||||
const RAW_SQL_TOOL_NAME_SET = new Set(['execute_sql'])
|
||||
const CHART_CAPABILITY_ANALYSIS_TOOL_SET = new Set<string>(CHART_CAPABILITY_ANALYSIS_TOOLS)
|
||||
|
||||
const preprocessLogger = {
|
||||
info: (category: string, message: string, extra?: Record<string, unknown>) => aiLogger.info(category, message, extra),
|
||||
warn: (category: string, message: string, extra?: Record<string, unknown>) => aiLogger.warn(category, message, extra),
|
||||
}
|
||||
import { getSkillConfig } from '../skills'
|
||||
import { createActivateSkillTool as sharedCreateActivateSkillTool } from '@openchatlab/node-runtime'
|
||||
|
||||
const TRUNCATION_STRATEGY_MAP = new Map<string, TruncationStrategy>(
|
||||
TOOL_REGISTRY.filter((e) => e.truncationStrategy).map((e) => [e.name, e.truncationStrategy!])
|
||||
@@ -116,7 +124,18 @@ export function getAllTools(context: ToolContext, allowedTools?: string[]): Agen
|
||||
)
|
||||
}
|
||||
|
||||
return [...coreTools, ...analysisTools].map(translateTool).map((t) => wrapWithPreprocessing(t, context))
|
||||
const chartSchemaGateState = createChartSchemaGateState()
|
||||
|
||||
return [...coreTools, ...analysisTools]
|
||||
.map(translateTool)
|
||||
.map((t) => wrapWithChartSchemaGate(t, chartSchemaGateState))
|
||||
.map((t) => wrapWithPreprocessing(t, context))
|
||||
}
|
||||
|
||||
export function getAllowedToolsForChartCapability(allowedTools?: readonly string[] | null): string[] {
|
||||
return getChartCapabilityAllowedBuiltinTools(allowedTools).filter(
|
||||
(toolName) => CHART_CAPABILITY_ANALYSIS_TOOL_SET.has(toolName) || !RAW_SQL_TOOL_NAME_SET.has(toolName)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +151,8 @@ export function createActivateSkillTool(
|
||||
chatType,
|
||||
allowedTools,
|
||||
locale,
|
||||
getSkillConfig: (id) => getSkillConfig(id),
|
||||
getSkillConfig: (id) =>
|
||||
getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => getSkillConfig(skillConfigId)),
|
||||
coreToolNames: CORE_TOOL_NAMES,
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -26,8 +26,8 @@
|
||||
"build:mac": "pnpm --filter @openchatlab/desktop build:mac",
|
||||
"build:win": "pnpm --filter @openchatlab/desktop build:win",
|
||||
"dev:app": "vite --config vite.web.config.mts",
|
||||
"dev:serve": "bash scripts/dev-serve.sh",
|
||||
"dev:web": "pnpm run ensure:server-native && CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts",
|
||||
"dev:serve": "node scripts/dev-serve.mjs",
|
||||
"dev:web": "node scripts/dev-web.mjs",
|
||||
"ensure:server-native": "pnpm --filter chatlab-cli run ensure-native",
|
||||
"build:web": "vite build --config vite.web.config.mts",
|
||||
"docs:dev": "pnpm --filter @openchatlab/docs dev",
|
||||
|
||||
@@ -3,9 +3,11 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
CHART_CAPABILITY_ANALYSIS_TOOLS,
|
||||
CHART_CAPABILITY_CORE_TOOLS,
|
||||
CHART_CAPABILITY_SKILL_ID,
|
||||
getChartCapabilityAllowedBuiltinTools,
|
||||
getChartCapabilitySkill,
|
||||
shouldUseChartCapabilityForMessage,
|
||||
} from './chart-capability'
|
||||
|
||||
describe('chart capability skill', () => {
|
||||
@@ -14,10 +16,43 @@ describe('chart capability skill', () => {
|
||||
|
||||
assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID)
|
||||
assert.deepEqual(skill.tools, ['render_chart', 'get_schema'])
|
||||
assert.deepEqual(CHART_CAPABILITY_CORE_TOOLS, ['get_schema'])
|
||||
assert.match(skill.prompt, /render_chart/)
|
||||
assert.match(skill.prompt, /get_schema/)
|
||||
})
|
||||
|
||||
it('instructs common phrase charts to exclude non-human placeholders', () => {
|
||||
const zhSkill = getChartCapabilitySkill('zh-CN')
|
||||
const enSkill = getChartCapabilitySkill('en-US')
|
||||
|
||||
assert.match(zhSkill.prompt, /常用语、口头禅、高频短句/)
|
||||
assert.match(zhSkill.prompt, /\[表情包\]/)
|
||||
assert.match(zhSkill.prompt, /撤回\/删除提示/)
|
||||
assert.match(enSkill.prompt, /catchphrase, common phrase, or frequent short-text charts/)
|
||||
assert.match(enSkill.prompt, /\[Sticker\]/)
|
||||
assert.match(enSkill.prompt, /recall\/deletion notices/)
|
||||
})
|
||||
|
||||
it('tightens chart SQL and final-answer behavior', () => {
|
||||
const zhSkill = getChartCapabilitySkill('zh-CN')
|
||||
const enSkill = getChartCapabilitySkill('en-US')
|
||||
|
||||
assert.match(zhSkill.prompt, /必须先调用 get_schema/)
|
||||
assert.match(zhSkill.prompt, /禁止猜测表名、字段名或时间字段/)
|
||||
assert.match(zhSkill.prompt, /MAX\(ts\)/)
|
||||
assert.match(zhSkill.prompt, /禁止写 ts\/1000/)
|
||||
assert.match(zhSkill.prompt, /补齐日期 × series 的 0 值/)
|
||||
assert.match(zhSkill.prompt, /工具失败、SQL 修正、schema 探索过程不要写进最终回答/)
|
||||
assert.match(zhSkill.prompt, /禁止夸张拟人化/)
|
||||
|
||||
assert.match(enSkill.prompt, /Always call get_schema/)
|
||||
assert.match(enSkill.prompt, /Do not guess table names/)
|
||||
assert.match(enSkill.prompt, /never write ts\/1000/)
|
||||
assert.match(enSkill.prompt, /fill the date x series grid with zero values/)
|
||||
assert.match(enSkill.prompt, /Do not include failed SQL attempts/)
|
||||
assert.match(enSkill.prompt, /Do not make exaggerated personality or relationship claims/)
|
||||
})
|
||||
|
||||
it('adds only chart analysis tools to assistant builtin tools', () => {
|
||||
assert.deepEqual(CHART_CAPABILITY_ANALYSIS_TOOLS, ['render_chart'])
|
||||
assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart'])
|
||||
@@ -33,4 +68,11 @@ describe('chart capability skill', () => {
|
||||
'time_stats',
|
||||
])
|
||||
})
|
||||
|
||||
it('detects explicit chart requests', () => {
|
||||
assert.equal(shouldUseChartCapabilityForMessage('画一个前五常用语占比饼图'), true)
|
||||
assert.equal(shouldUseChartCapabilityForMessage('帮我做一下热力图'), true)
|
||||
assert.equal(shouldUseChartCapabilityForMessage('visualize member trends as a line chart'), true)
|
||||
assert.equal(shouldUseChartCapabilityForMessage('分析一下谁说话最多'), false)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 模型系统类型
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -80,6 +80,8 @@ export {
|
||||
getClusterGraph,
|
||||
getRelationshipStats,
|
||||
getLanguagePreferenceAnalysis,
|
||||
isHumanCatchphraseContent,
|
||||
NON_HUMAN_CATCHPHRASE_PLACEHOLDERS,
|
||||
FULL_MSG_COLUMNS,
|
||||
FULL_MSG_FROM,
|
||||
FULL_MSG_SELECT,
|
||||
@@ -247,9 +249,11 @@ export {
|
||||
getBuiltinModelsByProvider,
|
||||
getBuiltinModelById,
|
||||
CHART_CAPABILITY_SKILL_ID,
|
||||
CHART_CAPABILITY_CORE_TOOLS,
|
||||
CHART_CAPABILITY_ANALYSIS_TOOLS,
|
||||
getChartCapabilityAllowedBuiltinTools,
|
||||
getChartCapabilitySkill,
|
||||
shouldUseChartCapabilityForMessage,
|
||||
THINK_TAGS,
|
||||
extractThinkingContent,
|
||||
stripToolCallTags,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core'
|
||||
|
||||
import { buildSkillMenuWithBuiltinChart, getSkillConfigWithBuiltinChart } from '../builtin-chart-skill'
|
||||
|
||||
describe('builtin chart skill helpers', () => {
|
||||
it('adds chart_runtime to an empty auto skill menu', () => {
|
||||
const menu = buildSkillMenuWithBuiltinChart(null, 'zh-CN')
|
||||
|
||||
assert.match(menu, /chart_runtime/)
|
||||
assert.match(menu, /绘图助手/)
|
||||
assert.match(menu, /不要输出 Python\/JS 绘图代码/)
|
||||
})
|
||||
|
||||
it('appends chart_runtime to an existing auto skill menu', () => {
|
||||
const baseMenu = `## 可用技能
|
||||
以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时,
|
||||
请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。
|
||||
|
||||
- existing: Existing Skill — Existing description
|
||||
|
||||
如果用户的问题不需要使用技能,直接回答即可。`
|
||||
|
||||
const menu = buildSkillMenuWithBuiltinChart(baseMenu, 'zh-CN')
|
||||
|
||||
assert.match(menu, /existing/)
|
||||
assert.match(menu, /chart_runtime/)
|
||||
assert.ok(menu.indexOf('existing') < menu.indexOf('chart_runtime'))
|
||||
})
|
||||
|
||||
it('resolves chart_runtime without a user-imported skill file', () => {
|
||||
const skill = getSkillConfigWithBuiltinChart(CHART_CAPABILITY_SKILL_ID, 'en-US', () => null)
|
||||
|
||||
assert.ok(skill)
|
||||
assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID)
|
||||
assert.match(skill.prompt, /render_chart/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AgentTool } from '@earendil-works/pi-agent-core'
|
||||
import {
|
||||
CHART_SCHEMA_REQUIRED_MESSAGE,
|
||||
createChartSchemaGateState,
|
||||
wrapWithChartSchemaGate,
|
||||
} from '../chart-schema-gate'
|
||||
|
||||
function createTool(name: string, calls: string[]): AgentTool<any, any> {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: name,
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
async execute() {
|
||||
calls.push(name)
|
||||
return { content: [{ type: 'text', text: `${name} ok` }], details: { name } }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('chart schema gate', () => {
|
||||
it('blocks render_chart until get_schema has been called in the same tool set', async () => {
|
||||
const calls: string[] = []
|
||||
const state = createChartSchemaGateState()
|
||||
const renderChart = wrapWithChartSchemaGate(createTool('render_chart', calls), state)
|
||||
|
||||
const result = await renderChart.execute('call-1', {})
|
||||
|
||||
assert.deepEqual(calls, [])
|
||||
assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }])
|
||||
assert.equal(result.details, null)
|
||||
})
|
||||
|
||||
it('allows render_chart after get_schema has been called', async () => {
|
||||
const calls: string[] = []
|
||||
const state = createChartSchemaGateState()
|
||||
const getSchema = wrapWithChartSchemaGate(createTool('get_schema', calls), state)
|
||||
const renderChart = wrapWithChartSchemaGate(createTool('render_chart', calls), state)
|
||||
|
||||
await getSchema.execute('call-1', {})
|
||||
const result = await renderChart.execute('call-2', {})
|
||||
|
||||
assert.deepEqual(calls, ['get_schema', 'render_chart'])
|
||||
assert.deepEqual(result.content, [{ type: 'text', text: 'render_chart ok' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CHART_CAPABILITY_SKILL_ID, getChartCapabilitySkill } from '@openchatlab/core'
|
||||
import type { SkillDef } from './types'
|
||||
|
||||
type SkillGetter = (id: string) => SkillDef | null
|
||||
|
||||
export function getBuiltinChartSkill(locale: string = 'zh-CN'): SkillDef {
|
||||
return { ...getChartCapabilitySkill(locale), builtinId: CHART_CAPABILITY_SKILL_ID }
|
||||
}
|
||||
|
||||
function getChartMenuLine(locale: string): string {
|
||||
const skill = getBuiltinChartSkill(locale)
|
||||
const isZh = locale.startsWith('zh')
|
||||
const guidance = isZh
|
||||
? '用户明确要求图表、画图、占比、趋势、分布、饼图、柱状图、折线图或热力图时优先激活;不要输出 Python/JS 绘图代码'
|
||||
: 'Activate first when the user explicitly asks for charts, visualization, ratios, trends, distributions, pie, bar, line, or heatmap charts; do not output Python/JS chart code'
|
||||
return `- ${skill.id}: ${skill.name} — ${skill.description}. ${guidance}`
|
||||
}
|
||||
|
||||
export function buildSkillMenuWithBuiltinChart(baseMenu: string | null | undefined, locale: string = 'zh-CN'): string {
|
||||
if (baseMenu?.includes(CHART_CAPABILITY_SKILL_ID)) return baseMenu
|
||||
|
||||
const chartLine = getChartMenuLine(locale)
|
||||
if (!baseMenu) {
|
||||
return `## 可用技能
|
||||
以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时,
|
||||
请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。
|
||||
|
||||
${chartLine}
|
||||
|
||||
如果用户的问题不需要使用技能,直接回答即可。`
|
||||
}
|
||||
|
||||
const closing = '\n\n如果用户的问题不需要使用技能,直接回答即可。'
|
||||
if (!baseMenu.includes(closing)) return `${baseMenu}\n${chartLine}`
|
||||
|
||||
return baseMenu.replace(closing, `\n${chartLine}${closing}`)
|
||||
}
|
||||
|
||||
export function getSkillConfigWithBuiltinChart(
|
||||
id: string,
|
||||
locale: string = 'zh-CN',
|
||||
getSkillConfig: SkillGetter
|
||||
): SkillDef | null {
|
||||
if (id === CHART_CAPABILITY_SKILL_ID) return getBuiltinChartSkill(locale)
|
||||
return getSkillConfig(id)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core'
|
||||
|
||||
export const CHART_SCHEMA_REQUIRED_MESSAGE =
|
||||
'Error: Call get_schema before render_chart. Do not guess table names, fields, or timestamp units.'
|
||||
|
||||
export interface ChartSchemaGateState {
|
||||
schemaSeen: boolean
|
||||
}
|
||||
|
||||
export function createChartSchemaGateState(): ChartSchemaGateState {
|
||||
return { schemaSeen: false }
|
||||
}
|
||||
|
||||
export function wrapWithChartSchemaGate(tool: AgentTool<any>, state: ChartSchemaGateState): AgentTool<any> {
|
||||
const originalExecute = tool.execute
|
||||
|
||||
return {
|
||||
...tool,
|
||||
execute: async (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: unknown
|
||||
): Promise<AgentToolResult<any>> => {
|
||||
if (tool.name === 'render_chart' && !state.schemaSeen) {
|
||||
return {
|
||||
content: [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }],
|
||||
details: null,
|
||||
} as AgentToolResult<any>
|
||||
}
|
||||
|
||||
const result = await originalExecute(toolCallId, params, signal, onUpdate as never)
|
||||
|
||||
if (tool.name === 'get_schema') {
|
||||
state.schemaSeen = true
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -126,6 +126,15 @@ export type { CreateCompressionLlmAdapterOptions } from './ai'
|
||||
|
||||
// SkillManager
|
||||
export { SkillManager } from './ai'
|
||||
export {
|
||||
buildSkillMenuWithBuiltinChart,
|
||||
getBuiltinChartSkill,
|
||||
getSkillConfigWithBuiltinChart,
|
||||
CHART_SCHEMA_REQUIRED_MESSAGE,
|
||||
createChartSchemaGateState,
|
||||
wrapWithChartSchemaGate,
|
||||
} from './ai'
|
||||
export type { ChartSchemaGateState } from './ai'
|
||||
|
||||
// SkillManagerCore
|
||||
export { SkillManagerCore } from './ai'
|
||||
|
||||
@@ -108,6 +108,22 @@ describe('renderChartTool', () => {
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
it('rejects dividing ChatLab second timestamps by 1000', async () => {
|
||||
const calls: Array<{ query: string; params: Record<string, unknown> }> = []
|
||||
const context = createContext([], calls)
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await renderChartTool.handler(
|
||||
{
|
||||
sql: "SELECT date(ts/1000, 'unixepoch') AS day, COUNT(*) AS message_count FROM message GROUP BY day",
|
||||
chartSpec: barSpec,
|
||||
},
|
||||
context
|
||||
)
|
||||
}, /message\.ts is already a Unix timestamp in seconds/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
it('enforces an outer row limit even when SQL already has a LIMIT', async () => {
|
||||
const calls: Array<{ query: string; params: Record<string, unknown> }> = []
|
||||
const context = createContext([{ name: 'Alice', message_count: 4 }], calls)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildChartPayload } from '@openchatlab/core'
|
||||
import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types'
|
||||
|
||||
const DEFAULT_MAX_ROWS = 1000
|
||||
const RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS = /\b(?:\w+\.)?ts\s*\/\s*1000\b/i
|
||||
|
||||
const inputSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
@@ -55,6 +56,10 @@ function normalizeSql(sql: unknown, maxRows: number): string {
|
||||
throw new Error('render_chart only accepts SELECT or WITH SELECT SQL')
|
||||
}
|
||||
|
||||
if (RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS.test(trimmed)) {
|
||||
throw new Error('message.ts is already a Unix timestamp in seconds; do not divide ts by 1000')
|
||||
}
|
||||
|
||||
return `SELECT * FROM (\n${trimmed}\n) AS chart_query LIMIT ${maxRows + 1}`
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export const sqlQueryTool: ToolDefinition = {
|
||||
description: '对聊天数据库执行只读 SELECT 查询。使用前可先调用 get_schema 查看表结构。',
|
||||
inputSchema,
|
||||
handler,
|
||||
category: 'analysis',
|
||||
}
|
||||
|
||||
export const schemaTool: ToolDefinition = {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
BarChartRenderData,
|
||||
ChartPayload,
|
||||
@@ -12,10 +13,13 @@ import EChartHeatmap from '@/components/charts/EChartHeatmap.vue'
|
||||
import EChartLine from '@/components/charts/EChartLine.vue'
|
||||
import EChartPie from '@/components/charts/EChartPie.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
chart: ChartPayload
|
||||
}>()
|
||||
|
||||
const isExpandedOpen = ref(false)
|
||||
const title = computed(() => props.chart.spec.title)
|
||||
const subtitle = computed(() => props.chart.spec.subtitle || props.chart.spec.description || '')
|
||||
const height = computed(() => props.chart.spec.display?.height ?? (props.chart.spec.type === 'heatmap' ? 300 : 260))
|
||||
@@ -33,6 +37,43 @@ const barData = computed(() => props.chart.data as BarChartRenderData)
|
||||
const lineData = computed(() => props.chart.data as LineChartRenderData)
|
||||
const pieData = computed(() => props.chart.data as PieChartRenderData)
|
||||
const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
|
||||
|
||||
const isDenseChart = computed(() => {
|
||||
if (props.chart.spec.type === 'pie') return pieData.value.labels.length > 8
|
||||
if (props.chart.spec.type === 'line') {
|
||||
return lineData.value.labels.length > 28 || (lineData.value.series?.length ?? 0) > 4
|
||||
}
|
||||
if (props.chart.spec.type === 'bar') return barData.value.labels.length > 12
|
||||
if (props.chart.spec.type === 'heatmap') {
|
||||
return heatmapData.value.xLabels.length * heatmapData.value.yLabels.length > 180
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const compactHeight = computed(() => {
|
||||
const maxHeight = isDenseChart.value ? 320 : 380
|
||||
return Math.min(height.value, maxHeight)
|
||||
})
|
||||
|
||||
const expandedHeight = computed(() => {
|
||||
const requested = height.value
|
||||
if (props.chart.spec.type === 'pie') return Math.max(requested, 560)
|
||||
if (props.chart.spec.type === 'line') return Math.max(requested, lineData.value.labels.length > 40 ? 620 : 540)
|
||||
if (props.chart.spec.type === 'bar') {
|
||||
const dynamicHeight = props.chart.spec.display?.horizontal ? barData.value.labels.length * 28 + 120 : 520
|
||||
return Math.min(Math.max(requested, dynamicHeight), 820)
|
||||
}
|
||||
if (props.chart.spec.type === 'heatmap') {
|
||||
return Math.min(Math.max(requested, heatmapData.value.yLabels.length * 22 + 150, 560), 860)
|
||||
}
|
||||
return Math.max(requested, 520)
|
||||
})
|
||||
|
||||
const expandedMinWidth = computed(() => {
|
||||
if (props.chart.spec.type === 'pie') return '640px'
|
||||
if (props.chart.spec.type === 'bar') return '720px'
|
||||
return '780px'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,11 +88,90 @@ const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase text-gray-500 dark:bg-gray-800"
|
||||
>
|
||||
{{ chart.spec.type }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase text-gray-500 dark:bg-gray-800">
|
||||
{{ chart.spec.type }}
|
||||
</span>
|
||||
<UModal
|
||||
v-model:open="isExpandedOpen"
|
||||
:ui="{
|
||||
overlay: 'z-[10040] backdrop-blur-sm',
|
||||
content: 'z-[10050] w-[96vw] max-w-6xl max-h-[92vh] overflow-hidden',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
icon="i-heroicons-arrows-pointing-out"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="no-capture"
|
||||
:title="t('ai.chat.message.chart.expand')"
|
||||
:aria-label="t('ai.chat.message.chart.expand')"
|
||||
@click.stop
|
||||
/>
|
||||
<template #content>
|
||||
<div class="flex max-h-[92vh] flex-col overflow-hidden bg-white dark:bg-gray-950">
|
||||
<div
|
||||
class="flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4 dark:border-gray-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-gray-100">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">{{ summary }}</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
:title="t('common.close')"
|
||||
:aria-label="t('common.close')"
|
||||
@click="isExpandedOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div class="mx-auto w-full" :style="{ minWidth: expandedMinWidth }">
|
||||
<div
|
||||
v-if="isEmpty"
|
||||
class="flex h-64 items-center justify-center text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
<EChartBar
|
||||
v-else-if="chart.spec.type === 'bar'"
|
||||
:data="barData"
|
||||
:height="expandedHeight"
|
||||
mode="expanded"
|
||||
:horizontal="chart.spec.display?.horizontal"
|
||||
/>
|
||||
<EChartLine
|
||||
v-else-if="chart.spec.type === 'line'"
|
||||
:data="lineData"
|
||||
:height="expandedHeight"
|
||||
mode="expanded"
|
||||
/>
|
||||
<EChartPie
|
||||
v-else-if="chart.spec.type === 'pie'"
|
||||
:data="pieData"
|
||||
:height="expandedHeight"
|
||||
mode="expanded"
|
||||
:show-legend="chart.spec.display?.showLegend ?? true"
|
||||
/>
|
||||
<EChartHeatmap
|
||||
v-else-if="chart.spec.type === 'heatmap'"
|
||||
:data="heatmapData"
|
||||
:height="expandedHeight"
|
||||
mode="expanded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">{{ summary }}</p>
|
||||
</div>
|
||||
@@ -63,17 +183,24 @@ const heatmapData = computed(() => props.chart.data as HeatmapChartRenderData)
|
||||
<EChartBar
|
||||
v-else-if="chart.spec.type === 'bar'"
|
||||
:data="barData"
|
||||
:height="height"
|
||||
:height="compactHeight"
|
||||
mode="compact"
|
||||
:horizontal="chart.spec.display?.horizontal"
|
||||
/>
|
||||
<EChartLine v-else-if="chart.spec.type === 'line'" :data="lineData" :height="height" />
|
||||
<EChartLine v-else-if="chart.spec.type === 'line'" :data="lineData" :height="compactHeight" mode="compact" />
|
||||
<EChartPie
|
||||
v-else-if="chart.spec.type === 'pie'"
|
||||
:data="pieData"
|
||||
:height="height"
|
||||
:height="compactHeight"
|
||||
mode="compact"
|
||||
:show-legend="chart.spec.display?.showLegend ?? true"
|
||||
/>
|
||||
<EChartHeatmap v-else-if="chart.spec.type === 'heatmap'" :data="heatmapData" :height="height" />
|
||||
<EChartHeatmap
|
||||
v-else-if="chart.spec.type === 'heatmap'"
|
||||
:data="heatmapData"
|
||||
:height="compactHeight"
|
||||
mode="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
VisualMapComponent,
|
||||
DataZoomComponent,
|
||||
} from 'echarts/components'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
@@ -28,6 +29,7 @@ echarts.use([
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
VisualMapComponent,
|
||||
DataZoomComponent,
|
||||
])
|
||||
|
||||
interface Props {
|
||||
@@ -45,6 +47,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// 计算高度样式
|
||||
const heightStyle = computed(() => {
|
||||
@@ -134,6 +137,10 @@ onMounted(() => {
|
||||
initChart()
|
||||
})
|
||||
window.addEventListener('resize', handleResize)
|
||||
if (chartRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => handleResize())
|
||||
resizeObserver.observe(chartRef.value)
|
||||
}
|
||||
|
||||
// 监听 HTML 元素的 class 变化(用于检测暗色模式切换)
|
||||
observer = new MutationObserver(() => {
|
||||
@@ -149,6 +156,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
resizeObserver?.disconnect()
|
||||
observer?.disconnect()
|
||||
chartInstance?.dispose()
|
||||
chartInstance = null
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface EChartBarData {
|
||||
interface Props {
|
||||
data: EChartBarData
|
||||
height?: number
|
||||
mode?: 'compact' | 'expanded'
|
||||
/** 是否为横向柱状图 */
|
||||
horizontal?: boolean
|
||||
/** 是否显示渐变色 */
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 200,
|
||||
mode: 'expanded',
|
||||
horizontal: false,
|
||||
gradient: true,
|
||||
borderRadius: 4,
|
||||
@@ -44,10 +46,14 @@ const gradientColor = {
|
||||
|
||||
const option = computed<EChartsOption>(() => {
|
||||
const isHorizontal = props.horizontal
|
||||
const isCompact = props.mode === 'compact'
|
||||
const labelCount = props.data.labels.length
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
extraCssText: 'max-width: min(360px, 70vw); white-space: normal; word-break: break-word;',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
@@ -58,11 +64,11 @@ const option = computed<EChartsOption>(() => {
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: isHorizontal ? 60 : 40,
|
||||
right: 20,
|
||||
left: isHorizontal ? (isCompact ? 72 : 96) : 36,
|
||||
right: 16,
|
||||
top: 20,
|
||||
bottom: isHorizontal ? 20 : 30,
|
||||
containLabel: false,
|
||||
bottom: isHorizontal ? 20 : labelCount > 8 ? 48 : 30,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: isHorizontal
|
||||
? {
|
||||
@@ -84,6 +90,9 @@ const option = computed<EChartsOption>(() => {
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
color: '#6b7280',
|
||||
interval: 'auto',
|
||||
rotate: !isHorizontal && labelCount > 8 ? 28 : 0,
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: isHorizontal
|
||||
@@ -95,6 +104,8 @@ const option = computed<EChartsOption>(() => {
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
color: '#6b7280',
|
||||
overflow: 'truncate',
|
||||
width: isCompact ? 70 : 110,
|
||||
},
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface EChartHeatmapData {
|
||||
interface Props {
|
||||
data: EChartHeatmapData
|
||||
height?: number
|
||||
mode?: 'compact' | 'expanded'
|
||||
/** 最小值颜色 */
|
||||
minColor?: string
|
||||
/** 最大值颜色 */
|
||||
@@ -27,6 +28,7 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 280,
|
||||
mode: 'expanded',
|
||||
minColor: '#fee5e8', // 项目主题 pink-100
|
||||
maxColor: '#ee4567', // 项目主题 pink-500
|
||||
})
|
||||
@@ -41,9 +43,14 @@ const maxValue = computed(() => {
|
||||
})
|
||||
|
||||
const option = computed<EChartsOption>(() => {
|
||||
const isCompact = props.mode === 'compact'
|
||||
const xCount = props.data.xLabels.length
|
||||
const yCount = props.data.yLabels.length
|
||||
return {
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
confine: true,
|
||||
extraCssText: 'max-width: min(360px, 70vw); white-space: normal; word-break: break-word;',
|
||||
formatter: (params: any) => {
|
||||
const xLabel = props.data.xLabels[params.data[0]]
|
||||
const yLabel = props.data.yLabels[params.data[1]]
|
||||
@@ -57,10 +64,11 @@ const option = computed<EChartsOption>(() => {
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 60,
|
||||
left: isCompact ? 52 : 72,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: 60,
|
||||
top: isCompact ? 12 : 24,
|
||||
bottom: isCompact ? 52 : 70,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -71,9 +79,10 @@ const option = computed<EChartsOption>(() => {
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
fontSize: isCompact ? 10 : 11,
|
||||
color: '#6b7280',
|
||||
interval: 0,
|
||||
interval: isCompact && xCount > 16 ? 'auto' : 0,
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
@@ -85,8 +94,10 @@ const option = computed<EChartsOption>(() => {
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
fontSize: isCompact ? 10 : 11,
|
||||
color: '#6b7280',
|
||||
interval: isCompact && yCount > 14 ? 'auto' : 0,
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
@@ -96,8 +107,8 @@ const option = computed<EChartsOption>(() => {
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 120,
|
||||
itemWidth: isCompact ? 8 : 10,
|
||||
itemHeight: isCompact ? 84 : 120,
|
||||
inRange: {
|
||||
color: [props.minColor, props.maxColor],
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface EChartLineData {
|
||||
interface Props {
|
||||
data: EChartLineData
|
||||
height?: number
|
||||
mode?: 'compact' | 'expanded'
|
||||
/** 是否显示面积 */
|
||||
showArea?: boolean
|
||||
/** 是否平滑曲线 */
|
||||
@@ -23,12 +24,18 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 288,
|
||||
mode: 'expanded',
|
||||
showArea: true,
|
||||
smooth: true,
|
||||
})
|
||||
|
||||
const option = computed<EChartsOption>(() => {
|
||||
const hasSeries = !!props.data.series?.length
|
||||
const isCompact = props.mode === 'compact'
|
||||
const seriesCount = props.data.series?.length ?? 0
|
||||
const labelCount = props.data.labels.length
|
||||
const useScrollableLegend = seriesCount > (isCompact ? 3 : 8)
|
||||
const useDataZoom = !isCompact && labelCount > 30
|
||||
const series = hasSeries
|
||||
? props.data.series!.map((item) => ({
|
||||
name: item.name,
|
||||
@@ -37,7 +44,7 @@ const option = computed<EChartsOption>(() => {
|
||||
smooth: props.smooth,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
showSymbol: false,
|
||||
showSymbol: !isCompact && labelCount <= 80,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
@@ -53,7 +60,7 @@ const option = computed<EChartsOption>(() => {
|
||||
smooth: props.smooth,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
showSymbol: false,
|
||||
showSymbol: !isCompact && labelCount <= 80,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#ee4567',
|
||||
@@ -90,6 +97,8 @@ const option = computed<EChartsOption>(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
extraCssText: 'max-width: min(420px, 76vw); white-space: normal; word-break: break-word;',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderColor: 'transparent',
|
||||
textStyle: {
|
||||
@@ -99,12 +108,17 @@ const option = computed<EChartsOption>(() => {
|
||||
grid: {
|
||||
left: 50,
|
||||
right: 20,
|
||||
top: hasSeries ? 36 : 20,
|
||||
bottom: 30,
|
||||
top: hasSeries ? (isCompact ? 44 : 56) : 20,
|
||||
bottom: useDataZoom ? 66 : 34,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: hasSeries
|
||||
? {
|
||||
type: useScrollableLegend ? 'scroll' : 'plain',
|
||||
top: 0,
|
||||
left: 8,
|
||||
right: 8,
|
||||
height: 32,
|
||||
textStyle: { color: '#6b7280', fontSize: 11 },
|
||||
}
|
||||
: undefined,
|
||||
@@ -119,6 +133,7 @@ const option = computed<EChartsOption>(() => {
|
||||
color: '#6b7280',
|
||||
// 自动间隔显示标签
|
||||
interval: 'auto',
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
@@ -132,6 +147,12 @@ const option = computed<EChartsOption>(() => {
|
||||
},
|
||||
},
|
||||
},
|
||||
dataZoom: useDataZoom
|
||||
? [
|
||||
{ type: 'inside', throttle: 50 },
|
||||
{ type: 'slider', height: 18, bottom: 18, showDetail: false },
|
||||
]
|
||||
: undefined,
|
||||
series,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface EChartPieData {
|
||||
interface Props {
|
||||
data: EChartPieData
|
||||
height?: number
|
||||
mode?: 'compact' | 'expanded'
|
||||
/** 是否为环形图 */
|
||||
doughnut?: boolean
|
||||
/** 内圈半径(环形图时生效) */
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 280,
|
||||
mode: 'expanded',
|
||||
doughnut: true,
|
||||
innerRadius: '50%',
|
||||
showLegend: true,
|
||||
@@ -48,33 +50,57 @@ const option = computed<EChartsOption>(() => {
|
||||
name: label,
|
||||
value: props.data.values[index],
|
||||
}))
|
||||
const itemCount = props.data.labels.length
|
||||
const isCompact = props.mode === 'compact'
|
||||
const isDense = itemCount > (isCompact ? 8 : 14)
|
||||
const useScrollableLegend = itemCount > (isCompact ? 6 : 12)
|
||||
const legendPlacement = isCompact
|
||||
? {
|
||||
type: useScrollableLegend ? 'scroll' : 'plain',
|
||||
orient: 'horizontal' as const,
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 0,
|
||||
height: 28,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
overflow: 'truncate' as const,
|
||||
width: 72,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: useScrollableLegend ? 'scroll' : 'plain',
|
||||
orient: 'vertical' as const,
|
||||
right: 10,
|
||||
top: 24,
|
||||
bottom: 24,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
overflow: 'truncate' as const,
|
||||
width: 120,
|
||||
},
|
||||
}
|
||||
const showOuterLabel = !isCompact && !isDense
|
||||
|
||||
return {
|
||||
color: colors,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
confine: true,
|
||||
extraCssText: 'max-width: min(360px, 70vw); white-space: normal; word-break: break-word;',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderColor: 'transparent',
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
},
|
||||
},
|
||||
legend: props.showLegend
|
||||
? {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
legend: props.showLegend ? legendPlacement : undefined,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: props.doughnut ? [props.innerRadius, '70%'] : '70%',
|
||||
center: props.showLegend ? ['35%', '50%'] : ['50%', '50%'],
|
||||
radius: props.doughnut ? [props.innerRadius, isCompact ? '58%' : '68%'] : isCompact ? '58%' : '68%',
|
||||
center: props.showLegend ? (isCompact ? ['50%', '42%'] : ['38%', '50%']) : ['50%', isCompact ? '46%' : '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
@@ -82,13 +108,20 @@ const option = computed<EChartsOption>(() => {
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
show: showOuterLabel,
|
||||
formatter: '{b}',
|
||||
overflow: 'truncate',
|
||||
width: 110,
|
||||
alignTo: 'edge',
|
||||
edgeDistance: 12,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
show: showOuterLabel,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
overflow: 'truncate',
|
||||
width: 140,
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"fork": {
|
||||
"tooltip": "ここから会話をフォーク"
|
||||
},
|
||||
"chart": {
|
||||
"expand": "大きなチャートを開く"
|
||||
},
|
||||
"tools": {
|
||||
"get_chat_overview": "チャット概要を取得",
|
||||
"search_messages": "チャット履歴を検索",
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"fork": {
|
||||
"tooltip": "从此处复刻对话"
|
||||
},
|
||||
"chart": {
|
||||
"expand": "打开大图"
|
||||
},
|
||||
"tools": {
|
||||
"get_chat_overview": "获取聊天概览",
|
||||
"search_messages": "搜索聊天记录",
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"fork": {
|
||||
"tooltip": "從此處複製對話"
|
||||
},
|
||||
"chart": {
|
||||
"expand": "開啟大圖"
|
||||
},
|
||||
"tools": {
|
||||
"get_chat_overview": "取得聊天概覽",
|
||||
"search_messages": "搜尋聊天紀錄",
|
||||
|
||||
@@ -151,9 +151,14 @@ describe('real external LLM chart flow', () => {
|
||||
Test-specific requirements:
|
||||
- Use get_schema if you need table names or columns.
|
||||
- Produce exactly two render_chart calls.
|
||||
- Do not make exploratory, retry, or debugging render_chart calls beyond those two charts.
|
||||
- The pie chart must include only Alice and Bob and must exclude Cara.
|
||||
- The heatmap must include only Alice and Bob and use hour on x, member name on y, and message count as value.
|
||||
- Use the data from June 1-2, 2026 inclusive. The database stores Unix seconds in message.ts.`,
|
||||
- Use the data from June 1-2, 2026 inclusive.
|
||||
- The database stores Unix seconds in message.ts. Use this exact UTC range:
|
||||
- start: 1780272000
|
||||
- end: 1780444799
|
||||
- Member display names in chat messages are stored as Alice, Bob, and Cara in message.sender_group_nickname.`,
|
||||
tools,
|
||||
history: [],
|
||||
userMessage:
|
||||
@@ -181,7 +186,8 @@ Test-specific requirements:
|
||||
assert.ok(!heatmap.data.yLabels.includes('Cara'), 'heatmap should exclude Cara')
|
||||
assert.ok(heatmap.data.yLabels.includes('Alice'), 'heatmap should include Alice')
|
||||
assert.ok(heatmap.data.yLabels.includes('Bob'), 'heatmap should include Bob')
|
||||
assert.ok(heatmap.data.xLabels.includes('09'), 'heatmap should include hour 09')
|
||||
const heatmapHours = new Set(heatmap.data.xLabels.map((label) => Number(label)))
|
||||
assert.ok(heatmapHours.has(9), 'heatmap should include hour 9')
|
||||
assert.ok(heatmap.data.data.length > 0, 'heatmap should include density cells')
|
||||
|
||||
const finalContent = events
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user