mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
dbc377854a
commit
782f67ba63
46 files changed
+2631
-73
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user