From 417137baf80dca41abdc9afab4f14883619a25e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:07:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=20DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E6=95=B0=E6=8D=AE=E6=B5=81=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=86=99=E8=BF=9B=E4=BB=A3=E7=A0=81=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDirectProjectChatController 模块注释补三层地图、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属 - DirectChatTurnState 注明三态各自能断言什么、判据的四个输入及来路、state 真值表、两个易读错的点与「finished 拿不到终态时间」的已知边界 - DirectThreadChatState.turnRunning 注明它只是原生真相,不等于界面上的「这一轮在跑吗」 - DirectProjectTurnUsage 注释写清两类「finished 却拿不到终态时间」的回合、覆盖范围与修法口径,说明不再甩给任何文档 - decision-log 的未修边界条目改为指向上述代码位置,避免同一套说明在代码与文档里各留一份 --- .../DirectProjectTurn.tsx | 8 +-- .../useDirectProjectChatController.ts | 48 +++++++++++++++++ .../controller/useDirectProjectTurnStatus.ts | 3 ++ .../chat/conversation/directThreadChat.ts | 9 +++- .../conversation/directTurnPresentation.ts | 53 ++++++++++++++++--- .../shared-memory/decision-log.md | 2 +- 6 files changed, 110 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index 362ecb5b4..b3d0df6c6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -68,9 +68,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、 // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。 - // 仍未修的另一半:`finished` 但没有可证明终态时间的回合(例如重进项目后读回来的历史回合, - // 以及发送后没有产生任何原生事件的本地回合)仍会被下面的 `Math.max` 兜底量化成 0.0 秒; - // 覆盖范围和产品口径见 decision-log 的 2026-09-22 条目。 + // 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底 + // 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里, + // 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。 + // 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失 + // 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。 if (turn.state !== 'finished' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 0ec2935c6..0a0250fb4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -91,6 +91,54 @@ export type DirectProjectChatControllerProps = { * * 订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、终止、草稿和本地消息都归这里; * 工作台壳只注入项目上下文与两条权限门,不再持有 Direct 专属 state/ref/effect。 + * + * ## 数据流(改判据前先读这一段) + * + * ``` + * Rust 宿主(事实的产生地) + * ├─ project.jsonl 持久化原始条目(AGC 写;app-server 回显的用户消息被过滤) + * └─ Thread Manager 事件队列(内存) per-thread 事件序列 + 每 subscriber 游标 + lifecycle_anchor + * │ append_direct_thread_event() → notify(只带 subscriptionId,纯唤醒) + * ▼ + * 传输层(三条通道,前端各拉各的) + * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) + * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) + * C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) + * ▼ + * 前端 + * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) + * ▼ + * useDirectProjectChatController 本地状态:localMessages / turnBusy / pendingUserItemId / 队列 + * ▼ + * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) + * ▼ + * DirectProjectTurn 用户气泡 / 过程块 / 最终回复 / 本轮耗时 + * ``` + * + * 三份原始输入各自是什么、带什么、活多久: + * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, + * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 + * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 + * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 + * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 + * (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 + * + * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗): + * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。 + * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 先落盘用户条目,再发 `turn/start`, + * **应答返回后**才 append `turn.started` 并 notify。 + * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 + * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。 + * 7. 命令收尾(`finally`):`endTurnCommand()` 清掉忙态与在途身份,刷新清单,按边沿推进队列出队。 + * + * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; + * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / + * `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的 + * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在 + * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 + * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ export function useDirectProjectChatController({ assets, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index 356e7d48c..05c9f456e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -21,6 +21,9 @@ import type { * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 + * `latestTurnState` 三态各自的含义、判据输入与真值表写在 + * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。 + * 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。 */ export type DirectProjectTurnStatus = { nativeRunning: boolean; 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 4ed45d4c6..8d9565951 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 @@ -45,7 +45,14 @@ export type DirectChatEntry = { }; export type DirectThreadChatState = { - /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + /** + * 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`) + * 的先后决定。 + * + * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段 + * 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见 + * `directTurnPresentation.ts` 的 `DirectChatTurnState`。 + */ turnRunning: boolean; /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */ turnStartedAt: number; 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 230f07255..33711fa2d 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 @@ -37,16 +37,53 @@ export type DirectChatBlock = | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; /** - * 界面上一轮的三态。 + * 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。 * - * - `running`:原生回合在跑(订阅流里出现过 `turn.started`、还没出现 `turn.completed`)。 - * - `awaiting-start`:本地已发出、原生还没认领(乐观用户气泡已出现,`turn.started` 未到)。 - * - `finished`:拿到明确终态,或不再有理由认为它在跑。 + * 三态各自能断言什么(渲染时按这个分两类,不要对调): + * - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现 + * `turn.completed`)。它是唯一能做肯定式断言的态。 + * - `awaiting-start`:**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started` + * 未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。 + * - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的 + * 历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。 * - * 它是**展示态**,不是第二套回合生命周期:`running` 只由 reducer 的 `turnRunning` 决定, - * `awaiting-start` 只由「这条用户条目身份就是本地这次发送的身份、且本轮还没有终态」决定。 - * 数据流与变量归属见 `../controller/useDirectProjectChatController.ts` 的模块注释; - * 三态在渲染上的映射见 `DirectProjectTurn.tsx`。 + * 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现): + * - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning` + * (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合, + * 所以「非最新一轮 + nativeRunning」不可达)。 + * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护, + * 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。 + * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用 + * `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。 + * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。 + * + * 真值表: + * + * | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state | + * | true | — | — | running | + * | false | false | 任意 | finished | + * | false | true | true | finished | + * | false | true | false | awaiting-start | + * + * 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开 + * 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么 + * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。 + * + * 两个容易读错的地方: + * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`invoke` 直到整轮结束才返回,所以 + * `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。 + * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 + * 缓存),它们必须落 `finished`。 + * + * 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与 + * 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。 + * + * 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑", + * **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被 + * `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合 + * (`turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件 + * 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题 + * (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。 */ export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished'; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fad14aa78..db5bf4521 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9328,5 +9328,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:上一条只把回合三态显式化,渲染层仍按 `state === 'running'` 判断,于是「本地已发出、宿主还没回 `turn.started`」的窗口里 `awaiting-start` 被当成 `finished` 渲染,显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」(用户现场反馈的现象)。 - 决策(判据分两类,不许对调):**否定式**判断(不要说它结束、不要折叠过程、不要显示终态文案)读 `state !== 'finished'`;**肯定式**判断(哪段正文在流式、「陶泥儿正在处理」卡片与滚动已耗时)读 `state === 'running'`。理由是 `awaiting-start` 能支持"还没结束",但不能支持"宿主已经在跑"——后者只有 `turn.started` 能证明。 - 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。 -- 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。修法是只在 `endedAt > 0` 时渲染终态文案并把耗时交给 `turnTotalDurationMs()`,属于产品口径变化(宁可隐藏也不编),需要单独确认。修法落地后要同步删除 `DirectProjectTurn.tsx` 里那段说明未修范围的注释。 +- 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx` 的 `DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts` 的 `DirectChatTurnState` 注释),改完删掉那段注释。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start` 与 `running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`(203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding`、`check:doc-index`、`git diff --check` 通过。真实客户端观感未复核。