From 2d413f42020ed756601934ce5fd02b09bcaef68f Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 8 Jun 2026 20:27:38 +0800 Subject: [PATCH] feat(ai): render analysis plan blocks --- packages/node-runtime/src/ai/chats.ts | 50 +++++++----------- src/components/AIChat/chat/ChatMessage.vue | 61 ++++++++++++++++++++++ src/i18n/locales/en-US/ai.json | 13 +++++ src/i18n/locales/ja-JP/ai.json | 13 +++++ src/i18n/locales/zh-CN/ai.json | 13 +++++ src/i18n/locales/zh-TW/ai.json | 13 +++++ src/services/ai-stream/types.ts | 5 +- src/services/ai/planBlocks.test.ts | 36 +++++++++++++ src/services/ai/planBlocks.ts | 42 +++++++++++++++ src/services/ai/types.ts | 2 + src/stores/aiChat.ts | 47 +++++++++++++++++ 11 files changed, 262 insertions(+), 33 deletions(-) create mode 100644 src/services/ai/planBlocks.test.ts create mode 100644 src/services/ai/planBlocks.ts diff --git a/packages/node-runtime/src/ai/chats.ts b/packages/node-runtime/src/ai/chats.ts index 6c61c069..d03ee9f6 100644 --- a/packages/node-runtime/src/ai/chats.ts +++ b/packages/node-runtime/src/ai/chats.ts @@ -9,6 +9,7 @@ import Database from 'better-sqlite3' import * as fs from 'fs' import * as path from 'path' import type { ChartPayload } from '@openchatlab/core' +import type { PlanContentBlock } from './agent' const DEFAULT_GENERAL_ID = 'general_cn' @@ -28,6 +29,7 @@ export type ContentBlock = | { type: 'text'; text: string } | { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'chart'; chart: ChartPayload } + | PlanContentBlock | { type: 'tool' tool: { @@ -154,9 +156,9 @@ export class AIChatManager { } private tableExists(db: Database.Database, tableName: string): boolean { - const row = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(tableName) as { 1: number } | undefined + const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName) as + | { 1: number } + | undefined return !!row } @@ -369,9 +371,9 @@ export class AIChatManager { private getActiveMessageId(aiChatId: string): string | null { const db = this.getDb() - const row = db - .prepare('SELECT active_message_id as activeMessageId FROM ai_chat WHERE id = ?') - .get(aiChatId) as { activeMessageId: string | null } | undefined + const row = db.prepare('SELECT active_message_id as activeMessageId FROM ai_chat WHERE id = ?').get(aiChatId) as + | { activeMessageId: string | null } + | undefined if (row?.activeMessageId) { const activeExists = db.prepare('SELECT 1 FROM ai_message WHERE id = ?').get(row.activeMessageId) if (activeExists) return row.activeMessageId @@ -516,9 +518,10 @@ export class AIChatManager { const result = new Map() try { const db = this.getDb() - const rows = db - .prepare('SELECT session_id, COUNT(*) as count FROM ai_chat GROUP BY session_id') - .all() as Array<{ session_id: string; count: number }> + const rows = db.prepare('SELECT session_id, COUNT(*) as count FROM ai_chat GROUP BY session_id').all() as Array<{ + session_id: string + count: number + }> for (const row of rows) { result.set(row.session_id, row.count) } @@ -556,9 +559,7 @@ export class AIChatManager { updateAIChatTitle(aiChatId: string, title: string): boolean { const db = this.getDb() const now = Math.floor(Date.now() / 1000) - const result = db - .prepare('UPDATE ai_chat SET title = ?, updated_at = ? WHERE id = ?') - .run(title, now, aiChatId) + const result = db.prepare('UPDATE ai_chat SET title = ?, updated_at = ? WHERE id = ?').run(title, now, aiChatId) return result.changes > 0 } @@ -614,11 +615,7 @@ export class AIChatManager { branchIndex ) - db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run( - id, - now, - aiChatId - ) + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run(id, now, aiChatId) return { id, @@ -663,11 +660,7 @@ export class AIChatManager { const newLeafId = targetIndex > 0 ? activePath[targetIndex - 1]!.id : null const now = Math.floor(Date.now() / 1000) - db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run( - newLeafId, - now, - aiChatId - ) + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run(newLeafId, now, aiChatId) } forkAIChat(sourceAIChatId: string, upToMessageId: string, title?: string): AIChat { @@ -814,11 +807,7 @@ export class AIChatManager { db.prepare('UPDATE ai_message SET parent_id = ? WHERE id = ?').run(id, childRow.id) db.prepare('UPDATE ai_chat SET updated_at = ? WHERE id = ?').run(now, aiChatId) } else { - db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run( - id, - now, - aiChatId - ) + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run(id, now, aiChatId) } return { @@ -954,9 +943,7 @@ export class AIChatManager { .map((row) => ({ role: row.role as AIMessageRole, content: row.content, timestamp: row.timestamp })) } - getAllUserAssistantMessages( - aiChatId: string - ): Array<{ role: AIMessageRole; content: string; timestamp: number }> { + getAllUserAssistantMessages(aiChatId: string): Array<{ role: AIMessageRole; content: string; timestamp: number }> { return this.getActivePathRows(aiChatId) .filter((row) => row.role === 'user' || row.role === 'assistant') .map((row) => ({ role: row.role as AIMessageRole, content: row.content, timestamp: row.timestamp })) @@ -965,8 +952,7 @@ export class AIChatManager { getMessageCountAfterSummary(aiChatId: string): number { const summary = this.getLatestSummary(aiChatId) if (!summary) { - return this.getActivePathRows(aiChatId).filter((row) => row.role === 'user' || row.role === 'assistant') - .length + return this.getActivePathRows(aiChatId).filter((row) => row.role === 'user' || row.role === 'assistant').length } const metaBlock = summary.contentBlocks?.find( diff --git a/src/components/AIChat/chat/ChatMessage.vue b/src/components/AIChat/chat/ChatMessage.vue index a489810c..3bd9995a 100644 --- a/src/components/AIChat/chat/ChatMessage.vue +++ b/src/components/AIChat/chat/ChatMessage.vue @@ -161,6 +161,15 @@ function formatToolStatusForCopy(status: ToolBlockContent['status']): string { return 'error' } +function getPlanStatusLabel(status: 'created' | 'executing' | 'done' | 'skipped'): string { + return t(`ai.chat.message.plan.status.${status}`) +} + +function formatPlanTools(tools: string[]): string { + if (tools.length === 0) return t('ai.chat.message.plan.noTools') + return tools.join(', ') +} + // 格式化时间参数显示 function formatTimeParams(params: Record): string { // 优先使用 start_time/end_time @@ -335,6 +344,17 @@ const copyMarkdownText = computed(() => { return `> Chart: ${block.chart.spec.title}` } + if (block.type === 'plan') { + const steps = block.plan.steps + .map( + (step, index) => + `${index + 1}. ${step.goal}\n - ${t('ai.chat.message.plan.evidenceNeeded')}: ${step.evidenceNeeded}\n - ${t('ai.chat.message.plan.suggestedTools')}: ${formatPlanTools(step.suggestedTools)}` + ) + .join('\n') + const criteria = block.plan.successCriteria.map((item) => `- ${item}`).join('\n') + return `> ${t('ai.chat.message.plan.label')}: ${block.plan.title} (${getPlanStatusLabel(block.status)})\n\n${steps}\n\n${t('ai.chat.message.plan.successCriteria')}:\n${criteria}` + } + if (block.type === 'tool') { const toolName = getToolDisplayName(block.tool) const toolParams = formatToolParams(block.tool) @@ -489,6 +509,47 @@ async function handleCopyMarkdown() { {{ t('ai.skill.active.label', { name: block.skillName }) }} + +
+ + + {{ t('ai.chat.message.plan.label') }} · {{ block.plan.title }} + + {{ getPlanStatusLabel(block.status) }} + + +
+
    +
  1. +
    {{ stepIndex + 1 }}. {{ step.goal }}
    +
    + {{ t('ai.chat.message.plan.evidenceNeeded') }}: {{ step.evidenceNeeded }} +
    +
    + {{ t('ai.chat.message.plan.suggestedTools') }}: {{ formatPlanTools(step.suggestedTools) }} +
    +
  2. +
+
+
+ {{ t('ai.chat.message.plan.successCriteria') }} +
+
    +
  • + {{ criterion }} +
  • +
+
+
+
+ diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json index ddcd3fae..f560373f 100644 --- a/src/i18n/locales/en-US/ai.json +++ b/src/i18n/locales/en-US/ai.json @@ -108,6 +108,19 @@ "collapse": "Collapse summary", "info": "Previous conversation has been compressed into a summary to save context space" }, + "plan": { + "label": "Analysis Plan", + "evidenceNeeded": "Evidence needed", + "suggestedTools": "Suggested tools", + "successCriteria": "Success criteria", + "noTools": "None", + "status": { + "created": "Created", + "executing": "Executing", + "done": "Done", + "skipped": "Skipped" + } + }, "think": { "labels": { "think": "Thinking", diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json index 78417f64..adf18eab 100644 --- a/src/i18n/locales/ja-JP/ai.json +++ b/src/i18n/locales/ja-JP/ai.json @@ -108,6 +108,19 @@ "collapse": "要約を折りたたむ", "info": "以前の会話がコンテキスト節約のため要約に圧縮されました" }, + "plan": { + "label": "分析計画", + "evidenceNeeded": "必要な証拠", + "suggestedTools": "推奨ツール", + "successCriteria": "成功基準", + "noTools": "なし", + "status": { + "created": "作成済み", + "executing": "実行中", + "done": "完了", + "skipped": "スキップ" + } + }, "think": { "labels": { "think": "思考", diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json index 333e8888..df839dbf 100644 --- a/src/i18n/locales/zh-CN/ai.json +++ b/src/i18n/locales/zh-CN/ai.json @@ -108,6 +108,19 @@ "collapse": "收起摘要", "info": "以上对话已被压缩为摘要以节省上下文空间" }, + "plan": { + "label": "分析计划", + "evidenceNeeded": "所需证据", + "suggestedTools": "建议工具", + "successCriteria": "完成标准", + "noTools": "无", + "status": { + "created": "已创建", + "executing": "执行中", + "done": "已完成", + "skipped": "已跳过" + } + }, "think": { "labels": { "think": "已思考", diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json index c4bfb64c..398d23d1 100644 --- a/src/i18n/locales/zh-TW/ai.json +++ b/src/i18n/locales/zh-TW/ai.json @@ -108,6 +108,19 @@ "collapse": "收起摘要", "info": "以上對話已被壓縮為摘要以節省上下文空間" }, + "plan": { + "label": "分析計畫", + "evidenceNeeded": "所需證據", + "suggestedTools": "建議工具", + "successCriteria": "完成標準", + "noTools": "無", + "status": { + "created": "已建立", + "executing": "執行中", + "done": "已完成", + "skipped": "已略過" + } + }, "think": { "labels": { "think": "已思考", diff --git a/src/services/ai-stream/types.ts b/src/services/ai-stream/types.ts index f5c7547c..d0078256 100644 --- a/src/services/ai-stream/types.ts +++ b/src/services/ai-stream/types.ts @@ -1,3 +1,5 @@ +import type { PlanContentBlock } from '@/services/ai/planBlocks' + export interface LlmStreamChunk { content: string isFinished: boolean @@ -8,7 +10,7 @@ export interface LlmStreamChunk { } export interface AgentStreamChunk { - type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'done' | 'error' + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'compression_done' | 'plan' | 'done' | 'error' content?: string thinkTag?: string thinkDurationMs?: number @@ -19,6 +21,7 @@ export interface AgentStreamChunk { isFinished?: boolean usage?: TokenUsage status?: AgentRuntimeStatus + plan?: PlanContentBlock compressionResult?: { summaryContent: string tokensBefore: number diff --git a/src/services/ai/planBlocks.test.ts b/src/services/ai/planBlocks.test.ts new file mode 100644 index 00000000..57148b65 --- /dev/null +++ b/src/services/ai/planBlocks.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { toPlanContentBlock, updateLastPlanBlockStatus, type PlanContentBlock } from './planBlocks' + +const planBlock: PlanContentBlock = { + type: 'plan', + version: 1, + status: 'created', + plan: { + version: 1, + title: '年度话题趋势分析', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖全年'], + }, +} + +describe('planBlocks', () => { + it('creates a serializable copy of plan blocks', () => { + const copy = toPlanContentBlock(planBlock) + + assert.deepEqual(copy, planBlock) + assert.notEqual(copy, planBlock) + assert.notEqual(copy.plan, planBlock.plan) + }) + + it('updates the last plan block status without mutating the original array', () => { + const blocks = [planBlock] + const updated = updateLastPlanBlockStatus(blocks, 'done') + + assert.equal(planBlock.status, 'created') + assert.equal(updated[0]?.status, 'done') + assert.notEqual(updated, blocks) + }) +}) diff --git a/src/services/ai/planBlocks.ts b/src/services/ai/planBlocks.ts new file mode 100644 index 00000000..f9f7f160 --- /dev/null +++ b/src/services/ai/planBlocks.ts @@ -0,0 +1,42 @@ +export type PlanIntent = 'summary' | 'trend' | 'relationship' | 'search' | 'comparison' | 'mixed' +export type PlanBlockStatus = 'created' | 'executing' | 'done' | 'skipped' + +export interface PlanStep { + goal: string + suggestedTools: string[] + evidenceNeeded: string +} + +export interface PlanSummary { + version: 1 + title: string + route: 'planned_execution' + intent: PlanIntent + steps: PlanStep[] + successCriteria: string[] +} + +export interface PlanContentBlock { + type: 'plan' + version: 1 + status: PlanBlockStatus + plan: PlanSummary +} + +export function toPlanContentBlock(block: PlanContentBlock): PlanContentBlock { + return JSON.parse(JSON.stringify(block)) as PlanContentBlock +} + +export function updateLastPlanBlockStatus( + blocks: readonly PlanContentBlock[], + status: PlanBlockStatus +): PlanContentBlock[] { + const nextBlocks = blocks.map(toPlanContentBlock) + for (let index = nextBlocks.length - 1; index >= 0; index--) { + if (nextBlocks[index].type === 'plan') { + nextBlocks[index].status = status + break + } + } + return nextBlocks +} diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 3406655e..0127df1b 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -1,5 +1,6 @@ import type { TimeFilter } from '@/types/base' import type { ChartPayload } from '@openchatlab/core' +import type { PlanContentBlock } from './planBlocks' export interface AIChat { id: string @@ -15,6 +16,7 @@ export type ContentBlock = | { type: 'text'; text: string } | { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'chart'; chart: ChartPayload } + | PlanContentBlock | { type: 'tool' tool: { diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index c8891856..74d8084d 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -25,6 +25,7 @@ import { toChartContentBlocks, toRenderOnlyToolErrorBlock, } from './aiChatChartBlocks' +import { toPlanContentBlock, type PlanBlockStatus, type PlanContentBlock } from '@/services/ai/planBlocks' // 工具调用记录 export interface ToolCallRecord { @@ -57,6 +58,7 @@ export type ContentBlock = | { type: 'text'; text: string } | { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'chart'; chart: ChartPayload } + | PlanContentBlock | { type: 'tool' tool: ToolBlockContent @@ -605,6 +607,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { appendTextToBlocks: (text: string) => void appendThinkToBlocks: (text: string, tag?: string, durationMs?: number) => void appendChartsToBlocks: (charts: ChartPayload[]) => void + appendPlanToBlocks: (plan: PlanContentBlock) => void + updatePlanBlockStatus: (status: PlanBlockStatus) => void appendErrorToBlocks: (error: SerializedErrorInfo) => void addToolBlock: (toolName: string, params?: Record) => void updateToolBlockStatus: (toolName: string, status: 'done' | 'error') => void @@ -665,6 +669,26 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { updateAIMessage({ contentBlocks: [...blocks] }) } + const appendPlanToBlocks = (plan: PlanContentBlock) => { + const idx = getAiMessageIndex() + const blocks = targetBuffer.messages[idx].contentBlocks || [] + blocks.push(toPlanContentBlock(plan)) + updateAIMessage({ contentBlocks: [...blocks] }) + } + + const updatePlanBlockStatus = (status: PlanBlockStatus) => { + const idx = getAiMessageIndex() + const blocks = targetBuffer.messages[idx].contentBlocks || [] + for (let index = blocks.length - 1; index >= 0; index--) { + const block = blocks[index] + if (block.type === 'plan') { + block.status = status + break + } + } + updateAIMessage({ contentBlocks: [...blocks] }) + } + const appendErrorToBlocks = (error: SerializedErrorInfo) => { const idx = getAiMessageIndex() const blocks = targetBuffer.messages[idx].contentBlocks || [] @@ -697,6 +721,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { appendTextToBlocks, appendThinkToBlocks, appendChartsToBlocks, + appendPlanToBlocks, + updatePlanBlockStatus, appendErrorToBlocks, addToolBlock, updateToolBlockStatus, @@ -813,6 +839,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { appendTextToBlocks, appendThinkToBlocks, appendChartsToBlocks, + appendPlanToBlocks, + updatePlanBlockStatus, appendErrorToBlocks, addToolBlock, updateToolBlockStatus, @@ -938,6 +966,13 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { state.isLoadingSource = false break + case 'plan': + if (chunk.plan) { + appendPlanToBlocks(chunk.plan) + updatePlanBlockStatus('executing') + } + break + case 'status': if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) { state.agentStatus = chunk.status @@ -960,6 +995,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { case 'done': state.currentToolStatus = null + updatePlanBlockStatus('done') if (chunk.usage) { lastDoneUsage = { ...chunk.usage } state.sessionTokenUsage = { @@ -972,6 +1008,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { break case 'error': + updatePlanBlockStatus('skipped') if (state.currentToolStatus) { state.currentToolStatus = { ...state.currentToolStatus, @@ -1362,6 +1399,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { appendTextToBlocks, appendThinkToBlocks, appendChartsToBlocks, + appendPlanToBlocks, + updatePlanBlockStatus, appendErrorToBlocks, addToolBlock, updateToolBlockStatus, @@ -1467,6 +1506,12 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { state.currentToolStatus = null state.isLoadingSource = false break + case 'plan': + if (chunk.plan) { + appendPlanToBlocks(chunk.plan) + updatePlanBlockStatus('executing') + } + break case 'status': if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) { state.agentStatus = chunk.status @@ -1474,10 +1519,12 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { break case 'done': state.currentToolStatus = null + updatePlanBlockStatus('done') if (chunk.usage) lastDoneUsage = { ...chunk.usage } setAgentPhase(state, 'completed', chunk.usage ? { totalUsage: chunk.usage } : undefined) break case 'error': { + updatePlanBlockStatus('skipped') hasStreamError = true if (state.currentToolStatus) updateToolBlockStatus(state.currentToolStatus.name, 'error') const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []