feat(ai): render analysis plan blocks

This commit is contained in:
digua committed 2026-06-10 00:15:25 +08:00
1 parent b61974136e
commit 2d413f4202
11 files changed
+262 -33

No files matched your search

+18 -32
View File
@@ -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<string, number>()
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(
@@ -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, unknown>): 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() {
<span>{{ t('ai.skill.active.label', { name: block.skillName }) }}</span>
</div>
<!-- 计划块 -->
<details
v-else-if="block.type === 'plan'"
class="mb-2 rounded-lg border border-blue-100 bg-blue-50/60 text-sm text-gray-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-gray-300"
>
<summary
class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs font-medium text-blue-700 transition-colors hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200"
>
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ t('ai.chat.message.plan.label') }} · {{ block.plan.title }}</span>
<span
class="ml-auto shrink-0 rounded-full bg-white/70 px-2 py-0.5 text-[11px] text-blue-600 dark:bg-blue-950/60 dark:text-blue-300"
>
{{ getPlanStatusLabel(block.status) }}
</span>
</summary>
<div class="border-t border-blue-100/70 px-3 py-2.5 dark:border-blue-900/40">
<ol class="space-y-2">
<li v-for="(step, stepIndex) in block.plan.steps" :key="stepIndex" class="text-xs leading-relaxed">
<div class="font-medium text-gray-800 dark:text-gray-200">{{ stepIndex + 1 }}. {{ step.goal }}</div>
<div class="mt-1 text-gray-500 dark:text-gray-400">
{{ t('ai.chat.message.plan.evidenceNeeded') }}: {{ step.evidenceNeeded }}
</div>
<div class="mt-0.5 text-gray-500 dark:text-gray-400">
{{ t('ai.chat.message.plan.suggestedTools') }}: {{ formatPlanTools(step.suggestedTools) }}
</div>
</li>
</ol>
<div class="mt-2 border-t border-blue-100/70 pt-2 dark:border-blue-900/40">
<div class="text-[11px] font-medium text-gray-500 dark:text-gray-400">
{{ t('ai.chat.message.plan.successCriteria') }}
</div>
<ul class="mt-1 list-disc space-y-0.5 pl-4 text-xs leading-relaxed text-gray-600 dark:text-gray-300">
<li v-for="(criterion, criterionIndex) in block.plan.successCriteria" :key="criterionIndex">
{{ criterion }}
</li>
</ul>
</div>
</div>
</details>
<!-- 图表块 -->
<ChartBlockRenderer v-else-if="block.type === 'chart'" :chart="block.chart" />
+13
View File
@@ -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",
+13
View File
@@ -108,6 +108,19 @@
"collapse": "要約を折りたたむ",
"info": "以前の会話がコンテキスト節約のため要約に圧縮されました"
},
"plan": {
"label": "分析計画",
"evidenceNeeded": "必要な証拠",
"suggestedTools": "推奨ツール",
"successCriteria": "成功基準",
"noTools": "なし",
"status": {
"created": "作成済み",
"executing": "実行中",
"done": "完了",
"skipped": "スキップ"
}
},
"think": {
"labels": {
"think": "思考",
+13
View File
@@ -108,6 +108,19 @@
"collapse": "收起摘要",
"info": "以上对话已被压缩为摘要以节省上下文空间"
},
"plan": {
"label": "分析计划",
"evidenceNeeded": "所需证据",
"suggestedTools": "建议工具",
"successCriteria": "完成标准",
"noTools": "无",
"status": {
"created": "已创建",
"executing": "执行中",
"done": "已完成",
"skipped": "已跳过"
}
},
"think": {
"labels": {
"think": "已思考",
+13
View File
@@ -108,6 +108,19 @@
"collapse": "收起摘要",
"info": "以上對話已被壓縮為摘要以節省上下文空間"
},
"plan": {
"label": "分析計畫",
"evidenceNeeded": "所需證據",
"suggestedTools": "建議工具",
"successCriteria": "完成標準",
"noTools": "無",
"status": {
"created": "已建立",
"executing": "執行中",
"done": "已完成",
"skipped": "已略過"
}
},
"think": {
"labels": {
"think": "已思考",
+4 -1
View File
@@ -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
+36
View File
@@ -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)
})
})
+42
View File
@@ -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
}
+2
View File
@@ -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: {
+47
View File
@@ -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<string, unknown>) => 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 || []