diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 2f24285b..6e2c6912 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -9,15 +9,6 @@ import { import { chatMachine } from "@/stores/chat/chat-machine"; import type { ChatEvent } from "@/stores/chat/chat-events"; -interface LoadHistoryOutput { - messages: UiMessage[]; - hasMore: boolean; - newOffset: number; - localOverwritten: boolean; - localCount: number; - networkCount: number; -} - interface LoadMoreHistoryOutput { messages: UiMessage[]; hasMore: boolean; @@ -94,14 +85,7 @@ function createTestChatMachine( ) { return chatMachine.provide({ actors: { - loadHistory: fromPromise(async () => ({ - messages: options.historyMessages ?? [], - hasMore: false, - newOffset: 0, - localOverwritten: true, - localCount: 0, - networkCount: 0, - })), + loadHistory: createLoadHistoryCallback(options.historyMessages ?? []), loadMoreHistory: fromPromise( async () => ({ messages: [], @@ -150,6 +134,35 @@ function createTestChatMachine( }); } +function createLoadHistoryCallback( + messages: UiMessage[], + networkMessages: UiMessage[] = messages, +) { + return fromCallback(({ sendBack }) => { + sendBack({ + type: "ChatLocalHistoryLoaded", + output: { + messages, + hasMore: false, + newOffset: messages.length, + localCount: messages.length, + }, + }); + sendBack({ + type: "ChatNetworkHistoryLoaded", + output: { + messages: networkMessages, + hasMore: false, + newOffset: networkMessages.length, + localOverwritten: true, + localCount: messages.length, + networkCount: networkMessages.length, + }, + }); + return () => undefined; + }); +} + describe("chatMachine transitions", () => { it("keeps guest logout disabled while allowing upgrade to user login", async () => { const actor = createActor(createTestChatMachine()).start(); @@ -187,6 +200,76 @@ describe("chatMachine transitions", () => { actor.stop(); }); + it("renders local history before network history sync finishes", async () => { + let resolveNetwork!: () => void; + const networkReleased = new Promise((resolve) => { + resolveNetwork = resolve; + }); + const localMessage: UiMessage = { + id: "local-msg", + content: "cached local message", + isFromAI: true, + date: "2026-07-02", + }; + const networkMessage: UiMessage = { + id: "network-msg", + content: "fresh network message", + isFromAI: true, + date: "2026-07-02", + }; + const machine = chatMachine.provide({ + actors: { + loadHistory: fromCallback(({ sendBack }) => { + sendBack({ + type: "ChatLocalHistoryLoaded", + output: { + messages: [localMessage], + hasMore: false, + newOffset: 1, + localCount: 1, + }, + }); + void networkReleased.then(() => { + sendBack({ + type: "ChatNetworkHistoryLoaded", + output: { + messages: [networkMessage], + hasMore: false, + newOffset: 1, + localOverwritten: true, + localCount: 1, + networkCount: 1, + }, + }); + }); + return () => undefined; + }), + }, + }); + const actor = createActor(machine).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + expect(actor.getSnapshot().context.messages).toMatchObject([ + { id: "local-msg", content: "cached local message" }, + ]); + + resolveNetwork(); + await waitFor( + actor, + (snapshot) => snapshot.context.messages[0]?.id === "network-msg", + ); + + expect(actor.getSnapshot().context.messages).toMatchObject([ + { id: "network-msg", content: "fresh network message" }, + ]); + + actor.stop(); + }); + it("ignores guest login while an authenticated user session is active", async () => { const actor = createActor(createTestChatMachine()).start(); @@ -254,14 +337,7 @@ describe("chatMachine transitions", () => { it("tracks replying state by queued batch instead of user message count", async () => { const machine = chatMachine.provide({ actors: { - loadHistory: fromPromise(async () => ({ - messages: [], - hasMore: false, - newOffset: 0, - localOverwritten: true, - localCount: 0, - networkCount: 0, - })), + loadHistory: createLoadHistoryCallback([]), loadMoreHistory: fromPromise( async () => ({ messages: [], diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index b8660f9a..b53b85fa 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -22,6 +22,15 @@ export type ChatEvent = | { type: "ChatGuestLogin" } | { type: "ChatUserLogin"; token: string } | { type: "ChatLogout" } + | { + type: "ChatLocalHistoryLoaded"; + output: import("./chat-history-sync").LocalHistorySnapshotOutput; + } + | { + type: "ChatNetworkHistoryLoaded"; + output: import("./chat-history-sync").NetworkHistorySyncOutput; + } + | { type: "ChatHistoryLoadFailed"; error: unknown } // 业务事件 | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } diff --git a/src/stores/chat/chat-history-flow.ts b/src/stores/chat/chat-history-flow.ts index f4a31419..869a2068 100644 --- a/src/stores/chat/chat-history-flow.ts +++ b/src/stores/chat/chat-history-flow.ts @@ -1,28 +1,51 @@ -import { fromPromise } from "xstate"; +import { fromCallback, fromPromise } from "xstate"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { Logger, Result } from "@/utils"; -import { readAndSyncHistory } from "./chat-history-sync"; +import { + readLocalHistorySnapshot, + syncNetworkHistory, +} from "./chat-history-sync"; import { PAGE_SIZE, localMessagesToUi, } from "./chat-machine.helpers"; +import type { ChatEvent } from "./chat-events"; const log = new Logger("StoresChatChatHistoryFlow"); type UiMessage = import("@/data/dto/chat").UiMessage; export const chatHistoryActors = { - loadHistory: fromPromise<{ - messages: UiMessage[]; - hasMore: boolean; - newOffset: number; - localOverwritten: boolean; - localCount: number; - networkCount: number; - }>(async () => { - return readAndSyncHistory(); + loadHistory: fromCallback(({ sendBack }) => { + let cancelled = false; + + void (async () => { + const localSnapshot = await readLocalHistorySnapshot(); + if (cancelled) return; + sendBack({ + type: "ChatLocalHistoryLoaded", + output: localSnapshot, + }); + + const networkSnapshot = await syncNetworkHistory( + localSnapshot.localCount, + ); + if (cancelled || !networkSnapshot) return; + sendBack({ + type: "ChatNetworkHistoryLoaded", + output: networkSnapshot, + }); + })().catch((error: unknown) => { + if (cancelled) return; + log.error("[chat-machine] loadHistoryActor failed", { error }); + sendBack({ type: "ChatHistoryLoadFailed", error }); + }); + + return () => { + cancelled = true; + }; }), loadMoreHistory: fromPromise< diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index 4e3b76b5..68ee543e 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -17,7 +17,17 @@ export type ReadAndSyncHistoryOutput = { networkCount: number; }; -function createGreetingMessage(): UiMessage { +export type LocalHistorySnapshotOutput = { + /** Local cached messages. Empty local history includes a UI greeting. */ + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + localCount: number; +}; + +export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput; + +export function createGreetingMessage(): UiMessage { return { content: "You're here! Facebook is so restrictive, " + @@ -28,13 +38,7 @@ function createGreetingMessage(): UiMessage { }; } -/** - * History sync flow: - * 1. Read local history for fallback. - * 2. Read network history as the authoritative source. - * 3. Overwrite local history with network data. - */ -export async function readAndSyncHistory(): Promise { +export async function readLocalHistorySnapshot(): Promise { const chatRepo = getChatRepository(); const greetingMessage = createGreetingMessage(); @@ -47,27 +51,32 @@ export async function readAndSyncHistory(): Promise { count: localMessages.length, }); + const snapshotMessages = + localMessages.length === 0 ? [greetingMessage] : localMessages; + if (snapshotMessages[0] === greetingMessage) { + log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting (local)"); + } + + return { + messages: snapshotMessages, + hasMore: localMessages.length >= PAGE_SIZE, + newOffset: snapshotMessages.length, + localCount: localMessages.length, + }; +} + +export async function syncNetworkHistory( + localCount: number, +): Promise { + const chatRepo = getChatRepository(); + const greetingMessage = createGreetingMessage(); + const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0); if (Result.isErr(networkResult)) { log.error("[chat-machine] loadHistory NETWORK FAILED", { error: networkResult.error, }); - - const fallbackMessages = - localMessages.length === 0 ? [greetingMessage] : localMessages; - if (fallbackMessages[0] === greetingMessage) { - log.debug( - "[chat-machine] loadHistory EMPTY -> prepend greeting (network-failed path)", - ); - } - return { - messages: fallbackMessages, - hasMore: false, - newOffset: fallbackMessages.length, - localOverwritten: false, - localCount: localMessages.length, - networkCount: 0, - }; + return null; } const networkUi = localMessagesToUi(networkResult.data.messages); @@ -95,7 +104,28 @@ export async function readAndSyncHistory(): Promise { hasMore: finalMessages.length >= PAGE_SIZE, newOffset: finalMessages.length, localOverwritten, - localCount: localMessages.length, + localCount, networkCount: networkUi.length, }; } + +/** + * History sync flow: + * 1. Read local history for fallback. + * 2. Read network history as the authoritative source. + * 3. Overwrite local history with network data. + */ +export async function readAndSyncHistory(): Promise { + const localSnapshot = await readLocalHistorySnapshot(); + const networkSnapshot = await syncNetworkHistory(localSnapshot.localCount); + if (networkSnapshot) return networkSnapshot; + + return { + messages: localSnapshot.messages, + hasMore: localSnapshot.hasMore, + newOffset: localSnapshot.newOffset, + localOverwritten: false, + localCount: localSnapshot.localCount, + networkCount: 0, + }; +} diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index f726c617..02661246 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -26,3 +26,23 @@ export function applyHistoryLoadedOutput( historyLoaded: true, }; } + +export function applyNetworkHistoryLoadedOutput( + context: ChatState, + output: { + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + }, +): Pick< + ChatState, + "messages" | "hasMore" | "historyOffset" | "historyLoaded" +> { + const optimisticTail = context.messages.slice(context.historyOffset); + return { + messages: [...output.messages, ...optimisticTail], + hasMore: output.hasMore, + historyOffset: output.newOffset, + historyLoaded: true, + }; +} diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index a4066792..5a7bcc66 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -34,6 +34,7 @@ import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput, applyHistoryLoadedOutput, + applyNetworkHistoryLoadedOutput, countLockedHistoryMessages, shouldAutoUnlockHistory, shouldPromptUnlockHistory, @@ -78,6 +79,46 @@ export const chatMachine = setup({ clearChatSession: assign(() => ({ ...initialState, })), + + applyLocalHistoryLoaded: assign(({ event }) => { + if (event.type !== "ChatLocalHistoryLoaded") return {}; + return applyHistoryLoadedOutput(event.output); + }), + + applyNetworkHistoryLoaded: assign(({ context, event }) => { + if (event.type !== "ChatNetworkHistoryLoaded") return {}; + return applyNetworkHistoryLoadedOutput(context, event.output); + }), + + applyNetworkHistoryLoadedAndClearPayment: assign(({ context, event }) => { + if (event.type !== "ChatNetworkHistoryLoaded") return {}; + return { + ...applyNetworkHistoryLoadedOutput(context, event.output), + isLoadingMore: false, + paymentUnlockPending: false, + }; + }), + + showUnlockHistoryPromptFromNetwork: assign(({ context, event }) => { + if (event.type !== "ChatNetworkHistoryLoaded") return {}; + const nextHistory = applyNetworkHistoryLoadedOutput( + context, + event.output, + ); + return { + ...nextHistory, + isLoadingMore: false, + paymentUnlockPending: false, + unlockHistoryPromptVisible: true, + lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages), + unlockHistoryError: null, + }; + }), + + markHistoryLoadFailed: assign({ + isLoadingMore: false, + historyLoaded: true, + }), }, }).createMachine({ id: "chat", @@ -98,15 +139,27 @@ export const chatMachine = setup({ }, guestSession: { - invoke: { - id: "messageQueue", - src: "httpMessageQueue", - }, + invoke: [ + { + id: "messageQueue", + src: "httpMessageQueue", + }, + { + id: "loadHistory", + src: "loadHistory", + }, + ], on: { ChatUserLogin: { target: "#chat.userSession", actions: "startUserSession", }, + ChatNetworkHistoryLoaded: { + actions: "applyNetworkHistoryLoaded", + }, + ChatHistoryLoadFailed: { + actions: "markHistoryLoadFailed", + }, ChatQueuedSendStarted: { actions: "markQueuedSendStarted", }, @@ -120,25 +173,18 @@ export const chatMachine = setup({ initial: "initializing", states: { initializing: { - always: [ - { + on: { + ChatLocalHistoryLoaded: { target: "ready", - guard: ({ context }) => context.historyLoaded, + actions: "applyLocalHistoryLoaded", }, - ], - invoke: { - id: "loadHistory", - src: "loadHistory", - onDone: { - actions: assign(({ event }) => - applyHistoryLoadedOutput(event.output), - ), + ChatNetworkHistoryLoaded: { + target: "ready", + actions: "applyNetworkHistoryLoaded", }, - onError: { - // 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空) - actions: assign({ - historyLoaded: true, - }), + ChatHistoryLoadFailed: { + target: "ready", + actions: "markHistoryLoadFailed", }, }, }, @@ -178,15 +224,50 @@ export const chatMachine = setup({ }, userSession: { - invoke: { - id: "messageQueue", - src: "httpMessageQueue", - }, + invoke: [ + { + id: "messageQueue", + src: "httpMessageQueue", + }, + { + id: "loadHistory", + src: "loadHistory", + }, + ], on: { ChatLogout: { target: "#chat.idle", actions: "clearChatSession", }, + ChatNetworkHistoryLoaded: [ + { + guard: ({ context, event }) => + event.type === "ChatNetworkHistoryLoaded" && + context.paymentUnlockPending && + shouldAutoUnlockHistory(event.output.messages), + target: ".unlockingHistory", + actions: [ + "applyNetworkHistoryLoadedAndClearPayment", + "markUnlockHistoryStarted", + ], + }, + { + guard: ({ context, event }) => + event.type === "ChatNetworkHistoryLoaded" && + context.paymentUnlockPending && + shouldPromptUnlockHistory(event.output.messages), + target: ".ready", + actions: "showUnlockHistoryPromptFromNetwork", + }, + { + guard: ({ context }) => + !context.isUnlockingHistory && !context.isUnlockingMessage, + actions: "applyNetworkHistoryLoaded", + }, + ], + ChatHistoryLoadFailed: { + actions: "markHistoryLoadFailed", + }, ChatQueuedSendStarted: { actions: "markQueuedSendStarted", }, @@ -232,53 +313,39 @@ export const chatMachine = setup({ initial: "initializing", states: { initializing: { - invoke: { - src: "loadHistory", - onDone: [ + on: { + ChatLocalHistoryLoaded: { + target: "ready", + actions: "applyLocalHistoryLoaded", + }, + ChatNetworkHistoryLoaded: [ { guard: ({ context, event }) => + event.type === "ChatNetworkHistoryLoaded" && context.paymentUnlockPending && shouldAutoUnlockHistory(event.output.messages), target: "unlockingHistory", actions: [ - assign(({ event }) => ({ - ...applyHistoryLoadedOutput(event.output), - isLoadingMore: false, - })), + "applyNetworkHistoryLoadedAndClearPayment", "markUnlockHistoryStarted", ], }, { guard: ({ context, event }) => + event.type === "ChatNetworkHistoryLoaded" && context.paymentUnlockPending && shouldPromptUnlockHistory(event.output.messages), target: "ready", - actions: assign(({ event }) => ({ - ...applyHistoryLoadedOutput(event.output), - isLoadingMore: false, - paymentUnlockPending: false, - unlockHistoryPromptVisible: true, - lockedHistoryCount: countLockedHistoryMessages( - event.output.messages, - ), - unlockHistoryError: null, - })), + actions: "showUnlockHistoryPromptFromNetwork", }, { target: "ready", - actions: assign(({ event }) => ({ - ...applyHistoryLoadedOutput(event.output), - isLoadingMore: false, - paymentUnlockPending: false, - })), + actions: "applyNetworkHistoryLoadedAndClearPayment", }, ], - onError: { + ChatHistoryLoadFailed: { target: "ready", - actions: assign({ - isLoadingMore: false, - historyLoaded: true, - }), + actions: "markHistoryLoadFailed", }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 83847682..414fab19 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -27,8 +27,8 @@ export interface ChatState { isLoadingMore: boolean; hasMore: boolean; historyOffset: number; - /** history 加载完成标志(initial load —— local → network → save 跑完) - * - guestSession.initializing / user sessions initializing 走 always barrier + /** history 首屏可展示标志(本地快照加载完成即可进入 ready) + * - 网络历史加载完成后会再次刷新消息列表 * - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事) */ historyLoaded: boolean;