diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 27f81fc6b..a5db418f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3013,7 +3013,12 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id: Some(item_id.clone()), call_id: None, - payload: serde_json::json!({ "delta": delta.clone() }), + // 事件自足:增量也要说明它是哪类条目的正文, + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + payload: serde_json::json!({ + "delta": delta.clone(), + "kind": "message", + }), }, ); } 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 new file mode 100644 index 000000000..e2764e51e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -0,0 +1,253 @@ +/** + * DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。 + * + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目 + * 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。 + */ + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { + DirectThreadRawEvent, + DirectThreadSubscriptionBootstrap, +} from './directThreadEvents'; + +export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; + +/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */ +export type DirectChatEntry = { + itemId: string; + callId?: string | null; + turnId?: string | null; + kind: DirectChatEntryKind; + role?: 'user' | 'assistant' | null; + text?: string | null; + toolCall?: GameCreatorDirectToolCall | null; + at?: number; +}; + +export type DirectThreadChatState = { + subscriptionId: string | null; + /** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */ + lastCompletedItemId: string | null; + /** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */ + activeTurnId: string | null; + /** 历史切片条目,保持文件顺序。 */ + history: DirectChatEntry[]; + /** 运行态条目,保持到达顺序。 */ + live: DirectChatEntry[]; + /** 流式正文累计(itemId → text)。 */ + deltaText: Record; +}; + +export function emptyDirectThreadChatState(): DirectThreadChatState { + return { + subscriptionId: null, + lastCompletedItemId: null, + activeTurnId: null, + history: [], + live: [], + deltaText: {}, + }; +} + +/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */ +export function directChatEntryIdentity(entry: DirectChatEntry): string { + return (entry.callId ?? '').trim() || entry.itemId; +} + +function mergeToolCall( + left: GameCreatorDirectToolCall, + right: GameCreatorDirectToolCall, +): GameCreatorDirectToolCall { + 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, + 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, + }, + startedAt: Math.min( + left.startedAt > 0 ? left.startedAt : right.startedAt, + right.startedAt > 0 ? right.startedAt : left.startedAt, + ), + }; +} + +/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */ +export function mergeDirectChatEntry( + existing: DirectChatEntry, + incoming: DirectChatEntry, +): DirectChatEntry { + const merged: DirectChatEntry = { + ...existing, + ...incoming, + itemId: existing.itemId || incoming.itemId, + kind: + incoming.kind === 'tool' || existing.kind === 'tool' + ? 'tool' + : incoming.kind, + text: incoming.text ?? existing.text, + role: incoming.role ?? existing.role, + turnId: incoming.turnId ?? existing.turnId, + }; + 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, + ); + if (index < 0) { + return { ...state, live: [...state.live, entry] }; + } + const existing = state.live[index]; + if (!existing) { + return { ...state, live: [...state.live, entry] }; + } + const live = [...state.live]; + live[index] = mergeDirectChatEntry(existing, entry); + return { ...state, live }; +} + +/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */ +export function directChatEntryFromEventPayload( + payload: unknown, +): DirectChatEntry | null { + if (!payload || typeof payload !== 'object') return null; + const entry = payload as DirectChatEntry; + if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null; + if (!entry.kind) return null; + return entry; +} + +function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadRawEvent, +): DirectThreadChatState { + switch (event.type) { + case 'turn.started': + return { ...state, activeTurnId: event.turnId }; + case 'turn.completed': + return { + ...state, + activeTurnId: + state.activeTurnId === event.turnId ? null : state.activeTurnId, + }; + 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.started': + case 'item.completed': { + const entry = directChatEntryFromEventPayload(event.payload); + if (!entry) return state; + return upsertLiveEntry(state, { + ...entry, + turnId: entry.turnId ?? event.turnId, + }); + } + default: + return state; + } +} + +export function reduceDirectThreadEvents( + state: DirectThreadChatState, + events: readonly DirectThreadRawEvent[], +): DirectThreadChatState { + return events.reduce(reduceDirectThreadEvent, state); +} + +/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */ +export function resolveDirectThreadBootstrap( + state: DirectThreadChatState, + bootstrap: DirectThreadSubscriptionBootstrap, +): DirectThreadChatState { + return reduceDirectThreadEvents( + { + ...emptyDirectThreadChatState(), + history: state.history, + subscriptionId: bootstrap.subscriptionId, + lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, + }, + bootstrap.events, + ); +} + +/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */ +export function mergeDirectHistoryEntries( + state: DirectThreadChatState, + entries: readonly DirectChatEntry[], +): 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)); + continue; + } + byIdentity.set(identity, entry); + order.push(identity); + } + return { + ...state, + history: order + .map((identity) => byIdentity.get(identity)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)), + }; +} + +/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ +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; +} diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..912e645c7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { + type DirectChatEntry, + directChatEntryIdentity, + emptyDirectThreadChatState, + mergeDirectHistoryEntries, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; + +function event( + partial: Partial & + Pick, +): DirectThreadRawEvent { + return { seq: 1, payload: {}, ...partial }; +} + +function toolEntry( + overrides: Partial = {}, +): DirectChatEntry { + return { + itemId: 'call-1', + callId: 'call-1', + turnId: 'turn-1', + kind: 'tool', + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-1', + turnId: 'turn-1', + kind: 'command', + title: '执行命令', + summary: 'ls', + status: 'running', + detail: { command: 'ls', output: undefined, changes: [] }, + startedAt: 1000, + updatedAt: 1000, + }, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('只有 turn.started 且未完成的回合才是活动回合', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', turnId: 'turn-1' }), + ]); + expect(started.activeTurnId).toBe('turn-1'); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', turnId: 'turn-1' }), + ]); + expect(completed.activeTurnId).toBeNull(); + }); + + 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(); + expect(bootstrapped.subscriptionId).toBe('sub-1'); + expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + }); + + it('增量正文按条目累计,完成快照覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { delta: '你', kind: 'message' }, + }), + event({ + type: 'item.delta', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { delta: '好', kind: 'message' }, + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { + itemId: 'msg-1', + turnId: 'turn-1', + kind: 'message', + role: 'assistant', + text: '你好,我是陶泥儿。', + }, + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('工具条目 started/completed 归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + turnId: 'turn-1', + itemId: 'call-1', + payload: toolEntry(), + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'call-1', + payload: { + ...toolEntry(), + toolCall: { + ...toolEntry().toolCall!, + status: 'completed', + detail: { command: 'ls', output: 'assets\ngame', changes: [] }, + updatedAt: 2000, + }, + }, + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.detail.command).toBe('ls'); + }); + + it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [ + toolEntry(), + { + itemId: 'msg-user', + kind: 'message', + role: 'user', + text: '做一个拼图游戏', + }, + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'call-1', + payload: { + ...toolEntry(), + itemId: 'call-1', + toolCall: { + ...toolEntry().toolCall!, + status: 'completed', + detail: { command: 'ls', output: 'assets', changes: [] }, + }, + }, + }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(directChatEntryIdentity(entries[0]!)).toBe('call-1'); + }); +});