From d5b52b35ac90e8baffd7ddbab4338cff746ffcef Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Wed, 29 Apr 2026 15:23:22 +0800 Subject: [PATCH] Version: 0.9.55.dev.260429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: 1. analyze_health 候选复诊改为轻量 after brief 口径,候选模拟执行后只展示基础诊断结论,不再递归触发全局候选枚举,避免 score-only 评估阶段重复扫描和误判继续优化。 前端: 2. thinking_summary 支持同一条回复内多轮思考块——按 backendKey 维护独立阶段状态,final 后再次收到摘要会新建 reasoning block,避免多轮思考被合并、去重状态互相吞掉。 3. timeline 历史恢复改为复用后端事件 seq,正文、工具、状态、思考块都按原始顺序回放,减少刷新后消息块错位、插队和布局跳变。 4. 思考流式态下沉到当前活跃 reasoning block,只让正在输出的思考块闪光、显示游标和接收逐字追加,旧思考块完成后稳定折叠,不再被新一轮思考“复活”。 5. 清理助手消息重置逻辑,补齐 reasoning block、短摘要、耗时、折叠态和流式队列的状态回收,降低连续会话/重发时的残留干扰。 --- .../schedule/analyze_health_candidates.go | 34 ++- .../components/dashboard/AssistantPanel.vue | 257 +++++++++++++----- 2 files changed, 223 insertions(+), 68 deletions(-) diff --git a/backend/newAgent/tools/schedule/analyze_health_candidates.go b/backend/newAgent/tools/schedule/analyze_health_candidates.go index 2b97e9d..decd3df 100644 --- a/backend/newAgent/tools/schedule/analyze_health_candidates.go +++ b/backend/newAgent/tools/schedule/analyze_health_candidates.go @@ -206,6 +206,38 @@ func buildAnalyzeHealthFinalDecisionBrief( 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 选择当前最值得处理的局部问题。 func pickPrimaryHealthProblem(state *ScheduleState, snapshot analyzeHealthSnapshot) (analyzeHealthProblem, bool) { best := analyzeHealthProblem{} @@ -868,7 +900,7 @@ func evaluateHealthCandidateOutcome( operation string, moveCost int, ) (string, int, analyzeHealthDecisionBase, bool) { - afterDecision := buildAnalyzeHealthFinalDecisionBrief(afterState, after) + afterDecision := buildAnalyzeHealthCandidateAfterBrief(afterState, after) effect, score, ok := evaluateHealthCandidateScoreOnly( baseline, after, diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index d9e983d..3b9d597 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -187,6 +187,13 @@ interface AssistantContentBlock { text: string } +interface ThinkingSummaryBlockState { + blockId: string + lastSummarySeq: number + lastSignature: string + finished: boolean +} + const props = withDefaults( defineProps<{ initialHistoryWidth?: number @@ -258,6 +265,7 @@ const reasoningCollapsedMap = reactive>({}) const reasoningStartedAtMap = reactive>({}) const reasoningCurrentShortSummaryMap = reactive>({}) const reasoningDurationMap = reactive>({}) +const activeReasoningBlockIdMap = reactive>({}) const confirmOnlyStreamMap = reactive>({}) const confirmVisiblePrefixMap = reactive>({}) const toolTraceEventsMap = reactive>({}) @@ -275,10 +283,8 @@ const scheduleResultMap = reactive>({}) const scheduleResultSeqMap = reactive>({}) const businessCardEventsMap = reactive>({}) -// thinking_summary 协议:每条 message 唯一一个 reasoning blockId -const messageThinkingBlockIdMap = reactive>({}) -// summary_seq 按 messageId + backendKey(block_id|stage|'thinking') 维度去重 -const thinkingSummarySeqMap = reactive>>({}) +// thinking_summary 协议:同一条 message 可能发生多轮思考,每轮都需要独立 reasoning block。 +const thinkingSummaryBlockStateMap = reactive>>({}) // 长摘要逐字流式状态:用普通 Map 存储,避免每字符触发 reactive 收集 interface ThinkingStreamState { queue: string; timerId: number | null } @@ -742,12 +748,13 @@ function clearToolTraceState(messageId: string) { delete statusTraceEventsMap[messageId] delete assistantReasoningSeqMap[messageId] delete assistantContentBlocksMap[messageId] + delete assistantReasoningBlocksMap[messageId] + delete activeReasoningBlockIdMap[messageId] delete assistantTimelineLastKindMap[messageId] delete scheduleResultMap[messageId] delete scheduleResultSeqMap[messageId] delete businessCardEventsMap[messageId] - delete messageThinkingBlockIdMap[messageId] - delete thinkingSummarySeqMap[messageId] + delete thinkingSummaryBlockStateMap[messageId] for (const key of Object.keys(toolTraceExpandedMap)) { if (key.startsWith(`${messageId}:tool:`)) { delete toolTraceExpandedMap[key] @@ -764,6 +771,18 @@ function clearToolTraceState(messageId: string) { 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( @@ -774,6 +793,7 @@ function appendToolTraceEvent( toolName = '', argumentView?: ToolView, resultView?: ToolView, + seq?: number, ) { const normalizedSummary = summary.trim() if (!normalizedSummary) { @@ -800,7 +820,7 @@ function appendToolTraceEvent( if (resultView) matchedPendingEvent.resultView = resultView return } - const eventSeq = nextAssistantTimelineSeq() + const eventSeq = typeof seq === 'number' && seq > 0 ? seq : nextAssistantTimelineSeq() const eventId = `${messageId}:tool:${eventSeq}` // 如果上一个阶段是推理,则结束并折叠它 @@ -898,6 +918,7 @@ function appendStatusTraceEvent( code: string, summary: string, stage = '', + seq?: number, ) { const normalizedSummary = summary.trim() if (!normalizedSummary) { @@ -915,7 +936,7 @@ function appendStatusTraceEvent( return } - const eventSeq = nextAssistantTimelineSeq() + const eventSeq = typeof seq === 'number' && seq > 0 ? seq : nextAssistantTimelineSeq() // 如果上一个阶段是推理,则结束并折叠它 if (assistantTimelineLastKindMap[messageId] === 'reasoning') { finishCurrentReasoningBlock(messageId) @@ -931,7 +952,7 @@ function appendStatusTraceEvent( assistantTimelineLastKindMap[messageId] = 'status' } -function appendAssistantContentChunk(messageId: string, chunk: string) { +function appendAssistantContentChunk(messageId: string, chunk: string, seq?: number) { if (!chunk) { return } @@ -949,10 +970,10 @@ function appendAssistantContentChunk(messageId: string, chunk: string) { return } - const seq = nextAssistantTimelineSeq() + const blockSeq = typeof seq === 'number' && seq > 0 ? seq : nextAssistantTimelineSeq() blocks.push({ - id: `${messageId}:content:${seq}`, - seq, + id: `${messageId}:content:${blockSeq}`, + seq: blockSeq, text: chunk, }) assistantTimelineLastKindMap[messageId] = 'content' @@ -1063,64 +1084,140 @@ interface ApplyThinkingSummaryOptions { backendBlockId?: string stage?: string summary: ThinkingSummaryPayload + /** 历史回放时使用 timeline 事件自身顺序;实时流未传时按真实到达顺序分配 */ + eventSeq?: number /** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入 */ fromHistory?: boolean } -/** - * 把后端 thinking_summary 事件(实时或历史)落到现有 reasoning UI 上: - * - 多 block_id 合并到该消息的同一个 reasoning block(友商风格) - * - 短摘要覆盖式更新(仅实时流;历史不恢复) - * - 长摘要 append(实时走逐字流式,历史一次性写入) - * - summary_seq 按 backendKey 维度去重,乱序/重复事件丢弃 - */ -function applyThinkingSummary(messageId: string, opts: ApplyThinkingSummaryOptions) { +function getThinkingBackendKey(opts: Pick) { const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim() - const backendKey = backendKeyRaw || 'thinking' + return backendKeyRaw || 'thinking' +} - // 1. 找/建该消息唯一的 reasoning block - let frontendBlockId = messageThinkingBlockIdMap[messageId] - if (!frontendBlockId) { - if (!assistantReasoningBlocksMap[messageId]) { - assistantReasoningBlocksMap[messageId] = [] - } - const seq = assistantReasoningSeqMap[messageId] || nextAssistantTimelineSeq() - frontendBlockId = opts.fromHistory ? `${messageId}:reasoning:${seq}` : getPendingAssistantIndicatorId(messageId) - assistantReasoningBlocksMap[messageId].push({ id: frontendBlockId, seq, text: '' }) - reasoningStartedAtMap[frontendBlockId] = Date.now() - reasoningCollapsedMap[frontendBlockId] = true - messageThinkingBlockIdMap[messageId] = frontendBlockId - // 写入 seq 索引:保持 pending 小圆点与真实 reasoning 使用同一顺序,替换时不触发布局重排。 - if (!assistantReasoningSeqMap[messageId]) { - assistantReasoningSeqMap[messageId] = seq - } - assistantTimelineLastKindMap[messageId] = 'reasoning' +function buildThinkingSummarySignature(summary: ThinkingSummaryPayload) { + return [ + typeof summary.summary_seq === 'number' ? summary.summary_seq : '', + (summary.short_summary || '').trim(), + (summary.detail_summary || '').trim(), + typeof summary.duration_seconds === 'number' ? summary.duration_seconds : '', + summary.final === true ? '1' : '0', + ].join('\u001f') +} + +function ensureThinkingSummaryBlockBucket(messageId: string) { + if (!thinkingSummaryBlockStateMap[messageId]) { + thinkingSummaryBlockStateMap[messageId] = {} + } + return thinkingSummaryBlockStateMap[messageId] +} + +function createThinkingReasoningBlock(messageId: string, seq: number) { + if (!assistantReasoningBlocksMap[messageId]) { + assistantReasoningBlocksMap[messageId] = [] } - // 2. summary_seq 按 backendKey 去重 - if (!thinkingSummarySeqMap[messageId]) thinkingSummarySeqMap[messageId] = {} - const lastSeq = thinkingSummarySeqMap[messageId][backendKey] || 0 - const seqRaw = opts.summary.summary_seq - const incomingSeq = typeof seqRaw === 'number' ? seqRaw : lastSeq + 1 - if (incomingSeq <= lastSeq) return - thinkingSummarySeqMap[messageId][backendKey] = incomingSeq + const frontendBlockId = `${messageId}:reasoning:${seq}` + assistantReasoningBlocksMap[messageId].push({ id: frontendBlockId, seq, text: '' }) + reasoningStartedAtMap[frontendBlockId] = Date.now() + reasoningCollapsedMap[frontendBlockId] = true + activeReasoningBlockIdMap[messageId] = frontendBlockId - // 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) { thinkingMessageMap[messageId] = opts.summary.final !== true } - // 4. 短摘要覆盖(仅实时流) + // 3. 短摘要覆盖(仅实时流) if (!opts.fromHistory) { const shortText = (opts.summary.short_summary || '').trim() if (shortText) reasoningCurrentShortSummaryMap[frontendBlockId] = shortText } - // 5. 长摘要:实时走逐字流式队列;历史一次性写入 + // 4. 长摘要:实时走逐字流式队列;历史一次性写入。 const detailText = (opts.summary.detail_summary || '').trim() if (detailText) { if (opts.fromHistory) { - const block = assistantReasoningBlocksMap[messageId].find(b => b.id === frontendBlockId) + const block = assistantReasoningBlocksMap[messageId].find((b) => b.id === frontendBlockId) if (block) { 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') { reasoningDurationMap[frontendBlockId] = Math.max(1, Math.round(opts.summary.duration_seconds)) } - // 7. final 收口(仅实时流;先 flush 残留再 mark) - if (!opts.fromHistory && opts.summary.final === true) { + // 6. final 收口:先 flush 残留再 mark,后续同 backendKey 摘要会开启新的思考块。 + if (opts.summary.final === true) { flushThinkingStream(messageId, frontendBlockId) markReasoningFinished(frontendBlockId, messageId) } @@ -1656,7 +1753,11 @@ function markReasoningFinished(blockId: string, messageId: string) { if (startedAt && !reasoningDurationMap[blockId]) { 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) { @@ -1679,7 +1780,7 @@ function getReasoningDurationSeconds(blockId: string) { } function getReasoningStatusLabel(block: DisplayAssistantBlock) { - const isThinking = block.sourceId === activeStreamingMessageId.value && thinkingMessageMap[block.sourceId] + const isThinking = isReasoningBlockActive(block) if (isThinking) { // 状态栏显示当前阶段的短摘要 @@ -1690,6 +1791,22 @@ function getReasoningStatusLabel(block: DisplayAssistantBlock) { 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. 计算耗时 @@ -1699,7 +1816,8 @@ function finishCurrentReasoningBlock(messageId: string) { const blocks = assistantReasoningBlocksMap[messageId] || [] if (blocks.length === 0) return const lastBlock = blocks[blocks.length - 1] - + + flushThinkingStream(messageId, lastBlock.id) markReasoningFinished(lastBlock.id, messageId) reasoningCollapsedMap[lastBlock.id] = true } @@ -1870,11 +1988,16 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[] const sortedBlocks = blocks.sort((left, right) => left.seq - right.seq) - // 核心修复:确保全消息流中只有一个点。 - // 只有当整个 DisplayMessage 处于流式状态,且当前块是最后一块时,才标记为 isStreaming。 + // 1. reasoning 的流式状态下沉到具体 block,避免新一轮思考把旧 reasoning block 一起点亮。 + // 2. 没有活跃 reasoning 时,仍沿用“最后一个块流式输出”的正文展示逻辑。 if (isDisplayStreaming(dm) && sortedBlocks.length > 0) { - const lastBlock = sortedBlocks[sortedBlocks.length - 1] as any - lastBlock.isStreaming = true + const activeReasoningBlock = sortedBlocks.find((block) => isReasoningBlockActive(block)) as any + if (activeReasoningBlock) { + activeReasoningBlock.isStreaming = true + } else { + const lastBlock = sortedBlocks[sortedBlocks.length - 1] as any + lastBlock.isStreaming = true + } } return sortedBlocks @@ -2389,7 +2512,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ if (chunk) { currentAssistantMessage.content += chunk // 同时存入 blocks 以支持和工具交错显示 - appendAssistantContentChunk(mid, chunk) + appendAssistantContentChunk(mid, chunk, event.seq) } } break @@ -2397,14 +2520,14 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ case 'tool_call': if (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 case 'tool_result': if (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 @@ -2451,6 +2574,7 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[ duration_seconds: payload.duration_seconds, final: payload.final, }, + eventSeq: event.seq, fromHistory: true, }) break @@ -2918,10 +3042,10 @@ function prepareAssistantMessageForStreaming(message: AssistantMessage, createdA delete reasoningStartedAtMap[message.id] delete reasoningDurationMap[message.id] clearToolTraceState(message.id) - assistantReasoningSeqMap[message.id] = nextAssistantTimelineSeq() toolTraceEventsMap[message.id] = [] statusTraceEventsMap[message.id] = [] assistantContentBlocksMap[message.id] = [] + assistantReasoningBlocksMap[message.id] = [] assistantTimelineLastKindMap[message.id] = 'other' } @@ -3296,8 +3420,7 @@ async function sendMessageInternal(options: SendMessageOptions = {}) { }) // thinking 态由 applyThinkingSummary 在第一段 thinking_summary 到达时接管, - // 发送瞬间不预设,避免空占位 block 闪现后被真实 block 替换。 - assistantReasoningSeqMap[assistantMessage.id] = nextAssistantTimelineSeq() + // 发送瞬间不预设 seq,避免后到的思考块排到先到的日志/工具事件前面。 reasoningCollapsedMap[assistantMessage.id] = true activeStreamingMessageId.value = assistantMessage.id shouldAutoFollowMessages.value = true @@ -3658,7 +3781,7 @@ onBeforeUnmount(() => {
{ :class="{ 'chat-message__markdown--streaming': (block as any).isStreaming }" v-html="renderMessageMarkdown(block.text || '', (block as any).isStreaming)" /> -
+