From 4a75de1c31139702aeb13e6ecd603ec7e7da4dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 18:02:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=8C=89=E5=9B=9E=E5=90=88=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E5=BD=92=E4=BD=8D=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=90=BD=E8=BF=9B?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E8=BD=AE=E3=80=81=E6=B0=94=E6=B3=A1=E4=B9=9F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E6=88=90=E5=81=87=E5=9B=9E=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮 - 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象 - 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份 - 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条 --- .../chat/conversation/directThreadChat.ts | 57 ++++++++++--- .../conversation/directTurnPresentation.ts | 62 ++++++++++++-- .../tests/directThreadChat.test.ts | 84 +++++++++++++++++++ .../tests/directTurnPresentation.test.ts | 48 +++++++++++ 4 files changed, 231 insertions(+), 20 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index aee4c8a08..e7061824e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -50,6 +50,18 @@ export type DirectChatEntry = { * 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。 */ turnEndedAt?: number; + /** + * 这条条目**属于哪一轮**(本轮开口用户条目的 canonical 身份)。 + * + * 只有失败说明带它:它不是原生条目,而是宿主终态载荷派生出来的说明(见 + * `directTurnFailureItemId`)。一旦本轮的开口用户条目晚到或压根没到(回合在宿主下发用户条目 + * 之前就失败、历史切片还没读回),说明条目在数组里的位置就会落在**上一轮**里,界面会渲染成 + * "错误显示在用户消息上面",上一轮还会把它那一轮的耗时显示成本轮的。 + * + * 有了身份,投影层就能按身份归位(`buildDirectChatTurns` 的回合判据),不再靠位置猜。 + * 缺失表示归属不可证明(旧事件 / 旧历史切片),那时保持原有的顺序语义。 + */ + turnUserItemId?: string; }; export type DirectThreadChatState = { @@ -223,6 +235,7 @@ export function mergeDirectChatEntry( // 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。 turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt, turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt, + turnUserItemId: existing.turnUserItemId || incoming.turnUserItemId, }; } @@ -337,10 +350,6 @@ export function reduceDirectThreadEvent( ) { return state; } - // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 - if (!state.turnRunning && state.live.length === 0) { - return state; - } // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 const failure = event.failure; @@ -351,14 +360,40 @@ export function reduceDirectThreadEvent( failure && typeof failure.message === 'string' ? failure.message.trim() : ''; + const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt); + const noticeOf = (): DirectChatEntry => ({ + itemId: noticeItemId, + kind: 'message', + role: 'assistant', + text: directTurnFailureNoticeText(failureText), + at: eventAt, + // 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、 + // 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。 + ...(eventUserItemId ? { turnUserItemId: eventUserItemId } : {}), + }); + // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 + // + // 唯一例外:这条终态带着**还没写进界面的失败说明**。订阅重建后的 bootstrap 只回放一条 + // 生命周期锚点(`direct_thread_manager.rs` 的 `lifecycle_anchor`),那一条可能正是某个已经 + // 收口的回合的失败——照原样早退就会把这一轮唯一的解释静默吞掉。这里只补说明与它的终点时间 + // 和"回合完成"这个计数(忙态放行靠它),不重开回合、不动本轮的起点 / 终点 / 身份。 + if (!state.turnRunning && state.live.length === 0) { + const alreadyRecorded = state.history.some( + (entry) => entry.itemId === noticeItemId, + ); + if (!failureText || alreadyRecorded) { + return state; + } + return { + ...state, + completedTurnCount: state.completedTurnCount + 1, + history: mergeHistoryEntries(state.history, [ + withTurnBoundary(noticeOf(), { turnEndedAt: eventAt }), + ]), + }; + } const withNotice = failureText - ? upsertLiveEntry(state, { - itemId: directTurnFailureItemId(eventUserItemId, eventAt), - kind: 'message', - role: 'assistant', - text: directTurnFailureNoticeText(failureText), - at: eventAt, - }) + ? upsertLiveEntry(state, noticeOf()) : state; return finishDirectThreadTurn(withNotice, eventAt); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 433f9bd3e..924a96c4e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -238,14 +238,31 @@ function newTurn(key: string): DirectChatTurnEntries { }; } +/** + * 条目自带的**回合身份**:用户条目就是自己的身份;失败说明带的是它所属回合的开口条目身份。 + * + * 只有这两种条目能开 / 认领一个回合:其余条目(工具、思考、正文)没有身份,只能跟着当前回合走。 + * 返回空串 = 归属不可证明,按原有顺序语义处理。 + */ +function directEntryTurnKey(entry: DirectChatEntry): string { + if (entry.kind === 'message' && entry.role === 'user') return entry.itemId; + return entry.turnUserItemId ?? ''; +} + /** * 条目 + 运行期本地消息 → 回合列表。 * - * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 - * (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是 + * 数组位置:用户条目、本地乐观气泡、以及这一轮的失败说明都按同一个身份归进同一轮。三种来源谁先到 + * 都行——回合在宿主下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会 + * 按位置落进上一轮(界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时),用户气泡再自成 + * 一轮(多出一个 0.0 秒的假回合)。 * - * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与 - * 顺序都归 reducer。 + * 其余条目(工具、思考、正文)不带身份,跟着当前回合走;本地 assistant 消息(终止说明、壳层 + * `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * + * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序 + * 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。 */ export function buildDirectChatTurns({ entries, @@ -272,19 +289,36 @@ export function buildDirectChatTurns({ pendingUserItemId?: string; }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; + const turnsByIdentity = new Map(); let current: DirectChatTurnEntries | null = null; const localSentAt = localSentTimes(localMessages); // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 const leadingEntries: DirectChatEntry[] = []; + const openTurn = (key: string) => { + const turn = newTurn(key); + turns.push(turn); + turnsByIdentity.set(key, turn); + return turn; + }; for (const entry of entries) { - if (entry.kind === 'message' && entry.role === 'user') { - current = newTurn(entry.itemId); - turns.push(current); + const identity = directEntryTurnKey(entry); + if (identity) { + // 同一身份的条目永远属于同一轮。用户条目与这一轮的失败说明可能分头到达(订阅重放、历史 + // 切片晚到、或本轮压根没有下发用户条目),靠位置分组会把说明留给上一轮 —— 界面就成了 + // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。 + const existing = turnsByIdentity.get(identity); + if (existing) { + existing.entries.push(entry); + continue; + } + current = openTurn(identity); if (leadingEntries.length > 0) { current.entries.push(...leadingEntries); leadingEntries.length = 0; } + current.entries.push(entry); + continue; } if (!current) { leadingEntries.push(entry); @@ -304,8 +338,18 @@ export function buildDirectChatTurns({ if (message.messageId && entryIds.has(message.messageId)) return; if (message.role === 'user') { const block = blockFromLocalMessage(message, index); - current = newTurn(message.messageId ?? `local:${index}`); - turns.push(current); + // 身份已经开过回合(本轮的开口用户条目还没到,但它的失败说明到了):挂回自己那一轮。 + // 另开一轮就会多出一个"用户气泡 + 0.0 秒终态"的假回合,而这一轮的说明还留在上面那一段。 + const claimed = message.messageId + ? turnsByIdentity.get(message.messageId) + : undefined; + if (claimed) { + if (block && claimed.localUsers.length === 0) { + claimed.localUsers.push(block); + } + return; + } + current = openTurn(message.messageId ?? `local:${index}`); if (block) current.localUsers.push(block); return; } diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 3ac659901..2b8982e8e 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -442,6 +442,90 @@ describe('DirectProject 聊天 reducer', () => { } }); + it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => { + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'transport-failed', message: '连接失败' }, + at: 2_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + // 身份是投影层"这条说明属于哪一轮"的唯一判据:本轮的开口用户条目可能还没到过界面 + // (回合在宿主下发用户条目之前就失败、或历史切片还没读回),那时只有它能把说明挂回自己的回合。 + expect(failed.history.at(-1)?.itemId).toBe( + 'direct-codex:turn-1:user:failure', + ); + expect(failed.history.at(-1)?.turnUserItemId).toBe( + 'direct-codex:turn-1:user', + ); + // 没有身份(旧事件)时不写这个字段,保持原有的顺序语义。 + const anonymous = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000 }), + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'model-failed', message: '第一轮失败' }, + at: 2_000, + }), + ]); + expect(anonymous.history.at(-1)?.turnUserItemId).toBeUndefined(); + }); + + it('收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)', () => { + const finished = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 2_000 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(finished.history).toHaveLength(0); + + // 订阅重建后的 bootstrap 只回放最新一条生命周期事件:它就是某个已收口回合的失败,界面上 + // 没有任何东西能解释这一轮,必须补上这条说明(而不是按"重复终态"早退)。 + const replayed = reduceDirectThreadEvents(finished, [ + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'transport-failed', message: '连接失败' }, + at: 3_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayed.history.map((entry) => entry.itemId)).toEqual([ + 'direct-codex:turn-1:user:failure', + ]); + expect(replayed.history[0]?.turnEndedAt).toBe(3_000); + expect(replayed.completedTurnCount).toBe(finished.completedTurnCount + 1); + // 重复回放同一条锚点:说明已经写进去了,计数不再涨,也不追加第二条。 + const replayAgain = reduceDirectThreadEvents(replayed, [ + withUserItemId( + event({ + type: 'turn.completed', + status: 'failed', + failure: { kind: 'transport-failed', message: '连接失败' }, + at: 3_000, + }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayAgain.history).toHaveLength(1); + expect(replayAgain.completedTurnCount).toBe(replayed.completedTurnCount); + }); + it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => { const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'turn.started', at: 1_000 }), diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 1b95a2c0a..13bab533e 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -285,6 +285,54 @@ describe('DirectProject 聊天分区', () => { }); }); + it('本轮开口条目没到时,失败说明按身份挂回自己那一轮,气泡不再自成假回合', () => { + // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上既没有正式用户条目、也没有历史 + // 切片,只有这一轮的失败说明和本地乐观气泡(这条消息的开口条目还没到)。 + const turns = buildDirectChatTurns({ + entries: [ + userEntry('direct-codex:turn-1:user'), + { + ...assistantEntry( + 'direct-codex:turn-1:user:failure', + '陶泥儿智能创作 连接失败,请重试', + ), + turnUserItemId: 'direct-codex:turn-1:user', + turnStartedAt: 1_800_000_010_000, + turnEndedAt: 1_800_000_010_400, + }, + { + ...assistantEntry( + 'direct-codex:turn-2:user:failure', + '陶泥儿智能创作 连接失败,请重试', + ), + turnUserItemId: 'direct-codex:turn-2:user', + turnStartedAt: 1_800_000_020_000, + turnEndedAt: 1_800_000_035_600, + }, + ], + localMessages: [localUser('hello', 'direct-codex:turn-2:user')], + turnRunning: false, + }); + + // 两个回合:第二个回合的用户气泡与它自己的失败说明同段 —— 说明不再落进上一轮(否则界面 + // 表现就是"错误显示在用户消息上面"),气泡也不再自成"0.0 秒"的假回合。 + expect(turns.map((turn) => turn.key)).toEqual([ + 'direct-codex:turn-1:user', + 'direct-codex:turn-2:user', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual([ + 'direct-codex:turn-1:user:direct-codex:turn-1:user:failure', + ]); + expect(turns[1]?.users.map((block) => block.text)).toEqual(['hello']); + expect(turns[1]?.finals.map((block) => block.key)).toEqual([ + 'direct-codex:turn-2:user:direct-codex:turn-2:user:failure', + ]); + // 耗时按用户气泡自己的发送时间起算,不再把上一轮的终点借过来。 + expect(turns[0]?.endedAt).toBe(1_800_000_010_400); + expect(turns[1]?.startedAt).toBe(1_800_000_002_000); + expect(turns[1]?.endedAt).toBe(1_800_000_035_600); + }); + it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { const turns = buildDirectChatTurns({ entries: [userEntry('u1'), assistantEntry('a1', '答复')],