修历史回合不渲染流:消息与回合按位置配对
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
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:
+107
-7
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user