Version: 0.9.55.dev.260429

后端:
1. analyze_health 候选复诊改为轻量 after brief 口径,候选模拟执行后只展示基础诊断结论,不再递归触发全局候选枚举,避免 score-only 评估阶段重复扫描和误判继续优化。

前端:
2. thinking_summary 支持同一条回复内多轮思考块——按 backendKey 维护独立阶段状态,final 后再次收到摘要会新建 reasoning block,避免多轮思考被合并、去重状态互相吞掉。
3. timeline 历史恢复改为复用后端事件 seq,正文、工具、状态、思考块都按原始顺序回放,减少刷新后消息块错位、插队和布局跳变。
4. 思考流式态下沉到当前活跃 reasoning block,只让正在输出的思考块闪光、显示游标和接收逐字追加,旧思考块完成后稳定折叠,不再被新一轮思考“复活”。
5. 清理助手消息重置逻辑,补齐 reasoning block、短摘要、耗时、折叠态和流式队列的状态回收,降低连续会话/重发时的残留干扰。
This commit is contained in:
Losita
2026-04-29 15:23:22 +08:00
parent bdf38f2f8d
commit d5b52b35ac
2 changed files with 223 additions and 68 deletions

View File

@@ -206,6 +206,38 @@ func buildAnalyzeHealthFinalDecisionBrief(
return decision return decision
} }
// buildAnalyzeHealthCandidateAfterBrief 生成候选执行后的轻量复诊摘要。
//
// 职责边界:
// 1. 只负责为 candidate.after / candidate.summary 提供“执行后看起来如何”的展示结论;
// 2. 不负责再次枚举 move/swap 候选,也不决定顶层 analyze_health 是否继续优化;
// 3. 输入是已经模拟执行后的 state/snapshot输出沿用 analyzeHealthDecisionBase 的字段语义。
func buildAnalyzeHealthCandidateAfterBrief(
state *ScheduleState,
snapshot analyzeHealthSnapshot,
) analyzeHealthDecisionBase {
base := buildAnalyzeHealthDecisionBase(state, snapshot)
decision := analyzeHealthDecisionBase{
ShouldContinueOptimize: base.ShouldContinueOptimize,
PrimaryProblem: base.PrimaryProblem,
ProblemScope: base.ProblemScope,
IsForcedImperfection: base.IsForcedImperfection,
RecommendedOperation: base.RecommendedOperation,
}
if !shouldEnterHealthCandidateLoop(base) {
decision.ShouldContinueOptimize = false
return decision
}
// 1. candidate.after 位于候选模拟内层,不能再跑全局候选枚举。
// 2. 顶层 buildAnalyzeHealthDecisionV2 已经负责严谨筛选“是否值得继续优化”;
// 这里保留基础诊断即可,避免每个候选递归触发 score-only 全局扫描。
// 3. 若仍存在基础诊断认为可优化的问题,则如实展示给前端和 LLM下一轮会再次调用
// analyze_health 做正式复诊,作为真正的收口依据。
return decision
}
// pickPrimaryHealthProblem 选择当前最值得处理的局部问题。 // pickPrimaryHealthProblem 选择当前最值得处理的局部问题。
func pickPrimaryHealthProblem(state *ScheduleState, snapshot analyzeHealthSnapshot) (analyzeHealthProblem, bool) { func pickPrimaryHealthProblem(state *ScheduleState, snapshot analyzeHealthSnapshot) (analyzeHealthProblem, bool) {
best := analyzeHealthProblem{} best := analyzeHealthProblem{}
@@ -868,7 +900,7 @@ func evaluateHealthCandidateOutcome(
operation string, operation string,
moveCost int, moveCost int,
) (string, int, analyzeHealthDecisionBase, bool) { ) (string, int, analyzeHealthDecisionBase, bool) {
afterDecision := buildAnalyzeHealthFinalDecisionBrief(afterState, after) afterDecision := buildAnalyzeHealthCandidateAfterBrief(afterState, after)
effect, score, ok := evaluateHealthCandidateScoreOnly( effect, score, ok := evaluateHealthCandidateScoreOnly(
baseline, baseline,
after, after,

View File

@@ -187,6 +187,13 @@ interface AssistantContentBlock {
text: string text: string
} }
interface ThinkingSummaryBlockState {
blockId: string
lastSummarySeq: number
lastSignature: string
finished: boolean
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
initialHistoryWidth?: number initialHistoryWidth?: number
@@ -258,6 +265,7 @@ const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
const reasoningStartedAtMap = reactive<Record<string, number>>({}) const reasoningStartedAtMap = reactive<Record<string, number>>({})
const reasoningCurrentShortSummaryMap = reactive<Record<string, string>>({}) const reasoningCurrentShortSummaryMap = reactive<Record<string, string>>({})
const reasoningDurationMap = reactive<Record<string, number>>({}) const reasoningDurationMap = reactive<Record<string, number>>({})
const activeReasoningBlockIdMap = reactive<Record<string, string>>({})
const confirmOnlyStreamMap = reactive<Record<string, boolean>>({}) const confirmOnlyStreamMap = reactive<Record<string, boolean>>({})
const confirmVisiblePrefixMap = reactive<Record<string, boolean>>({}) const confirmVisiblePrefixMap = reactive<Record<string, boolean>>({})
const toolTraceEventsMap = reactive<Record<string, ToolTraceEvent[]>>({}) const toolTraceEventsMap = reactive<Record<string, ToolTraceEvent[]>>({})
@@ -275,10 +283,8 @@ const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
const scheduleResultSeqMap = reactive<Record<string, number>>({}) const scheduleResultSeqMap = reactive<Record<string, number>>({})
const businessCardEventsMap = reactive<Record<string, TimelineBusinessCardPayload[]>>({}) const businessCardEventsMap = reactive<Record<string, TimelineBusinessCardPayload[]>>({})
// thinking_summary 协议:条 message 唯一一个 reasoning blockId // thinking_summary 协议:同一条 message 可能发生多轮思考,每轮都需要独立 reasoning block
const messageThinkingBlockIdMap = reactive<Record<string, string>>({}) const thinkingSummaryBlockStateMap = reactive<Record<string, Record<string, ThinkingSummaryBlockState>>>({})
// summary_seq 按 messageId + backendKey(block_id|stage|'thinking') 维度去重
const thinkingSummarySeqMap = reactive<Record<string, Record<string, number>>>({})
// 长摘要逐字流式状态:用普通 Map 存储,避免每字符触发 reactive 收集 // 长摘要逐字流式状态:用普通 Map 存储,避免每字符触发 reactive 收集
interface ThinkingStreamState { queue: string; timerId: number | null } interface ThinkingStreamState { queue: string; timerId: number | null }
@@ -742,12 +748,13 @@ function clearToolTraceState(messageId: string) {
delete statusTraceEventsMap[messageId] delete statusTraceEventsMap[messageId]
delete assistantReasoningSeqMap[messageId] delete assistantReasoningSeqMap[messageId]
delete assistantContentBlocksMap[messageId] delete assistantContentBlocksMap[messageId]
delete assistantReasoningBlocksMap[messageId]
delete activeReasoningBlockIdMap[messageId]
delete assistantTimelineLastKindMap[messageId] delete assistantTimelineLastKindMap[messageId]
delete scheduleResultMap[messageId] delete scheduleResultMap[messageId]
delete scheduleResultSeqMap[messageId] delete scheduleResultSeqMap[messageId]
delete businessCardEventsMap[messageId] delete businessCardEventsMap[messageId]
delete messageThinkingBlockIdMap[messageId] delete thinkingSummaryBlockStateMap[messageId]
delete thinkingSummarySeqMap[messageId]
for (const key of Object.keys(toolTraceExpandedMap)) { for (const key of Object.keys(toolTraceExpandedMap)) {
if (key.startsWith(`${messageId}:tool:`)) { if (key.startsWith(`${messageId}:tool:`)) {
delete toolTraceExpandedMap[key] delete toolTraceExpandedMap[key]
@@ -764,6 +771,18 @@ function clearToolTraceState(messageId: string) {
thinkingSummaryStreamMap.delete(blockId) thinkingSummaryStreamMap.delete(blockId)
} }
} }
for (const key of Object.keys(reasoningCollapsedMap)) {
if (key.startsWith(prefix)) delete reasoningCollapsedMap[key]
}
for (const key of Object.keys(reasoningStartedAtMap)) {
if (key.startsWith(prefix)) delete reasoningStartedAtMap[key]
}
for (const key of Object.keys(reasoningCurrentShortSummaryMap)) {
if (key.startsWith(prefix)) delete reasoningCurrentShortSummaryMap[key]
}
for (const key of Object.keys(reasoningDurationMap)) {
if (key.startsWith(prefix)) delete reasoningDurationMap[key]
}
} }
function appendToolTraceEvent( function appendToolTraceEvent(
@@ -774,6 +793,7 @@ function appendToolTraceEvent(
toolName = '', toolName = '',
argumentView?: ToolView, argumentView?: ToolView,
resultView?: ToolView, resultView?: ToolView,
seq?: number,
) { ) {
const normalizedSummary = summary.trim() const normalizedSummary = summary.trim()
if (!normalizedSummary) { if (!normalizedSummary) {
@@ -800,7 +820,7 @@ function appendToolTraceEvent(
if (resultView) matchedPendingEvent.resultView = resultView if (resultView) matchedPendingEvent.resultView = resultView
return return
} }
const eventSeq = nextAssistantTimelineSeq() const eventSeq = typeof seq === 'number' && seq > 0 ? seq : nextAssistantTimelineSeq()
const eventId = `${messageId}:tool:${eventSeq}` const eventId = `${messageId}:tool:${eventSeq}`
// 如果上一个阶段是推理,则结束并折叠它 // 如果上一个阶段是推理,则结束并折叠它
@@ -898,6 +918,7 @@ function appendStatusTraceEvent(
code: string, code: string,
summary: string, summary: string,
stage = '', stage = '',
seq?: number,
) { ) {
const normalizedSummary = summary.trim() const normalizedSummary = summary.trim()
if (!normalizedSummary) { if (!normalizedSummary) {
@@ -915,7 +936,7 @@ function appendStatusTraceEvent(
return return
} }
const eventSeq = nextAssistantTimelineSeq() const eventSeq = typeof seq === 'number' && seq > 0 ? seq : nextAssistantTimelineSeq()
// 如果上一个阶段是推理,则结束并折叠它 // 如果上一个阶段是推理,则结束并折叠它
if (assistantTimelineLastKindMap[messageId] === 'reasoning') { if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
finishCurrentReasoningBlock(messageId) finishCurrentReasoningBlock(messageId)
@@ -931,7 +952,7 @@ function appendStatusTraceEvent(
assistantTimelineLastKindMap[messageId] = 'status' assistantTimelineLastKindMap[messageId] = 'status'
} }
function appendAssistantContentChunk(messageId: string, chunk: string) { function appendAssistantContentChunk(messageId: string, chunk: string, seq?: number) {
if (!chunk) { if (!chunk) {
return return
} }
@@ -949,10 +970,10 @@ function appendAssistantContentChunk(messageId: string, chunk: string) {
return return
} }
const seq = nextAssistantTimelineSeq() const blockSeq = typeof seq === 'number' && seq > 0 ? seq : nextAssistantTimelineSeq()
blocks.push({ blocks.push({
id: `${messageId}:content:${seq}`, id: `${messageId}:content:${blockSeq}`,
seq, seq: blockSeq,
text: chunk, text: chunk,
}) })
assistantTimelineLastKindMap[messageId] = 'content' assistantTimelineLastKindMap[messageId] = 'content'
@@ -1063,64 +1084,140 @@ interface ApplyThinkingSummaryOptions {
backendBlockId?: string backendBlockId?: string
stage?: string stage?: string
summary: ThinkingSummaryPayload summary: ThinkingSummaryPayload
/** 历史回放时使用 timeline 事件自身顺序;实时流未传时按真实到达顺序分配 */
eventSeq?: number
/** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入 */ /** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入 */
fromHistory?: boolean fromHistory?: boolean
} }
/** function getThinkingBackendKey(opts: Pick<ApplyThinkingSummaryOptions, 'backendBlockId' | 'stage'>) {
* 把后端 thinking_summary 事件(实时或历史)落到现有 reasoning UI 上:
* - 多 block_id 合并到该消息的同一个 reasoning block友商风格
* - 短摘要覆盖式更新(仅实时流;历史不恢复)
* - 长摘要 append实时走逐字流式历史一次性写入
* - summary_seq 按 backendKey 维度去重,乱序/重复事件丢弃
*/
function applyThinkingSummary(messageId: string, opts: ApplyThinkingSummaryOptions) {
const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim() const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim()
const backendKey = backendKeyRaw || 'thinking' return backendKeyRaw || 'thinking'
}
// 1. 找/建该消息唯一的 reasoning block function buildThinkingSummarySignature(summary: ThinkingSummaryPayload) {
let frontendBlockId = messageThinkingBlockIdMap[messageId] return [
if (!frontendBlockId) { typeof summary.summary_seq === 'number' ? summary.summary_seq : '',
if (!assistantReasoningBlocksMap[messageId]) { (summary.short_summary || '').trim(),
assistantReasoningBlocksMap[messageId] = [] (summary.detail_summary || '').trim(),
} typeof summary.duration_seconds === 'number' ? summary.duration_seconds : '',
const seq = assistantReasoningSeqMap[messageId] || nextAssistantTimelineSeq() summary.final === true ? '1' : '0',
frontendBlockId = opts.fromHistory ? `${messageId}:reasoning:${seq}` : getPendingAssistantIndicatorId(messageId) ].join('\u001f')
assistantReasoningBlocksMap[messageId].push({ id: frontendBlockId, seq, text: '' }) }
reasoningStartedAtMap[frontendBlockId] = Date.now()
reasoningCollapsedMap[frontendBlockId] = true function ensureThinkingSummaryBlockBucket(messageId: string) {
messageThinkingBlockIdMap[messageId] = frontendBlockId if (!thinkingSummaryBlockStateMap[messageId]) {
// 写入 seq 索引:保持 pending 小圆点与真实 reasoning 使用同一顺序,替换时不触发布局重排。 thinkingSummaryBlockStateMap[messageId] = {}
if (!assistantReasoningSeqMap[messageId]) { }
assistantReasoningSeqMap[messageId] = seq return thinkingSummaryBlockStateMap[messageId]
} }
assistantTimelineLastKindMap[messageId] = 'reasoning'
function createThinkingReasoningBlock(messageId: string, seq: number) {
if (!assistantReasoningBlocksMap[messageId]) {
assistantReasoningBlocksMap[messageId] = []
} }
// 2. summary_seq 按 backendKey 去重 const frontendBlockId = `${messageId}:reasoning:${seq}`
if (!thinkingSummarySeqMap[messageId]) thinkingSummarySeqMap[messageId] = {} assistantReasoningBlocksMap[messageId].push({ id: frontendBlockId, seq, text: '' })
const lastSeq = thinkingSummarySeqMap[messageId][backendKey] || 0 reasoningStartedAtMap[frontendBlockId] = Date.now()
const seqRaw = opts.summary.summary_seq reasoningCollapsedMap[frontendBlockId] = true
const incomingSeq = typeof seqRaw === 'number' ? seqRaw : lastSeq + 1 activeReasoningBlockIdMap[messageId] = frontendBlockId
if (incomingSeq <= lastSeq) return
thinkingSummarySeqMap[messageId][backendKey] = incomingSeq
// 3. 思考态(仅实时流) // 1. 仅记录该消息最早的 reasoning seq用于旧的占位/合并判断兜底。
// 2. 真正展示顺序由每个 block 自己的 seq 决定,避免多轮思考互相挤到一起。
const currentSeq = assistantReasoningSeqMap[messageId]
if (typeof currentSeq !== 'number' || currentSeq <= 0 || seq < currentSeq) {
assistantReasoningSeqMap[messageId] = seq
}
assistantTimelineLastKindMap[messageId] = 'reasoning'
return frontendBlockId
}
function markThinkingSummaryBlockFinished(messageId: string, blockId: string) {
const bucket = thinkingSummaryBlockStateMap[messageId]
if (!bucket) return
Object.values(bucket).forEach((state) => {
if (state.blockId === blockId) {
state.finished = true
}
})
}
/**
* 把后端 thinking_summary 事件(实时或历史)落到现有 reasoning UI 上:
* - 同一 backendKey 连续摘要进入同一块backendKey 变化、final 后续写入或 summary_seq 重启都会新建块
* - 短摘要覆盖式更新(仅实时流;历史不恢复)
* - 长摘要 append实时走逐字流式历史一次性写入
* - summary_seq 只在当前思考阶段内去重,避免下一轮思考被上一轮的序号状态吞掉
*/
function applyThinkingSummary(messageId: string, opts: ApplyThinkingSummaryOptions) {
const backendKey = getThinkingBackendKey(opts)
const stateBucket = ensureThinkingSummaryBlockBucket(messageId)
const existingState = stateBucket[backendKey]
const seqRaw = opts.summary.summary_seq
const incomingSeq = typeof seqRaw === 'number' ? seqRaw : (existingState?.lastSummarySeq || 0) + 1
const signature = buildThinkingSummarySignature(opts.summary)
// 1. 同一阶段的完全重复事件直接丢弃;若序号回退但内容不同,视为后端开启了新一轮思考。
if (
existingState &&
incomingSeq <= existingState.lastSummarySeq &&
incomingSeq === existingState.lastSummarySeq &&
signature === existingState.lastSignature
) {
return
}
const existingBlock = existingState
? assistantReasoningBlocksMap[messageId]?.find((block) => block.id === existingState.blockId)
: null
const shouldStartNewBlock = !existingState ||
!existingBlock ||
existingState.finished ||
incomingSeq <= existingState.lastSummarySeq
let frontendBlockId = existingState?.blockId || ''
let currentState = existingState
if (shouldStartNewBlock) {
if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
finishCurrentReasoningBlock(messageId)
}
const blockSeq = typeof opts.eventSeq === 'number' && opts.eventSeq > 0
? opts.eventSeq
: nextAssistantTimelineSeq()
frontendBlockId = createThinkingReasoningBlock(messageId, blockSeq)
currentState = {
blockId: frontendBlockId,
lastSummarySeq: 0,
lastSignature: '',
finished: false,
}
stateBucket[backendKey] = currentState
}
if (!currentState) return
currentState.lastSummarySeq = incomingSeq
currentState.lastSignature = signature
// 2. 思考态(仅实时流)
if (!opts.fromHistory) { if (!opts.fromHistory) {
thinkingMessageMap[messageId] = opts.summary.final !== true thinkingMessageMap[messageId] = opts.summary.final !== true
} }
// 4. 短摘要覆盖(仅实时流) // 3. 短摘要覆盖(仅实时流)
if (!opts.fromHistory) { if (!opts.fromHistory) {
const shortText = (opts.summary.short_summary || '').trim() const shortText = (opts.summary.short_summary || '').trim()
if (shortText) reasoningCurrentShortSummaryMap[frontendBlockId] = shortText if (shortText) reasoningCurrentShortSummaryMap[frontendBlockId] = shortText
} }
// 5. 长摘要:实时走逐字流式队列;历史一次性写入 // 4. 长摘要:实时走逐字流式队列;历史一次性写入
const detailText = (opts.summary.detail_summary || '').trim() const detailText = (opts.summary.detail_summary || '').trim()
if (detailText) { if (detailText) {
if (opts.fromHistory) { if (opts.fromHistory) {
const block = assistantReasoningBlocksMap[messageId].find(b => b.id === frontendBlockId) const block = assistantReasoningBlocksMap[messageId].find((b) => b.id === frontendBlockId)
if (block) { if (block) {
block.text = block.text ? `${block.text}\n\n${detailText}` : detailText block.text = block.text ? `${block.text}\n\n${detailText}` : detailText
} }
@@ -1129,13 +1226,13 @@ function applyThinkingSummary(messageId: string, opts: ApplyThinkingSummaryOptio
} }
} }
// 6. 累计耗时 // 5. 累计耗时
if (typeof opts.summary.duration_seconds === 'number') { if (typeof opts.summary.duration_seconds === 'number') {
reasoningDurationMap[frontendBlockId] = Math.max(1, Math.round(opts.summary.duration_seconds)) reasoningDurationMap[frontendBlockId] = Math.max(1, Math.round(opts.summary.duration_seconds))
} }
// 7. final 收口(仅实时流;先 flush 残留再 mark // 6. final 收口先 flush 残留再 mark,后续同 backendKey 摘要会开启新的思考块。
if (!opts.fromHistory && opts.summary.final === true) { if (opts.summary.final === true) {
flushThinkingStream(messageId, frontendBlockId) flushThinkingStream(messageId, frontendBlockId)
markReasoningFinished(frontendBlockId, messageId) markReasoningFinished(frontendBlockId, messageId)
} }
@@ -1656,7 +1753,11 @@ function markReasoningFinished(blockId: string, messageId: string) {
if (startedAt && !reasoningDurationMap[blockId]) { if (startedAt && !reasoningDurationMap[blockId]) {
reasoningDurationMap[blockId] = Math.max(1, Math.round((Date.now() - startedAt) / 1000)) reasoningDurationMap[blockId] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
} }
thinkingMessageMap[messageId] = false if (activeReasoningBlockIdMap[messageId] === blockId) {
delete activeReasoningBlockIdMap[messageId]
thinkingMessageMap[messageId] = false
}
markThinkingSummaryBlockFinished(messageId, blockId)
// 若被展开,则思考完毕后自动闭合 // 若被展开,则思考完毕后自动闭合
if (reasoningCollapsedMap[blockId] === false) { if (reasoningCollapsedMap[blockId] === false) {
@@ -1679,7 +1780,7 @@ function getReasoningDurationSeconds(blockId: string) {
} }
function getReasoningStatusLabel(block: DisplayAssistantBlock) { function getReasoningStatusLabel(block: DisplayAssistantBlock) {
const isThinking = block.sourceId === activeStreamingMessageId.value && thinkingMessageMap[block.sourceId] const isThinking = isReasoningBlockActive(block)
if (isThinking) { if (isThinking) {
// 状态栏显示当前阶段的短摘要 // 状态栏显示当前阶段的短摘要
@@ -1690,6 +1791,22 @@ function getReasoningStatusLabel(block: DisplayAssistantBlock) {
return '已完成深度思考' return '已完成深度思考'
} }
function isReasoningBlockActive(block: DisplayAssistantBlock) {
const sourceId = block.sourceId || ''
if (!sourceId) {
return false
}
// 1. message 级 thinking 只表示“这条回复仍在思考”,不能直接套到所有 reasoning block。
// 2. 只有当前活跃 block 才允许闪光、显示游标和继续逐字更新,避免新一轮思考把旧块复活。
return (
block.type === 'reasoning' &&
sourceId === activeStreamingMessageId.value &&
thinkingMessageMap[sourceId] === true &&
activeReasoningBlockIdMap[sourceId] === block.id
)
}
/** /**
* 结束当前消息正在进行的推理块 * 结束当前消息正在进行的推理块
* 1. 计算耗时 * 1. 计算耗时
@@ -1699,7 +1816,8 @@ function finishCurrentReasoningBlock(messageId: string) {
const blocks = assistantReasoningBlocksMap[messageId] || [] const blocks = assistantReasoningBlocksMap[messageId] || []
if (blocks.length === 0) return if (blocks.length === 0) return
const lastBlock = blocks[blocks.length - 1] const lastBlock = blocks[blocks.length - 1]
flushThinkingStream(messageId, lastBlock.id)
markReasoningFinished(lastBlock.id, messageId) markReasoningFinished(lastBlock.id, messageId)
reasoningCollapsedMap[lastBlock.id] = true reasoningCollapsedMap[lastBlock.id] = true
} }
@@ -1870,11 +1988,16 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
const sortedBlocks = blocks.sort((left, right) => left.seq - right.seq) const sortedBlocks = blocks.sort((left, right) => left.seq - right.seq)
// 核心修复:确保全消息流中只有一个点 // 1. reasoning 的流式状态下沉到具体 block避免新一轮思考把旧 reasoning block 一起点亮
// 只有当整个 DisplayMessage 处于流式状态,且当前块是最后一块时,才标记为 isStreaming // 2. 没有活跃 reasoning 时,仍沿用“最后一个块流式输出”的正文展示逻辑
if (isDisplayStreaming(dm) && sortedBlocks.length > 0) { if (isDisplayStreaming(dm) && sortedBlocks.length > 0) {
const lastBlock = sortedBlocks[sortedBlocks.length - 1] as any const activeReasoningBlock = sortedBlocks.find((block) => isReasoningBlockActive(block)) as any
lastBlock.isStreaming = true if (activeReasoningBlock) {
activeReasoningBlock.isStreaming = true
} else {
const lastBlock = sortedBlocks[sortedBlocks.length - 1] as any
lastBlock.isStreaming = true
}
} }
return sortedBlocks return sortedBlocks
@@ -2389,7 +2512,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
if (chunk) { if (chunk) {
currentAssistantMessage.content += chunk currentAssistantMessage.content += chunk
// 同时存入 blocks 以支持和工具交错显示 // 同时存入 blocks 以支持和工具交错显示
appendAssistantContentChunk(mid, chunk) appendAssistantContentChunk(mid, chunk, event.seq)
} }
} }
break break
@@ -2397,14 +2520,14 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
case 'tool_call': case 'tool_call':
if (event.payload?.tool) { if (event.payload?.tool) {
const t = event.payload.tool const t = event.payload.tool
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view) appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view, event.seq)
} }
break break
case 'tool_result': case 'tool_result':
if (event.payload?.tool) { if (event.payload?.tool) {
const t = event.payload.tool const t = event.payload.tool
appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view) appendToolTraceEvent(mid, mapToolEventState(t.status), normalizeToolSummary(t), buildToolDetail(t), t.name, t.argument_view, t.result_view, event.seq)
} }
break break
@@ -2451,6 +2574,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
duration_seconds: payload.duration_seconds, duration_seconds: payload.duration_seconds,
final: payload.final, final: payload.final,
}, },
eventSeq: event.seq,
fromHistory: true, fromHistory: true,
}) })
break break
@@ -2918,10 +3042,10 @@ function prepareAssistantMessageForStreaming(message: AssistantMessage, createdA
delete reasoningStartedAtMap[message.id] delete reasoningStartedAtMap[message.id]
delete reasoningDurationMap[message.id] delete reasoningDurationMap[message.id]
clearToolTraceState(message.id) clearToolTraceState(message.id)
assistantReasoningSeqMap[message.id] = nextAssistantTimelineSeq()
toolTraceEventsMap[message.id] = [] toolTraceEventsMap[message.id] = []
statusTraceEventsMap[message.id] = [] statusTraceEventsMap[message.id] = []
assistantContentBlocksMap[message.id] = [] assistantContentBlocksMap[message.id] = []
assistantReasoningBlocksMap[message.id] = []
assistantTimelineLastKindMap[message.id] = 'other' assistantTimelineLastKindMap[message.id] = 'other'
} }
@@ -3296,8 +3420,7 @@ async function sendMessageInternal(options: SendMessageOptions = {}) {
}) })
// thinking 态由 applyThinkingSummary 在第一段 thinking_summary 到达时接管, // thinking 态由 applyThinkingSummary 在第一段 thinking_summary 到达时接管,
// 发送瞬间不预设,避免空占位 block 闪现后被真实 block 替换 // 发送瞬间不预设 seq避免后到的思考块排到先到的日志/工具事件前面
assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq()
reasoningCollapsedMap[assistantMessage.id] = true reasoningCollapsedMap[assistantMessage.id] = true
activeStreamingMessageId.value = assistantMessage.id activeStreamingMessageId.value = assistantMessage.id
shouldAutoFollowMessages.value = true shouldAutoFollowMessages.value = true
@@ -3658,7 +3781,7 @@ onBeforeUnmount(() => {
<div class="chat-message__reasoning-head"> <div class="chat-message__reasoning-head">
<div <div
class="chat-message__reasoning-title" class="chat-message__reasoning-title"
:class="{ 'chat-message__reasoning-title--shimmering': activeStreamingMessageId === block.sourceId && thinkingMessageMap[block.sourceId] }" :class="{ 'chat-message__reasoning-title--shimmering': isReasoningBlockActive(block) }"
> >
<span class="chat-message__reasoning-icon"> <span class="chat-message__reasoning-icon">
<svg <svg
@@ -3716,7 +3839,7 @@ onBeforeUnmount(() => {
:class="{ 'chat-message__markdown--streaming': (block as any).isStreaming }" :class="{ 'chat-message__markdown--streaming': (block as any).isStreaming }"
v-html="renderMessageMarkdown(block.text || '', (block as any).isStreaming)" v-html="renderMessageMarkdown(block.text || '', (block as any).isStreaming)"
/> />
<div v-else class="chat-message__streaming chat-message__streaming--reasoning"> <div v-else-if="isReasoningBlockActive(block)" class="chat-message__streaming chat-message__streaming--reasoning">
<div class="thinking-dot"></div> <div class="thinking-dot"></div>
</div> </div>
</div> </div>