/** * Chat 状态机(XState v5) * * 设计要点: * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API * - HTTP 操作用 `fromPromise` actor(一次性 Promise) * - WebSocket 用 `fromCallback` actor(长生命周期)—— **机器内**完整管理 * - chat-screen **不**直接管理 WS —— 只派 3 个鉴权生命周期事件 * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 * * **鉴权解耦**(**事件驱动**): * chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus * chat-screen **只**派 3 个事件: * - `ChatGuestLogin` → 游客会话(**断** WS = 不连) * - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS) * - `ChatLogout` → 登出(机器自动 cleanup WS actor) * * **状态结构**(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(**parent**):游客会话 —— **不** invoke WS * - `initializing`:invoke chatInit(拉本地 + 初始化配额) * - `ready`:用户聊天 + 翻历史 * - `sending`:invoke sendMessageHttpActor(HTTP 发送消息) * - `loadingMore`:invoke loadMoreHistory(翻历史) * - `userSession`(**parent**):非游客会话 —— **invoke WS**(持久) * - `initializing`:invoke loadMoreHistory(拉服务器端首屏) * - `ready`:用户聊天 + 翻历史 * - `sending`:invoke sendMessageHttpActor(HTTP fallback —— WS 真发**未** wire) * - `loadingMore`:invoke loadMoreHistory(翻历史) * WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/sending/loadingMore 持续 * * **消息发送路径**(**业务事实**): * - `wsConnected: true`(**仅** userSession 期间)→ **走 WS**,**不**消耗次数(**仅**配额规则) * - `wsConnected: false` → **走 HTTP**,消耗次数(**仅**游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op) * - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时**也**会跑 exit) * * **配额(**仅**游客)**: * - 非游客"无消息限制"(业务事实)—— 永不被赋值 * - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时**不**递减 * * **文件结构**: * - `chat-machine.ts`(本文件)—— **只**含 setup + createMachine(actions + states) * - `chat-machine.helpers.ts` —— 纯函数 + 数据加载 * - `chat-machine.actors.ts` —— 4 个 XState actor(含全链日志) * * **调试日志**(保留 —— 用户要求"不要将其删除"): * - 命名约定:`[]` 前缀标识来源 * - 3 actor **全链日志**(ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到 */ import { setup, assign } from "xstate"; import { formatDate } from "@/utils/date"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { chatInitActor, sendMessageHttpActor, loadMoreHistoryActor, chatWebSocketActor, } from "./chat-machine.actors"; /** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */ export const GuestChatQuota = { warningThreshold: 5, quotaExhausted: 0, } as const; // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { ChatState } from "./chat-state"; export { initialState } from "./chat-state"; export type { ChatEvent } from "./chat-events"; // ============================================================ // Machine // ============================================================ export const chatMachine = setup({ types: { context: {} as ChatState, events: {} as ChatEvent, }, actors: { chatInit: chatInitActor, sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, }, actions: { appendUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; const newQuota = context.wsConnected ? context.guestRemainingQuota : Math.max(0, context.guestRemainingQuota - 1); console.log("[chat-machine] appendUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), oldMessagesCount: context.messages.length, newMessagesCount: context.messages.length + 1, wsConnected: context.wsConnected, quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)", oldQuota: context.guestRemainingQuota, newQuota, isReplyingAI: true, }); return { messages: [ ...context.messages, { content: event.content, isFromAI: false, date: formatDate(), }, ], isReplyingAI: true, guestRemainingQuota: newQuota, }; }), appendUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; const newQuota = context.wsConnected ? context.guestRemainingQuota : Math.max(0, context.guestRemainingQuota - 1); console.log("[chat-machine] appendUserImage", { oldMessagesCount: context.messages.length, wsConnected: context.wsConnected, quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)", oldQuota: context.guestRemainingQuota, newQuota, }); return { messages: [ ...context.messages, { content: "[Image]", isFromAI: false, date: formatDate(), imageUrl: event.imageBase64, }, ], isReplyingAI: true, guestRemainingQuota: newQuota, }; }), appendOrUpdateAISentence: assign(({ context, event }) => { if (event.type !== "ChatAISentenceReceived") return {}; const messages = [...context.messages]; if (event.index === 0) { messages.push({ content: event.text, isFromAI: true, date: formatDate(), }); } else { const last = messages[messages.length - 1]; if (last && last.isFromAI) { messages[messages.length - 1] = { ...last, content: `${last.content} ${event.text}`, }; } } console.log("[chat-machine] appendOrUpdateAISentence", { index: event.index, total: event.total, done: event.done, messagesCount: messages.length, }); return { messages, isReplyingAI: !event.done }; }), appendSocketErrorMessage: assign(({ context }) => { const messages = [ ...context.messages, { content: "Something went wrong. Try sending again?", isFromAI: true, date: formatDate(), }, ]; return { messages, isReplyingAI: false }; }), incrementQuotaExceeded: assign(({ context }) => ({ quotaExceededTrigger: context.quotaExceededTrigger + 1, })), setWsConnected: assign({ wsConnected: true }), clearWsConnected: assign({ wsConnected: false }), logSendMessageReceived: ({ event, context }) => console.log("[chat-machine] SendMessage received", { contentLength: event.type === "ChatSendMessage" ? event.content.length : 0, isReplyingAI: context.isReplyingAI, wsConnected: context.wsConnected, currentMessagesCount: context.messages.length, }), logSendImageReceived: ({ event, context }) => console.log("[chat-machine] SendImage received", { hasImageBase64: event.type === "ChatSendImage" ? !!event.imageBase64 : false, wsConnected: context.wsConnected, }), logLoadMoreHistoryReceived: ({ context }) => console.log("[chat-machine] LoadMoreHistory received", { currentOffset: context.historyOffset, hasMore: context.hasMore, }), }, }).createMachine({ id: "chat", initial: "idle", context: initialState, states: { idle: { on: { ChatGuestLogin: "#chat.guestSession", ChatNonGuestLogin: "#chat.userSession", }, }, guestSession: { initial: "initializing", states: { initializing: { invoke: { src: "chatInit", onDone: { 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; }), }, onError: { target: "ready", actions: ({ event }) => console.error("[chat-machine] guestSession.initializing.onError", { error: event.error instanceof Error ? event.error.message : String(event.error), }), }, }, }, ready: { on: { ChatSendMessage: { actions: ["logSendMessageReceived", "appendUserMessage"], guard: ({ event }) => event.content.trim().length > 0, target: "sending", }, ChatSendImage: { actions: ["logSendImageReceived", "appendUserImage"], }, ChatLoadMoreHistory: { actions: [ "logLoadMoreHistoryReceived", ({ context }) => console.log("[chat-machine] guestSession.ready.ChatLoadMoreHistory received", { currentOffset: context.historyOffset, hasMore: context.hasMore, }), ], target: "loadingMore", }, ChatLogout: "#chat.idle", ChatNonGuestLogin: "#chat.userSession", ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, sending: { entry: ({ event }) => console.log("[chat-machine] → guestSession.sending", { contentLength: event.type === "ChatSendMessage" ? event.content.length : 0, contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "", }), invoke: { src: "sendMessageHttp", input: ({ event }) => ({ content: event.type === "ChatSendMessage" ? event.content : "", }), onDone: { target: "ready", actions: [ assign(({ context, event }) => ({ messages: [...context.messages, event.output.reply], isReplyingAI: false, })), ({ context, event }) => console.log("[chat-machine] guestSession.sending.onDone", { newReplyContentLength: event.output.reply.content.length, totalMessagesCount: context.messages.length + 1, isReplyingAI: false, }), ], }, onError: { target: "ready", actions: [ assign({ isReplyingAI: false }), ({ event }) => console.error("[chat-machine] guestSession.sending.onError", { error: event.error instanceof Error ? event.error.message : String(event.error), }), ], }, }, }, 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), }), ], }, }, }, }, }, userSession: { initial: "initializing", exit: "clearWsConnected", invoke: { src: "chatWebSocket", input: ({ event }) => ({ token: event.type === "ChatNonGuestLogin" ? event.token : "", }), }, states: { initializing: { invoke: { src: "loadMoreHistory", input: ({ context }) => ({ offset: context.historyOffset }), onDone: { target: "ready", actions: [ assign(({ context, event }) => ({ 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, }), ], }, 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), }), ], }, }, }, ready: { on: { ChatSendMessage: { actions: ["logSendMessageReceived", "appendUserMessage"], guard: ({ event }) => event.content.trim().length > 0, target: "sending", }, ChatSendImage: { actions: ["logSendImageReceived", "appendUserImage"], }, ChatLoadMoreHistory: { actions: [ "logLoadMoreHistoryReceived", ({ context }) => console.log("[chat-machine] userSession.ready.ChatLoadMoreHistory received", { currentOffset: context.historyOffset, hasMore: context.hasMore, }), ], target: "loadingMore", }, ChatLogout: "#chat.idle", ChatGuestLogin: "#chat.guestSession", ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, sending: { entry: ({ event }) => console.log("[chat-machine] → userSession.sending", { contentLength: event.type === "ChatSendMessage" ? event.content.length : 0, contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "", wsConnected: true, }), invoke: { src: "sendMessageHttp", input: ({ event }) => ({ content: event.type === "ChatSendMessage" ? event.content : "", }), onDone: { target: "ready", actions: [ assign(({ context, event }) => ({ messages: [...context.messages, event.output.reply], isReplyingAI: false, })), ({ context, event }) => console.log("[chat-machine] userSession.sending.onDone", { newReplyContentLength: event.output.reply.content.length, totalMessagesCount: context.messages.length + 1, isReplyingAI: false, }), ], }, onError: { target: "ready", actions: [ assign({ isReplyingAI: false }), ({ event }) => console.error("[chat-machine] userSession.sending.onError", { error: event.error instanceof Error ? event.error.message : String(event.error), }), ], }, }, }, loadingMore: { entry: ({ context }) => console.log("[chat-machine] → userSession.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] userSession.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] userSession.loadingMore.onError", { error: event.error instanceof Error ? event.error.message : String(event.error), }), ], }, }, }, }, }, }, }); export type ChatMachine = typeof chatMachine;