修历史回合不渲染流:消息与回合按位置配对
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- 根因:流的 key 是 turnId,而持久化历史消息的 id 是 Codex 原始 id(如 a0e105c1-…),前端用 direct-codex:<turnId>:assistant 合成 id 配对永远失败 → 有流的回合既不渲染流也不渲染工具(实机 DOM:groups=0,只有 user+assistant×2)
- 修法:回合按首次出现时间排序后与用户消息按位置配对(第 N 个回合 ↔ 第 N 个用户消息);流序列渲染在该用户消息之后,该回合的 assistant 消息交给流(避免文本重复);没配上用户消息的回合兜底渲染在列表末尾
- 保护:若流的文本明显短于持久化 assistant 原文(老数据被截断过),流只渲染工具块(toolsOnly),正文仍由消息渲染,避免丢内容
- tsc 0 错误
This commit is contained in:
2026-09-16 01:02:14 +08:00
parent 08eb475845
commit f4890d02b5
@@ -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 : (
<div
key={run.key}
className={className ? `message message--assistant ${className}` : 'message message--assistant'}
@@ -198,6 +202,7 @@ function TurnStreamSequence({
streaming={active && index === runs.length - 1}
/>
</div>
)
) : (
<ToolCallGroup
key={run.key}
@@ -489,6 +494,74 @@ export function ProjectSupervisorView({
return leftAt - rightAt;
})
.map(([turnId, calls]) => ({ turnId, calls }));
// 历史消息的 id 是 Codex 原始 id(不是 `direct-codex:<turnId>:...`),无法从 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<number, string>();
{
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<string, number>();
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<string, number>();
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 ? (
<TurnStreamSequence
items={turnStream}
toolCalls={toolCalls}
toolsOnly={streamTextIncomplete}
active={Boolean(activeTurnId) && streamTurnIdForMessage === activeTurnId}
userSentAt={userMessageUpdatedAtForTurn(
streamTurnIdForMessage ?? '',
@@ -742,16 +835,23 @@ export function ProjectSupervisorView({
)}
{/* 有流的回合把用量行跟在最后一条**用户**消息之后(结束于 xxx,总耗时 xxx);
没有流的回合保持原样:跟在回合最后一条消息之后。 */}
{streamCovered
? streamTurnIdForMessage
? renderTurnUsage(streamTurnIdForMessage)
: null
: isTurnEnd && !isActiveTurn && anchoredTurnId
{ownsStream && streamTurnIdForMessage
? renderTurnUsage(streamTurnIdForMessage)
: !streamCovered && isTurnEnd && !isActiveTurn && anchoredTurnId
? renderTurnUsage(anchoredTurnId)
: null}
</Fragment>
);
})}
{unmappedStreamTurns.map((turnId) => (
<TurnStreamSequence
key={`unmapped-stream-${turnId}`}
items={turnStreamByTurn.get(turnId) ?? []}
toolCalls={toolCalls}
active={Boolean(activeTurnId) && turnId === activeTurnId}
userSentAt={userMessageUpdatedAtForTurn(turnId)}
/>
))}
{unanchoredToolCalls.map(({ turnId, calls }) => (
<ToolCallGroup
key={`unanchored-tools-${turnId}`}