/** * Chat 状态机(XState v5) * * 鉴权解耦(事件驱动): * chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus * chat-screen 只派 3 个事件: * - `ChatGuestLogin` → 游客会话(断 WS = 不连) * - `ChatNonGuestLogin { token }` → 非游客会话(连 WS) * - `ChatLogout` → 登出(机器自动 cleanup WS actor) * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(parent):游客会话 —— 不 invoke WS * - `userSession`(parent):非游客会话 —— invoke WS(持久) * * 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) * - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit) * * 配额(仅游客): * - 非游客"无消息限制"(业务事实)—— 永不被赋值 * - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时不递减 * */ import { setup, assign } from "xstate"; import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { formatDate } from "@/utils/date"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { loadQuotaActor, loadHistoryActor, 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: { loadQuota: loadQuotaActor, loadHistory: loadHistoryActor, sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, }, actions: { appendUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; // 两个额度同步更新:WS 已连不递减(实时会话不消耗),HTTP 必须减 const newRemaining = context.wsConnected ? context.guestRemainingQuota : Math.max(0, context.guestRemainingQuota - 1); const newTotal = context.wsConnected ? context.guestTotalQuota : Math.max(0, context.guestTotalQuota - 1); // 持久化两个额度( fire-and-forget,不 await —— assign 是 sync) // daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset) const today = formatDate(); ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); ChatStorage.getInstance().setGuestTotalQuota(newTotal); console.log("[chat-machine] appendUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)", oldRemaining: context.guestRemainingQuota, newRemaining, oldTotal: context.guestTotalQuota, newTotal, isReplyingAI: true, }); return { messages: [ ...context.messages, { content: event.content, isFromAI: false, date: today, }, ], isReplyingAI: true, guestRemainingQuota: newRemaining, guestTotalQuota: newTotal, }; }), appendUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; // 同 appendUserMessage:两个额度同步减 const newRemaining = context.wsConnected ? context.guestRemainingQuota : Math.max(0, context.guestRemainingQuota - 1); const newTotal = context.wsConnected ? context.guestTotalQuota : Math.max(0, context.guestTotalQuota - 1); const today = formatDate(); void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); void ChatStorage.getInstance().setGuestTotalQuota(newTotal); console.log("[chat-machine] appendUserImage", { oldMessagesCount: context.messages.length, wsConnected: context.wsConnected, quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)", oldRemaining: context.guestRemainingQuota, newRemaining, oldTotal: context.guestTotalQuota, newTotal, }); return { messages: [ ...context.messages, { content: "[Image]", isFromAI: false, date: today, imageUrl: event.imageBase64, }, ], isReplyingAI: true, guestRemainingQuota: newRemaining, guestTotalQuota: newTotal, }; }), 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 }), }, }).createMachine({ id: "chat", initial: "idle", context: initialState, states: { idle: { on: { ChatGuestLogin: "#chat.guestSession", ChatNonGuestLogin: "#chat.userSession", }, }, // ======================================================================== // guestSession(游客会话 —— 不连 WS,2 个 init 任务并行) // ======================================================================== guestSession: { initial: "initializing", states: { initializing: { // "always" barrier:两个任务都完成才进 ready always: [ { target: "ready", guard: ({ context }) => context.quotaLoaded && context.historyLoaded, }, ], invoke: [ // 任务 1:加载配额(fromPromise,返结果 + onDone 设 flag) { id: "loadQuota", src: "loadQuota", onDone: { actions: assign(({ event }) => ({ 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 }) => ({ 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: { // 配额检查 + 发送消息(3 条 guard,按顺序匹配) // 1. 总配额(guestTotalQuota)不足 → 触发 quota exceeded dialog,不发送 // 2. 每日配额(guestRemainingQuota)不足 → 同上 // 3. 两个配额都 OK + 内容非空 → 发送 + 减配额 ChatSendMessage: [ // 1. 总配额 不足(guestTotalQuota <= 0) → 触发 quota exceeded dialog { guard: ({ context }) => context.guestTotalQuota <= 0, actions: "incrementQuotaExceeded", }, // 2. 每日配额 不足(guestRemainingQuota <= 0) → 触发 quota exceeded dialog { guard: ({ context }) => context.guestRemainingQuota <= 0, actions: "incrementQuotaExceeded", }, // 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志) { actions: "appendUserMessage", guard: ({ event }) => event.content.trim().length > 0, target: "sending", }, ], // 配额检查 + 发送图片(同样 3 条 guard,不检查 "内容空") ChatSendImage: [ { guard: ({ context }) => context.guestTotalQuota <= 0, actions: "incrementQuotaExceeded", }, { guard: ({ context }) => context.guestRemainingQuota <= 0, actions: "incrementQuotaExceeded", }, { actions: "appendUserImage", target: "sending", }, ], // 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页 ChatLogout: "#chat.idle", ChatNonGuestLogin: "#chat.userSession", ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, sending: { 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, })), }, onError: { target: "ready", actions: assign({ isReplyingAI: false }), }, }, }, // 删除 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 : "", }), }, states: { initializing: { // barrier:WS 连上 + history 加载完成才进 ready always: [ { target: "ready", guard: ({ context }) => context.wsConnected && context.historyLoaded, }, ], invoke: { // 任务 2:拉 history(只调用 loadHistoryActor,不再用 loadMoreHistoryActor) src: "loadHistory", onDone: { actions: assign(({ event }) => ({ messages: event.output.messages, isLoadingMore: false, hasMore: event.output.hasMore, historyOffset: event.output.newOffset, historyLoaded: true, })), }, onError: { // 失败也标 loaded,不卡 init actions: assign({ isLoadingMore: false, historyLoaded: true, }), }, }, }, ready: { on: { ChatSendMessage: { actions: "appendUserMessage", guard: ({ event }) => event.content.trim().length > 0, target: "sending", }, ChatSendImage: { actions: "appendUserImage", }, ChatLoadMoreHistory: { target: "loadingMore", }, ChatLogout: "#chat.idle", ChatGuestLogin: "#chat.guestSession", ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, sending: { 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, })), }, onError: { target: "ready", actions: assign({ isReplyingAI: false }), }, }, }, loadingMore: { 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, })), }, onError: { target: "ready", actions: assign({ isLoadingMore: false }), }, }, }, }, }, }, }); export type ChatMachine = typeof chatMachine;