mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): stream structured analysis plans
This commit is contained in:
1 parent
9ad012680d
commit
0801ccf940
21 files changed
+669
-36
No files matched your search
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -110,6 +110,7 @@ export type {
|
||||
PlannerCapabilitySummary,
|
||||
PlannerInput,
|
||||
PlanContentBlock,
|
||||
PlanDraftContentBlock,
|
||||
} from './agent'
|
||||
|
||||
// Agent Event Handler
|
||||
|
||||
@@ -216,6 +216,7 @@ export type {
|
||||
PlannerCapabilitySummary,
|
||||
PlannerInput,
|
||||
PlanContentBlock,
|
||||
PlanDraftContentBlock,
|
||||
} from './ai'
|
||||
|
||||
// Summary generation
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"analysis": "Analysis",
|
||||
"reasoning": "Reasoning",
|
||||
"reflection": "Reflection",
|
||||
"planValidation": "Generate and validate steps",
|
||||
"other": "Thinking ({tag})"
|
||||
},
|
||||
"loading": "Thinking",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"analysis": "分析",
|
||||
"reasoning": "推論",
|
||||
"reflection": "振り返り",
|
||||
"planValidation": "手順の生成・検証",
|
||||
"other": "思考({tag})"
|
||||
},
|
||||
"loading": "思考中...",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"analysis": "分析",
|
||||
"reasoning": "推理",
|
||||
"reflection": "反思",
|
||||
"planValidation": "生成并校验步骤",
|
||||
"other": "思考({tag})"
|
||||
},
|
||||
"loading": "思考中",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"analysis": "分析",
|
||||
"reasoning": "推理",
|
||||
"reflection": "反思",
|
||||
"planValidation": "產生並校驗步驟",
|
||||
"other": "思考({tag})"
|
||||
},
|
||||
"loading": "思考中",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user