feat(ai): stream structured analysis plans

This commit is contained in:
digua committed 2026-06-10 00:15:25 +08:00
1 parent 9ad012680d
commit 0801ccf940
21 files changed
+669 -36

No files matched your search

+12 -1
View File
@@ -204,7 +204,17 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
let effectiveSystemPrompt = systemPrompt
if (routeDecision.route === 'planned_execution') {
const planStartedAt = Date.now()
const planner = createAnalysisPlanner({ piModel, apiKey: llmConfig.apiKey })
const planner = createAnalysisPlanner({
piModel,
apiKey: llmConfig.apiKey,
onPlanDelta: (delta) => onEvent({ type: 'plan_delta', planDelta: delta }),
onThinkingDelta: (delta) => onEvent({ type: 'think', content: delta, thinkTag: 'thinking' }),
onThinkingEnd: (durationMs) =>
onEvent({ type: 'think', content: '', thinkTag: 'thinking', thinkDurationMs: durationMs }),
onValidationDelta: (delta) => onEvent({ type: 'think', content: delta, thinkTag: 'plan_validation' }),
onValidationEnd: (durationMs) =>
onEvent({ type: 'think', content: '', thinkTag: 'plan_validation', thinkDurationMs: durationMs }),
})
const plan = await planner(routeInput, abortSignal)
if (plan) {
const planBlock = createPlanContentBlock(plan)
@@ -221,6 +231,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
elapsedMs: Date.now() - planStartedAt,
route: routeDecision.route,
})
onEvent({ type: 'plan_skipped' })
}
}
+40 -4
View File
@@ -200,6 +200,8 @@ describe('runChatTurn', () => {
source: 'rule',
},
})
onEvent({ type: 'plan_delta', planDelta: '年度话题趋势\n' })
onEvent({ type: 'plan_delta', planDelta: '1. 按季度检索\n' })
onEvent({
type: 'plan',
plan: {
@@ -227,12 +229,18 @@ describe('runChatTurn', () => {
)
assert.equal(result.answer, '年度趋势如下。')
assert.equal(result.events?.length, 4)
assert.equal(result.events?.length, 6)
assert.equal(result.events?.[0]?.type, 'route')
assert.equal(result.events?.[0]?.routeDecision?.route, 'planned_execution')
assert.equal(result.events?.[1]?.type, 'plan')
assert.equal(result.contentBlocks?.[0]?.type, 'plan')
assert.equal(result.contentBlocks?.[0]?.status, 'done')
assert.equal(result.events?.[1]?.type, 'plan_delta')
assert.equal(result.events?.[2]?.type, 'plan_delta')
assert.equal(result.events?.[3]?.type, 'plan')
const firstBlock = result.contentBlocks?.[0]
assert.equal(firstBlock?.type, 'plan')
if (firstBlock?.type === 'plan') {
assert.equal(firstBlock.status, 'done')
assert.equal(firstBlock.displayText, '年度话题趋势\n1. 按季度检索')
}
assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [
{ aiChatId: 'ai_chat_1', role: 'user', content: '分析过去一年话题趋势' },
{
@@ -245,6 +253,34 @@ describe('runChatTurn', () => {
])
})
it('drops streamed plan drafts when planner validation is skipped', async () => {
const stdout = new MemoryWritable()
const aiChatManager = createAIChatManager()
const result = await runChatTurn(
{ sessionId: 'session-1', question: '分析过去一年话题趋势', json: true, includeEvents: true },
{
dbManager: createDbManager(['session-1']),
pathProvider: {} as never,
aiChatManager,
stdout,
createRunAgentStream: () => async (_params, onEvent) => {
onEvent({ type: 'plan_delta', planDelta: '无法校验的计划草稿\n' })
onEvent({ type: 'plan_skipped' })
onEvent({ type: 'content', content: '直接给出分析。' })
onEvent({ type: 'done', isFinished: true })
},
}
)
assert.equal(result.events?.[0]?.type, 'plan_delta')
assert.equal(result.events?.[1]?.type, 'plan_skipped')
assert.deepEqual(
result.contentBlocks?.map((block) => block.type),
['text']
)
})
it('persists streamed thinking and answer text blocks for full CLI replay', async () => {
const stdout = new MemoryWritable()
const aiChatManager = createAIChatManager()
+46 -2
View File
@@ -6,6 +6,7 @@ import type {
AgentStreamChunk,
ContentBlock,
PlanContentBlock,
PlanDraftContentBlock,
TokenUsageData,
} from '@openchatlab/node-runtime'
import type { ChartPayload, PathProvider } from '@openchatlab/core'
@@ -211,6 +212,41 @@ export async function runChatTurn(
hasReplayContentBlocks = true
}
const appendPlanDraftBlock = (delta: string) => {
if (!delta) return
const lastBlock = contentBlocks[contentBlocks.length - 1]
if (lastBlock?.type === 'plan_draft') {
lastBlock.text += delta
} else {
contentBlocks.push({ type: 'plan_draft', version: 1, status: 'streaming', text: delta } as PlanDraftContentBlock)
}
hasReplayContentBlocks = true
}
const removePlanDraftBlocks = () => {
for (let index = contentBlocks.length - 1; index >= 0; index--) {
if (contentBlocks[index]?.type === 'plan_draft') {
contentBlocks.splice(index, 1)
}
}
}
const appendFinalPlanBlock = (plan: PlanContentBlock) => {
const planBlock = JSON.parse(JSON.stringify(plan)) as PlanContentBlock
for (let index = contentBlocks.length - 1; index >= 0; index--) {
const block = contentBlocks[index]
if (block?.type === 'plan_draft') {
const displayText = block.text.trim()
if (displayText) planBlock.displayText = displayText
contentBlocks[index] = planBlock
hasReplayContentBlocks = true
return
}
}
contentBlocks.push(planBlock)
hasReplayContentBlocks = true
}
await runAgentStream(
{
userMessage: options.question,
@@ -226,12 +262,20 @@ export async function runChatTurn(
events.push(JSON.parse(JSON.stringify(chunk)) as AgentStreamChunk)
}
if (chunk.type === 'error') {
removePlanDraftBlocks()
streamError = createAgentStreamError(chunk.error)
return
}
if (chunk.type === 'plan_delta' && chunk.planDelta) {
appendPlanDraftBlock(chunk.planDelta)
return
}
if (chunk.type === 'plan' && chunk.plan) {
hasReplayContentBlocks = true
contentBlocks.push(JSON.parse(JSON.stringify(chunk.plan)) as PlanContentBlock)
appendFinalPlanBlock(chunk.plan)
return
}
if (chunk.type === 'plan_skipped') {
removePlanDraftBlocks()
return
}
if (chunk.type === 'think') {
+12 -1
View File
@@ -177,7 +177,17 @@ export class Agent {
let effectiveSystemPrompt = systemPrompt
if (routeDecision.route === 'planned_execution') {
const planStartedAt = Date.now()
const planner = createAnalysisPlanner({ piModel: this.piModel, apiKey: this.apiKey })
const planner = createAnalysisPlanner({
piModel: this.piModel,
apiKey: this.apiKey,
onPlanDelta: (delta) => onChunk({ type: 'plan_delta', planDelta: delta }),
onThinkingDelta: (delta) => onChunk({ type: 'think', content: delta, thinkTag: 'thinking' }),
onThinkingEnd: (durationMs) =>
onChunk({ type: 'think', content: '', thinkTag: 'thinking', thinkDurationMs: durationMs }),
onValidationDelta: (delta) => onChunk({ type: 'think', content: delta, thinkTag: 'plan_validation' }),
onValidationEnd: (durationMs) =>
onChunk({ type: 'think', content: '', thinkTag: 'plan_validation', thinkDurationMs: durationMs }),
})
const plan = await planner(routeInput, this.abortSignal)
if (plan) {
const planBlock = createPlanContentBlock(plan)
@@ -194,6 +204,7 @@ export class Agent {
elapsedMs: Date.now() - planStartedAt,
route: routeDecision.route,
})
onChunk({ type: 'plan_skipped' })
}
}
@@ -66,6 +66,64 @@ describe('createAnalysisPlanner', () => {
assert.equal(plan, null)
})
it('streams planning draft as thinking and final plan text as plan deltas', async () => {
const deltas: string[] = []
const thinkingDeltas: string[] = []
const validationDeltas: string[] = []
let thinkingEnded = false
let validationEnded = false
const planner = createAnalysisPlanner({
stream: async (_prompt, callbacks) => {
callbacks.onThinkingDelta?.('先判断问题类型。')
callbacks.onThinkingEnd?.(120)
callbacks.onPlanDelta('年度话题趋势分析\n')
callbacks.onPlanDelta('1. 按季度检索代表性话题\n')
const json = JSON.stringify({
title: '年度话题趋势分析',
intent: 'trend',
steps: [
{
goal: '按季度检索代表性话题',
suggestedTools: ['search_messages'],
evidenceNeeded: '每个季度的代表消息',
},
],
successCriteria: ['覆盖全年'],
})
callbacks.onValidationDelta?.(json)
callbacks.onValidationEnd?.(80)
return `<draft>
先判断问题类型。
</draft>
<plan>
年度话题趋势分析
1. 按季度检索代表性话题
</plan>
<json>
${json}
</json>`
},
onPlanDelta: (delta) => deltas.push(delta),
onThinkingDelta: (delta) => thinkingDeltas.push(delta),
onThinkingEnd: () => {
thinkingEnded = true
},
onValidationDelta: (delta) => validationDeltas.push(delta),
onValidationEnd: () => {
validationEnded = true
},
})
const plan = await planner(baseInput)
assert.equal(plan?.title, '年度话题趋势分析')
assert.deepEqual(deltas, ['年度话题趋势分析\n', '1. 按季度检索代表性话题\n'])
assert.deepEqual(thinkingDeltas, ['先判断问题类型。'])
assert.equal(validationDeltas.join('').includes('"suggestedTools":["search_messages"]'), true)
assert.equal(thinkingEnded, true)
assert.equal(validationEnded, true)
})
it('creates versioned plan content blocks', () => {
const plan: AnalysisPlanSummary = {
version: 1,
@@ -140,4 +198,24 @@ describe('createAnalysisPlanner', () => {
assert.match(capturedPrompt, /get_schema/)
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 <json>/)
})
})
@@ -37,7 +37,9 @@ export interface AgentStreamChunk {
| 'status'
| 'compression_done'
| 'route'
| 'plan_delta'
| 'plan'
| 'plan_skipped'
| 'done'
| 'error'
content?: string
@@ -51,6 +53,7 @@ export interface AgentStreamChunk {
usage?: TokenUsage
status?: AgentRuntimeStatus
routeDecision?: RouteDecision
planDelta?: string
plan?: PlanContentBlock
compressionResult?: {
summaryContent: string
@@ -12,4 +12,5 @@ export type {
PlannerCapabilitySummary,
PlannerInput,
PlanContentBlock,
PlanDraftContentBlock,
} from './planning-types'
+155 -7
View File
@@ -1,5 +1,6 @@
import {
completeSimple,
streamSimple,
type Api as PiApi,
type Model as PiModel,
type TextContent as PiTextContent,
@@ -14,10 +15,24 @@ import type {
export type PlannerCompletionResult = string | { text: string }
export interface PlannerStreamCallbacks {
onPlanDelta: (delta: string) => void
onThinkingDelta?: (delta: string) => void
onThinkingEnd?: (durationMs?: number) => void
onValidationDelta?: (delta: string) => void
onValidationEnd?: (durationMs?: number) => void
}
export interface CreateAnalysisPlannerOptions {
piModel?: PiModel<PiApi>
apiKey?: string
complete?: (prompt: string, signal?: AbortSignal) => Promise<PlannerCompletionResult> | PlannerCompletionResult
stream?: (prompt: string, callbacks: PlannerStreamCallbacks, signal?: AbortSignal) => Promise<PlannerCompletionResult>
onPlanDelta?: (delta: string) => void
onThinkingDelta?: (delta: string) => void
onThinkingEnd?: (durationMs?: number) => void
onValidationDelta?: (delta: string) => void
onValidationEnd?: (durationMs?: number) => void
maxTokens?: number
temperature?: number
}
@@ -30,6 +45,7 @@ const PLAN_INTENTS: readonly AnalysisPlanIntent[] = [
'comparison',
'mixed',
]
const STREAM_MARKER_HOLDBACK_CHARS = Math.max('</draft>'.length, '</plan>'.length, '<json>'.length) - 1
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -120,7 +136,14 @@ function buildPlannerPrompt(input: PlannerInput): string {
return `Create a concise user-visible analysis plan for a ChatLab planned_execution request.
Return strict JSON only:
Return exactly three blocks:
<draft>
A brief user-visible planning note in the user's locale. Keep it high-level; do not reveal hidden chain-of-thought.
</draft>
<plan>
A concise user-facing analysis approach in the user's locale. Do not include suggested tools, evidence fields, or success criteria; those details belong in <json>.
</plan>
<json>
{
"title": "short title",
"intent": "summary|trend|relationship|search|comparison|mixed",
@@ -129,13 +152,14 @@ Return strict JSON only:
],
"successCriteria": ["criterion"]
}
</json>
Limits:
- Max 5 steps.
- Max 5 successCriteria.
- suggestedTools must come only from availableTools.
- If availableCapabilities are listed, you may plan steps that use their tools when the capability helps the user.
- Do not reveal hidden chain-of-thought; write a user-readable plan summary.
- Do not reveal hidden chain-of-thought; draft and plan are user-readable summaries only.
- The plan is soft guidance, not a mandatory execution script.
Context:
@@ -157,9 +181,20 @@ export function createAnalysisPlanner(options: CreateAnalysisPlannerOptions): An
return async (input, signal) => {
try {
const prompt = buildPlannerPrompt(input)
const rawResult = options.complete
? await options.complete(prompt, signal)
: await completeWithPiAi(prompt, options, signal)
const callbacks: PlannerStreamCallbacks = {
onPlanDelta: options.onPlanDelta ?? (() => {}),
onThinkingDelta: options.onThinkingDelta,
onThinkingEnd: options.onThinkingEnd,
onValidationDelta: options.onValidationDelta,
onValidationEnd: options.onValidationEnd,
}
const rawResult = options.stream
? await options.stream(prompt, callbacks, signal)
: options.complete
? await options.complete(prompt, signal)
: options.onPlanDelta
? await streamWithPiAi(prompt, options, signal)
: await completeWithPiAi(prompt, options, signal)
return parsePlanJson(resultToText(rawResult), new Set(input.availableTools))
} catch {
return null
@@ -212,12 +247,12 @@ async function completeWithPiAi(
const result = await completeSimple(
options.piModel,
{
systemPrompt: 'You are a strict JSON planner for ChatLab analysis. Return JSON only.',
systemPrompt: 'You are a strict planner for ChatLab analysis. Return the requested blocks only.',
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
},
{
apiKey: options.apiKey,
maxTokens: options.maxTokens ?? 700,
maxTokens: options.maxTokens ?? 900,
temperature: options.temperature ?? 0.2,
signal,
}
@@ -230,3 +265,116 @@ async function completeWithPiAi(
.join(''),
}
}
function extractTaggedBlockText(rawText: string, tag: 'draft' | 'plan' | 'json', nextTags: string[]): string {
const openTag = `<${tag}>`
const closeTag = `</${tag}>`
const blockStart = rawText.indexOf(openTag)
if (blockStart < 0) return ''
const bodyStart = blockStart + openTag.length
const closeStart = rawText.indexOf(closeTag, bodyStart)
const nextStarts = nextTags.map((nextTag) => rawText.indexOf(nextTag, bodyStart)).filter((index) => index >= 0)
const endCandidates = [closeStart, ...nextStarts].filter((index) => index >= 0)
const bodyEnd =
endCandidates.length > 0
? Math.min(...endCandidates)
: Math.max(bodyStart, rawText.length - STREAM_MARKER_HOLDBACK_CHARS)
const text = rawText.slice(bodyStart, bodyEnd)
return text.replace(/^\s+/, '')
}
async function streamWithPiAi(
prompt: string,
options: CreateAnalysisPlannerOptions,
signal?: AbortSignal
): Promise<PlannerCompletionResult> {
if (!options.piModel || !options.apiKey) {
throw new Error('Planner requires piModel and apiKey')
}
const stream = streamSimple(
options.piModel,
{
systemPrompt: 'You are a strict planner for ChatLab analysis. Return the requested plan and JSON blocks only.',
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
},
{
apiKey: options.apiKey,
maxTokens: options.maxTokens ?? 900,
temperature: options.temperature ?? 0.2,
signal,
}
)
let text = ''
let emittedDraftLength = 0
let emittedPlanLength = 0
let emittedJsonLength = 0
let thinkingStartedAt: number | undefined
let draftThinkingStartedAt: number | undefined
let draftThinkingEnded = false
let validationStartedAt: number | undefined
let validationEnded = false
for await (const event of stream) {
if (signal?.aborted) break
if (event.type === 'thinking_start') {
thinkingStartedAt = Date.now()
continue
}
if (event.type === 'thinking_delta') {
options.onThinkingDelta?.(event.delta)
continue
}
if (event.type === 'thinking_end') {
const durationMs = thinkingStartedAt ? Date.now() - thinkingStartedAt : undefined
thinkingStartedAt = undefined
options.onThinkingEnd?.(durationMs)
continue
}
if (event.type !== 'text_delta') continue
text += event.delta
const draftText = extractTaggedBlockText(text, 'draft', ['<plan>', '<json>'])
if (draftText.length > emittedDraftLength) {
if (!draftThinkingStartedAt) draftThinkingStartedAt = Date.now()
const delta = draftText.slice(emittedDraftLength)
emittedDraftLength = draftText.length
if (delta.trim().length > 0 || /\s/.test(delta)) {
options.onThinkingDelta?.(delta)
}
}
if (!draftThinkingEnded && emittedDraftLength > 0 && (text.includes('</draft>') || text.includes('<plan>'))) {
draftThinkingEnded = true
const durationMs = draftThinkingStartedAt ? Date.now() - draftThinkingStartedAt : undefined
options.onThinkingEnd?.(durationMs)
}
const planText = extractTaggedBlockText(text, 'plan', ['<json>'])
if (planText.length > emittedPlanLength) {
const delta = planText.slice(emittedPlanLength)
emittedPlanLength = planText.length
if (delta.trim().length > 0 || /\s/.test(delta)) {
options.onPlanDelta?.(delta)
}
}
const jsonText = extractTaggedBlockText(text, 'json', [])
if (jsonText.length > emittedJsonLength) {
if (!validationStartedAt) validationStartedAt = Date.now()
const delta = jsonText.slice(emittedJsonLength)
emittedJsonLength = jsonText.length
if (delta.trim().length > 0 || /\s/.test(delta)) {
options.onValidationDelta?.(delta)
}
}
if (!validationEnded && emittedJsonLength > 0 && text.includes('</json>')) {
validationEnded = true
const durationMs = validationStartedAt ? Date.now() - validationStartedAt : undefined
options.onValidationEnd?.(durationMs)
}
}
return { text }
}
@@ -29,6 +29,14 @@ export interface PlanContentBlock {
version: 1
status: 'created' | 'executing' | 'done' | 'skipped'
plan: AnalysisPlanSummary
displayText?: string
}
export interface PlanDraftContentBlock {
type: 'plan_draft'
version: 1
status: 'streaming'
text: string
}
export interface PlannerInput {
+2 -1
View File
@@ -9,7 +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'
import type { PlanContentBlock, PlanDraftContentBlock } from './agent'
const DEFAULT_GENERAL_ID = 'general_cn'
@@ -30,6 +30,7 @@ export type ContentBlock =
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| PlanContentBlock
| PlanDraftContentBlock
| {
type: 'tool'
tool: {
+1
View File
@@ -110,6 +110,7 @@ export type {
PlannerCapabilitySummary,
PlannerInput,
PlanContentBlock,
PlanDraftContentBlock,
} from './agent'
// Agent Event Handler
+1
View File
@@ -216,6 +216,7 @@ export type {
PlannerCapabilitySummary,
PlannerInput,
PlanContentBlock,
PlanDraftContentBlock,
} from './ai'
// Summary generation
+141 -16
View File
@@ -73,6 +73,7 @@ function getThinkLabel(tag: string): string {
if (normalized === 'analysis') return t('ai.chat.message.think.labels.analysis')
if (normalized === 'reasoning') return t('ai.chat.message.think.labels.reasoning')
if (normalized === 'reflection') return t('ai.chat.message.think.labels.reflection')
if (normalized === 'plan_validation') return t('ai.chat.message.think.labels.planValidation')
if (normalized === 'think' || normalized === 'thought' || normalized === 'thinking') {
return t('ai.chat.message.think.labels.think')
}
@@ -171,15 +172,54 @@ 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 parsePlanValidation(text: string): {
title?: string
steps: Array<{ goal: string; suggestedTools: string[]; evidenceNeeded: string }>
successCriteria: string[]
} | null {
try {
const parsed = JSON.parse(text.trim()) as unknown
if (!parsed || typeof parsed !== 'object') return null
const record = parsed as Record<string, unknown>
const steps = Array.isArray(record.steps)
? record.steps
.filter((step): step is Record<string, unknown> => !!step && typeof step === 'object')
.map((step) => ({
goal: typeof step.goal === 'string' ? step.goal : '',
suggestedTools: Array.isArray(step.suggestedTools)
? step.suggestedTools.filter((tool): tool is string => typeof tool === 'string')
: [],
evidenceNeeded: typeof step.evidenceNeeded === 'string' ? step.evidenceNeeded : '',
}))
.filter((step) => step.goal)
: []
return {
title: typeof record.title === 'string' ? record.title : undefined,
steps,
successCriteria: Array.isArray(record.successCriteria)
? record.successCriteria.filter((item): item is string => typeof item === 'string')
: [],
}
} catch {
return null
}
}
const planValidationCache = new Map<string, NonNullable<ReturnType<typeof parsePlanValidation>>>()
function getPlanValidation(text: string): ReturnType<typeof parsePlanValidation> {
const cached = planValidationCache.get(text)
if (cached) return cached
const parsed = parsePlanValidation(text)
if (parsed) planValidationCache.set(text, parsed)
return parsed
}
// 格式化时间参数显示
function formatTimeParams(params: Record<string, unknown>): string {
// 优先使用 start_time/end_time
@@ -362,7 +402,23 @@ const copyMarkdownText = computed(() => {
)
.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}`
const displayText = block.displayText
? `${block.displayText
.split('\n')
.map((line) => `> ${line}`)
.join('\n')}\n\n`
: ''
if (block.displayText) {
return `> ${t('ai.chat.message.plan.label')}: ${block.plan.title}\n\n${displayText.trimEnd()}`
}
return `> ${t('ai.chat.message.plan.label')}: ${block.plan.title}\n\n${steps}\n\n${t('ai.chat.message.plan.successCriteria')}:\n${criteria}`
}
if (block.type === 'plan_draft') {
return `> ${t('ai.chat.message.plan.label')}\n>\n${block.text
.split('\n')
.map((line) => `> ${line}`)
.join('\n')}`
}
if (block.type === 'tool') {
@@ -506,7 +562,50 @@ async function handleCopyMarkdown() {
</span>
</summary>
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none leading-relaxed text-xs">
<div v-html="renderMarkdown(block.text)" />
<template v-if="block.tag === 'plan_validation' && getPlanValidation(block.text)">
<div
v-if="getPlanValidation(block.text)?.title"
class="mb-2 font-medium text-gray-700 dark:text-gray-200"
>
{{ getPlanValidation(block.text)?.title }}
</div>
<ol class="not-prose space-y-2 text-xs leading-relaxed">
<li
v-for="(step, stepIndex) in getPlanValidation(block.text)?.steps"
:key="stepIndex"
class="text-gray-600 dark:text-gray-300"
>
<div class="font-medium text-gray-800 dark:text-gray-200">
{{ stepIndex + 1 }}. {{ step.goal }}
</div>
<div v-if="step.evidenceNeeded" 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
v-if="getPlanValidation(block.text)?.successCriteria.length"
class="not-prose mt-2 border-t border-gray-100 pt-2 dark:border-gray-800"
>
<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 getPlanValidation(block.text)?.successCriteria"
:key="criterionIndex"
>
{{ criterion }}
</li>
</ul>
</div>
</template>
<div v-else v-html="renderMarkdown(block.text)" />
</div>
</details>
@@ -522,21 +621,22 @@ async function handleCopyMarkdown() {
<!-- 计划块 -->
<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"
open
class="mb-2 border-l-2 border-gray-200 py-1 pl-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
<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"
class="flex cursor-pointer select-none items-center gap-2 text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<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">
<div class="mt-2">
<div
v-if="block.displayText"
class="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed text-gray-600 dark:text-gray-300"
v-html="renderMarkdown(block.displayText)"
/>
<ol v-else 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">
@@ -547,7 +647,7 @@ async function handleCopyMarkdown() {
</div>
</li>
</ol>
<div class="mt-2 border-t border-blue-100/70 pt-2 dark:border-blue-900/40">
<div v-if="!block.displayText" class="mt-2 border-t border-gray-100 pt-2 dark:border-gray-800">
<div class="text-[11px] font-medium text-gray-500 dark:text-gray-400">
{{ t('ai.chat.message.plan.successCriteria') }}
</div>
@@ -560,6 +660,31 @@ async function handleCopyMarkdown() {
</div>
</details>
<!-- 计划草稿块 -->
<div
v-else-if="block.type === 'plan_draft'"
class="mb-2 border-l-2 border-gray-200 py-1 pl-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
<div class="flex items-center gap-2 text-xs font-medium text-gray-500 dark:text-gray-400">
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0" />
<span>{{ t('ai.chat.message.plan.label') }}</span>
<span
v-if="isStreaming"
class="inline-flex items-center gap-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"
>
<span class="flex gap-0.5">
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
</span>
</span>
</div>
<div
class="mt-2 prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed text-gray-600 dark:text-gray-300"
v-html="renderMarkdown(block.text)"
/>
</div>
<!-- 图表块 -->
<ChartBlockRenderer v-else-if="block.type === 'chart'" :chart="block.chart" />
+1
View File
@@ -127,6 +127,7 @@
"analysis": "Analysis",
"reasoning": "Reasoning",
"reflection": "Reflection",
"planValidation": "Generate and validate steps",
"other": "Thinking ({tag})"
},
"loading": "Thinking",
+1
View File
@@ -127,6 +127,7 @@
"analysis": "分析",
"reasoning": "推論",
"reflection": "振り返り",
"planValidation": "手順の生成・検証",
"other": "思考({tag})"
},
"loading": "思考中...",
+1
View File
@@ -127,6 +127,7 @@
"analysis": "分析",
"reasoning": "推理",
"reflection": "反思",
"planValidation": "生成并校验步骤",
"other": "思考({tag})"
},
"loading": "思考中",
+1
View File
@@ -127,6 +127,7 @@
"analysis": "分析",
"reasoning": "推理",
"reflection": "反思",
"planValidation": "產生並校驗步驟",
"other": "思考({tag})"
},
"loading": "思考中",
+3
View File
@@ -18,7 +18,9 @@ export interface AgentStreamChunk {
| 'status'
| 'compression_done'
| 'route'
| 'plan_delta'
| 'plan'
| 'plan_skipped'
| 'done'
| 'error'
content?: string
@@ -32,6 +34,7 @@ export interface AgentStreamChunk {
usage?: TokenUsage
status?: AgentRuntimeStatus
routeDecision?: RouteDecision
planDelta?: string
plan?: PlanContentBlock
compressionResult?: {
summaryContent: string
+56 -1
View File
@@ -1,6 +1,13 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { toPlanContentBlock, updateLastPlanBlockStatus, type PlanContentBlock } from './planBlocks'
import {
appendPlanDraftDelta,
removePlanDraftBlocks,
replacePlanDraftWithPlan,
toPlanContentBlock,
updateLastPlanBlockStatus,
type PlanContentBlock,
} from './planBlocks'
const planBlock: PlanContentBlock = {
type: 'plan',
@@ -33,4 +40,52 @@ describe('planBlocks', () => {
assert.equal(updated[0]?.status, 'done')
assert.notEqual(updated, blocks)
})
it('appends streamed plan draft text and replaces it with the final plan', () => {
const draftBlocks = appendPlanDraftDelta([], '年度趋势分析\n')
const updatedDraftBlocks = appendPlanDraftDelta(draftBlocks, '1. 按季度检索\n')
assert.equal(updatedDraftBlocks.length, 1)
assert.equal(updatedDraftBlocks[0]?.type, 'plan_draft')
assert.equal(updatedDraftBlocks[0]?.text, '年度趋势分析\n1. 按季度检索\n')
const finalBlocks = replacePlanDraftWithPlan(updatedDraftBlocks, planBlock)
assert.equal(finalBlocks.length, 1)
assert.equal(finalBlocks[0]?.type, 'plan')
if (finalBlocks[0]?.type === 'plan') {
assert.equal(finalBlocks[0].status, 'created')
assert.equal(finalBlocks[0].displayText, '年度趋势分析\n1. 按季度检索')
}
})
it('removes skipped plan draft blocks', () => {
const draftBlocks = appendPlanDraftDelta([], '无法校验的计划草稿')
const finalBlocks = removePlanDraftBlocks(draftBlocks)
assert.deepEqual(finalBlocks, [])
})
it('replaces plan drafts in place without moving validation blocks', () => {
const blocks = [
{ type: 'think' as const, tag: 'thinking', text: '准备计划' },
{ type: 'plan_draft' as const, version: 1 as const, status: 'streaming' as const, text: '正式计划' },
{ type: 'think' as const, tag: 'plan_validation', text: '{"steps":[]}' },
]
const finalBlocks = replacePlanDraftWithPlan(blocks, planBlock)
assert.deepEqual(
finalBlocks.map((block) => block.type),
['think', 'plan', 'think']
)
assert.equal(finalBlocks[1]?.type, 'plan')
if (finalBlocks[1]?.type === 'plan') {
assert.equal(finalBlocks[1].displayText, '正式计划')
}
assert.equal(finalBlocks[2]?.type, 'think')
if (finalBlocks[2]?.type === 'think') {
assert.equal(finalBlocks[2].tag, 'plan_validation')
}
})
})
+57
View File
@@ -21,12 +21,69 @@ export interface PlanContentBlock {
version: 1
status: PlanBlockStatus
plan: PlanSummary
displayText?: string
}
export interface PlanDraftContentBlock {
type: 'plan_draft'
version: 1
status: 'streaming'
text: string
}
export type PlanLikeContentBlock = PlanContentBlock | PlanDraftContentBlock
type BlockWithType = { type: string }
export function toPlanContentBlock(block: PlanContentBlock): PlanContentBlock {
return JSON.parse(JSON.stringify(block)) as PlanContentBlock
}
function cloneBlock<T>(block: T): T {
return JSON.parse(JSON.stringify(block)) as T
}
function isPlanDraftBlock(block: BlockWithType | undefined): block is PlanDraftContentBlock {
return block?.type === 'plan_draft' && typeof (block as { text?: unknown }).text === 'string'
}
export function appendPlanDraftDelta<T extends BlockWithType>(
blocks: readonly T[],
delta: string
): Array<T | PlanDraftContentBlock> {
const nextBlocks = blocks.map(cloneBlock) as Array<T | PlanDraftContentBlock>
const lastBlock = nextBlocks[nextBlocks.length - 1]
if (isPlanDraftBlock(lastBlock)) {
lastBlock.text += delta
} else {
nextBlocks.push({ type: 'plan_draft', version: 1, status: 'streaming', text: delta })
}
return nextBlocks
}
export function replacePlanDraftWithPlan<T extends BlockWithType>(
blocks: readonly T[],
plan: PlanContentBlock
): Array<T | PlanContentBlock> {
const nextBlocks = blocks.map(cloneBlock) as Array<T | PlanContentBlock>
for (let index = nextBlocks.length - 1; index >= 0; index--) {
const block = nextBlocks[index]
if (isPlanDraftBlock(block)) {
const displayText = block.text.trim()
nextBlocks[index] = {
...toPlanContentBlock(plan),
...(displayText ? { displayText } : {}),
}
return nextBlocks
}
}
nextBlocks.push(toPlanContentBlock(plan))
return nextBlocks
}
export function removePlanDraftBlocks<T extends BlockWithType>(blocks: readonly T[]): T[] {
return blocks.filter((block) => block.type !== 'plan_draft').map(cloneBlock)
}
export function updateLastPlanBlockStatus(
blocks: readonly PlanContentBlock[],
status: PlanBlockStatus
+49 -3
View File
@@ -25,7 +25,14 @@ import {
toChartContentBlocks,
toRenderOnlyToolErrorBlock,
} from './aiChatChartBlocks'
import { toPlanContentBlock, type PlanBlockStatus, type PlanContentBlock } from '@/services/ai/planBlocks'
import {
appendPlanDraftDelta,
removePlanDraftBlocks,
replacePlanDraftWithPlan,
type PlanBlockStatus,
type PlanContentBlock,
type PlanDraftContentBlock,
} from '@/services/ai/planBlocks'
// 工具调用记录
export interface ToolCallRecord {
@@ -59,6 +66,7 @@ export type ContentBlock =
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { type: 'chart'; chart: ChartPayload }
| PlanContentBlock
| PlanDraftContentBlock
| {
type: 'tool'
tool: ToolBlockContent
@@ -607,7 +615,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
appendTextToBlocks: (text: string) => void
appendThinkToBlocks: (text: string, tag?: string, durationMs?: number) => void
appendChartsToBlocks: (charts: ChartPayload[]) => void
appendPlanDraftToBlocks: (delta: string) => void
appendPlanToBlocks: (plan: PlanContentBlock) => void
removePlanDraftsFromBlocks: () => void
updatePlanBlockStatus: (status: PlanBlockStatus) => void
appendErrorToBlocks: (error: SerializedErrorInfo) => void
addToolBlock: (toolName: string, params?: Record<string, unknown>) => void
@@ -669,11 +679,23 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateAIMessage({ contentBlocks: [...blocks] })
}
const appendPlanDraftToBlocks = (delta: string) => {
if (!delta) return
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
updateAIMessage({ contentBlocks: [...appendPlanDraftDelta(blocks, delta)] })
}
const appendPlanToBlocks = (plan: PlanContentBlock) => {
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
blocks.push(toPlanContentBlock(plan))
updateAIMessage({ contentBlocks: [...blocks] })
updateAIMessage({ contentBlocks: [...replacePlanDraftWithPlan(blocks, plan)] })
}
const removePlanDraftsFromBlocks = () => {
const idx = getAiMessageIndex()
const blocks = targetBuffer.messages[idx].contentBlocks || []
updateAIMessage({ contentBlocks: [...removePlanDraftBlocks(blocks)] })
}
const updatePlanBlockStatus = (status: PlanBlockStatus) => {
@@ -721,7 +743,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
appendTextToBlocks,
appendThinkToBlocks,
appendChartsToBlocks,
appendPlanDraftToBlocks,
appendPlanToBlocks,
removePlanDraftsFromBlocks,
updatePlanBlockStatus,
appendErrorToBlocks,
addToolBlock,
@@ -839,7 +863,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
appendTextToBlocks,
appendThinkToBlocks,
appendChartsToBlocks,
appendPlanDraftToBlocks,
appendPlanToBlocks,
removePlanDraftsFromBlocks,
updatePlanBlockStatus,
appendErrorToBlocks,
addToolBlock,
@@ -966,6 +992,12 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
state.isLoadingSource = false
break
case 'plan_delta':
if (chunk.planDelta) {
appendPlanDraftToBlocks(chunk.planDelta)
}
break
case 'plan':
if (chunk.plan) {
appendPlanToBlocks(chunk.plan)
@@ -973,6 +1005,10 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
break
case 'plan_skipped':
removePlanDraftsFromBlocks()
break
case 'status':
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
@@ -1008,6 +1044,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
break
case 'error':
removePlanDraftsFromBlocks()
updatePlanBlockStatus('skipped')
if (state.currentToolStatus) {
state.currentToolStatus = {
@@ -1399,7 +1436,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
appendTextToBlocks,
appendThinkToBlocks,
appendChartsToBlocks,
appendPlanDraftToBlocks,
appendPlanToBlocks,
removePlanDraftsFromBlocks,
updatePlanBlockStatus,
appendErrorToBlocks,
addToolBlock,
@@ -1506,12 +1545,18 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
state.currentToolStatus = null
state.isLoadingSource = false
break
case 'plan_delta':
if (chunk.planDelta) appendPlanDraftToBlocks(chunk.planDelta)
break
case 'plan':
if (chunk.plan) {
appendPlanToBlocks(chunk.plan)
updatePlanBlockStatus('executing')
}
break
case 'plan_skipped':
removePlanDraftsFromBlocks()
break
case 'status':
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
@@ -1524,6 +1569,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
setAgentPhase(state, 'completed', chunk.usage ? { totalUsage: chunk.usage } : undefined)
break
case 'error': {
removePlanDraftsFromBlocks()
updatePlanBlockStatus('skipped')
hasStreamError = true
if (state.currentToolStatus) updateToolBlockStatus(state.currentToolStatus.name, 'error')