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') {