From 258645f1821471f5c0310810b9ff8c382b752eac 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 17:14:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=A4=B1=E8=B4=A5=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=94=B9=E7=94=B1=E4=BA=8B=E4=BB=B6=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=EF=BC=9Aturn.completed.failure=20=E8=90=BD=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E8=BD=AE=E8=AF=B4=E6=98=8E=E6=9D=A1=E7=9B=AE=EF=BC=8C=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=BF=94=E5=9B=9E=E5=8F=AA=E7=95=99=E6=A8=AA=E5=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处 - directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期 - useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕 - 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce --- .../useDirectProjectChatController.ts | 17 ++++--- .../chat/conversation/directThreadChat.ts | 26 ++++++++++- .../chat/conversation/directTurnFailure.ts | 46 +++++++++++++++++++ .../conversation/directTurnPresentation.ts | 5 +- 4 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts 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 d6aa7a7d0..595846a28 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 @@ -103,7 +103,7 @@ export type DirectProjectChatControllerProps = { * 传输层(三条通道,前端各拉各的) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) - * C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) + * C 本地: 前端自己造(乐观用户气泡、忙态、终止说明) * ▼ * 前端 * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) @@ -120,6 +120,8 @@ export type DirectProjectChatControllerProps = { * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 + * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; + * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 * (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 * @@ -130,7 +132,8 @@ export type DirectProjectChatControllerProps = { * 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` 冻结,边界按身份盖到本轮开口条目上。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上; + * 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 @@ -577,14 +580,10 @@ export function useDirectProjectChatController({ true, ); if (projectPathRef.current !== nextProjectPath) return; + // 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了 + // `turn.completed.failure`,reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留 + // 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。 onRuntimeError(visibleMessage); - appendLocalMessage({ - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - messageId: `direct-codex:${input.clientTurnId}:failure`, - updatedAt: Date.now(), - }); } } 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 8d9565951..8ff0a7424 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 @@ -5,6 +5,10 @@ * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * + * 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了 + * `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源 + * 就是事件流,命令返回只服务运行错误横幅与诊断。 */ import type { GameCreatorDirectToolCall } from '../../../../app/types'; @@ -14,6 +18,10 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; import { projectDirectThreadItem } from './directThreadItemProjection'; +import { + directTurnFailureItemId, + directTurnFailureNoticeText, +} from './directTurnFailure'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; @@ -326,7 +334,20 @@ export function reduceDirectThreadEvent( if (!state.turnRunning && state.live.length === 0) { return state; } - return finishDirectThreadTurn(state, eventAt); + // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 + // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 + const failure = event.failure; + const withNotice = + failure && failure.message.trim() + ? upsertLiveEntry(state, { + itemId: directTurnFailureItemId(eventUserItemId, eventAt), + kind: 'message', + role: 'assistant', + text: directTurnFailureNoticeText(failure.message), + at: eventAt, + }) + : state; + return finishDirectThreadTurn(withNotice, eventAt); } case 'item.delta': return appendLiveText(state, event); @@ -369,7 +390,8 @@ export function reduceDirectThreadEvent( /** * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 * - * `endedAt` 只接受明确的终态时间(`turn.completed.at`,或宿主终止收口时观测到的时刻): + * `endedAt` 只接受明确的终态时间:`turn.completed.at`(正常与失败同源),或宿主终止收口时 + * 观测到的时刻。 * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 * 由投影层决定(条目上的 `at` 才是气泡时间)。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts new file mode 100644 index 000000000..b9b9ef416 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -0,0 +1,46 @@ +/** + * 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。 + * + * 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): + * 1. 说明条目的展示身份怎么派生; + * 2. 事件里的原始原因怎么变成用户可见文案。 + * + * 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) + * 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人 + * 只能靠猜"这条失败说明是从哪冒出来的"。 + */ + +import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; + +/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */ +const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; + +/** + * 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。 + * + * 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。 + * 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。 + * 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、 + * 重放不变,又不会让两轮失败互相覆盖。 + */ +export function directTurnFailureItemId( + userItemId: string | null | undefined, + at: number, +): string { + const identity = typeof userItemId === 'string' ? userItemId.trim() : ''; + if (identity) return `${identity}:failure`; + return at > 0 + ? `${DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID}:${at}` + : DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID; +} + +/** + * 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。 + * + * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 + * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + */ +export function directTurnFailureNoticeText(message: string): string { + const raw = typeof message === 'string' ? message.trim() : ''; + return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true); +} 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 33711fa2d..bd898c5b6 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 @@ -239,7 +239,10 @@ function newTurn(key: string): DirectChatTurnEntries { * 条目 + 运行期本地消息 → 回合列表。 * * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 - * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * + * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与 + * 顺序都归 reducer。 */ export function buildDirectChatTurns({ entries,