From 296573ed5cf0056f2b9739da66c22c451284f76b Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 4 Jun 2026 23:28:45 +0800 Subject: [PATCH] refactor(ai): centralize chart runtime policy --- apps/cli/src/ai/agent-stream-runner.test.ts | 2 +- apps/cli/src/ai/agent-stream-runner.ts | 32 +-- apps/desktop/main/ai/agent-stream-runner.ts | 29 +-- apps/desktop/main/ai/tools/index.test.ts | 50 ----- apps/desktop/main/ai/tools/index.ts | 23 +-- .../desktop/main/ai/tools/tool-filter.test.ts | 16 ++ apps/desktop/main/ai/tools/tool-filter.ts | 3 + package.json | 1 + packages/core/src/ai/chart-capability.test.ts | 78 ------- packages/core/src/ai/chart-capability.ts | 114 ----------- packages/core/src/ai/index.ts | 9 +- packages/core/src/index.ts | 7 - .../src/ai/__tests__/chart-runtime.test.ts | 66 ++++++ .../src/ai/builtin-chart-skill.ts | 51 +---- packages/node-runtime/src/ai/chart-runtime.ts | 192 ++++++++++++++++++ packages/node-runtime/src/ai/conversations.ts | 1 + packages/node-runtime/src/ai/index.ts | 12 +- packages/node-runtime/src/index.ts | 8 + .../AIChat/chat/ChartBlockRenderer.vue | 8 +- src/i18n/locales/en-US/ai.json | 5 +- src/i18n/locales/ja-JP/ai.json | 5 +- src/i18n/locales/zh-CN/ai.json | 5 +- src/i18n/locales/zh-TW/ai.json | 5 +- src/stores/aiChatChartBlocks.test.ts | 23 ++- src/stores/aiChatChartBlocks.ts | 13 +- src/stores/skill.test.ts | 2 +- src/stores/skill.ts | 22 +- ...flow.test.ts => agent-chart-flow.test.mts} | 0 28 files changed, 413 insertions(+), 369 deletions(-) delete mode 100644 apps/desktop/main/ai/tools/index.test.ts create mode 100644 apps/desktop/main/ai/tools/tool-filter.test.ts create mode 100644 apps/desktop/main/ai/tools/tool-filter.ts delete mode 100644 packages/core/src/ai/chart-capability.test.ts create mode 100644 packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts create mode 100644 packages/node-runtime/src/ai/chart-runtime.ts rename tests/chart-runtime/{agent-chart-flow.test.ts => agent-chart-flow.test.mts} (100%) diff --git a/apps/cli/src/ai/agent-stream-runner.test.ts b/apps/cli/src/ai/agent-stream-runner.test.ts index 1c6cc095..3403b367 100644 --- a/apps/cli/src/ai/agent-stream-runner.test.ts +++ b/apps/cli/src/ai/agent-stream-runner.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { getChartCapabilityAllowedBuiltinTools } from '@openchatlab/core' +import { getChartCapabilityAllowedBuiltinTools } from '@openchatlab/node-runtime' import { getAvailableToolDefs } from './agent-stream-runner' describe('CLI chart capability tool filtering', () => { diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 44090e79..9fc77842 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -5,19 +5,16 @@ import type { DatabaseManager, AIConversationManager, AgentStreamChunk } from '@openchatlab/node-runtime' import { + CHART_CAPABILITY_CORE_TOOLS, SkillManager, buildSkillMenuWithBuiltinChart, createActivateSkillTool, - getSkillConfigWithBuiltinChart, -} from '@openchatlab/node-runtime' -import { - CHART_CAPABILITY_CORE_TOOLS, - CHART_CAPABILITY_SKILL_ID, - getChartCapabilityAllowedBuiltinTools, + getAllowedBuiltinToolsForChartAutoSkill, getChartCapabilitySkill, - getChatOverview, - shouldUseChartCapabilityForMessage, -} from '@openchatlab/core' + getSkillConfigWithBuiltinChart, + resolveChartRuntimeForRequest, +} from '@openchatlab/node-runtime' +import { 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' @@ -90,14 +87,19 @@ export function createCliRunAgentStream( const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) const db = (dbManager as any).open?.(sessionId) - const isChartCapability = - skillId === CHART_CAPABILITY_SKILL_ID || (!skillId && shouldUseChartCapabilityForMessage(userMessage)) + const chartRuntime = resolveChartRuntimeForRequest({ + skillId, + userMessage, + locale, + assistantAllowedTools, + }) + const isChartCapability = chartRuntime.isChartCapability const autoSkillAllowedTools = - !skillId && enableAutoSkill && assistantAllowedTools && assistantAllowedTools.length > 0 - ? getChartCapabilityAllowedBuiltinTools(assistantAllowedTools) + !skillId && enableAutoSkill + ? getAllowedBuiltinToolsForChartAutoSkill(assistantAllowedTools) : assistantAllowedTools const allowedToolSet = isChartCapability - ? new Set(getChartCapabilityAllowedBuiltinTools(assistantAllowedTools)) + ? new Set(chartRuntime.allowedBuiltinTools) : autoSkillAllowedTools && autoSkillAllowedTools.length > 0 ? new Set(autoSkillAllowedTools) : null @@ -114,7 +116,7 @@ export function createCliRunAgentStream( let resolvedSkillDef: { name: string; prompt: string } | undefined let resolvedSkillMenu: string | undefined if (isChartCapability) { - const def = getChartCapabilitySkill(locale ?? 'zh-CN') + const def = chartRuntime.skillDef ?? getChartCapabilitySkill(locale ?? 'zh-CN') resolvedSkillDef = { name: def.name, prompt: def.prompt } } else if (skillId) { const def = skillMgr.getSkillConfig(skillId) diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index bd0d3c1b..e3a70b58 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -13,21 +13,17 @@ import { checkAndCompress, createCompressionLlmAdapter, formatAIError, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilitySkill, initTokenizer, + resolveChartRuntimeForRequest, } 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' -import { - CHART_CAPABILITY_SKILL_ID, - getChartCapabilityAllowedBuiltinTools, - getChartCapabilitySkill, - shouldUseChartCapabilityForMessage, -} from '@openchatlab/core' import * as assistantManager from './assistant' import type { AssistantConfig } from './assistant/types' import * as skillManager from './skills' @@ -160,10 +156,14 @@ export function createElectronRunAgentStream(): ( } let skillCtx: SkillContext | undefined - const isChartCapability = - skillId === CHART_CAPABILITY_SKILL_ID || (!skillId && shouldUseChartCapabilityForMessage(userMessage)) - if (isChartCapability) { - const chartSkill = getChartCapabilitySkill(locale ?? 'zh-CN') + const chartRuntime = resolveChartRuntimeForRequest({ + skillId, + userMessage, + locale, + assistantAllowedTools: assistantConfig?.allowedBuiltinTools, + }) + if (chartRuntime.isChartCapability) { + const chartSkill = chartRuntime.skillDef ?? getChartCapabilitySkill(locale ?? 'zh-CN') skillCtx = { skillDef: { ...chartSkill, chatScope: 'all' } as SkillContext['skillDef'] } assistantConfig = { ...(assistantConfig ?? { @@ -172,7 +172,7 @@ export function createElectronRunAgentStream(): ( systemPrompt: '', presetQuestions: [], }), - allowedBuiltinTools: getAllowedToolsForChartCapability(assistantConfig?.allowedBuiltinTools), + allowedBuiltinTools: chartRuntime.allowedBuiltinTools, } } else if (skillId) { const skillDef = skillManager.getSkillConfig(skillId) ?? undefined @@ -181,6 +181,7 @@ export function createElectronRunAgentStream(): ( } } else if (enableAutoSkill) { const effectiveChatType = chatType ?? 'group' + const autoSkillAllowedTools = getAllowedBuiltinToolsForChartAutoSkill(assistantConfig?.allowedBuiltinTools) assistantConfig = { ...(assistantConfig ?? { id: resolvedAssistantId, @@ -188,9 +189,9 @@ export function createElectronRunAgentStream(): ( systemPrompt: '', presetQuestions: [], }), - allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(assistantConfig?.allowedBuiltinTools), + allowedBuiltinTools: autoSkillAllowedTools, } - const allowedTools = assistantConfig.allowedBuiltinTools + const allowedTools = autoSkillAllowedTools const menu = buildSkillMenuWithBuiltinChart(skillManager.getSkillMenu(effectiveChatType, allowedTools), locale) if (menu) { skillCtx = { skillMenu: menu } diff --git a/apps/desktop/main/ai/tools/index.test.ts b/apps/desktop/main/ai/tools/index.test.ts deleted file mode 100644 index aade52a7..00000000 --- a/apps/desktop/main/ai/tools/index.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' -import { CHART_SCHEMA_REQUIRED_MESSAGE } from '@openchatlab/node-runtime' -import { createActivateSkillTool, getAllowedToolsForChartCapability, getAllTools } from './index' - -describe('desktop chart capability tool filtering', () => { - it('does not expose raw SQL tools in chart-only turns', () => { - const toolNames = getAllowedToolsForChartCapability(['execute_sql']) - - assert.deepEqual(toolNames, ['render_chart']) - assert.ok(!toolNames.includes('execute_sql')) - }) - - it('keeps explicitly allowed non-SQL analysis tools', () => { - const toolNames = getAllowedToolsForChartCapability(['keyword_frequency', 'execute_sql']) - - assert.deepEqual(toolNames.sort(), ['keyword_frequency', 'render_chart']) - assert.ok(!toolNames.includes('execute_sql')) - }) - - it('activates the built-in chart skill without an imported skill file', async () => { - const tool = createActivateSkillTool('group', ['render_chart'], 'zh-CN') - const result = await tool.execute('call_1', { skill_id: CHART_CAPABILITY_SKILL_ID }) - const block = result.content[0] - - assert.equal(result.details.skillId, CHART_CAPABILITY_SKILL_ID) - assert.equal(result.details.applicable, true) - assert.equal(block.type, 'text') - if (block.type !== 'text') throw new Error('expected text content') - assert.match(block.text, /render_chart/) - }) - - it('requires get_schema before desktop render_chart execution', async () => { - const tools = getAllTools( - { - sessionId: 'session-1', - locale: 'en-US', - }, - ['render_chart'] - ) - const renderChart = tools.find((tool) => tool.name === 'render_chart') - - assert.ok(renderChart) - const result = await renderChart.execute('call-1', {}) - - assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }]) - assert.equal(result.details, null) - }) -}) diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts index c39fb1bc..74774e25 100644 --- a/apps/desktop/main/ai/tools/index.ts +++ b/apps/desktop/main/ai/tools/index.ts @@ -6,9 +6,9 @@ */ 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' +import { isAnalysisToolAllowed } from './tool-filter' import { t as i18nT } from '../../i18n' import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime' import { aiLogger } from '../logger' @@ -21,8 +21,6 @@ import { } from '@openchatlab/node-runtime' const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name)) -const RAW_SQL_TOOL_NAME_SET = new Set(['execute_sql']) -const CHART_CAPABILITY_ANALYSIS_TOOL_SET = new Set(CHART_CAPABILITY_ANALYSIS_TOOLS) const preprocessLogger = { info: (category: string, message: string, extra?: Record) => aiLogger.info(category, message, extra), @@ -109,20 +107,17 @@ function wrapWithPreprocessing(tool: AgentTool, context: ToolContext): Agen * 获取所有可用的 AgentTool * * - Core 工具始终加载,不受 allowedTools 白名单影响 - * - Analysis 工具仅在 allowedTools 中显式列出时才加载(opt-in) + * - Analysis 工具仅在 allowedTools 中显式列出时加载 * * @param context 工具上下文 - * @param allowedTools analysis 工具白名单(仅控制 analysis 工具) + * @param allowedTools analysis 工具白名单;undefined/[] 表示不加载 analysis 工具 */ export function getAllTools(context: ToolContext, allowedTools?: string[]): AgentTool[] { const coreTools = TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.factory(context)) - let analysisTools: AgentTool[] = [] - if (allowedTools && allowedTools.length > 0) { - analysisTools = TOOL_REGISTRY.filter((e) => e.category === 'analysis' && allowedTools.includes(e.name)).map((e) => - e.factory(context) - ) - } + const analysisTools = TOOL_REGISTRY.filter( + (e) => e.category === 'analysis' && isAnalysisToolAllowed(e.name, allowedTools) + ).map((e) => e.factory(context)) const chartSchemaGateState = createChartSchemaGateState() @@ -132,12 +127,6 @@ export function getAllTools(context: ToolContext, allowedTools?: string[]): Agen .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) - ) -} - /** * 创建 activate_skill 元工具(AI 自选模式专用) * 委托给共享包的 createActivateSkillTool,注入 Electron 端的 getSkillConfig diff --git a/apps/desktop/main/ai/tools/tool-filter.test.ts b/apps/desktop/main/ai/tools/tool-filter.test.ts new file mode 100644 index 00000000..7830c379 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-filter.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { isAnalysisToolAllowed } from './tool-filter' + +describe('Electron analysis tool filtering', () => { + it('keeps analysis tools opt-in when no allowlist is configured', () => { + assert.equal(isAnalysisToolAllowed('keyword_frequency', undefined), false) + assert.equal(isAnalysisToolAllowed('keyword_frequency', []), false) + }) + + it('allows only explicitly listed analysis tools', () => { + assert.equal(isAnalysisToolAllowed('keyword_frequency', ['keyword_frequency']), true) + assert.equal(isAnalysisToolAllowed('execute_sql', ['keyword_frequency']), false) + }) +}) diff --git a/apps/desktop/main/ai/tools/tool-filter.ts b/apps/desktop/main/ai/tools/tool-filter.ts new file mode 100644 index 00000000..79407371 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-filter.ts @@ -0,0 +1,3 @@ +export function isAnalysisToolAllowed(toolName: string, allowedTools?: readonly string[] | null): boolean { + return !!allowedTools?.includes(toolName) +} diff --git a/package.json b/package.json index 241b0c0e..3b391473 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "type-check": "vue-tsc --noEmit -p tsconfig.web.json", "test:auth": "tsx --test apps/cli/src/http/auth.test.ts src/services/utils/http.test.ts", "test:agent-context": "node --experimental-strip-types --test apps/desktop/main/ai/context/sessionLog.test.mjs", + "test:chart-runtime": "tsx --test packages/core/src/chart/index.test.ts packages/tools/src/definitions/render-chart.test.ts packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts apps/cli/src/ai/agent-stream-runner.test.ts apps/cli/src/ai/tool-adapter.test.ts apps/desktop/main/ai/tools/tool-filter.test.ts src/stores/aiChatChartBlocks.test.ts src/stores/skill.test.ts && node --import tsx --test tests/chart-runtime/agent-chart-flow.test.mts", "test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js", "test:e2e:smoke": "node tests/e2e/run-smoke.js" }, diff --git a/packages/core/src/ai/chart-capability.test.ts b/packages/core/src/ai/chart-capability.test.ts deleted file mode 100644 index 3605dabe..00000000 --- a/packages/core/src/ai/chart-capability.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it } from 'node:test' -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', () => { - it('advertises render_chart and schema lookup tools', () => { - const skill = getChartCapabilitySkill('en-US') - - 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']) - assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['keyword_frequency']), [ - 'keyword_frequency', - 'render_chart', - ]) - }) - - it('does not duplicate render_chart when it is already allowed', () => { - assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['render_chart', 'time_stats']), [ - 'render_chart', - 'time_stats', - ]) - }) - - it('detects explicit chart requests', () => { - assert.equal(shouldUseChartCapabilityForMessage('画一个前五常用语占比饼图'), true) - assert.equal(shouldUseChartCapabilityForMessage('帮我做一下热力图'), true) - assert.equal(shouldUseChartCapabilityForMessage('visualize member trends as a line chart'), true) - assert.equal(shouldUseChartCapabilityForMessage('分析一下谁说话最多'), false) - }) -}) diff --git a/packages/core/src/ai/chart-capability.ts b/packages/core/src/ai/chart-capability.ts index 6bdfa070..306984f4 100644 --- a/packages/core/src/ai/chart-capability.ts +++ b/packages/core/src/ai/chart-capability.ts @@ -1,115 +1 @@ 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 - description: string - tags: string[] - chatScope: 'all' - tools: string[] - prompt: string -} { - const isZh = locale.startsWith('zh') - return { - id: CHART_CAPABILITY_SKILL_ID, - name: isZh ? '绘图助手' : 'Chart Assistant', - description: isZh ? '按本轮问题生成灵活的聊天数据图表' : 'Generate flexible charts for this chat question', - tags: [isZh ? '图表' : 'chart'], - chatScope: 'all', - tools: ['render_chart', 'get_schema'], - prompt: isZh ? ZH_PROMPT : EN_PROMPT, - } -} - -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. 根据 schema 和用户要求确定统计对象、时间范围、维度、指标和图表类型。 -3. 必要时用只读 SQL 在 render_chart 中完成成员筛选、时间范围确认、聚合和补零。 -4. 调用 render_chart,提交只读 SQL 和 ChartSpec v1。 -5. 图表生成后,用 1-3 句简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart。 - -常用语、口头禅、高频短句类图表必须排除非真人文本:发送者为系统消息、媒体占位(如 [表情包]、[图片]、[语音]、[视频]、[文件]、[链接]、[红包]、[转账])以及撤回/删除提示都不能进入统计。 - -ChartSpec v1 支持: -- bar: encoding.x + encoding.y -- line: encoding.x + encoding.y,可选 encoding.series 表示每条线的含义 -- pie: encoding.label + encoding.value -- heatmap: encoding.x + encoding.y + encoding.value - -ChartSpec 示例: -{ - "version": 1, - "type": "line", - "title": "最近 30 天成员发言趋势", - "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, - "unit": "条" -} - -用户明确要求图表时必须尝试生成。用户没有明确要求时,只有排名、趋势、分布、占比或二维密度明显更清楚时才主动生成,且默认最多一张。` - -const EN_PROMPT = `You are the ChatLab Chart Assistant. In this turn, the user wants one or more charts embedded naturally in the answer. - -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. 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 -- line: encoding.x + encoding.y, optional encoding.series for the meaning of each line -- pie: encoding.label + encoding.value -- heatmap: encoding.x + encoding.y + encoding.value - -ChartSpec example: -{ - "version": 1, - "type": "line", - "title": "Member message trend in the last 30 days", - "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, - "unit": "messages" -} - -When the user explicitly asks for charts, you must try to generate them. If the user does not explicitly ask, add at most one chart only when ranking, trend, distribution, ratio, or two-dimensional density is clearly easier to understand visually.` diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts index 3def6da7..c6d4f0bc 100644 --- a/packages/core/src/ai/index.ts +++ b/packages/core/src/ai/index.ts @@ -5,14 +5,7 @@ // 内置工具目录 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' +export { CHART_CAPABILITY_SKILL_ID } from './chart-capability' // LLM 模型系统类型 export type { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 52434a34..cb52d5e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,8 +80,6 @@ export { getClusterGraph, getRelationshipStats, getLanguagePreferenceAnalysis, - isHumanCatchphraseContent, - NON_HUMAN_CATCHPHRASE_PLACEHOLDERS, FULL_MSG_COLUMNS, FULL_MSG_FROM, FULL_MSG_SELECT, @@ -249,11 +247,6 @@ export { getBuiltinModelsByProvider, getBuiltinModelById, CHART_CAPABILITY_SKILL_ID, - CHART_CAPABILITY_CORE_TOOLS, - CHART_CAPABILITY_ANALYSIS_TOOLS, - getChartCapabilityAllowedBuiltinTools, - getChartCapabilitySkill, - shouldUseChartCapabilityForMessage, THINK_TAGS, extractThinkingContent, stripToolCallTags, diff --git a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts new file mode 100644 index 00000000..b603d143 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + CHART_CAPABILITY_SKILL_ID, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, + resolveChartRuntimeForRequest, +} from '../chart-runtime' + +describe('chart runtime policy', () => { + it('keeps chart prompt and skill metadata in node runtime', () => { + const skill = getChartCapabilitySkill('en-US') + + assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID) + assert.equal(skill.name, 'Chart Assistant') + assert.deepEqual(skill.tools, ['render_chart', 'get_schema']) + assert.match(skill.prompt, /render_chart/) + }) + + it('enables chart runtime only for explicit chart skill by default', () => { + assert.equal( + resolveChartRuntimeForRequest({ + skillId: CHART_CAPABILITY_SKILL_ID, + userMessage: 'draw a chart', + locale: 'en-US', + }).isChartCapability, + true + ) + + assert.equal( + resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '画一个趋势图', + locale: 'zh-CN', + }).isChartCapability, + false + ) + }) + + it('keeps chart tool allowlists free of raw SQL', () => { + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart']) + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql']), [ + 'keyword_frequency', + 'render_chart', + ]) + }) + + it('does not narrow unrestricted auto-skill assistant tools', () => { + assert.equal(getAllowedBuiltinToolsForChartAutoSkill(undefined), undefined) + assert.deepEqual(getAllowedBuiltinToolsForChartAutoSkill([]), []) + assert.deepEqual(getAllowedBuiltinToolsForChartAutoSkill(['keyword_frequency']), [ + 'keyword_frequency', + 'render_chart', + ]) + }) + + it('does not remove raw SQL from non-chart auto-skill turns', () => { + assert.deepEqual(getAllowedBuiltinToolsForChartAutoSkill(['execute_sql', 'keyword_frequency']), [ + 'execute_sql', + 'keyword_frequency', + 'render_chart', + ]) + }) +}) diff --git a/packages/node-runtime/src/ai/builtin-chart-skill.ts b/packages/node-runtime/src/ai/builtin-chart-skill.ts index cb0d4aec..a24dc3a8 100644 --- a/packages/node-runtime/src/ai/builtin-chart-skill.ts +++ b/packages/node-runtime/src/ai/builtin-chart-skill.ts @@ -1,46 +1,5 @@ -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) -} +export { + buildSkillMenuWithBuiltinChart, + getChartCapabilitySkill as getBuiltinChartSkill, + getSkillConfigWithBuiltinChart, +} from './chart-runtime' diff --git a/packages/node-runtime/src/ai/chart-runtime.ts b/packages/node-runtime/src/ai/chart-runtime.ts new file mode 100644 index 00000000..b51ee2c6 --- /dev/null +++ b/packages/node-runtime/src/ai/chart-runtime.ts @@ -0,0 +1,192 @@ +import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' +import type { SkillDef } from './types' + +export { CHART_CAPABILITY_SKILL_ID } + +export const CHART_CAPABILITY_CORE_TOOLS = ['get_schema'] as const +export const CHART_CAPABILITY_ANALYSIS_TOOLS = ['render_chart'] as const + +const RAW_SQL_TOOL_NAMES = new Set(['execute_sql']) + +export interface ResolveChartRuntimeOptions { + skillId?: string | null + userMessage?: string + locale?: string + assistantAllowedTools?: readonly string[] | null + enableAutoDetection?: boolean +} + +export interface ResolvedChartRuntime { + isChartCapability: boolean + skillDef?: SkillDef + allowedBuiltinTools?: string[] +} + +export function getChartCapabilityAllowedBuiltinTools(allowedTools?: readonly string[] | null): string[] { + const baseTools = (allowedTools ?? []).filter((toolName) => !RAW_SQL_TOOL_NAMES.has(toolName)) + return Array.from(new Set([...baseTools, ...CHART_CAPABILITY_ANALYSIS_TOOLS])) +} + +export function getAllowedBuiltinToolsForChartAutoSkill(allowedTools?: readonly string[] | null): string[] | undefined { + if (!allowedTools) return undefined + if (allowedTools.length === 0) return [] + 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 resolveChartRuntimeForRequest(options: ResolveChartRuntimeOptions): ResolvedChartRuntime { + const locale = options.locale ?? 'zh-CN' + const isExplicitChartSkill = options.skillId === CHART_CAPABILITY_SKILL_ID + const isAutoChartSkill = + options.enableAutoDetection === true && + !options.skillId && + shouldUseChartCapabilityForMessage(options.userMessage ?? '') + + if (!isExplicitChartSkill && !isAutoChartSkill) { + return { isChartCapability: false } + } + + return { + isChartCapability: true, + skillDef: { ...getChartCapabilitySkill(locale), chatScope: 'all' }, + allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(options.assistantAllowedTools), + } +} + +export function getChartCapabilitySkill(locale: string = 'zh-CN'): SkillDef { + const isZh = locale.startsWith('zh') + return { + id: CHART_CAPABILITY_SKILL_ID, + name: isZh ? '绘图助手' : 'Chart Assistant', + description: isZh ? '按本轮问题生成灵活的聊天数据图表' : 'Generate flexible charts for this chat question', + tags: [isZh ? '图表' : 'chart'], + chatScope: 'all', + tools: ['render_chart', 'get_schema'], + prompt: isZh ? ZH_PROMPT : EN_PROMPT, + builtinId: CHART_CAPABILITY_SKILL_ID, + } +} + +function getChartMenuLine(locale: string): string { + const skill = getChartCapabilitySkill(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: (id: string) => SkillDef | null +): SkillDef | null { + if (id === CHART_CAPABILITY_SKILL_ID) return getChartCapabilitySkill(locale) + return getSkillConfig(id) +} + +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. 根据 schema 和用户要求确定统计对象、时间范围、维度、指标和图表类型。 +3. 必要时用只读 SQL 在 render_chart 中完成成员筛选、时间范围确认、聚合和补零。 +4. 调用 render_chart,提交只读 SQL 和 ChartSpec v1。 +5. 图表生成后,用 1-3 句简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart。 + +常用语、口头禅、高频短句类图表必须排除非真人文本:发送者为系统消息、媒体占位(如 [表情包]、[图片]、[语音]、[视频]、[文件]、[链接]、[红包]、[转账])以及撤回/删除提示都不能进入统计。 + +ChartSpec v1 支持: +- bar: encoding.x + encoding.y +- line: encoding.x + encoding.y,可选 encoding.series 表示每条线的含义 +- pie: encoding.label + encoding.value +- heatmap: encoding.x + encoding.y + encoding.value + +ChartSpec 示例: +{ + "version": 1, + "type": "line", + "title": "最近 30 天成员发言趋势", + "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, + "unit": "条" +} + +用户明确要求图表时必须尝试生成。用户没有明确要求时,只有排名、趋势、分布、占比或二维密度明显更清楚时才主动生成,且默认最多一张。` + +const EN_PROMPT = `You are the ChatLab Chart Assistant. In this turn, the user wants one or more charts embedded naturally in the answer. + +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. 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 +- line: encoding.x + encoding.y, optional encoding.series for the meaning of each line +- pie: encoding.label + encoding.value +- heatmap: encoding.x + encoding.y + encoding.value + +ChartSpec example: +{ + "version": 1, + "type": "line", + "title": "Member message trend in the last 30 days", + "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, + "unit": "messages" +} + +When the user explicitly asks for charts, you must try to generate them. If the user does not explicitly ask, add at most one chart only when ranking, trend, distribution, ratio, or two-dimensional density is clearly easier to understand visually.` diff --git a/packages/node-runtime/src/ai/conversations.ts b/packages/node-runtime/src/ai/conversations.ts index c00d8716..c45efa76 100644 --- a/packages/node-runtime/src/ai/conversations.ts +++ b/packages/node-runtime/src/ai/conversations.ts @@ -37,6 +37,7 @@ export type ContentBlock = params?: Record } } + | { type: 'error'; error: { name: string | null; message: string; stack: string | null } } | { type: 'summary_meta' bufferBoundaryTimestamp: number diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 8aa897cf..4d8f124f 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -53,10 +53,18 @@ export type { SkillManagerLogger } from './skill-manager' export { createActivateSkillTool } from './activate-skill-tool' export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './activate-skill-tool' export { + CHART_CAPABILITY_ANALYSIS_TOOLS, + CHART_CAPABILITY_CORE_TOOLS, + CHART_CAPABILITY_SKILL_ID, buildSkillMenuWithBuiltinChart, - getBuiltinChartSkill, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, + getChartCapabilitySkill as getBuiltinChartSkill, getSkillConfigWithBuiltinChart, -} from './builtin-chart-skill' + resolveChartRuntimeForRequest, + shouldUseChartCapabilityForMessage, +} from './chart-runtime' export { CHART_SCHEMA_REQUIRED_MESSAGE, createChartSchemaGateState, wrapWithChartSchemaGate } from './chart-schema-gate' export type { ChartSchemaGateState } from './chart-schema-gate' diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 041be957..fcc98cf7 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -127,9 +127,17 @@ export type { CreateCompressionLlmAdapterOptions } from './ai' // SkillManager export { SkillManager } from './ai' export { + CHART_CAPABILITY_ANALYSIS_TOOLS, + CHART_CAPABILITY_CORE_TOOLS, + CHART_CAPABILITY_SKILL_ID, buildSkillMenuWithBuiltinChart, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, getBuiltinChartSkill, getSkillConfigWithBuiltinChart, + resolveChartRuntimeForRequest, + shouldUseChartCapabilityForMessage, CHART_SCHEMA_REQUIRED_MESSAGE, createChartSchemaGateState, wrapWithChartSchemaGate, diff --git a/src/components/AIChat/chat/ChartBlockRenderer.vue b/src/components/AIChat/chat/ChartBlockRenderer.vue index 282a04b6..ae20393c 100644 --- a/src/components/AIChat/chat/ChartBlockRenderer.vue +++ b/src/components/AIChat/chat/ChartBlockRenderer.vue @@ -28,8 +28,8 @@ const isEmpty = computed(() => props.chart.rowCount === 0) const summary = computed(() => { const parts: string[] = [] if (props.chart.spec.unit) parts.push(props.chart.spec.unit) - if (props.chart.truncated) parts.push('truncated') - parts.push(`${props.chart.rowCount} rows`) + if (props.chart.truncated) parts.push(t('ai.chat.message.chart.truncated')) + parts.push(t('ai.chat.message.chart.rows', { count: props.chart.rowCount })) return parts.join(' · ') }) @@ -138,7 +138,7 @@ const expandedMinWidth = computed(() => { v-if="isEmpty" class="flex h-64 items-center justify-center text-xs text-gray-400 dark:text-gray-500" > - No data + {{ t('ai.chat.message.chart.empty') }} {
- No data + {{ t('ai.chat.message.chart.empty') }}
{ }) it('converts chart payloads to content blocks', () => { - assert.deepEqual(toChartContentBlocks([chart, secondChart]), [ - { type: 'chart', chart }, - { type: 'chart', chart: secondChart }, + const blocks = toChartContentBlocks([chart, secondChart]) + + assert.deepEqual(blocks, [ + { type: 'chart', chart: { ...chart, dataset: { ...chart.dataset, rows: [] } } }, + { type: 'chart', chart: { ...secondChart, dataset: { ...secondChart.dataset, rows: [] } } }, ]) + assert.equal(blocks[0].chart.rowCount, chart.rowCount) }) it('converts render-only chart tool failures to visible error blocks', () => { @@ -108,4 +111,18 @@ describe('aiChat chart block helpers', () => { null ) }) + + it('does not expose recoverable schema-gate guidance as a user-visible error', () => { + assert.equal( + toRenderOnlyToolErrorBlock('render_chart', { + content: [ + { + type: 'text', + text: 'Error: Call get_schema before render_chart. Do not guess table names, fields, or timestamp units.', + }, + ], + }), + null + ) + }) }) diff --git a/src/stores/aiChatChartBlocks.ts b/src/stores/aiChatChartBlocks.ts index 180d98ab..9705a4d4 100644 --- a/src/stores/aiChatChartBlocks.ts +++ b/src/stores/aiChatChartBlocks.ts @@ -33,8 +33,18 @@ export function extractChartPayloads(toolResult: unknown): ChartPayload[] { return charts } +export function toPersistedChartPayload(chart: ChartPayload): ChartPayload { + return { + ...chart, + dataset: { + ...chart.dataset, + rows: [], + }, + } +} + export function toChartContentBlocks(charts: ChartPayload[]): ChartContentBlock[] { - return charts.map((chart) => ({ type: 'chart', chart })) + return charts.map((chart) => ({ type: 'chart', chart: toPersistedChartPayload(chart) })) } function extractToolResultText(toolResult: unknown): string | null { @@ -63,6 +73,7 @@ export function toRenderOnlyToolErrorBlock( const match = /^Error:\s*(.+)$/is.exec(text) const message = match?.[1]?.trim() if (!message) return null + if (/^Call get_schema before render_chart\b/i.test(message)) return null return { type: 'error', diff --git a/src/stores/skill.test.ts b/src/stores/skill.test.ts index 7a7fbb77..dcaae7a3 100644 --- a/src/stores/skill.test.ts +++ b/src/stores/skill.test.ts @@ -40,7 +40,7 @@ describe('skill store chart capability', () => { const config = await store.getSkillConfig(CHART_CAPABILITY_SKILL_ID) assert.equal(config?.id, CHART_CAPABILITY_SKILL_ID) assert.deepEqual(config?.tools, ['render_chart', 'get_schema']) - assert.match(config?.prompt ?? '', /render_chart/) + assert.equal(config?.prompt, '') store.activateSkill(null) assert.equal(store.activeSkillId, null) diff --git a/src/stores/skill.ts b/src/stores/skill.ts index a189d2ed..73a7e079 100644 --- a/src/stores/skill.ts +++ b/src/stores/skill.ts @@ -7,7 +7,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useAssistantStore } from './assistant' import { usePlatformService, useSkillService } from '@/services' -import { CHART_CAPABILITY_SKILL_ID, getChartCapabilitySkill } from '@openchatlab/core' +import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale' const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE @@ -64,15 +64,28 @@ export const useSkillStore = defineStore('skill', () => { const currentChatType = ref<'group' | 'private'>('group') const currentLocale = ref('zh-CN') + function getChartCapabilitySkillSummary(): SkillSummary { + const isZh = currentLocale.value.startsWith('zh') + return { + id: CHART_CAPABILITY_SKILL_ID, + name: isZh ? '绘图助手' : 'Chart Assistant', + description: isZh ? '按本轮问题生成灵活的聊天数据图表' : 'Generate flexible charts for this chat question', + tags: [isZh ? '图表' : 'chart'], + chatScope: 'all', + tools: ['render_chart', 'get_schema'], + builtinId: CHART_CAPABILITY_SKILL_ID, + } + } + const activeSkill = computed(() => { if (!activeSkillId.value) return null if (activeSkillId.value === CHART_CAPABILITY_SKILL_ID) { - return getChartCapabilitySkill(currentLocale.value) + return getChartCapabilitySkillSummary() } return skills.value.find((s) => s.id === activeSkillId.value) ?? null }) - const chartCapabilitySkill = computed(() => getChartCapabilitySkill(currentLocale.value)) + const chartCapabilitySkill = computed(() => getChartCapabilitySkillSummary()) const scopedSkills = computed(() => { return skills.value.filter((s) => s.chatScope === 'all' || s.chatScope === currentChatType.value) @@ -196,9 +209,10 @@ export const useSkillStore = defineStore('skill', () => { async function getSkillConfig(id: string): Promise { if (id === CHART_CAPABILITY_SKILL_ID) { - const skill = getChartCapabilitySkill(currentLocale.value) + const skill = getChartCapabilitySkillSummary() return { ...skill, + prompt: '', builtinId: CHART_CAPABILITY_SKILL_ID, } } diff --git a/tests/chart-runtime/agent-chart-flow.test.ts b/tests/chart-runtime/agent-chart-flow.test.mts similarity index 100% rename from tests/chart-runtime/agent-chart-flow.test.ts rename to tests/chart-runtime/agent-chart-flow.test.mts