From fdc48fe72516badabf7b6d88dc9098363c3716c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:49:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=81=8A=E5=A4=A9=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E6=8C=89=20ts-rs=20=E7=94=9F=E6=88=90=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=94=B6=E5=8F=A3=EF=BC=8C=E5=88=A0=E6=8E=89=20callId?= =?UTF-8?q?=20=E4=B8=8E=E5=9B=9E=E5=90=88=E8=BA=AB=E4=BB=BD=20-=20directTh?= =?UTF-8?q?readItemProjection.ts=20=E7=9B=B4=E6=8E=A5=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=20DirectThreadItem=EF=BC=8C?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8D=A1=E7=89=87=20kind=20/=20=E6=A0=87?= =?UTF-8?q?=E9=A2=98=20/=20=E6=91=98=E8=A6=81=20/=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E5=8F=AF=E8=A7=81=E6=80=A7=E5=88=A4=E5=AE=9A=E9=83=BD?= =?UTF-8?q?=E5=9C=A8=E8=BF=99=E9=87=8C=20-=20directThreadChat.ts=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE=E5=8F=AA=E7=95=99=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=20itemId=EF=BC=9A=E5=8E=BB=E6=8E=89=20callId=20?= =?UTF-8?q?=E6=A6=82=E5=BF=B5=EF=BC=8C=E5=8E=BB=E6=8E=89=20deltaText=20?= =?UTF-8?q?=E7=BC=93=E5=86=B2=EF=BC=8C=E5=A2=9E=E9=87=8F=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=88=B0=E8=BF=90=E8=A1=8C=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=AD=A3=E6=96=87=20-=20=E5=90=88=E5=B9=B6=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=94=B6=E6=88=90=E5=85=88=E5=88=B0=E8=80=85=E8=B5=A2?= =?UTF-8?q?=E3=80=81=E5=90=8E=E5=88=B0=E5=8F=AA=E8=A1=A5=E7=A9=BA=EF=BC=9A?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=87=8F=E3=80=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=8A=B6=E6=80=81=E5=85=81=E8=AE=B8=E4=BB=8E?= =?UTF-8?q?=20running=20=E5=8D=87=E7=BA=A7=E5=88=B0=E7=BB=88=E6=80=81=20-?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88=E7=BB=93=E6=9D=9F=E6=8A=8A=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=80=81=E6=9D=A1=E7=9B=AE=E5=B9=B6=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=B9=B6=E6=B8=85=E7=A9=BA=20live=EF=BC=8C=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=97=A2=E4=B8=8D=E6=B6=88=E5=A4=B1=E4=B9=9F=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=20-=20directThreadEvents.ts=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=BD=AC=E5=8F=91=20ts-rs=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E4=BF=9D=E7=95=99=E5=8E=86=E5=8F=B2=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E8=BD=AC=E6=B6=88=E6=81=AF=E7=9A=84=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E5=87=BD=E6=95=B0=20-=20ToolCallGroup=20=E4=B8=8E=20toolCallGr?= =?UTF-8?q?oupPresentation=20=E6=94=B9=E7=94=A8=E5=8E=BB=E6=8E=89=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=BA=AB=E4=BB=BD=E7=9A=84=20DirectChatToolCard=20-?= =?UTF-8?q?=20generated/index.ts=20=E8=A1=A5=E4=B8=8A=20DirectThread*=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=AF=BC=E5=87=BA=20-=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=9C=AC=E4=BB=93=E5=BA=93=E5=85=B6=E5=AE=83=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=BB=8D=E5=9C=A8=E6=94=B9=E7=9A=84=20DirectCodexUser*=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=EF=BC=88?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=AF=E6=94=B9=E4=BB=96=E4=BA=BA=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E7=9A=84=E5=A5=91=E7=BA=A6=EF=BC=89=20-=20?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20vitest=20=E7=94=A8=E4=BE=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8C=89=E6=96=B0=20API=20=E9=87=8D=E5=86=99=EF=BC=8C?= =?UTF-8?q?11=20=E6=9D=A1=E7=94=A8=E4=BE=8B=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/ToolCallGroup.tsx | 6 +- .../project-workspace/directThreadChat.ts | 280 +++++++------- .../project-workspace/directThreadEvents.ts | 72 ++-- .../directThreadItemProjection.ts | 365 +++++++++--------- .../generated/DirectCodexUserContentPart.ts | 3 +- .../generated/DirectCodexUserItem.ts | 10 +- .../DirectCodexUserMessageEnvelope.ts | 4 - .../generated/DirectCodexUserMessageItem.ts | 6 +- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 +- .../project-workspace/generated/index.ts | 8 + .../toolCallGroupPresentation.ts | 22 +- .../tests/directThreadChat.test.ts | 200 +++++----- .../tests/directThreadEvents.test.ts | 6 +- 14 files changed, 475 insertions(+), 527 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx index d5e904f51..683e5c2c0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -9,7 +9,7 @@ import { import { useEffect, useId, useState } from 'react'; import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; -import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; import { formatToolCallDuration, formatTurnDuration, @@ -38,7 +38,7 @@ export function ToolCallGroup({ active = false, className, }: { - calls: GameCreatorDirectToolCall[]; + calls: DirectChatToolCard[]; /** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */ userSentAt?: number | null; /** @@ -169,7 +169,7 @@ function ToolCallRow({ call, active, }: { - call: GameCreatorDirectToolCall; + call: DirectChatToolCard; active: boolean; }) { const [expanded, setExpanded] = useState(false); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index ab5de6c00..37adb0c7e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -1,120 +1,133 @@ /** - * DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。 + * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * - * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目 - * 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。 + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + + * 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject + * 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。 */ import type { GameCreatorDirectToolCall } from '../../app/types'; import type { - DirectThreadRawEvent, + DirectThreadConsumeResult, + DirectThreadEvent, + DirectThreadHistorySlice, + DirectThreadItem, DirectThreadSubscriptionBootstrap, } from './directThreadEvents'; -import { - type DirectThreadRawItem, - isDirectThreadRawItem, - projectDirectThreadRawItem, -} from './directThreadItemProjection'; +import { projectDirectThreadItem } from './directThreadItemProjection'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; -/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */ +/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */ +export type DirectChatToolCard = Omit; + +/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */ export type DirectChatEntry = { itemId: string; - callId?: string | null; - turnId?: string | null; kind: DirectChatEntryKind; role?: 'user' | 'assistant' | null; text?: string | null; - toolCall?: GameCreatorDirectToolCall | null; + toolCall?: DirectChatToolCard | null; at?: number; }; export type DirectThreadChatState = { subscriptionId: string | null; - /** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */ + /** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */ lastCompletedItemId: string | null; - /** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */ - activeTurnId: string | null; + /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + turnRunning: boolean; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; - /** 运行态条目,保持到达顺序。 */ + /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ live: DirectChatEntry[]; - /** 流式正文累计(itemId → text)。 */ - deltaText: Record; }; export function emptyDirectThreadChatState(): DirectThreadChatState { return { subscriptionId: null, lastCompletedItemId: null, - activeTurnId: null, + turnRunning: false, history: [], live: [], - deltaText: {}, }; } -/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */ -export function directChatEntryIdentity(entry: DirectChatEntry): string { - return (entry.callId ?? '').trim() || entry.itemId; +function longerText( + left: string | null | undefined, + right: string | null | undefined, +): string | null { + const a = typeof left === 'string' ? left : ''; + const b = typeof right === 'string' ? right : ''; + // 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。 + return b.length > a.length ? b : a; } -function mergeToolCall( - left: GameCreatorDirectToolCall, - right: GameCreatorDirectToolCall, -): GameCreatorDirectToolCall { +function mergeToolStatus( + left: DirectChatToolCard['status'] | null | undefined, + right: DirectChatToolCard['status'] | null | undefined, +): DirectChatToolCard['status'] { + // 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。 + if (left === 'running' || !left) return right ?? left ?? 'running'; + return left; +} + +function mergeToolCard( + left: DirectChatToolCard | null, + right: DirectChatToolCard | null, +): DirectChatToolCard | null { + if (!left) return right; + if (!right) return left; return { ...left, - ...right, - title: right.title?.trim() ? right.title : left.title, - summary: right.summary?.trim() ? right.summary : left.summary, - kind: right.kind && right.kind !== 'other' ? right.kind : left.kind, + kind: left.kind && left.kind !== 'other' ? left.kind : right.kind, + title: left.title?.trim() ? left.title : right.title, + summary: left.summary?.trim() ? left.summary : right.summary, + status: mergeToolStatus(left.status, right.status), detail: { - command: right.detail?.command ?? left.detail?.command, - output: right.detail?.output ?? left.detail?.output, - changes: right.detail?.changes?.length - ? right.detail.changes - : left.detail?.changes, + command: left.detail.command ?? right.detail.command, + output: left.detail.output ?? right.detail.output, + changes: left.detail.changes?.length + ? left.detail.changes + : right.detail.changes, }, - startedAt: Math.min( - left.startedAt > 0 ? left.startedAt : right.startedAt, - right.startedAt > 0 ? right.startedAt : left.startedAt, - ), + startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, + updatedAt: Math.max(left.updatedAt, right.updatedAt), }; } -/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */ +/** + * 先到的快照赢,后到的只补空字段。 + * + * 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running` + * 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。 + */ export function mergeDirectChatEntry( existing: DirectChatEntry, incoming: DirectChatEntry, ): DirectChatEntry { - const merged: DirectChatEntry = { - ...existing, - ...incoming, + return { itemId: existing.itemId || incoming.itemId, kind: - incoming.kind === 'tool' || existing.kind === 'tool' + existing.kind === 'tool' || incoming.kind === 'tool' ? 'tool' - : incoming.kind, - text: incoming.text ?? existing.text, - role: incoming.role ?? existing.role, - turnId: incoming.turnId ?? existing.turnId, + : existing.kind, + role: existing.role ?? incoming.role ?? null, + text: longerText(existing.text, incoming.text), + toolCall: mergeToolCard( + existing.toolCall ?? null, + incoming.toolCall ?? null, + ), + at: existing.at || incoming.at, }; - const left = existing.toolCall ?? null; - const right = incoming.toolCall ?? null; - merged.toolCall = - left && right ? mergeToolCall(left, right) : (right ?? left); - return merged; } function upsertLiveEntry( state: DirectThreadChatState, entry: DirectChatEntry, ): DirectThreadChatState { - const identity = directChatEntryIdentity(entry); const index = state.live.findIndex( - (existing) => directChatEntryIdentity(existing) === identity, + (existing) => existing.itemId === entry.itemId, ); if (index < 0) { return { ...state, live: [...state.live, entry] }; @@ -128,49 +141,47 @@ function upsertLiveEntry( return { ...state, live }; } -function reduceDirectThreadEvent( +function appendLiveText( state: DirectThreadChatState, - event: DirectThreadRawEvent, + event: Extract, +): DirectThreadChatState { + const itemId = event.itemId.trim(); + if (!itemId || !event.delta) return state; + const reasoning = event.kind === 'reasoning'; + const existing = state.live.find((entry) => entry.itemId === itemId); + return upsertLiveEntry(state, { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + text: `${existing?.text ?? ''}${event.delta}`, + }); +} + +export function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadEvent, ): DirectThreadChatState { switch (event.type) { case 'turn.started': - return { ...state, activeTurnId: event.turnId }; + return { ...state, turnRunning: true }; case 'turn.completed': + // 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。 return { ...state, - activeTurnId: - state.activeTurnId === event.turnId ? null : state.activeTurnId, + turnRunning: false, + history: mergeHistoryEntries(state.history, state.live), + live: [], }; - case 'item.delta': { - const itemId = event.itemId?.trim(); - if (!itemId) return state; - const payload = event.payload as { delta?: unknown; kind?: unknown }; - const delta = typeof payload?.delta === 'string' ? payload.delta : ''; - if (!delta) return state; - const text = `${state.deltaText[itemId] ?? ''}${delta}`; - const reasoning = payload?.kind === 'reasoning'; - return upsertLiveEntry( - { ...state, deltaText: { ...state.deltaText, [itemId]: text } }, - { - itemId, - kind: reasoning ? 'reasoning' : 'message', - role: reasoning ? null : 'assistant', - turnId: event.turnId, - text, - }, - ); - } + case 'item.delta': + return appendLiveText(state, event); case 'item.started': case 'item.completed': { - const entry = isDirectThreadRawItem(event.payload) - ? projectDirectThreadRawItem(event.payload) - : null; - if (!entry) return state; - return upsertLiveEntry(state, { - ...entry, - turnId: entry.turnId ?? event.turnId, - }); + const entry = projectDirectThreadItem(event.item); + return entry ? upsertLiveEntry(state, entry) : state; } + case 'request': + // 审批 / 提问只影响面板交互,不并入聊天条目。 + return state; default: return state; } @@ -178,20 +189,23 @@ function reduceDirectThreadEvent( export function reduceDirectThreadEvents( state: DirectThreadChatState, - events: readonly DirectThreadRawEvent[], + events: readonly DirectThreadEvent[], ): DirectThreadChatState { return events.reduce(reduceDirectThreadEvent, state); } -/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */ +/** + * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 + * + * 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。 + */ export function resolveDirectThreadBootstrap( state: DirectThreadChatState, bootstrap: DirectThreadSubscriptionBootstrap, ): DirectThreadChatState { return reduceDirectThreadEvents( { - ...emptyDirectThreadChatState(), - history: state.history, + ...state, subscriptionId: bootstrap.subscriptionId, lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, }, @@ -199,69 +213,59 @@ export function resolveDirectThreadBootstrap( ); } -/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */ -export function mergeDirectHistoryEntries( +/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ +export function applyDirectThreadConsumeResult( state: DirectThreadChatState, - entries: readonly DirectChatEntry[], + result: DirectThreadConsumeResult, ): DirectThreadChatState { - const byIdentity = new Map(); - const order: string[] = []; - for (const entry of [...entries, ...state.history]) { - const identity = directChatEntryIdentity(entry); - const existing = byIdentity.get(identity); - if (existing) { - byIdentity.set(identity, mergeDirectChatEntry(existing, entry)); + return reduceDirectThreadEvents(state, result.events); +} + +/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */ +export function mergeHistoryEntries( + leading: readonly DirectChatEntry[], + trailing: readonly DirectChatEntry[], +): DirectChatEntry[] { + const byId = new Map(); + const entries: DirectChatEntry[] = []; + for (const entry of [...leading, ...trailing]) { + const index = byId.get(entry.itemId); + if (index === undefined) { + byId.set(entry.itemId, entries.length); + entries.push(entry); continue; } - byIdentity.set(identity, entry); - order.push(identity); + const existing = entries[index]; + if (existing) entries[index] = mergeDirectChatEntry(existing, entry); } - return { - ...state, - history: order - .map((identity) => byIdentity.get(identity)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)), - }; + return entries; } /** * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 * - * `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output` - * 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + * 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 */ export function mergeDirectHistoryItems( state: DirectThreadChatState, - items: readonly DirectThreadRawItem[], + items: readonly DirectThreadItem[], ): DirectThreadChatState { - return mergeDirectHistoryEntries( - state, - items - .map((item) => projectDirectThreadRawItem(item)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)), - ); + const entries = items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); + return { ...state, history: mergeHistoryEntries(entries, state.history) }; +} + +export function mergeDirectThreadHistorySlice( + state: DirectThreadChatState, + slice: DirectThreadHistorySlice, +): DirectThreadChatState { + return mergeDirectHistoryItems(state, slice.items); } /** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ export function selectDirectChatEntries( state: DirectThreadChatState, ): DirectChatEntry[] { - const liveByIdentity = new Map( - state.live.map((entry) => [directChatEntryIdentity(entry), entry]), - ); - const seen = new Set(); - const entries: DirectChatEntry[] = []; - for (const entry of state.history) { - const identity = directChatEntryIdentity(entry); - seen.add(identity); - const live = liveByIdentity.get(identity); - entries.push(live ? mergeDirectChatEntry(entry, live) : entry); - } - for (const entry of state.live) { - const identity = directChatEntryIdentity(entry); - if (seen.has(identity)) continue; - seen.add(identity); - entries.push(entry); - } - return entries; + return mergeHistoryEntries(state.history, state.live); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 78b8af25d..e52462b56 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,62 +1,44 @@ +/** + * DirectProject 运行态事件的线上类型。 + * + * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 + * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 + */ + import type { LocalConversationMessageRecord } from '../../app/types'; -import type { DirectThreadRawItem } from './directThreadItemProjection'; +import type { DirectThreadItem } from './generated'; -export type DirectThreadRawEvent = { - seq: number; - type: string; - turnId: string; - itemId?: string; - payload: Record; -}; - -export type DirectThreadSubscriptionBootstrap = { - subscriptionId: string; - lastCompletedItemId: string | null; - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadConsumeResult = { - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadHistorySlice = { - /** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */ - items: DirectThreadRawItem[]; - hasMore: boolean; - /** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */ - firstItemId: string | null; -}; +export type { + DirectThreadConsumeResult, + DirectThreadDeltaKind, + DirectThreadEvent, + DirectThreadFileChange, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadRequestKind, + DirectThreadSubscriptionBootstrap, +} from './generated'; /** * 历史条目转聊天消息。 * - * 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序 - * 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。 + * 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。 + * 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。 */ export function directThreadHistoryItemsToMessages( - items: readonly DirectThreadRawItem[], + items: readonly DirectThreadItem[], ): LocalConversationMessageRecord[] { return items.flatMap((item) => { - if ( - item.itemType !== 'message' && - item.itemType !== 'agentMessage' && - item.itemType !== 'userMessage' - ) { - return []; - } - const role = item.role; - if (role !== 'user' && role !== 'assistant') return []; - const messageRole = role as 'user' | 'assistant'; - const content = typeof item.text === 'string' ? item.text : ''; - if (!content) return []; - const messageId = item.itemId; + if (item.itemType !== 'message') return []; + if (item.role !== 'user' && item.role !== 'assistant') return []; + if (!item.text) return []; return [ { schemaVersion: 'agc-direct-project-context.v1', - role: messageRole, - content, + role: item.role, + content: item.text, agentId: null, - messageId, + messageId: item.itemId, updatedAt: item.at ?? 0, }, ]; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts index 5ff47d33c..f1982c80f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -1,57 +1,22 @@ /** * DirectProject「原始条目 → 聊天条目」投影。 * - * Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的 - * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片 - * 走同一个函数,因此实时与回读不可能出现两套口径。 + * 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个 + * 函数,因此实时与回读不可能出现两套口径。 */ import type { - GameCreatorDirectToolCall, GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallDetail, GameCreatorDirectToolCallKind, GameCreatorDirectToolCallStatus, } from '../../app/types'; -import type { DirectChatEntry } from './directThreadChat'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; +import type { DirectThreadItem } from './directThreadEvents'; -/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */ -export type DirectThreadRawChange = { - path: string; - kind: string; -}; - -/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */ -export type DirectThreadRawItem = { - itemId: string; - callId?: string | null; - turnId?: string | null; - itemType: string; - role?: string | null; - text?: string | null; - name?: string | null; - arguments?: string | null; - output?: string | null; - command?: string | null; - tool?: string | null; - changes?: DirectThreadRawChange[] | null; - itemStatus?: string | null; - exitCode?: number | null; - success?: boolean | null; - completed: boolean; - at?: number | null; -}; - -const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']); -const REASONING_ITEM_TYPES = new Set(['reasoning']); -const TOOL_ITEM_TYPES = new Set([ - 'function_call', - 'function_call_output', - 'commandExecution', - 'fileChange', - 'mcpToolCall', - 'webSearch', - 'contextCompaction', -]); +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; const FAILED_ITEM_STATUS = new Set([ 'failed', @@ -61,13 +26,6 @@ const FAILED_ITEM_STATUS = new Set([ 'aborted', ]); -/** 折叠态摘要上限,与卡片契约一致。 */ -const TOOL_SUMMARY_MAX_CHARS = 120; - -function text(value: unknown): string { - return typeof value === 'string' ? value : ''; -} - function firstLine(value: string): string { const [line = ''] = value.split('\n'); const trimmed = line.trim(); @@ -95,51 +53,17 @@ function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { } } -function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind { - switch (raw.itemType) { - case 'function_call': - return toolKindFromFunctionName(text(raw.name)); - // 输出条目本身不携带工具语义:它只负责补上 `output`。 - case 'function_call_output': - return 'other'; - case 'commandExecution': - return 'command'; - case 'fileChange': - return 'file_change'; - case 'mcpToolCall': - return 'mcp_tool'; - case 'webSearch': - return 'web_search'; - case 'contextCompaction': - return 'context_compaction'; - default: - return 'other'; - } -} - -function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus { - const itemStatus = text(raw.itemStatus); - if (itemStatus === 'completed') return 'completed'; - if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed'; +function toolStatus( + status: string | null, + exitCode: number | null, +): GameCreatorDirectToolCallStatus { + if (status === 'completed') return 'completed'; + if (status && FAILED_ITEM_STATUS.has(status)) return 'failed'; // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 - if (typeof raw.exitCode === 'number') { - return raw.exitCode === 0 ? 'completed' : 'failed'; + if (typeof exitCode === 'number') { + return exitCode === 0 ? 'completed' : 'failed'; } - if (typeof raw.success === 'boolean') { - return raw.success ? 'completed' : 'failed'; - } - return raw.completed ? 'completed' : 'running'; -} - -function toolChanges( - raw: DirectThreadRawItem, -): GameCreatorDirectToolCallChange[] { - return (raw.changes ?? []) - .filter((change) => text(change?.path).trim().length > 0) - .map((change) => ({ - path: text(change.path), - kind: text(change.kind) || 'update', - })); + return status ? 'running' : 'completed'; } function toolTitle( @@ -162,120 +86,183 @@ function toolTitle( } } -/** - * 工具卡片投影。 - * - * `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许 - * 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。 - */ -function projectToolCard( - raw: DirectThreadRawItem, -): GameCreatorDirectToolCall | null { - const kind = toolKind(raw); - const changes = toolChanges(raw); - const command = text(raw.command) || text(raw.arguments); - const tool = text(raw.tool); - const output = text(raw.output); - const isOutputOnly = raw.itemType === 'function_call_output'; - const detail: GameCreatorDirectToolCall['detail'] = {}; - if (command && !isOutputOnly) detail.command = command; - if (output) detail.output = output; +function fileChanges( + item: Extract, +): GameCreatorDirectToolCallChange[] { + return item.changes + .filter((change) => change.path.trim().length > 0) + .map((change) => ({ + path: change.path, + kind: change.kind || 'update', + })); +} + +type ToolCardInput = { + itemId: string; + at: number; + kind: GameCreatorDirectToolCallKind; + /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ + outputOnly?: boolean; + tool?: string; + command?: string; + output?: string; + status: GameCreatorDirectToolCallStatus; + changes?: GameCreatorDirectToolCallChange[]; +}; + +function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { + const changes = input.changes ?? []; + const detail: GameCreatorDirectToolCallDetail = {}; + if (input.command && !input.outputOnly) detail.command = input.command; + if (input.output) detail.output = input.output; if (changes.length > 0) detail.changes = changes; + // 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。 if (!detail.command && !detail.output && !detail.changes?.length) return null; const summarySource = - (kind === 'mcp_tool' ? tool : '') || + (input.kind === 'mcp_tool' ? (input.tool ?? '') : '') || detail.command || changes[0]?.path || - tool || + input.tool || ''; - const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0; return { schemaVersion: 'agc-tool-call.v1', - id: text(raw.callId) || raw.itemId, - turnId: text(raw.turnId), - kind, - title: isOutputOnly ? '' : toolTitle(kind, changes), - summary: isOutputOnly ? '' : firstLine(summarySource), - status: toolStatus(raw), + id: input.itemId, + kind: input.kind, + title: input.outputOnly ? '' : toolTitle(input.kind, changes), + summary: input.outputOnly ? '' : firstLine(summarySource), + status: input.status, detail, - startedAt: at, - updatedAt: at, + startedAt: input.at, + updatedAt: input.at, }; } +function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { + switch (item.itemType) { + case 'function_call': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }); + case 'function_call_output': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }); + case 'commandExecution': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }); + case 'fileChange': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }); + case 'mcpToolCall': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }); + case 'webSearch': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }); + case 'contextCompaction': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }); + default: + return null; + } +} + /** * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 * - * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型 - * 都不进聊天视图。 + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进 + * 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。 */ -export function projectDirectThreadRawItem( - raw: DirectThreadRawItem | null | undefined, +export function projectDirectThreadItem( + item: DirectThreadItem | null | undefined, ): DirectChatEntry | null { - if (!raw) return null; - const itemId = text(raw.itemId).trim(); - const itemType = text(raw.itemType).trim(); - if (!itemId || !itemType) return null; + if (!item) return null; + const itemId = item.itemId.trim(); + if (!itemId) return null; - if (REASONING_ITEM_TYPES.has(itemType)) { - const body = text(raw.text); - if (!body.trim()) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'reasoning', - role: null, - text: body, - toolCall: null, - at: raw.at ?? 0, - }; + switch (item.itemType) { + case 'message': { + const role = + item.role === 'user' + ? 'user' + : item.role === 'assistant' + ? 'assistant' + : null; + if (!role || !item.text.trim()) return null; + return { + itemId, + kind: 'message', + role, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'reasoning': { + if (!item.text.trim()) return null; + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'other': + // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 + return null; + default: { + const toolCall = toolCardFromItem(item); + if (!toolCall) return null; + return { + itemId, + kind: 'tool', + role: null, + text: null, + toolCall, + at: item.at, + }; + } } - - if (MESSAGE_ITEM_TYPES.has(itemType)) { - const role = text(raw.role); - if (role !== 'user' && role !== 'assistant') return null; - const body = text(raw.text); - if (!body) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'message', - role, - text: body, - toolCall: null, - at: raw.at ?? 0, - }; - } - - if (TOOL_ITEM_TYPES.has(itemType)) { - const toolCall = projectToolCard(raw); - if (!toolCall) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'tool', - role: null, - text: null, - toolCall, - at: raw.at ?? 0, - }; - } - - return null; -} - -/** 事件 / 历史切片载荷是否像一条原始条目。 */ -export function isDirectThreadRawItem( - payload: unknown, -): payload is DirectThreadRawItem { - if (!payload || typeof payload !== 'object') return false; - const candidate = payload as Partial; - return ( - typeof candidate.itemId === 'string' && - typeof candidate.itemType === 'string' - ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index eec5662e2..10c4e0f5a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,4 +1,5 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index aa1ee88dd..4c9ea8ca3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,9 +1,5 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -/** - * DirectProject 本轮 user input 的唯一结构化入口。 - */ -export type DirectCodexUserItem = { - type: 'message'; -} & DirectCodexUserMessageItem; +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts deleted file mode 100644 index 97e0b0dae..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserItem } from './DirectCodexUserItem'; - -export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index a1101c8cb..f7447dc5d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,9 +1,11 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { + type: 'message'; role: DirectCodexUserRole; - content: Array; + content: DirectCodexUserContentPart[]; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index 0c2540122..e1dc59c59 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index b134fb7bc..296c0d19a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId: string | null; - versionId: string | null; - elementTag: string | null; - elementRole: string | null; - text: string | null; - width: number | null; - height: number | null; - resourceIds: Array; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index f1012a4c5..aeeebc8f8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; +export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +export type { DirectThreadEvent } from './DirectThreadEvent'; +export type { DirectThreadFileChange } from './DirectThreadFileChange'; +export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice'; +export type { DirectThreadItem } from './DirectThreadItem'; +export type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index a48a125bd..e41ca0354 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -1,7 +1,5 @@ -import type { - GameCreatorDirectToolCall, - GameCreatorDirectToolCallKind, -} from '../../app/types'; +import type { GameCreatorDirectToolCallKind } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; /** * 工具调用折叠块的纯文案计算:汇总 / 行文案。 @@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial< /** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ export function toolCallGroupSummary( - calls: GameCreatorDirectToolCall[], + calls: DirectChatToolCard[], running = false, ) { const counts = new Map(); @@ -77,7 +75,7 @@ export function toolCallGroupSummary( } /** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */ -export function toolCallRowText(call: GameCreatorDirectToolCall) { +export function toolCallRowText(call: DirectChatToolCard) { // 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达 // (执行中 / 已执行 / 失败),前缀会和它重复。 if (call.kind === 'context_compaction') { @@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) { return toolCallRowSummary(call); } -function toolCallRowSummary(call: GameCreatorDirectToolCall) { +function toolCallRowSummary(call: DirectChatToolCard) { if (call.kind === 'command') { // 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。 const script = windowsPowerShellCommandBody( @@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) { } /** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */ -export function toolCallInputText(call: GameCreatorDirectToolCall) { +export function toolCallInputText(call: DirectChatToolCard) { const input = call.detail.command?.trim() ?? ''; return call.kind === 'command' ? (windowsPowerShellCommandBody(input) ?? input) @@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) { * 这两种情况不显示耗时,不显示 `0s` / 负数。 */ export function toolCallDurationMs( - call: Pick, + call: Pick, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; @@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) { /** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ export function turnToolCallDurationMs( - calls: Array>, + calls: Array>, ): number | null { let minStartedAt = Number.POSITIVE_INFINITY; let maxUpdatedAt = Number.NEGATIVE_INFINITY; @@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) { /** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ export function turnToolCallEndedAt( - calls: Array>, + calls: Array>, ) { let maxUpdatedAt = 0; for (const call of calls) { @@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) { * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 */ export function turnToolCallTimeLabel( - calls: Array>, + calls: Array>, userSentAt: number | null | undefined, ) { const endLabel = formatClockTime(turnToolCallEndedAt(calls)); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index feeffe84c..e381805dd 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -1,95 +1,104 @@ import { describe, expect, it } from 'vitest'; import { - directChatEntryIdentity, emptyDirectThreadChatState, mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../src/features/project-workspace/directThreadChat'; -import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; -import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; function event( - partial: Partial & - Pick, -): DirectThreadRawEvent { - return { seq: 1, payload: {}, ...partial }; + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; } -/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */ -function rawToolStarted( - overrides: Partial = {}, -): DirectThreadRawItem { +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { return { - itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - turnId: 'turn-1', itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', name: 'exec_command', arguments: '{"cmd": "ls"}', - completed: false, at: 1000, ...overrides, }; } -/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */ -function rawToolOutput( - overrides: Partial = {}, -): DirectThreadRawItem { +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { return { - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - turnId: 'turn-1', itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', output: 'assets\ngame', - completed: true, at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, ...overrides, }; } describe('DirectProject 聊天 reducer', () => { - it('只有 turn.started 且未完成的回合才是活动回合', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', turnId: 'turn-1' }), + event({ type: 'turn.started' }), ]); - expect(started.activeTurnId).toBe('turn-1'); + expect(started.turnRunning).toBe(true); const completed = reduceDirectThreadEvents(started, [ - event({ type: 'turn.completed', turnId: 'turn-1' }), + event({ type: 'turn.completed', status: 'completed' }), ]); - expect(completed.activeTurnId).toBeNull(); + expect(completed.turnRunning).toBe(false); }); - it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => { - const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', turnId: 'turn-old' }), - ]); - const bootstrapped = resolveDirectThreadBootstrap(stale, { - subscriptionId: 'sub-1', - lastCompletedItemId: 'msg-9', - events: [], - }); - expect(bootstrapped.activeTurnId).toBeNull(); + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); expect(bootstrapped.subscriptionId).toBe('sub-1'); expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); }); - it('增量正文按条目累计,完成快照覆盖同一段', () => { + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'msg-1', - payload: { delta: '你', kind: 'message' }, + kind: 'message', + delta: '你', }), event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'msg-1', - payload: { delta: '好', kind: 'message' }, + kind: 'message', + delta: '好', }), ]); expect(selectDirectChatEntries(streamed)).toHaveLength(1); @@ -98,17 +107,7 @@ describe('DirectProject 聊天 reducer', () => { const done = reduceDirectThreadEvents(streamed, [ event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'msg-1', - payload: { - itemId: 'msg-1', - turnId: 'turn-1', - itemType: 'message', - role: 'assistant', - text: '你好,我是陶泥儿。', - completed: true, - at: 3000, - } satisfies DirectThreadRawItem, + item: messageItem({ text: '你好,我是陶泥儿。' }), }), ]); const entries = selectDirectChatEntries(done); @@ -120,9 +119,9 @@ describe('DirectProject 聊天 reducer', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'reason-1', - payload: { delta: '先看目录', kind: 'reasoning' }, + kind: 'reasoning', + delta: '先看目录', }), ]); const entries = selectDirectChatEntries(state); @@ -132,26 +131,14 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.text).toBe('先看目录'); }); - it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => { + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ - type: 'item.started', - turnId: 'turn-1', - itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - payload: rawToolStarted(), - }), - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - payload: rawToolOutput(), - }), + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(1); - expect(directChatEntryIdentity(entries[0]!)).toBe( - 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - ); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); expect(entries[0]?.toolCall?.kind).toBe('command'); expect(entries[0]?.toolCall?.title).toBe('执行命令'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); @@ -159,18 +146,27 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.status).toBe('completed'); }); + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + it('历史切片搬运层不合并,合并发生在前端投影', () => { const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ - rawToolStarted(), - rawToolOutput(), - { - itemId: 'msg-user', - itemType: 'message', - role: 'user', - text: '做一个拼图游戏', - completed: true, - at: 0, - }, + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(2); @@ -182,58 +178,36 @@ describe('DirectProject 聊天 reducer', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'sys-1', - payload: { + item: messageItem({ itemId: 'sys-1', - itemType: 'message', role: 'system', text: '内部指令', - completed: true, - at: 0, - } satisfies DirectThreadRawItem, + }), }), event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'plan-1', - payload: { + item: { + itemType: 'other', itemId: 'plan-1', - itemType: 'plan', - completed: true, + rawType: 'plan', at: 0, - } satisfies DirectThreadRawItem, + } satisfies DirectThreadItem, }), ]); expect(selectDirectChatEntries(state)).toHaveLength(0); }); - it('历史条目与运行态按身份合并,重复条目只出现一次', () => { + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ - rawToolStarted(), - { - itemId: 'msg-user', - itemType: 'message', - role: 'user', - text: '做一个拼图游戏', - completed: true, - at: 0, - }, + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), ]); const live = reduceDirectThreadEvents(historical, [ - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - payload: rawToolOutput(), - }), + event({ type: 'item.completed', item: toolOutput() }), ]); const entries = selectDirectChatEntries(live); expect(entries).toHaveLength(2); expect(entries[0]?.toolCall?.status).toBe('completed'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); - expect(directChatEntryIdentity(entries[0]!)).toBe( - 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - ); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index ccaebe645..6229b26b5 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -4,6 +4,7 @@ import { directThreadHistoryItemsToMessages, isDirectTurnInProgress, } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; describe('Direct 回合状态与历史时间', () => { it('终态和空状态不恢复为活动回合', () => { @@ -27,7 +28,6 @@ describe('Direct 回合状态与历史时间', () => { itemType: 'message', role: 'user', text: '帮我修改游戏', - completed: true, at: 1_800_000_000_001, }, { @@ -35,9 +35,9 @@ describe('Direct 回合状态与历史时间', () => { itemType: 'message', role: 'assistant', text: '好的', - completed: true, + at: 0, }, - ]; + ] satisfies DirectThreadItem[]; expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( 1_800_000_000_001, );