From f4890d02b51806ab70b61a648a1526f4af86bebb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 16 Sep 2026 01:02:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=8E=86=E5=8F=B2=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E4=B8=8D=E6=B8=B2=E6=9F=93=E6=B5=81=EF=BC=9A=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=8E=E5=9B=9E=E5=90=88=E6=8C=89=E4=BD=8D=E7=BD=AE=E9=85=8D?= =?UTF-8?q?=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:流的 key 是 turnId,而持久化历史消息的 id 是 Codex 原始 id(如 a0e105c1-…),前端用 direct-codex::assistant 合成 id 配对永远失败 → 有流的回合既不渲染流也不渲染工具(实机 DOM:groups=0,只有 user+assistant×2) - 修法:回合按首次出现时间排序后与用户消息按位置配对(第 N 个回合 ↔ 第 N 个用户消息);流序列渲染在该用户消息之后,该回合的 assistant 消息交给流(避免文本重复);没配上用户消息的回合兜底渲染在列表末尾 - 保护:若流的文本明显短于持久化 assistant 原文(老数据被截断过),流只渲染工具块(toolsOnly),正文仍由消息渲染,避免丢内容 - tsc 0 错误 --- .../ProjectSupervisorView.tsx | 114 ++++++++++++++++-- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 73b987cf8..22ed7d4c0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -168,9 +168,12 @@ function TurnStreamSequence({ active, userSentAt, className, + toolsOnly = false, }: { items: readonly TurnStreamItem[]; toolCalls: readonly GameCreatorDirectToolCall[]; + /** 只渲染工具块:当流的文本不完整(老数据被截断)时用,正文交给持久化消息渲染。 */ + toolsOnly?: boolean; /** 这个回合是否正在跑(决定工具行显示"执行中")。 */ active: boolean; userSentAt: number; @@ -188,6 +191,7 @@ function TurnStreamSequence({ <> {runs.map((run, index) => run.kind === 'text' ? ( + toolsOnly ? null : (
+ ) ) : ( ({ turnId, calls })); + // 历史消息的 id 是 Codex 原始 id(不是 `direct-codex::...`),无法从 id 反解回合; + // 因此按位置配对:回合按首次出现时间排序后,第 N 个回合 ↔ 第 N 个用户消息。 + const turnFirstAt = (turnId: string) => { + let first = Number.POSITIVE_INFINITY; + for (const item of turnStreamByTurn.get(turnId) ?? []) { + first = Math.min(first, Number(item.at) || Number.POSITIVE_INFINITY); + } + for (const call of toolCalls) { + if (call.turnId === turnId) { + first = Math.min(first, Number(call.startedAt) || Number.POSITIVE_INFINITY); + } + } + return Number.isFinite(first) ? first : 0; + }; + const orderedStreamTurnIds = [ + ...new Set([ + ...turnStreamByTurn.keys(), + ...toolCalls.map((call) => call.turnId ?? ''), + ]), + ] + .filter((turnId) => Boolean(turnId.trim())) + .sort((left, right) => turnFirstAt(left) - turnFirstAt(right)); + const turnIdByMessageIndex = new Map(); + { + let userIndex = -1; + visibleMessages.forEach((message, index) => { + if (message.role === 'user') { + userIndex += 1; + } + const turnId = orderedStreamTurnIds[userIndex]; + if (turnId && userIndex >= 0) { + turnIdByMessageIndex.set(index, turnId); + } + }); + } + // 每个回合的 assistant 原文长度:用于判断流的文本是否完整(老数据可能被截断)。 + const assistantTextLengthByTurn = new Map(); + visibleMessages.forEach((message, index) => { + if (message.role !== 'assistant') { + return; + } + const turnId = turnIdByMessageIndex.get(index); + if (!turnId) { + return; + } + const length = projectSupervisorChatMessageText(message).length; + assistantTextLengthByTurn.set( + turnId, + (assistantTextLengthByTurn.get(turnId) ?? 0) + length, + ); + }); + const streamTextLengthByTurn = new Map(); + for (const [turnId, items] of turnStreamByTurn) { + let total = 0; + for (const item of items) { + if (item.kind === 'text') { + total += item.text?.length ?? 0; + } + } + streamTextLengthByTurn.set(turnId, total); + } + const mappedStreamTurnIds = new Set(turnIdByMessageIndex.values()); + const unmappedStreamTurns = orderedStreamTurnIds.filter( + (turnId) => + !mappedStreamTurnIds.has(turnId) && + (turnStreamByTurn.get(turnId)?.length ?? 0) > 0, + ); + // 该回合用户消息的 `updatedAt`:拿得到就在块头显示「发送 → 结束」,拿不到只显示结束时间。 const userMessageUpdatedAtForTurn = (turnId: string) => { if (!turnId) { @@ -684,11 +757,30 @@ export function ProjectSupervisorView({ const userTurnId = message.messageId ? directCodexTurnIdFromUserMessageId(message.messageId) : null; - const streamTurnIdForMessage = userTurnId ?? anchoredTurnId; + const streamTurnIdForMessage = + (userTurnId ?? anchoredTurnId) ?? + turnIdByMessageIndex.get(index) ?? + null; const turnStream = streamTurnIdForMessage ? (turnStreamByTurn.get(streamTurnIdForMessage) ?? []) : []; const streamCovered = turnStream.length > 0; + // 有流时只在用户消息这一格渲染流(用户消息 → 文本/工具交替,按 seq 顺序); + // 该回合的 assistant 消息内容已经在流里,跳过以免重复。 + const streamTextLength = streamTurnIdForMessage + ? (streamTextLengthByTurn.get(streamTurnIdForMessage) ?? 0) + : 0; + const assistantTextLength = streamTurnIdForMessage + ? (assistantTextLengthByTurn.get(streamTurnIdForMessage) ?? 0) + : 0; + // 流的文本明显短于持久化原文 → 视为不完整:流只画工具块,正文由消息渲染。 + const streamTextIncomplete = + streamCovered && + assistantTextLength > 0 && + streamTextLength < assistantTextLength * 0.8; + const ownsStream = streamCovered && message.role === 'user'; + const suppressForStream = + streamCovered && message.role !== 'user' && !streamTextIncomplete; const nextTurnId = index + 1 < visibleMessages.length && visibleMessages[index + 1]?.messageId @@ -714,10 +806,11 @@ export function ProjectSupervisorView({ ) : null} {/* 有回合流的回合:assistant 消息这一格交给流自己渲染——文本段 → 工具块 → 文本段 → 最终回复,按条目的 `seq` 顺序出现,不再把工具抽到消息前面。 */} - {streamCovered ? ( + {suppressForStream ? null : ownsStream ? ( ); })} + {unmappedStreamTurns.map((turnId) => ( + + ))} {unanchoredToolCalls.map(({ turnId, calls }) => (