/** * Chat 状态机(XState v5) * * 鉴权解耦(事件驱动): * chat 机器不感知鉴权 / 不管 WebSocket —— 由 派生 loginStatus * ChatAuthSync 派发登录态生命周期事件: * - `ChatGuestLogin` → 游客会话(断 WS = 不连) * - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS) * - `ChatVipLogin { token }` → VIP 用户会话(连 WS) * - `ChatLogout` → 正式登录用户登出(机器自动 cleanup WS actor) * * 登录态流转约束: * - 未登录:可以进入游客登录 / 其他登录。 * - 游客登录:只可以升级为其他登录,不响应退出。 * - 其他登录:可以退出,也可以在非 VIP / VIP 间切换,不响应游客登录。 * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(parent):游客会话 —— 不 invoke WS * - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS * - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久) * * init 任务: * - guestSession.initializing:loadHistory * - nonVipUserSession.initializing:loadHistory * - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行 * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier) * * 消息发送路径(业务事实): * - guestSession:走 HTTP,消息数量限制以后端 `blocked` 响应为准 * - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked * - vipUserSession + WS 已连:走 WS,不受每日免费次数限制 * - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit) * * 配额: * - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。 * - 后端返回 `blocked=true && blockReason="daily_limit"` 时展示会员引导。 * */ import { setup, assign } from "xstate"; import { todayString, Logger } from "@/utils"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput } from "./chat-machine.helpers"; import { loadHistoryActor, sendMessageHttpActor, sendMessageWsActor, loadMoreHistoryActor, chatWebSocketActor, unlockPrivateMessageActor, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); // 重新导出 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: { loadHistory: loadHistoryActor, sendMessageHttp: sendMessageHttpActor, sendMessageWs: sendMessageWsActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, unlockPrivateMessage: unlockPrivateMessageActor, }, actions: { startGuestSession: assign(() => ({ ...initialState, })), startUserSession: assign(() => ({ ...initialState, })), clearChatSession: assign(() => ({ ...initialState, })), appendGuestUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; const today = todayString(); log.debug("[chat-machine] appendGuestUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), quotaMode: "server controlled", isReplyingAI: true, }); return { messages: [ ...context.messages, { content: event.content, isFromAI: false, date: today, }, ], isReplyingAI: true, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; }), appendUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; const today = todayString(); log.debug("[chat-machine] appendUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), wsConnected: context.wsConnected, isReplyingAI: true, }); return { messages: [ ...context.messages, { content: event.content, isFromAI: false, date: today, }, ], isReplyingAI: true, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; }), appendGuestUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; const today = todayString(); log.debug("[chat-machine] appendGuestUserImage", { oldMessagesCount: context.messages.length, quotaMode: "server controlled", }); return { messages: [ ...context.messages, { content: "[Image]", isFromAI: false, date: today, imageUrl: event.imageBase64, }, ], isReplyingAI: true, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; }), appendUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; const today = todayString(); log.debug("[chat-machine] appendUserImage", { oldMessagesCount: context.messages.length, wsConnected: context.wsConnected, }); return { messages: [ ...context.messages, { content: "[Image]", isFromAI: false, date: today, imageUrl: event.imageBase64, }, ], isReplyingAI: true, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; }), 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: todayString(), }); } else { const last = messages[messages.length - 1]; if (last && last.isFromAI) { messages[messages.length - 1] = { ...last, content: `${last.content} ${event.text}`, }; } } log.debug("[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: todayString(), }, ]; return { messages, isReplyingAI: false }; }), appendAIImage: assign(({ context, event }) => { if (event.type !== "ChatImageReceived") return {}; const messages = [...context.messages]; const last = messages[messages.length - 1]; if (last?.isFromAI && !last.imageUrl) { messages[messages.length - 1] = { ...last, imageUrl: event.imageUrl, }; } else { messages.push({ content: "", isFromAI: true, date: todayString(), imageUrl: event.imageUrl, }); } log.debug("[chat-machine] appendAIImage", { messagesCount: messages.length, hasMergedIntoLastMessage: Boolean(last?.isFromAI && !last.imageUrl), }); return { messages, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; }), applyPaywallStatus: assign(({ event }) => { if (event.type !== "ChatPaywallStatusReceived") return {}; if (!event.showUpgrade) { return { paywallTriggered: false, paywallReason: null, paywallDetail: null, }; } return { paywallTriggered: true, paywallReason: "photo_paywall", paywallDetail: null, }; }), setUnlockingPrivateMessage: assign(({ event }) => { if (event.type !== "ChatUnlockPrivateMessage") return {}; return { unlockingPrivateMessageId: event.messageId, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; }), applyUnlockPrivateOutput: assign(({ context, event }) => { if (!("output" in event)) return {}; const output = event.output as { messageId: string; response: import("@/data/dto/chat").UnlockPrivateResponse; }; const { messageId, response } = output; if (response.unlocked && response.content != null) { return { messages: context.messages.map((message) => message.id === messageId ? { ...message, content: response.content ?? message.content, privateLocked: false, privateHint: null, isPrivate: message.isPrivate ?? true, } : message, ), unlockingPrivateMessageId: null, paywallTriggered: false, paywallReason: null, paywallDetail: null, }; } if (response.showUpgrade) { return { unlockingPrivateMessageId: null, paywallTriggered: true, paywallReason: "private_paywall", paywallDetail: { usedToday: response.privateUsedToday, limit: response.privateFreeLimit, }, }; } return { unlockingPrivateMessageId: null, }; }), clearUnlockingPrivateMessage: assign({ unlockingPrivateMessageId: null, }), setWsConnected: assign({ wsConnected: true }), clearWsConnected: assign({ wsConnected: false }), }, }).createMachine({ id: "chat", initial: "idle", context: initialState, states: { idle: { on: { ChatGuestLogin: { target: "#chat.guestSession", actions: "startGuestSession", }, ChatNonVipLogin: { target: "#chat.nonVipUserSession", actions: "startUserSession", }, ChatVipLogin: { target: "#chat.vipUserSession", actions: "startUserSession", }, }, }, guestSession: { on: { ChatNonVipLogin: { target: "#chat.nonVipUserSession", actions: "startUserSession", }, ChatVipLogin: { target: "#chat.vipUserSession", actions: "startUserSession", }, }, initial: "initializing", states: { initializing: { // 本地游客额度逻辑已停用:只等待历史加载完成。 always: [ { target: "ready", guard: ({ context }) => context.historyLoaded, }, ], invoke: { 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: { ChatSendMessage: { actions: "appendGuestUserMessage", guard: ({ event }) => event.content.trim().length > 0, target: "sending", }, ChatSendImage: { actions: "appendGuestUserImage", target: "sending", }, ChatUnlockPrivateMessage: { actions: "setUnlockingPrivateMessage", target: "unlockingPrivate", }, // 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS }, }, sending: { invoke: { src: "sendMessageHttp", input: ({ event }) => ({ content: event.type === "ChatSendMessage" ? event.content : "", }), onDone: { target: "ready", actions: assign(({ context, event }) => applyHttpSendOutput(context, event.output), ), }, onError: { target: "ready", actions: assign({ isReplyingAI: false }), }, }, }, unlockingPrivate: { invoke: { src: "unlockPrivateMessage", input: ({ event }) => ({ messageId: event.type === "ChatUnlockPrivateMessage" ? event.messageId : "", }), onDone: { target: "ready", actions: "applyUnlockPrivateOutput", }, onError: { target: "ready", actions: "clearUnlockingPrivateMessage", }, }, }, }, }, nonVipUserSession: { on: { ChatLogout: { target: "#chat.idle", actions: "clearChatSession", }, ChatNonVipLogin: { target: "#chat.nonVipUserSession", reenter: true, actions: "startUserSession", }, ChatVipLogin: { target: "#chat.vipUserSession", actions: "startUserSession", }, }, initial: "initializing", states: { initializing: { invoke: { src: "loadHistory", onDone: { target: "ready", actions: assign(({ event }) => ({ messages: event.output.messages, isLoadingMore: false, hasMore: event.output.hasMore, historyOffset: event.output.newOffset, historyLoaded: true, })), }, onError: { target: "ready", actions: assign({ isLoadingMore: false, historyLoaded: true, }), }, }, }, ready: { on: { ChatSendMessage: { guard: ({ event }) => event.content.trim().length > 0, actions: "appendUserMessage", target: "sendingViaHttp", }, ChatSendImage: { actions: "appendUserImage", }, ChatLoadMoreHistory: { target: "loadingMore", }, ChatUnlockPrivateMessage: { actions: "setUnlockingPrivateMessage", target: "unlockingPrivate", }, }, }, sendingViaHttp: { invoke: { src: "sendMessageHttp", input: ({ event }) => ({ content: event.type === "ChatSendMessage" ? event.content : "", }), onDone: { target: "ready", actions: assign(({ context, event }) => applyHttpSendOutput(context, event.output), ), }, 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 }), }, }, }, unlockingPrivate: { invoke: { src: "unlockPrivateMessage", input: ({ event }) => ({ messageId: event.type === "ChatUnlockPrivateMessage" ? event.messageId : "", }), onDone: { target: "ready", actions: "applyUnlockPrivateOutput", }, onError: { target: "ready", actions: "clearUnlockingPrivateMessage", }, }, }, }, }, vipUserSession: { // 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on // —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore) // 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。 // // 子级 override 规则: // - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action) // —— XState v5: child handler 替换 parent;复制 action on: { ChatLogout: { target: "#chat.idle", actions: "clearChatSession", }, ChatNonVipLogin: { target: "#chat.nonVipUserSession", actions: "startUserSession", }, ChatVipLogin: { target: "#chat.vipUserSession", reenter: true, actions: "startUserSession", }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatImageReceived: { actions: "appendAIImage" }, ChatPaywallStatusReceived: { actions: "applyPaywallStatus" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketConnected: { actions: "setWsConnected" }, }, initial: "initializing", exit: "clearWsConnected", invoke: { // 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore) src: "chatWebSocket", input: ({ event }) => ({ token: event.type === "ChatVipLogin" ? 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: { // 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply) // 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需 // 内容非空;wsConnected=false 同理) ChatSendMessage: [ { // VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。 guard: ({ context, event }) => context.wsConnected && event.content.trim().length > 0, actions: "appendUserMessage", target: "sendingViaWs", }, { // WS 未连(或 fallback) + 内容非空 → 走 HTTP(sendingViaHttp 一次性回 reply) guard: ({ event }) => event.content.trim().length > 0, actions: "appendUserMessage", target: "sendingViaHttp", }, ], ChatSendImage: { actions: "appendUserImage", }, ChatLoadMoreHistory: { target: "loadingMore", }, ChatUnlockPrivateMessage: { actions: "setUnlockingPrivateMessage", target: "unlockingPrivate", }, // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected // 已上提到 vipUserSession.on,子级不再声明 }, }, // WS 发送:invoke sendMessageWsActor 触发一次 send, // 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence // 当句尾 done: true → isReplyingAI = false → always 跳回 ready sendingViaWs: { on: { // 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs) // 这个 child handler 替换 vipUserSession.on 的 ChatWebSocketError(XState v5 不合并) // 所以需要重复声明 action: "appendSocketErrorMessage" ChatWebSocketError: { target: "ready", actions: "appendSocketErrorMessage", }, }, invoke: { src: "sendMessageWs", input: ({ event }) => ({ content: event.type === "ChatSendMessage" ? event.content : "", }), onError: { // sendMessageWsActor 自身抛错(无 active ws / ws not OPEN) → 回 ready + 清 typing target: "ready", actions: assign({ isReplyingAI: false }), }, }, // 监听 isReplyingAI —— appendOrUpdateAISentence 在 done: true 时设 false always: [ { target: "ready", guard: ({ context }) => !context.isReplyingAI, }, ], }, // HTTP 发送:一次发送 = 一次返回(保留原 sending 状态语义,guest 与 ws-down fallback 走此) sendingViaHttp: { invoke: { src: "sendMessageHttp", input: ({ event }) => ({ content: event.type === "ChatSendMessage" ? event.content : "", }), onDone: { target: "ready", actions: assign(({ context, event }) => applyHttpSendOutput(context, event.output), ), }, 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 }), }, }, }, unlockingPrivate: { invoke: { src: "unlockPrivateMessage", input: ({ event }) => ({ messageId: event.type === "ChatUnlockPrivateMessage" ? event.messageId : "", }), onDone: { target: "ready", actions: "applyUnlockPrivateOutput", }, onError: { target: "ready", actions: "clearUnlockingPrivateMessage", }, }, }, }, }, }, }); export type ChatMachine = typeof chatMachine;