fix(chat): render cached history before network sync
This commit is contained in:
@@ -9,15 +9,6 @@ import {
|
|||||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||||
|
|
||||||
interface LoadHistoryOutput {
|
|
||||||
messages: UiMessage[];
|
|
||||||
hasMore: boolean;
|
|
||||||
newOffset: number;
|
|
||||||
localOverwritten: boolean;
|
|
||||||
localCount: number;
|
|
||||||
networkCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadMoreHistoryOutput {
|
interface LoadMoreHistoryOutput {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -94,14 +85,7 @@ function createTestChatMachine(
|
|||||||
) {
|
) {
|
||||||
return chatMachine.provide({
|
return chatMachine.provide({
|
||||||
actors: {
|
actors: {
|
||||||
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({
|
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
|
||||||
messages: options.historyMessages ?? [],
|
|
||||||
hasMore: false,
|
|
||||||
newOffset: 0,
|
|
||||||
localOverwritten: true,
|
|
||||||
localCount: 0,
|
|
||||||
networkCount: 0,
|
|
||||||
})),
|
|
||||||
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
||||||
async () => ({
|
async () => ({
|
||||||
messages: [],
|
messages: [],
|
||||||
@@ -150,6 +134,35 @@ function createTestChatMachine(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLoadHistoryCallback(
|
||||||
|
messages: UiMessage[],
|
||||||
|
networkMessages: UiMessage[] = messages,
|
||||||
|
) {
|
||||||
|
return fromCallback<ChatEvent>(({ 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", () => {
|
describe("chatMachine transitions", () => {
|
||||||
it("keeps guest logout disabled while allowing upgrade to user login", async () => {
|
it("keeps guest logout disabled while allowing upgrade to user login", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
const actor = createActor(createTestChatMachine()).start();
|
||||||
@@ -187,6 +200,76 @@ describe("chatMachine transitions", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders local history before network history sync finishes", async () => {
|
||||||
|
let resolveNetwork!: () => void;
|
||||||
|
const networkReleased = new Promise<void>((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<ChatEvent>(({ 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 () => {
|
it("ignores guest login while an authenticated user session is active", async () => {
|
||||||
const actor = createActor(createTestChatMachine()).start();
|
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 () => {
|
it("tracks replying state by queued batch instead of user message count", async () => {
|
||||||
const machine = chatMachine.provide({
|
const machine = chatMachine.provide({
|
||||||
actors: {
|
actors: {
|
||||||
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({
|
loadHistory: createLoadHistoryCallback([]),
|
||||||
messages: [],
|
|
||||||
hasMore: false,
|
|
||||||
newOffset: 0,
|
|
||||||
localOverwritten: true,
|
|
||||||
localCount: 0,
|
|
||||||
networkCount: 0,
|
|
||||||
})),
|
|
||||||
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
||||||
async () => ({
|
async () => ({
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export type ChatEvent =
|
|||||||
| { type: "ChatGuestLogin" }
|
| { type: "ChatGuestLogin" }
|
||||||
| { type: "ChatUserLogin"; token: string }
|
| { type: "ChatUserLogin"; token: string }
|
||||||
| { type: "ChatLogout" }
|
| { 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: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
|
|||||||
@@ -1,28 +1,51 @@
|
|||||||
import { fromPromise } from "xstate";
|
import { fromCallback, fromPromise } from "xstate";
|
||||||
|
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
import { Logger, Result } from "@/utils";
|
import { Logger, Result } from "@/utils";
|
||||||
|
|
||||||
import { readAndSyncHistory } from "./chat-history-sync";
|
import {
|
||||||
|
readLocalHistorySnapshot,
|
||||||
|
syncNetworkHistory,
|
||||||
|
} from "./chat-history-sync";
|
||||||
import {
|
import {
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
localMessagesToUi,
|
localMessagesToUi,
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
|
import type { ChatEvent } from "./chat-events";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatHistoryFlow");
|
const log = new Logger("StoresChatChatHistoryFlow");
|
||||||
|
|
||||||
type UiMessage = import("@/data/dto/chat").UiMessage;
|
type UiMessage = import("@/data/dto/chat").UiMessage;
|
||||||
|
|
||||||
export const chatHistoryActors = {
|
export const chatHistoryActors = {
|
||||||
loadHistory: fromPromise<{
|
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
|
||||||
messages: UiMessage[];
|
let cancelled = false;
|
||||||
hasMore: boolean;
|
|
||||||
newOffset: number;
|
void (async () => {
|
||||||
localOverwritten: boolean;
|
const localSnapshot = await readLocalHistorySnapshot();
|
||||||
localCount: number;
|
if (cancelled) return;
|
||||||
networkCount: number;
|
sendBack({
|
||||||
}>(async () => {
|
type: "ChatLocalHistoryLoaded",
|
||||||
return readAndSyncHistory();
|
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<
|
loadMoreHistory: fromPromise<
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ export type ReadAndSyncHistoryOutput = {
|
|||||||
networkCount: number;
|
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 {
|
return {
|
||||||
content:
|
content:
|
||||||
"You're here! Facebook is so restrictive, " +
|
"You're here! Facebook is so restrictive, " +
|
||||||
@@ -28,13 +38,7 @@ function createGreetingMessage(): UiMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function readLocalHistorySnapshot(): Promise<LocalHistorySnapshotOutput> {
|
||||||
* 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<ReadAndSyncHistoryOutput> {
|
|
||||||
const chatRepo = getChatRepository();
|
const chatRepo = getChatRepository();
|
||||||
const greetingMessage = createGreetingMessage();
|
const greetingMessage = createGreetingMessage();
|
||||||
|
|
||||||
@@ -47,27 +51,32 @@ export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
|||||||
count: localMessages.length,
|
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<NetworkHistorySyncOutput | null> {
|
||||||
|
const chatRepo = getChatRepository();
|
||||||
|
const greetingMessage = createGreetingMessage();
|
||||||
|
|
||||||
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
|
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
|
||||||
if (Result.isErr(networkResult)) {
|
if (Result.isErr(networkResult)) {
|
||||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||||
error: networkResult.error,
|
error: networkResult.error,
|
||||||
});
|
});
|
||||||
|
return null;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const networkUi = localMessagesToUi(networkResult.data.messages);
|
const networkUi = localMessagesToUi(networkResult.data.messages);
|
||||||
@@ -95,7 +104,28 @@ export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
|||||||
hasMore: finalMessages.length >= PAGE_SIZE,
|
hasMore: finalMessages.length >= PAGE_SIZE,
|
||||||
newOffset: finalMessages.length,
|
newOffset: finalMessages.length,
|
||||||
localOverwritten,
|
localOverwritten,
|
||||||
localCount: localMessages.length,
|
localCount,
|
||||||
networkCount: networkUi.length,
|
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<ReadAndSyncHistoryOutput> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,3 +26,23 @@ export function applyHistoryLoadedOutput(
|
|||||||
historyLoaded: true,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+118
-51
@@ -34,6 +34,7 @@ import type { ChatEvent } from "./chat-events";
|
|||||||
import {
|
import {
|
||||||
applyHttpSendOutput,
|
applyHttpSendOutput,
|
||||||
applyHistoryLoadedOutput,
|
applyHistoryLoadedOutput,
|
||||||
|
applyNetworkHistoryLoadedOutput,
|
||||||
countLockedHistoryMessages,
|
countLockedHistoryMessages,
|
||||||
shouldAutoUnlockHistory,
|
shouldAutoUnlockHistory,
|
||||||
shouldPromptUnlockHistory,
|
shouldPromptUnlockHistory,
|
||||||
@@ -78,6 +79,46 @@ export const chatMachine = setup({
|
|||||||
clearChatSession: assign(() => ({
|
clearChatSession: assign(() => ({
|
||||||
...initialState,
|
...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({
|
}).createMachine({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
@@ -98,15 +139,27 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
guestSession: {
|
guestSession: {
|
||||||
invoke: {
|
invoke: [
|
||||||
id: "messageQueue",
|
{
|
||||||
src: "httpMessageQueue",
|
id: "messageQueue",
|
||||||
},
|
src: "httpMessageQueue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadHistory",
|
||||||
|
src: "loadHistory",
|
||||||
|
},
|
||||||
|
],
|
||||||
on: {
|
on: {
|
||||||
ChatUserLogin: {
|
ChatUserLogin: {
|
||||||
target: "#chat.userSession",
|
target: "#chat.userSession",
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
|
ChatNetworkHistoryLoaded: {
|
||||||
|
actions: "applyNetworkHistoryLoaded",
|
||||||
|
},
|
||||||
|
ChatHistoryLoadFailed: {
|
||||||
|
actions: "markHistoryLoadFailed",
|
||||||
|
},
|
||||||
ChatQueuedSendStarted: {
|
ChatQueuedSendStarted: {
|
||||||
actions: "markQueuedSendStarted",
|
actions: "markQueuedSendStarted",
|
||||||
},
|
},
|
||||||
@@ -120,25 +173,18 @@ export const chatMachine = setup({
|
|||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
states: {
|
states: {
|
||||||
initializing: {
|
initializing: {
|
||||||
always: [
|
on: {
|
||||||
{
|
ChatLocalHistoryLoaded: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
guard: ({ context }) => context.historyLoaded,
|
actions: "applyLocalHistoryLoaded",
|
||||||
},
|
},
|
||||||
],
|
ChatNetworkHistoryLoaded: {
|
||||||
invoke: {
|
target: "ready",
|
||||||
id: "loadHistory",
|
actions: "applyNetworkHistoryLoaded",
|
||||||
src: "loadHistory",
|
|
||||||
onDone: {
|
|
||||||
actions: assign(({ event }) =>
|
|
||||||
applyHistoryLoadedOutput(event.output),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
onError: {
|
ChatHistoryLoadFailed: {
|
||||||
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
|
target: "ready",
|
||||||
actions: assign({
|
actions: "markHistoryLoadFailed",
|
||||||
historyLoaded: true,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -178,15 +224,50 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
userSession: {
|
userSession: {
|
||||||
invoke: {
|
invoke: [
|
||||||
id: "messageQueue",
|
{
|
||||||
src: "httpMessageQueue",
|
id: "messageQueue",
|
||||||
},
|
src: "httpMessageQueue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadHistory",
|
||||||
|
src: "loadHistory",
|
||||||
|
},
|
||||||
|
],
|
||||||
on: {
|
on: {
|
||||||
ChatLogout: {
|
ChatLogout: {
|
||||||
target: "#chat.idle",
|
target: "#chat.idle",
|
||||||
actions: "clearChatSession",
|
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: {
|
ChatQueuedSendStarted: {
|
||||||
actions: "markQueuedSendStarted",
|
actions: "markQueuedSendStarted",
|
||||||
},
|
},
|
||||||
@@ -232,53 +313,39 @@ export const chatMachine = setup({
|
|||||||
initial: "initializing",
|
initial: "initializing",
|
||||||
states: {
|
states: {
|
||||||
initializing: {
|
initializing: {
|
||||||
invoke: {
|
on: {
|
||||||
src: "loadHistory",
|
ChatLocalHistoryLoaded: {
|
||||||
onDone: [
|
target: "ready",
|
||||||
|
actions: "applyLocalHistoryLoaded",
|
||||||
|
},
|
||||||
|
ChatNetworkHistoryLoaded: [
|
||||||
{
|
{
|
||||||
guard: ({ context, event }) =>
|
guard: ({ context, event }) =>
|
||||||
|
event.type === "ChatNetworkHistoryLoaded" &&
|
||||||
context.paymentUnlockPending &&
|
context.paymentUnlockPending &&
|
||||||
shouldAutoUnlockHistory(event.output.messages),
|
shouldAutoUnlockHistory(event.output.messages),
|
||||||
target: "unlockingHistory",
|
target: "unlockingHistory",
|
||||||
actions: [
|
actions: [
|
||||||
assign(({ event }) => ({
|
"applyNetworkHistoryLoadedAndClearPayment",
|
||||||
...applyHistoryLoadedOutput(event.output),
|
|
||||||
isLoadingMore: false,
|
|
||||||
})),
|
|
||||||
"markUnlockHistoryStarted",
|
"markUnlockHistoryStarted",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
guard: ({ context, event }) =>
|
guard: ({ context, event }) =>
|
||||||
|
event.type === "ChatNetworkHistoryLoaded" &&
|
||||||
context.paymentUnlockPending &&
|
context.paymentUnlockPending &&
|
||||||
shouldPromptUnlockHistory(event.output.messages),
|
shouldPromptUnlockHistory(event.output.messages),
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ event }) => ({
|
actions: "showUnlockHistoryPromptFromNetwork",
|
||||||
...applyHistoryLoadedOutput(event.output),
|
|
||||||
isLoadingMore: false,
|
|
||||||
paymentUnlockPending: false,
|
|
||||||
unlockHistoryPromptVisible: true,
|
|
||||||
lockedHistoryCount: countLockedHistoryMessages(
|
|
||||||
event.output.messages,
|
|
||||||
),
|
|
||||||
unlockHistoryError: null,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ event }) => ({
|
actions: "applyNetworkHistoryLoadedAndClearPayment",
|
||||||
...applyHistoryLoadedOutput(event.output),
|
|
||||||
isLoadingMore: false,
|
|
||||||
paymentUnlockPending: false,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onError: {
|
ChatHistoryLoadFailed: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign({
|
actions: "markHistoryLoadFailed",
|
||||||
isLoadingMore: false,
|
|
||||||
historyLoaded: true,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export interface ChatState {
|
|||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
/** history 加载完成标志(initial load —— local → network → save 跑完)
|
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)
|
||||||
* - guestSession.initializing / user sessions initializing 走 always barrier
|
* - 网络历史加载完成后会再次刷新消息列表
|
||||||
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
||||||
*/
|
*/
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user