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 }) => (