diff --git a/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts index a7656cbc..0bd802c0 100644 --- a/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts +++ b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts @@ -52,6 +52,5 @@ describe('builtin chart skill helpers', () => { assert.ok(skill) assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID) - assert.match(skill.prompt, /render_chart/) }) }) diff --git a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts index 5afa6995..e06adfa3 100644 --- a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts +++ b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts @@ -11,15 +11,12 @@ import { } from '../chart-runtime' describe('chart runtime policy', () => { - it('keeps chart prompt and skill metadata in node runtime', () => { + it('keeps chart 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/) - assert.match(skill.prompt, /chart1\.png/) - assert.match(skill.prompt, /Data preview/) }) it('enables chart runtime only for explicit chart skill by default', () => { @@ -90,9 +87,6 @@ describe('chart runtime policy', () => { assert.equal(capability?.id, 'chart_generation') assert.deepEqual(capability?.tools, ['get_schema', 'render_chart']) - assert.match(capability?.guidance ?? '', /趋势/) - assert.match(capability?.guidance ?? '', /get_schema/) - assert.match(capability?.guidance ?? '', /render_chart/) }) it('does not offer chart planner capability when render_chart is unavailable', () => { diff --git a/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts b/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts index 30a8e61e..4bd4398e 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts @@ -6,7 +6,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { createDataSnapshotFromOverview } from '../data-snapshot' +import { createDataSnapshotFromOverview, formatDataSnapshotForPlanner } from '../data-snapshot' describe('createDataSnapshotFromOverview', () => { it('maps overview members and summary count into an extended data snapshot', () => { @@ -97,4 +97,24 @@ describe('createDataSnapshotFromOverview', () => { assert.equal(createDataSnapshotFromOverview(null), undefined) assert.equal(createDataSnapshotFromOverview(undefined), undefined) }) + + it('warns the planner about default recent-day tools when data coverage stops before today', () => { + const snapshot = createDataSnapshotFromOverview({ + name: 'Dorm Chat', + platform: 'wechat', + type: 'group', + totalMessages: 100, + totalMembers: 2, + firstMessageTs: 1735689600, + lastMessageTs: 1776051993, + summaryCount: 3, + topMembers: [{ id: 1, name: 'Alice', count: 60 }], + }) + + const formatted = formatDataSnapshotForPlanner(snapshot) + + assert.match(formatted, /real current date/) + assert.match(formatted, /default recent-day tools/) + assert.match(formatted, /database bounds/) + }) }) diff --git a/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts b/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts index 32cbe6da..d296473e 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { DEFAULT_MAX_TOOL_ROUNDS } from '../constants' describe('Agent defaults', () => { - it('uses 10 tool rounds by default', () => { - assert.equal(DEFAULT_MAX_TOOL_ROUNDS, 10) + it('uses 20 tool rounds by default', () => { + assert.equal(DEFAULT_MAX_TOOL_ROUNDS, 20) }) }) diff --git a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts index dbcfe683..92e82bca 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts @@ -165,8 +165,6 @@ ${json} successCriteria: ['覆盖全年'], }) - assert.match(guidance, /suggested analysis plan/i) - assert.match(guidance, /do not follow it mechanically/i) assert.match(guidance, /年度话题趋势分析/) assert.match(guidance, /search_messages/) assert.match(guidance, /覆盖全年/) @@ -212,27 +210,7 @@ ${json} assert.match(capturedPrompt, /render_chart/) }) - it('keeps display plan concise and leaves structured details to JSON', async () => { - let capturedPrompt = '' - const planner = createAnalysisPlanner({ - complete: async (prompt) => { - capturedPrompt = prompt - return JSON.stringify({ - title: '年度话题趋势分析', - intent: 'trend', - steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], - successCriteria: ['覆盖全年'], - }) - }, - }) - - await planner(baseInput) - - assert.match(capturedPrompt, /A concise user-facing analysis approach/) - assert.match(capturedPrompt, /those details belong in /) - }) - - it('includes extended data snapshot context and reconnaissance strategy in the planner prompt', async () => { + it('includes extended data snapshot context in the planner prompt', async () => { let capturedPrompt = '' const planner = createAnalysisPlanner({ complete: async (prompt) => { @@ -259,7 +237,5 @@ ${json} assert.match(capturedPrompt, /segment_summaries_available: 8/) assert.match(capturedPrompt, /member_id=3/) assert.match(capturedPrompt, /display_name=Alice/) - assert.match(capturedPrompt, /lightweight reconnaissance step/i) - assert.match(capturedPrompt, /topic\/member activity map/i) }) }) diff --git a/packages/node-runtime/src/ai/agent/__tests__/router.test.ts b/packages/node-runtime/src/ai/agent/__tests__/router.test.ts index e982e4c2..a5bcd5e0 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/router.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/router.test.ts @@ -63,6 +63,17 @@ describe('decideRequestRoute', () => { assert.ok(decision.confidence >= 0.8) }) + it('routes open-ended recent-year topic and core-member retrospectives to planned execution by rule', async () => { + const decision = await decideRequestRoute({ + ...baseInput, + userMessage: '请分析这个群最近一年的话题演变和核心成员变化。先建立话题地图和发言规律,再按阶段总结主要变化。', + }) + + assert.equal(decision.route, 'planned_execution') + assert.equal(decision.source, 'rule') + assert.ok(decision.confidence >= 0.8) + }) + it('uses injected LLM fallback for ambiguous requests', async () => { const llmDecision: RouteDecision = { route: 'planned_execution', diff --git a/packages/node-runtime/src/ai/agent/constants.ts b/packages/node-runtime/src/ai/agent/constants.ts index 71d872de..2a7c8ba8 100644 --- a/packages/node-runtime/src/ai/agent/constants.ts +++ b/packages/node-runtime/src/ai/agent/constants.ts @@ -1 +1 @@ -export const DEFAULT_MAX_TOOL_ROUNDS = 10 +export const DEFAULT_MAX_TOOL_ROUNDS = 20 diff --git a/packages/node-runtime/src/ai/agent/data-snapshot.ts b/packages/node-runtime/src/ai/agent/data-snapshot.ts index 938eca99..87867305 100644 --- a/packages/node-runtime/src/ai/agent/data-snapshot.ts +++ b/packages/node-runtime/src/ai/agent/data-snapshot.ts @@ -92,5 +92,6 @@ notes: - member_id is a tool lookup hint; display_name may not be unique. - Active member hints reflect historical total message volume, not recent activity. - Use real current date for relative ranges; database bounds only describe data coverage. -- Do not plan a tool call only to rediscover min/max timestamp.` +- Do not plan a tool call only to rediscover min/max timestamp. +- When using default recent-day tools, choose a range that intersects database bounds instead of probing an empty real-current-date window first.` } diff --git a/packages/node-runtime/src/ai/agent/router.ts b/packages/node-runtime/src/ai/agent/router.ts index d789a22c..00df70c6 100644 --- a/packages/node-runtime/src/ai/agent/router.ts +++ b/packages/node-runtime/src/ai/agent/router.ts @@ -158,10 +158,12 @@ function plannedRuleDecision(text: string): RouteDecision | null { const dimensions = [ matchAny(text, [/分析|复盘|总结.*规律|趋势|变化|演变|compare|comparison/i]), - matchAny(text, [/过去一年|最近半年|今年|去年|上半年|下半年|长期|按季度|按月份|first half|second half/i]), - matchAny(text, [/关系|互动|影响力|最强|最活跃|被忽略|主动|回应|turning point|转折点/i]), + matchAny(text, [ + /过去一年|最近一年|近一年|过去.*年|最近半年|今年|去年|上半年|下半年|长期|按季度|按月份|first half|second half/i, + ]), + matchAny(text, [/关系|互动|影响力|核心成员|最强|最活跃|被忽略|主动|回应|turning point|转折点/i]), matchAny(text, [/证据|举出|例子|关键分歧|主要矛盾|evidence/i]), - matchAny(text, [/并且|同时|以及|分别|每个阶段|多条件|参与|回复|topic|theme/i]), + matchAny(text, [/并且|同时|以及|分别|每个阶段|按阶段|阶段|多条件|参与|回复|话题|主题|发言规律|topic|theme/i]), matchAny(text, [/证据不够|不足以|不够|uncertain|insufficient/i]), ] const score = dimensions.filter(Boolean).length diff --git a/packages/node-runtime/src/ai/chart-runtime.ts b/packages/node-runtime/src/ai/chart-runtime.ts index 6bac93e6..e771dc50 100644 --- a/packages/node-runtime/src/ai/chart-runtime.ts +++ b/packages/node-runtime/src/ai/chart-runtime.ts @@ -154,20 +154,24 @@ const ZH_PROMPT = `你是 ChatLab 绘图助手。本轮用户希望你在回答 硬性规则: 1. 第一次写 SQL 前必须先调用 get_schema。禁止猜测表名、字段名或时间字段。 -2. “最近”“到现在”“到最近”必须用数据中的 MAX(ts) 或等价字段确定结束日期;不要手算或臆测日期。 +2. “最近”“过去 N 天/年”等相对时间以系统提示中的真实当前日期为基准;如果启动上下文显示数据库覆盖不足,只分析用户时间范围与数据库覆盖范围的交集并说明缺口。 3. ChatLab 的 message.ts 是秒级 Unix 时间戳,使用 date(ts, 'unixepoch', 'localtime');禁止写 ts/1000。 4. “最近的 N 人/成员”必须先用数据定义并筛选出 N 个成员,再只统计这些成员。 5. 折线图有多条线时,每条线必须有明确 series 字段含义;按日期统计时应补齐日期 × series 的 0 值,避免缺失日期导致折线断裂或误连。 6. 工具失败、SQL 修正、schema 探索过程不要写进最终回答;最终只保留原生图表和简短结论,禁止用 chart1.png、chart2.png 等文件名引用图表。 -7. render_chart 的工具结果会包含数据预览(Data preview);用它分析峰值、低谷和差异,不要为了读取数值重复生成等价图表。 -8. 图表解释只能描述数据直接支持的趋势、峰值、低谷、差异和同步性,禁止夸张拟人化或无法从图中验证的关系判断。 +7. 调用 render_chart 时,ChatLab 会立即在该位置插入原生图表;需要图表就直接调用工具,不要写 SQL 注释、Markdown 图片或“已生成图表/见上图”这类文本占位。 +8. 用户要求多张图时,图表应分散出现在相关总结或解释附近;不要把所有图集中生成后再在最后用文字引用。 +9. 调查数据、筛选对象、确认统计口径时,优先使用可用的普通分析工具;render_chart 只用于最终要展示成图的聚合数据,不要把它用于元数据探测、标量查询、单行日期字符串或纯说明性结果。 +10. render_chart 的工具结果会包含数据预览(Data preview);用它分析峰值、低谷和差异,不要为了读取数值重复生成等价图表。 +11. 图表解释只能描述数据直接支持的趋势、峰值、低谷、差异和同步性,禁止夸张拟人化或无法从图中验证的关系判断。 工作流程: 1. 调用 get_schema。 -2. 根据 schema 和用户要求确定统计对象、时间范围、维度、指标和图表类型。 -3. 必要时用只读 SQL 在 render_chart 中完成成员筛选、时间范围确认、聚合和补零。 -4. 调用 render_chart,提交只读 SQL 和 ChartSpec v1。 -5. 图表生成后,用 1-3 句简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart。 +2. 根据 schema、用户要求、系统当前日期和启动上下文确定统计对象、时间范围、维度、指标和图表类型。 +3. 如需调查事实、建立话题地图、筛选成员或确认统计口径,先用可用的普通分析工具完成;不要为了调查而生成临时图表。 +4. 准备图表数据集 SQL:SQL 结果必须直接对应 ChartSpec 需要的维度、指标和 series;必要时在这条数据集 SQL 中完成筛选、聚合和补零。 +5. 在希望图表出现的位置调用 render_chart,提交图表数据集 SQL 和 ChartSpec v1。 +6. 图表生成后,用 1-3 句简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart,并把每张图放在最相关的段落附近。 常用语、口头禅、高频短句类图表必须排除非真人文本:发送者为系统消息、媒体占位(如 [表情包]、[图片]、[语音]、[视频]、[文件]、[链接]、[红包]、[转账])以及撤回/删除提示都不能进入统计。 @@ -194,20 +198,24 @@ You must generate charts through the render_chart tool. Do not output HTML, Java 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. +2. For relative ranges like "recent" or "past N days/years", use the real current date from the system prompt as the baseline. If startup context shows incomplete database coverage, analyze only the intersection between the requested range and database coverage, and state the gap. 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 native charts and a short conclusion; never reference charts with filenames like chart1.png or chart2.png. -7. render_chart tool results include a Data preview; use it to identify peaks, lows, and differences. Do not generate equivalent charts again just to read values. -8. Explain only trends, peaks, lows, differences, and synchrony directly supported by chart data. Do not make exaggerated personality or relationship claims. +7. When you call render_chart, ChatLab immediately inserts the native chart at that position. If a chart is needed, call the tool directly; do not write SQL-comment placeholders, Markdown images, or text such as "generated chart" or "see chart above". +8. If the user asks for multiple charts, place each chart near the relevant summary or explanation. Do not generate all charts together and then reference them later with text. +9. Use available ordinary analysis tools to investigate data, choose subjects, and confirm statistical definitions. Use render_chart only for aggregated data that should be displayed as a chart; do not use it for metadata inspection, scalar queries, single-row date strings, or purely explanatory results. +10. render_chart tool results include a Data preview; use it to identify peaks, lows, and differences. Do not generate equivalent charts again just to read values. +11. 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. +2. Derive the target members, time range, dimensions, metrics, and chart type from schema, the user request, system current date, and startup context. +3. If you need to investigate facts, build a topic map, select members, or confirm statistical definitions, use available ordinary analysis tools first; do not generate temporary charts for investigation. +4. Prepare the chart dataset SQL: its result must directly match the dimensions, metrics, and series required by ChartSpec; when needed, do filtering, aggregation, and zero-filling in this dataset SQL. +5. Call render_chart at the position where the chart should appear, with the chart dataset SQL and ChartSpec v1. +6. 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 and place each chart near its most relevant section. 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. diff --git a/packages/node-runtime/src/ai/i18n/locales/en-US.ts b/packages/node-runtime/src/ai/i18n/locales/en-US.ts index eb37add4..7f685304 100644 --- a/packages/node-runtime/src/ai/i18n/locales/en-US.ts +++ b/packages/node-runtime/src/ai/i18n/locales/en-US.ts @@ -324,6 +324,7 @@ Usage rules: - Interpret relative time expressions using the real current date, not the database's last message time. - "recent year" / "past year" means one year back from the real current date to today; "last year" means the previous calendar year. - Database time bounds are only for explaining coverage, not for redefining the user's requested time range. +- When using default recent-day tools, first choose a range that intersects database time bounds instead of probing an empty real-current-date window. - Do not call tools only to rediscover min/max timestamp; concrete chat facts, statistics, and conclusions still require tool evidence.`, evidencePolicy: `Evidence policy: - AI conversation history, prior AI replies, and compressed summaries are only for understanding the user's intent; they are not evidence for chat-record facts. diff --git a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts index 8e54b3c5..149471ea 100644 --- a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts +++ b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts @@ -323,6 +323,7 @@ export default { - 相対的な時間表現は、データベースの最終メッセージ時刻ではなく、実際の現在日付を基準に解釈してください。 - 「最近一年/過去一年」は実際の現在日付から今日までの1年間を指し、「去年」は前の暦年を指します。 - データベースの時間境界はカバレッジ説明にのみ使い、ユーザーが求めた時間範囲を再定義するために使わないでください。 +- デフォルトの「直近 N 日」ツールを使う場合は、空の実際現在日付ウィンドウを試すのではなく、データベース時間境界と交差する範囲を先に選んでください。 - min/max timestamp を再発見するためだけにツールを呼ばないでください。ただし、具体的なチャット事実、統計、結論には引き続きツール証拠が必要です。`, evidencePolicy: `証拠ポリシー: - AI 会話履歴、過去の AI 回答、圧縮要約はユーザー意図の理解にのみ使用し、チャット記録の事実証拠として扱わない。 diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts index bc0beaa5..8bf3f4d0 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts @@ -313,6 +313,7 @@ export default { - 相对时间表达以真实当前日期为基准,而不是数据库最后消息时间。 - “最近一年/过去一年”表示从真实当前日期回推一年到今天;“去年”表示上一自然年。 - 数据库时间边界只用于说明覆盖范围,不用于重定义用户要求的时间范围。 +- 使用默认“近 N 天”的工具时,先选择与数据库时间边界有交集的范围,不要先探测真实当前日期窗口导致空结果。 - 不要只为了重新发现 min/max timestamp 调用工具;但回答具体聊天事实、统计和结论仍必须调用工具获取证据。`, evidencePolicy: `证据策略: - AI 对话历史、历史 AI 回复和压缩摘要只用于理解用户意图,不能作为聊天记录事实证据。 diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts index fec34ee4..8e1679f3 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts @@ -313,6 +313,7 @@ export default { - 相對時間表達以真實目前日期為基準,而不是資料庫最後訊息時間。 -「最近一年/過去一年」表示從真實目前日期回推一年到今天;「去年」表示上一自然年。 - 資料庫時間邊界只用於說明覆蓋範圍,不用於重定義使用者要求的時間範圍。 +- 使用預設「近 N 天」的工具時,先選擇與資料庫時間邊界有交集的範圍,不要先探測真實目前日期視窗導致空結果。 - 不要只為了重新發現 min/max timestamp 呼叫工具;但回答具體聊天事實、統計和結論仍必須呼叫工具取得證據。`, evidencePolicy: `證據策略: - AI 對話歷史、歷史 AI 回覆和壓縮摘要只用於理解使用者意圖,不能作為聊天紀錄事實證據。 diff --git a/src/components/AIChat/chat/ChatMessage.vue b/src/components/AIChat/chat/ChatMessage.vue index 699a204e..6785852c 100644 --- a/src/components/AIChat/chat/ChatMessage.vue +++ b/src/components/AIChat/chat/ChatMessage.vue @@ -9,6 +9,7 @@ import ErrorBlock from './ErrorBlock.vue' import ChartBlockRenderer from './ChartBlockRenderer.vue' import { useToast } from '@/composables/useToast' import { stripChartImagePlaceholders } from '@/services/ai/chartMarkdownPlaceholders' +import { shouldHideRecoverableChartError } from '@/stores/aiChatChartBlocks' const { t, te, locale } = useI18n() const toast = useToast() @@ -137,22 +138,23 @@ function submitEditing() { overwriteSubsequent.value = false } -const hasChartBlocks = computed(() => (props.contentBlocks || []).some((block) => block.type === 'chart')) - function getDisplayText(text: string): string { - return hasChartBlocks.value ? stripChartImagePlaceholders(text) : text + return stripChartImagePlaceholders(text) } // 过滤无内容的文本/思考块,避免显示空气泡 const visibleBlocks = computed(() => { const blocks = props.contentBlocks || [] - return blocks.filter((block) => { + return blocks.filter((block, index) => { if (block.type === 'text') { return getDisplayText(block.text).trim().length > 0 } if (block.type === 'think') { return block.text.trim().length > 0 } + if (block.type === 'error') { + return !shouldHideRecoverableChartError(blocks, index) + } return true }) }) diff --git a/src/services/ai/chartMarkdownPlaceholders.test.ts b/src/services/ai/chartMarkdownPlaceholders.test.ts index 20549cd5..ff5c5633 100644 --- a/src/services/ai/chartMarkdownPlaceholders.test.ts +++ b/src/services/ai/chartMarkdownPlaceholders.test.ts @@ -14,6 +14,18 @@ describe('stripChartImagePlaceholders', () => { assert.equal(result, '## 月度消息量趋势\n\n下面是趋势分析。') }) + it('removes extensionless chart image placeholders that browsers resolve as local routes', () => { + const result = stripChartImagePlaceholders(`### 图表 + +![高频关键词](chart_keyword_top15) +![活跃热力图](/chart_heatmap) +![成员趋势](http://localhost:3100/chart_member_trend) + +继续分析。`) + + assert.equal(result, '### 图表\n\n继续分析。') + }) + it('keeps non-chart markdown images', () => { const input = '![真实截图](screenshots/report.png)' diff --git a/src/services/ai/chartMarkdownPlaceholders.ts b/src/services/ai/chartMarkdownPlaceholders.ts index bac5cbf9..42f926d4 100644 --- a/src/services/ai/chartMarkdownPlaceholders.ts +++ b/src/services/ai/chartMarkdownPlaceholders.ts @@ -1,5 +1,5 @@ const CHART_IMAGE_PLACEHOLDER_RE = - /^\s*!\[[^\]\n]*\]\(\s*(?:\.\/)?chart[\w-]*\.(?:png|jpg|jpeg|webp|svg)(?:\?[^)\s]*)?\s*(?:"[^"]*")?\s*\)\s*$/gim + /^\s*!\[[^\]\n]*\]\(\s*(?:(?:https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?)?\/?|\.\/)?chart[\w-]*(?:\.(?:png|jpg|jpeg|webp|svg))?(?:\?[^)\s]*)?\s*(?:"[^"]*")?\s*\)\s*$/gim export function stripChartImagePlaceholders(text: string): string { if (!text) return text diff --git a/src/stores/aiChatChartBlocks.test.ts b/src/stores/aiChatChartBlocks.test.ts index e11b551c..87ea256c 100644 --- a/src/stores/aiChatChartBlocks.test.ts +++ b/src/stores/aiChatChartBlocks.test.ts @@ -4,6 +4,7 @@ import type { ChartPayload } from '@openchatlab/core' import { extractChartPayloads, isRenderOnlyTool, + shouldHideRecoverableChartError, toChartContentBlocks, toRenderOnlyToolErrorBlock, } from './aiChatChartBlocks' @@ -125,4 +126,22 @@ describe('aiChat chart block helpers', () => { null ) }) + + it('hides recovered chart render errors when a later native chart exists', () => { + const blocks = [ + { type: 'error', error: { name: 'ChartRenderError', message: 'Field "metric" does not exist in SQL result' } }, + { type: 'think', text: '修正 SQL' }, + { type: 'chart', chart }, + ] + + assert.equal(shouldHideRecoverableChartError(blocks, 0), true) + assert.equal(shouldHideRecoverableChartError(blocks.slice(0, 2), 0), false) + assert.equal( + shouldHideRecoverableChartError( + [{ type: 'error', error: { name: 'OtherError', message: 'boom' } }, blocks[2]], + 0 + ), + false + ) + }) }) diff --git a/src/stores/aiChatChartBlocks.ts b/src/stores/aiChatChartBlocks.ts index 9705a4d4..442a9952 100644 --- a/src/stores/aiChatChartBlocks.ts +++ b/src/stores/aiChatChartBlocks.ts @@ -47,6 +47,14 @@ export function toChartContentBlocks(charts: ChartPayload[]): ChartContentBlock[ return charts.map((chart) => ({ type: 'chart', chart: toPersistedChartPayload(chart) })) } +export function shouldHideRecoverableChartError(blocks: readonly unknown[], index: number): boolean { + const block = blocks[index] + if (!isRecord(block) || block.type !== 'error' || !isRecord(block.error)) return false + if (block.error.name !== 'ChartRenderError') return false + + return blocks.slice(index + 1).some((nextBlock) => isRecord(nextBlock) && nextBlock.type === 'chart') +} + function extractToolResultText(toolResult: unknown): string | null { if (!isRecord(toolResult)) return null if (typeof toolResult.error === 'string') return toolResult.error