feat(ai): add slash-activated chart runtime with EChart init fix

- Add ChartSpec validation and chart data normalization (bar/line/pie/heatmap)
- Add render_chart analysis tool with SQL safety (readonly check, outer LIMIT wrapper, CTE write rejection)
- Add chart content block type and ChartBlockRenderer for inline chart rendering
- Add slash menu chart capability activation (single-use per message)
- Add chart payload transport through Electron and CLI Web tool adapters
- Add error block handling for render-only chart tool failures
- Add chart payload persistence through content_blocks JSON storage
- Fix EChart.vue init timing: defer initChart() to nextTick so container
  layout completes before ECharts reads width (was rendering 92px canvases)
- Add comprehensive test coverage: core, integration, agent flow, frontend, smoke
- Add i18n for render_chart and get_schema in 4 locales
- Add project handoff documentation
This commit is contained in:
CEQ151 authored and digua committed 2026-06-05 11:12:42 +08:00
1 parent dbc377854a
commit 782f67ba63
46 files changed
+2631 -73

No files matched your search

+23 -3
View File
@@ -5,7 +5,12 @@
import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime'
import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime'
import { getChatOverview } from '@openchatlab/core'
import {
CHART_CAPABILITY_SKILL_ID,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
getChatOverview,
} from '@openchatlab/core'
import type { DataSnapshot } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools'
@@ -46,11 +51,13 @@ export function createCliRunAgentStream(
const aiDataDir = getAiDir(dbManager)
let assistantSystemPrompt: string | undefined
let assistantAllowedTools: string[] | undefined
if (assistantId) {
const assistantConfig = loadAssistantConfig(aiDataDir, assistantId)
if (assistantConfig?.systemPrompt) {
assistantSystemPrompt = assistantConfig.systemPrompt
}
assistantAllowedTools = assistantConfig?.allowedBuiltinTools
}
const llmConfig = getDefaultAssistantConfig(aiDataDir)
@@ -59,8 +66,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 allowedToolSet = isChartCapability
? new Set(getChartCapabilityAllowedBuiltinTools(assistantAllowedTools))
: assistantAllowedTools && assistantAllowedTools.length > 0
? new Set(assistantAllowedTools)
: null
const availableToolDefs = allowedToolSet
? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name))
: AGENT_TOOL_REGISTRY
const agentTools = db
? adaptToolsForAgent(AGENT_TOOL_REGISTRY, () => ({ db, sessionId, locale }), { maxToolResultTokens })
? adaptToolsForAgent(availableToolDefs, () => ({ db, sessionId, locale }), { maxToolResultTokens })
: []
const skillMgr = new SkillManager(aiDataDir)
@@ -69,7 +86,10 @@ export function createCliRunAgentStream(
let resolvedSkillDef: { name: string; prompt: string } | undefined
let resolvedSkillMenu: string | undefined
if (skillId) {
if (isChartCapability) {
const def = getChartCapabilitySkill(locale ?? 'zh-CN')
resolvedSkillDef = { name: def.name, prompt: def.prompt }
} else if (skillId) {
const def = skillMgr.getSkillConfig(skillId)
if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt }
} else if (enableAutoSkill) {
+59
View File
@@ -0,0 +1,59 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { adaptToolsForAgent } from './tool-adapter'
import type { ToolDefinition } from '@openchatlab/tools'
import type { ChartPayload, DatabaseAdapter } from '@openchatlab/core'
const chart: ChartPayload = {
version: 1,
spec: {
version: 1,
type: 'pie',
title: 'Selected members',
encoding: { label: 'name', value: 'message_count' },
},
dataset: {
columns: [
{ name: 'name', type: 'category' },
{ name: 'message_count', type: 'integer' },
],
rows: [
{ name: 'Alice', message_count: 4 },
{ name: 'Bob', message_count: 3 },
],
},
data: {
labels: ['Alice', 'Bob'],
values: [4, 3],
},
rowCount: 2,
}
describe('adaptToolsForAgent', () => {
it('preserves chart payloads in tool result details', async () => {
const tool: ToolDefinition = {
name: 'render_chart',
description: 'Render a chart',
inputSchema: { type: 'object', properties: {} },
async handler() {
return {
content: 'Generated chart.',
data: { rowCount: 2 },
chart,
}
},
}
const [agentTool] = adaptToolsForAgent([tool], () => ({
db: {} as DatabaseAdapter,
sessionId: 'session-1',
locale: 'en-US',
}))
assert.ok(agentTool)
const result = await agentTool.execute('call-1', {})
assert.deepEqual(result.content, [{ type: 'text', text: 'Generated chart.' }])
assert.deepEqual(result.details, { rowCount: 2, chart })
})
})
+22 -2
View File
@@ -75,6 +75,13 @@ export function adaptToolsForAgent(
}
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({
@@ -85,10 +92,23 @@ export function adaptToolsForAgent(
extraDetails: (result.data ?? {}) as Record<string, unknown>,
logger: getServerAiLogger() ?? undefined,
})
return { content: [{ type: 'text', text: pipelineResult.text }], details: null }
return {
content: [{ type: 'text', text: pipelineResult.text }],
details: Object.keys(chartDetails).length > 0 ? chartDetails : null,
}
}
return { content: [{ type: 'text', text: result.content }], details: null }
const baseDetails =
typeof result.data === 'object' && result.data !== null
? (result.data as Record<string, unknown>)
: result.data === undefined
? null
: { value: result.data }
return {
content: [{ type: 'text', text: result.content }],
details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails,
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null }
+19 -1
View File
@@ -15,6 +15,11 @@ import type { ToolContext } from './tools/types'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm'
import type { AIServiceConfig } from './llm/types'
import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral'
import {
CHART_CAPABILITY_SKILL_ID,
getChartCapabilityAllowedBuiltinTools,
getChartCapabilitySkill,
} from '@openchatlab/core'
import * as assistantManager from './assistant'
import type { AssistantConfig } from './assistant/types'
import * as skillManager from './skills'
@@ -147,7 +152,20 @@ export function createElectronRunAgentStream(): (
}
let skillCtx: SkillContext | undefined
if (skillId) {
const isChartCapability = skillId === CHART_CAPABILITY_SKILL_ID
if (isChartCapability) {
const chartSkill = getChartCapabilitySkill(locale ?? 'zh-CN')
skillCtx = { skillDef: { ...chartSkill, chatScope: 'all' } as SkillContext['skillDef'] }
assistantConfig = {
...(assistantConfig ?? {
id: resolvedAssistantId,
name: resolvedAssistantId,
systemPrompt: '',
presetQuestions: [],
}),
allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(assistantConfig?.allowedBuiltinTools),
}
} else if (skillId) {
const skillDef = skillManager.getSkillConfig(skillId) ?? undefined
if (skillDef) {
skillCtx = { skillDef }
@@ -16,6 +16,7 @@ import {
searchSessionsTool,
getSessionMessagesTool,
getMembersTool,
schemaTool,
memberStatsTool,
timeStatsTool,
getMemberNameHistoryTool,
@@ -23,6 +24,7 @@ import {
getSessionSummariesTool,
responseTimeAnalysisTool,
keywordFrequencyTool,
renderChartTool,
SQL_TOOL_DEFS,
createSqlToolDefinition,
} from '@openchatlab/tools'
@@ -48,6 +50,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [
adaptSharedTool(searchSessionsTool, { category: 'core' }),
adaptSharedTool(getSessionMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }),
adaptSharedTool(getMembersTool, { category: 'core' }),
adaptSharedTool(schemaTool, { category: 'core' }),
// ==================== Analysis 工具(按需加载) ====================
adaptSharedTool(memberStatsTool, { category: 'analysis' }),
@@ -57,6 +60,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [
adaptSharedTool(getSessionSummariesTool, { category: 'analysis' }),
adaptSharedTool(responseTimeAnalysisTool, { category: 'analysis' }),
adaptSharedTool(keywordFrequencyTool, { category: 'analysis' }),
adaptSharedTool(renderChartTool, { category: 'analysis' }),
// ==================== SQL 分析工具 ====================
...sqlToolEntries,
@@ -56,6 +56,13 @@ export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): To
const execCtx = buildExecutionContext(context)
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 baseData = (typeof result.data === 'object' && result.data !== null ? result.data : {}) as Record<
@@ -64,13 +71,20 @@ export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): To
>
return {
content: [{ type: 'text', text: result.content }],
details: { ...baseData, rawMessages: result.rawMessages },
details: { ...baseData, ...chartDetails, rawMessages: result.rawMessages },
}
}
const baseDetails =
typeof result.data === 'object' && result.data !== null
? (result.data as Record<string, unknown>)
: result.data === undefined
? null
: { value: result.data }
return {
content: [{ type: 'text', text: result.content }],
details: result.data ?? null,
details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails,
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)