diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 0a936c1fa..91bb2b6f1 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -14,6 +14,8 @@ export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; export const CONVERSATION_VISIBLE_STEP = 20; +/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */ +export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts new file mode 100644 index 000000000..3065848b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts @@ -0,0 +1,84 @@ +/** + * DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。 + * + * 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏, + * 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后 + * 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。 + * + * 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。 + */ + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants'; +import { + type DirectChatEntry, + mergeHistoryEntries, + projectDirectHistoryItems, +} from './directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from './directThreadEvents'; +import { buildDirectChatTurns } from './directTurnPresentation'; + +export type DirectHistoryPages = { + /** 累积到的条目,保持文件顺序(旧 → 新)。 */ + items: DirectThreadItem[]; + /** 后端声明的"还有更早的历史";取不动时收口为 false。 */ + hasMore: boolean; + /** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */ + firstItemId: string | null; + /** 中断原因;`null` 表示正常停止。 */ + error: unknown; +}; + +function visibleTurnCount(entries: readonly DirectChatEntry[]): number { + return buildDirectChatTurns({ entries }).length; +} + +/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */ +function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) { + return [...pages].reverse().flat(); +} + +export async function readDirectHistoryPages({ + existingEntries, + beforeItemId, + readSlice, +}: { + /** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */ + existingEntries: readonly DirectChatEntry[]; + beforeItemId: string | null; + readSlice: (beforeItemId: string | null) => Promise; +}): Promise { + const baseTurnCount = visibleTurnCount(existingEntries); + const pages: DirectThreadItem[][] = []; + let anchor = beforeItemId; + let hasMore = false; + let error: unknown = null; + for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) { + let slice: DirectThreadHistorySlice; + try { + slice = await readSlice(anchor); + } catch (thrown) { + // 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。 + error = thrown; + break; + } + pages.push([...slice.items]); + hasMore = slice.hasMore; + const nextAnchor = slice.firstItemId; + if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) { + // 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。 + hasMore = false; + break; + } + anchor = nextAnchor; + if (!hasMore) break; + const merged = mergeHistoryEntries( + projectDirectHistoryItems(inFileOrder(pages)), + existingEntries, + ); + if (visibleTurnCount(merged) > baseTurnCount) break; + } + return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error }; +} 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 d9b978db5..76e4a5fa3 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 @@ -230,6 +230,15 @@ export function mergeHistoryEntries( return entries; } +/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */ +export function projectDirectHistoryItems( + items: readonly DirectThreadItem[], +): DirectChatEntry[] { + return items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); +} + /** * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 * @@ -239,10 +248,13 @@ export function mergeDirectHistoryItems( state: DirectThreadChatState, items: readonly DirectThreadItem[], ): DirectThreadChatState { - const entries = items - .map((item) => projectDirectThreadItem(item)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)); - return { ...state, history: mergeHistoryEntries(entries, state.history) }; + return { + ...state, + history: mergeHistoryEntries( + projectDirectHistoryItems(items), + state.history, + ), + }; } export function mergeDirectThreadHistorySlice( diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts new file mode 100644 index 000000000..50bfe55bb --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; +import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from '../src/features/project-workspace/directThreadEvents'; + +function userItem(itemId: string): DirectThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function assistantItem(itemId: string): DirectThreadItem { + return { + itemType: 'message', + itemId, + role: 'assistant', + text: `答复 ${itemId}`, + at: 2000, + }; +} + +function toolItem(itemId: string): DirectThreadItem { + return { + itemType: 'function_call', + itemId, + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1500, + }; +} + +function reasoningItem(itemId: string): DirectThreadItem { + return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; +} + +function slice( + items: DirectThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): DirectThreadHistorySlice { + return { items, hasMore, firstItemId }; +} + +/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ +function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { + return selectDirectChatEntries( + mergeDirectHistoryItems(emptyDirectThreadChatState(), items), + ); +} + +function readerOf(pages: DirectThreadHistorySlice[]) { + const remaining = [...pages]; + return vi.fn(async (_beforeItemId: string | null) => { + const next = remaining.shift(); + if (!next) throw new Error('测试桩:不该再取页'); + return next; + }); +} + +describe('DirectProject 历史分页连拉', () => { + it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { + // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 + const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); + const readSlice = readerOf([ + slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), + slice( + [userItem('turn-1-user'), assistantItem('turn-1-assistant')], + false, + 'turn-1-user', + ), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 + expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); + expect(result.items.map((item) => item.itemId)).toEqual([ + 'turn-1-user', + 'turn-1-assistant', + 'call-3', + 'reason-3', + ]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-1-user'); + expect(result.error).toBeNull(); + const merged = selectDirectChatEntries( + mergeDirectHistoryItems( + { turnRunning: false, history: existing, live: [] }, + result.items, + ), + ); + expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); + }); + + it('第一页就出现新的用户气泡时只取一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([userItem('turn-1-user')], true, 'turn-1-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 + expect(result.hasMore).toBe(true); + expect(result.firstItemId).toBe('turn-1-user'); + }); + + it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { + const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice.mock.calls).toEqual([[null]]); + expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); + }); + + it('hasMore=false 时即使没有新回合也立即停止', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('一直没有新回合时最多连拉上限页数就停手', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf( + Array.from( + { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, + (_, index) => + slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), + ), + ); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes( + DIRECT_HISTORY_MAX_PAGES_PER_ACTION, + ); + // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); + expect(result.firstItemId).toBe( + `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, + ); + }); + + it('锚点不前进时立即停止,不空转也不留按钮', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([], true, null)]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-2-user'); + }); + + it('锚点原地打转时立即停止,不重复取同一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([toolItem('call-3')], true, 'turn-2-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('某一页读取失败时保留已经取到的页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const failure = new Error('读取失败'); + const readSlice = vi.fn(async (beforeItemId: string | null) => { + if (beforeItemId === 'turn-2-user') { + return slice([toolItem('call-3')], true, 'call-3'); + } + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(2); + expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); + // 失败的页没有推进锚点:下一次点击会重取这一页。 + expect(result.firstItemId).toBe('call-3'); + expect(result.hasMore).toBe(true); + expect(result.error).toBe(failure); + }); +});