From 1f3980d461f88a0c4e4f3fa76a2135f39e3d0a54 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 15 Jun 2026 16:21:40 +0800 Subject: [PATCH] chore(chat): refactor chat init into separate quota and history actors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split `chatInitActor` into `loadQuotaActor` (guest quota fetch) and `loadHistoryActor` (local → network → save sync), and add `quotaLoaded` / `historyLoaded` state flags so the UI can display loading status during the new local-first history hydration flow. Also drop the now-unused `ChatInit` event and pipe Next.js stdout/stderr to a persistent log file in the post-receive hook for easier troubleshooting. --- .githooks/post-receive | 4 +- src/app/chat/components/chat-screen.tsx | 1 - src/stores/chat/chat-context.tsx | 6 + src/stores/chat/chat-events.ts | 2 - src/stores/chat/chat-machine.actors.ts | 65 ++++++- src/stores/chat/chat-machine.helpers.ts | 134 ++++++++++---- src/stores/chat/chat-machine.ts | 224 +++++++++++++----------- src/stores/chat/chat-state.ts | 12 ++ 8 files changed, 296 insertions(+), 152 deletions(-) diff --git a/.githooks/post-receive b/.githooks/post-receive index d53cd9b9..6069c739 100755 --- a/.githooks/post-receive +++ b/.githooks/post-receive @@ -116,8 +116,10 @@ stop_existing_next() { # 6. launch_next —— 后**台**启 `pnpm run start`,捕 PID # ============================================================ launch_next() { + # next stdout/stderr 接**到** logs/next-server.log(**追**加**模式**,**保**留**历**史** log **用**于排**查**) + NEXT_LOG_FILE="$REPO_TOPLEVEL/logs/next-server.log" echo "=== start LAUNCH @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" - nohup pnpm run start > /dev/null 2>&1 & + nohup pnpm run start >> "$NEXT_LOG_FILE" 2>&1 & START_PID=$! echo "=== start PID=$START_PID @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" } diff --git a/src/app/chat/components/chat-screen.tsx b/src/app/chat/components/chat-screen.tsx index d4808c27..a2f601ae 100644 --- a/src/app/chat/components/chat-screen.tsx +++ b/src/app/chat/components/chat-screen.tsx @@ -17,7 +17,6 @@ * - QuotaDialog:配额告警弹窗(**仅**游客) * - PwaInstallOverlay:PWA 安装提示触发器 * - BrowserHintOverlay:浏览器提示 - * - CharacterIntro:角色介绍卡片(首屏) * * **鉴权解耦**(**事件驱动**): * - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 87105e40..60e483f0 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -40,6 +40,10 @@ interface ChatState { isLoadingMore: boolean; hasMore: boolean; historyOffset: number; + /** 游客配额加载完成标**志**(**仅** guestSession 期间) —— UI 可**用**此**显**示 loading */ + quotaLoaded: boolean; + /** history 初始加**载**完成标**志**(local → network → save 跑**完**) —— UI 可**用**此**显**示 loading */ + historyLoaded: boolean; } const ChatStateCtx = createContext(null); @@ -63,6 +67,8 @@ export function ChatProvider({ children }: ChatProviderProps) { isLoadingMore: state.context.isLoadingMore, hasMore: state.context.hasMore, historyOffset: state.context.historyOffset, + quotaLoaded: state.context.quotaLoaded, + historyLoaded: state.context.historyLoaded, }), [state], ); diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 6caf7e0f..747f26ae 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -16,8 +16,6 @@ * chat-screen **不**直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。 */ export type ChatEvent = - // 内部 UI 事件 - | { type: "ChatInit" } // 鉴权生命周期(**用户**显式触发登录后由 chat-screen 派) | { type: "ChatGuestLogin" } | { type: "ChatNonGuestLogin"; token: string } diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index b5d9fb78..66383787 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -2,9 +2,10 @@ * Chat 状态机:4 个 XState actor * * 从 `chat-machine.ts` 抽出的"Actors"段: - * - `chatInitActor`(fromPromise,**仅**游客有意义) + * - `loadQuotaActor`(fromPromise,**仅**游客有意义 —— 拉**日**配 + 总**配**) + * - `loadHistoryActor`(fromPromise —— local → network → save network to local 3 **步**) * - `sendMessageHttpActor`(fromPromise,**已** wire —— "一次发送 = 一次返回") - * - `loadMoreHistoryActor`(fromPromise) + * - `loadMoreHistoryActor`(fromPromise —— 翻**页**,**不**走 local-first) * - `chatWebSocketActor`(fromCallback,长生命周期 —— **仅** userSession 期间 invoke) * * 设计: @@ -23,17 +24,62 @@ import { PAGE_SIZE, chatRepo, localMessagesToUi, - readInitData, + readAndSyncHistory, + readGuestQuota, sendResponseToUiMessage, - type InitResult, } from "./chat-machine.helpers"; import type { ChatEvent } from "./chat-events"; -// ============================================================// HTTP: one-shot Promise // ============================================================ -/** 拉初始数据(**仅**游客有意义) */ -export const chatInitActor = fromPromise(async () => readInitData()); +// Init 任务 1:拉游客配额(fromPromise,**纯** local) +// ============================================================ +/** + * 拉游客**日**配 + 总**配**(**仅** guestSession 期间 invoke) + * - 失**败**返 `{ remaining: 0, total: 0 }`(**不**抛,**让** UI **还**是**能**进** ready) + * - onDone 在 state machine **里**赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true + */ +export const loadQuotaActor = fromPromise<{ + remaining: number; + total: number; +}>(async () => { + console.log("[chat-machine] loadQuotaActor ENTRY"); + const result = await readGuestQuota(); + console.log("[chat-machine] loadQuotaActor DONE", result); + return result; +}); +// ============================================================ +// Init 任务 2:拉 history 3 步流(fromPromise,**返**回**最**终**结**果**) +// ============================================================ +/** + * local → network → save network to local 3 步流(**返**回** network **最**终** messages**) + * - 选**方**案 A(fromPromise):UI **不**会**看**到** local 中**间**态**,**直**接**显**示** network **结**果** + * - 内部 3 步**走** helper `readAndSyncHistory`(**日**志**全**在** helper **里**) + * - 失**败**返 local(**退**而**求**其**次**) + */ +export const loadHistoryActor = fromPromise<{ + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + localOverwritten: boolean; + localCount: number; + networkCount: number; +}>(async () => { + console.log("[chat-machine] loadHistoryActor ENTRY"); + const result = await readAndSyncHistory(); + console.log("[chat-machine] loadHistoryActor DONE", { + finalCount: result.messages.length, + localCount: result.localCount, + networkCount: result.networkCount, + hasMore: result.hasMore, + localOverwritten: result.localOverwritten, + }); + return result; +}); + +// ============================================================ +// HTTP: one-shot Promise +// ============================================================ /** * HTTP 发送消息(**一次发送 = 一次返回**) * - 后端响应**就**是 AI 回复(`ChatSendResponse.reply: string`) @@ -76,7 +122,7 @@ export const sendMessageHttpActor = fromPromise< return { reply }; }); -/** 翻历史(pagination) */ +/** 翻历史(pagination)—— **不**走 local-first,**纯** server fetch */ export const loadMoreHistoryActor = fromPromise< { messages: UiMessage[]; hasMore: boolean; newOffset: number }, { offset: number } @@ -123,7 +169,8 @@ export const loadMoreHistoryActor = fromPromise< }; }); -// ============================================================// WebSocket: long-lived callback +// ============================================================ +// WebSocket: long-lived callback // ============================================================ /** * 长生命周期 WebSocket(**仅** `userSession` 期间 invoke) diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 042e304a..b3de76bd 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -6,34 +6,42 @@ * - 仓库注入(接口类型 → 单例) * - DTO ↔ UiMessage 映射(**纯函数** —— 可单测) * - Result → number 映射(**纯函数** —— 可单测) - * - 初始数据装配(**仅**游客有意义 —— `chatInitActor` 调用) + * - 2 **个**独立 init 任务**的**数据加载**: + * - `readGuestQuota()` —— 游客**日**配 + 总**配**(**仅**游客有意义,**纯** ChatStorage) + * - `readAndSyncHistory()` —— local → network → save network to local 3 **步** * * 设计目标: * - helpers **无 XState 依赖**(可独立 import / 测试) * - actors 依赖 helpers(import) * - machine 依赖 actors(import)—— **不**直接依赖 helpers + * + * 历史: + * - `readInitData` + `InitResult` 已**删**(**被** 2 **个**独立 helper **替**换**) + * - `AuthStorage` import **已**删**(**只** `readInitData` 用过,**移**到** `chatInit` actor **上**层**调**用**) */ import type { UiMessage } from "@/models/chat/ui-message"; import { chatRepository } from "@/data/repositories/chat_repository"; import type { IChatRepository } from "@/data/repositories/interfaces"; import { ChatStorage } from "@/data/storage/chat/chat_storage"; -import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { formatDate } from "@/utils/date"; import { Result } from "@/utils/result"; -// ============================================================// Constants +// ============================================================ +// Constants // ============================================================ /** 翻历史每页大小(**仅** `loadMoreHistoryActor` 用) */ export const PAGE_SIZE = 20; -// ============================================================// Repository injection +// ============================================================ +// Repository injection // ============================================================ // 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例 export const chatRepo: IChatRepository = chatRepository; -// ============================================================// DTO ↔ UiMessage 映射 +// ============================================================ +// DTO ↔ UiMessage 映射 // ============================================================ /** * ChatMessage[] → UiMessage[](**纯函数**) @@ -67,7 +75,8 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { }; } -// ============================================================// Result → number 映射 +// ============================================================ +// Result → number 映射 // ============================================================ /** 日配额 Result → number(**纯函数**) */ export function mapQuotaResult( @@ -87,45 +96,100 @@ export function mapTotalQuotaResult( return fallback; } -// ============================================================// Initial data // ============================================================ -export interface InitResult { - localMessages: UiMessage[]; - isGuest: boolean; - guestRemainingQuota: number; - guestTotalQuota: number; -} - +// Init 任务 1:游客配额(**纯** local)—— `loadQuotaActor` 调 +// ============================================================ /** - * 读取初始数据(`chatInitActor` 调用) - * - * - `isGuest` 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额" - * —— **不**是鉴权信号。鉴权判断已上提到 chat-screen 派生。 - * - 配额 fallback 0 —— 非游客路径用不到;onDone 会**条件**赋。 + * 读取游客配额(**仅** guestSession 有意义) + * - 游客**日**配 + 总**配** 两**项**都**从** ChatStorage 拉(**纯** local,**无**网络) + * - 失**败**返 fallback 0(**不**抛)—— **让** UI **还**是**能**进** ready */ -export async function readInitData(): Promise { +export async function readGuestQuota(): Promise<{ + remaining: number; + total: number; +}> { const chatStorage = ChatStorage.getInstance(); - const isGuest = !AuthStorage.getInstance().hasLoginToken(); - - const [dailyResult, totalResult, localResult] = await Promise.all([ + const [dailyResult, totalResult] = await Promise.all([ chatStorage.getGuestDailyChatQuota(), chatStorage.getGuestTotalQuota(), - isGuest ? chatRepo.getLocalMessages() : Promise.resolve(null), ]); - return { - isGuest, - guestRemainingQuota: mapQuotaResult( + remaining: mapQuotaResult( dailyResult as Result<{ remaining: number } | null>, 0, ), - guestTotalQuota: mapTotalQuotaResult( - totalResult as Result, - 0, - ), - localMessages: - localResult && Result.isOk(localResult) && localResult.data - ? localMessagesToUi(localResult.data) - : [], + total: mapTotalQuotaResult(totalResult as Result, 0), + }; +} + +// ============================================================ +// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调 +// ============================================================ +/** + * 拉 history **流**程(**每**步**都**记**日**志** —— 用户要求"**不**要**删**除**") + * 1. **读** local(chatRepo.getLocalMessages)→ **返** localMessages + * 2. **读** network(chatRepo.getHistory)→ **返** networkMessages + * 3. **用** network **覆**盖** local(chatRepo.saveMessagesToLocal)→ local **被**同步 + * + * **返**回** network **最**终** messages**(authorititative),**不**返** local(UI **不**需**要**过渡**态**) + * + * **保**留**日**志**(**前**缀 `[chat-machine]` 与 chat-machine.actors.ts **对**齐**) + */ +export async function readAndSyncHistory(): Promise<{ + messages: UiMessage[]; // network **最**终** messages(authorititative) + hasMore: boolean; + newOffset: number; + localOverwritten: boolean; // true = local **已**被 network **覆**盖**(**写**入** ChatStorage) + /** 真实从 local 读到的消息数(**调**试**用**) */ + localCount: number; + /** 真实从 network 读到的消息数(**调**试**用**) */ + networkCount: number; +}> { + // 1. **读** local + const localResult = await chatRepo.getLocalMessages(); + const localMessages = + Result.isOk(localResult) && localResult.data + ? localMessagesToUi(localResult.data) + : []; + console.log("[chat-machine] loadHistory LOCAL DONE", { + count: localMessages.length, + }); + + // 2. **读** network + const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0); + if (!Result.isOk(networkResult)) { + // network 失**败** —— **返**空 messages(**不**让 UI 卡**住**) + console.error( + "[chat-machine] loadHistory NETWORK FAILED", + networkResult.success ? null : (networkResult as { error: unknown }).error, + ); + return { + messages: localMessages, // **退**而**求**其**次**:返 local(**不**然**屏**幕**空**) + hasMore: false, + newOffset: 0, + localOverwritten: false, + localCount: localMessages.length, + networkCount: 0, + }; + } + const networkUi = localMessagesToUi(networkResult.data.messages); + console.log("[chat-machine] loadHistory NETWORK DONE", { + count: networkUi.length, + }); + + // 3. **用** network **覆**盖** local("**再**用**网**络**数**据**覆**盖**本**地**数**据"**这**句**的**字**面**实**现**) + const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages); + const localOverwritten = Result.isOk(saveResult); + console.log("[chat-machine] loadHistory SAVE TO LOCAL DONE", { + localOverwritten, + }); + + return { + messages: networkUi, // authorititative + hasMore: networkUi.length >= PAGE_SIZE, + newOffset: networkUi.length, + localOverwritten, + localCount: localMessages.length, + networkCount: networkUi.length, }; } diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 950c649e..5bf03486 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -18,17 +18,27 @@ * **状态结构**(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(**parent**):游客会话 —— **不** invoke WS - * - `initializing`:invoke chatInit(拉本地 + 初始化配额) - * - `ready`:用户聊天 + 翻历史 + * - `initializing`:**双** invoke + `always` barrier + * - `loadQuotaActor`(**纯** local,ChatStorage **日**配 + 总**配**) + * - `loadHistoryActor`(local → network → save 3 步) + * - 2 **个** `quotaLoaded` / `historyLoaded` **都** true **才**进** ready + * - `ready`:用户聊天 + 看消息 * - `sending`:invoke sendMessageHttpActor(HTTP 发送消息) - * - `loadingMore`:invoke loadMoreHistory(翻历史) + * - ~~`loadingMore`~~:**已**删**(游客**无**服**务**端** history,**不**支持翻**页**) * - `userSession`(**parent**):非游客会话 —— **invoke WS**(持久) - * - `initializing`:invoke loadMoreHistory(拉服务器端首屏) - * - `ready`:用户聊天 + 翻历史 + * - 父级 invoke `chatWebSocket`(**一**进**来****就**连) + * - `initializing`:invoke `loadHistory` + `always` barrier + * - WS **已**连** + history **加**载**完**成** → ready + * - `ready`:用户聊天 + 看消息 * - `sending`:invoke sendMessageHttpActor(HTTP fallback —— WS 真发**未** wire) - * - `loadingMore`:invoke loadMoreHistory(翻历史) + * - `loadingMore`:invoke loadMoreHistory(**翻**页**,**保**留**) * WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/sending/loadingMore 持续 * + * **init 双任务**(**核**心**重**构**): + * - guestSession.initializing:loadQuota + loadHistory **并**行**(`invoke: [...]` **数**组**) + * - userSession.initializing:loadHistory + parent-level chatWebSocket **并**行 + * - **每**个**任务**都**是**独**立** actor,**不**互**相**等**待**(**除**非** `always` barrier) + * * **消息发送路径**(**业务事实**): * - `wsConnected: true`(**仅** userSession 期间)→ **走 WS**,**不**消耗次数(**仅**配额规则) * - `wsConnected: false` → **走 HTTP**,消耗次数(**仅**游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op) @@ -41,12 +51,12 @@ * * **文件结构**: * - `chat-machine.ts`(本文件)—— **只**含 setup + createMachine(actions + states) - * - `chat-machine.helpers.ts` —— 纯函数 + 数据加载 - * - `chat-machine.actors.ts` —— 4 个 XState actor(含全链日志) + * - `chat-machine.helpers.ts` —— 纯函数 + 数据加载(readGuestQuota + readAndSyncHistory) + * - `chat-machine.actors.ts` —— 5 个 XState actor(loadQuota + loadHistory + sendMessage + loadMoreHistory + chatWebSocket) * * **调试日志**(保留 —— 用户要求"不要将其删除"): * - 命名约定:`[]` 前缀标识来源 - * - 3 actor **全链日志**(ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到 + * - 4 actor **全链日志**(ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到 */ import { setup, assign } from "xstate"; @@ -56,7 +66,8 @@ import { formatDate } from "@/utils/date"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { - chatInitActor, + loadQuotaActor, + loadHistoryActor, sendMessageHttpActor, loadMoreHistoryActor, chatWebSocketActor, @@ -82,7 +93,8 @@ export const chatMachine = setup({ events: {} as ChatEvent, }, actors: { - chatInit: chatInitActor, + loadQuota: loadQuotaActor, + loadHistory: loadHistoryActor, sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, @@ -223,39 +235,76 @@ export const chatMachine = setup({ }, }, + // ======================================================================== + // guestSession(游客会话 —— **不**连 WS,2 **个** init 任务**并**行**) + // ======================================================================== guestSession: { initial: "initializing", states: { initializing: { - invoke: { - src: "chatInit", - onDone: { + // "always" barrier:两**个**任务**都**完**成**才**进** ready + always: [ + { target: "ready", - actions: assign(({ event }) => { - const updates: Partial = { - messages: event.output.localMessages, - }; - if (event.output.isGuest) { - updates.guestRemainingQuota = event.output.guestRemainingQuota; - updates.guestTotalQuota = event.output.guestTotalQuota; - } - console.log("[chat-machine] guestSession.initializing.onDone", { - messagesCount: event.output.localMessages.length, - isGuest: event.output.isGuest, - guestRemainingQuota: updates.guestRemainingQuota ?? 0, - guestTotalQuota: updates.guestTotalQuota ?? 0, - }); - return updates; - }), + guard: ({ context }) => context.quotaLoaded && context.historyLoaded, }, - onError: { - target: "ready", - actions: ({ event }) => - console.error("[chat-machine] guestSession.initializing.onError", { - error: event.error instanceof Error ? event.error.message : String(event.error), + ], + invoke: [ + // 任务 1:加载配额(fromPromise,**返**结果 + onDone 设 flag) + { + id: "loadQuota", + src: "loadQuota", + onDone: { + actions: assign(({ event }) => { + console.log("[chat-machine] guestSession.initializing.loadQuota.onDone", { + remaining: event.output.remaining, + total: event.output.total, + }); + return { + guestRemainingQuota: event.output.remaining, + guestTotalQuota: event.output.total, + quotaLoaded: true, + }; }), + }, + onError: { + // 失败**也**标 loaded,**不**卡 init(**游**客**可**能 quota 服务**不**可**用**,**让** UI **进** ready) + actions: assign({ + quotaLoaded: true, + }), + }, }, - }, + // 任务 2:拉 history(fromPromise,**返**回**最**终** network messages**) + { + id: "loadHistory", + src: "loadHistory", + onDone: { + actions: assign(({ event }) => { + console.log( + "[chat-machine] guestSession.initializing.loadHistory.onDone", + { + finalCount: event.output.messages.length, + hasMore: event.output.hasMore, + newOffset: event.output.newOffset, + localOverwritten: event.output.localOverwritten, + }, + ); + return { + messages: event.output.messages, + hasMore: event.output.hasMore, + historyOffset: event.output.newOffset, + historyLoaded: true, + }; + }), + }, + onError: { + // 失败**也**标 loaded,**不**卡 init(**让** UI **还**是**能**进** ready,**屏**幕**可**能**空**) + actions: assign({ + historyLoaded: true, + }), + }, + }, + ], }, ready: { on: { @@ -267,17 +316,7 @@ export const chatMachine = setup({ ChatSendImage: { actions: ["logSendImageReceived", "appendUserImage"], }, - ChatLoadMoreHistory: { - actions: [ - "logLoadMoreHistoryReceived", - ({ context }) => - console.log("[chat-machine] guestSession.ready.ChatLoadMoreHistory received", { - currentOffset: context.historyOffset, - hasMore: context.hasMore, - }), - ], - target: "loadingMore", - }, + // **删**除 ChatLoadMoreHistory handler —— 游客**无**服**务**端** history,**不**支**持**翻**页** ChatLogout: "#chat.idle", ChatNonGuestLogin: "#chat.userSession", ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, @@ -324,48 +363,18 @@ export const chatMachine = setup({ }, }, }, - loadingMore: { - entry: ({ context }) => - console.log("[chat-machine] → guestSession.loadingMore", { offset: context.historyOffset }), - invoke: { - src: "loadMoreHistory", - input: ({ context }) => ({ offset: context.historyOffset }), - onDone: { - target: "ready", - actions: [ - assign(({ context, event }) => ({ - messages: [...event.output.messages, ...context.messages], - isLoadingMore: false, - hasMore: event.output.hasMore, - historyOffset: event.output.newOffset, - })), - ({ event }) => - console.log("[chat-machine] guestSession.loadingMore.onDone", { - newMessagesCount: event.output.messages.length, - newOffset: event.output.newOffset, - hasMore: event.output.hasMore, - }), - ], - }, - onError: { - target: "ready", - actions: [ - assign({ isLoadingMore: false }), - ({ event }) => - console.error("[chat-machine] guestSession.loadingMore.onError", { - error: event.error instanceof Error ? event.error.message : String(event.error), - }), - ], - }, - }, - }, + // **删**除 loadingMore(游客**无**翻**页**) }, }, + // ======================================================================== + // userSession(非游客会话 —— WS **父**级 + history **子**级**) + // ======================================================================== userSession: { initial: "initializing", exit: "clearWsConnected", invoke: { + // 父级:WS 长**连**接(**一**进**来****就**连,跨 initializing/ready/sending/loadingMore) src: "chatWebSocket", input: ({ event }) => ({ token: event.type === "ChatNonGuestLogin" ? event.token : "", @@ -373,35 +382,42 @@ export const chatMachine = setup({ }, states: { initializing: { - invoke: { - src: "loadMoreHistory", - input: ({ context }) => ({ offset: context.historyOffset }), - onDone: { + // barrier:WS **连**上** + history **加**载**完**成**才**进** ready + always: [ + { target: "ready", - actions: [ - assign(({ context, event }) => ({ + guard: ({ context }) => context.wsConnected && context.historyLoaded, + }, + ], + invoke: { + // 任务 2:拉 history(**只**调**用** loadHistoryActor,**不**再**用** loadMoreHistoryActor) + src: "loadHistory", + onDone: { + actions: assign(({ event }) => { + console.log( + "[chat-machine] userSession.initializing.loadHistory.onDone", + { + finalCount: event.output.messages.length, + hasMore: event.output.hasMore, + newOffset: event.output.newOffset, + localOverwritten: event.output.localOverwritten, + }, + ); + return { messages: event.output.messages, isLoadingMore: false, hasMore: event.output.hasMore, historyOffset: event.output.newOffset, - })), - ({ event }) => - console.log("[chat-machine] userSession.initializing.onDone", { - newMessagesCount: event.output.messages.length, - newOffset: event.output.newOffset, - hasMore: event.output.hasMore, - }), - ], + historyLoaded: true, + }; + }), }, onError: { - target: "ready", - actions: [ - assign({ isLoadingMore: false }), - ({ event }) => - console.error("[chat-machine] userSession.initializing.onError", { - error: event.error instanceof Error ? event.error.message : String(event.error), - }), - ], + // 失败**也**标 loaded,**不**卡 init + actions: assign({ + isLoadingMore: false, + historyLoaded: true, + }), }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index e7bbbcaa..090fd5d5 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -39,6 +39,16 @@ export interface ChatState { isLoadingMore: boolean; hasMore: boolean; historyOffset: number; + /** 游客配额加载完成标**志**(**仅** guestSession 期间) —— default false + * - guestSession.initializing **走** always barrier:`quotaLoaded && historyLoaded` **才**进** ready + * - userSession **永不为** true(**不**调 loadQuota)—— OK,**不**影响 barrier + */ + quotaLoaded: boolean; + /** history 加**载**完成标**志**(initial load —— local → network → save 跑**完**) + * - guestSession.initializing / userSession.initializing 走 always barrier + * - **不**被 `loadMoreHistoryActor` 设**置**(**翻**页**是**另**一**码**事**) + */ + historyLoaded: boolean; } export const initialState: ChatState = { @@ -51,4 +61,6 @@ export const initialState: ChatState = { isLoadingMore: false, hasMore: true, historyOffset: 0, + quotaLoaded: false, + historyLoaded: false, };