/** * Chat 状态机:4 个 XState actor */ import { fromPromise, fromCallback } from "xstate"; import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket"; import type { ChatSendResponse } from "@/data/dto/chat"; import { Result, Logger } from "@/utils"; import { PAGE_SIZE, chatRepo, localMessagesToUi, readAndSyncHistory, sendResponseToUiMessage, } from "./chat-machine.helpers"; import type { ChatEvent } from "./chat-events"; const log = new Logger("StoresChatChatMachineActors"); // ============================================================ // 共享 WS 引用(chatWebSocketActor ↔ sendMessageWsActor) // ============================================================ /** * 活跃 ChatWebSocket 单例引用(module-level)。 * - `chatWebSocketActor` 在 connect 后写入、cleanup 时清空 * - `sendMessageWsActor` 读取并调 `ws.sendMessage(content)` * * 为什么用 module-level 而非 context.wsRef: * 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进 * XState context 会污染快照、警告 non-serializable * 2) vipUserSession 同一时间只可能有一个活跃 WS(parent-level invoke), * module 单例的语义和"VIP 会话内一个活跃连接"一一对应 * 3) 不需要重构 ChatWebSocket 类的对外 API */ let activeChatWebSocket: ChatWebSocket | null = null; /** 暴露给 `sendMessageWsActor` 读取(测试也可 import) */ export function getActiveChatWebSocket(): ChatWebSocket | null { return activeChatWebSocket; } // 本地游客消息额度 actor 已停用:消息数量限制统一交由后端 blocked/daily_limit 响应处理。 // ============================================================ // 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 () => { return readAndSyncHistory(); }); // ============================================================ // HTTP: one-shot Promise // ============================================================ /** * HTTP 发送消息(一次发送 = 一次返回) * - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`) * - 不再调 `getLocalMessages()`(多此一举) * - 返回完整 response + 可追加的 reply,方便处理后端 paywall blocked 响应 */ export const sendMessageHttpActor = fromPromise< { response: ChatSendResponse; reply: UiMessage | null }, { content: string } >(async ({ input }) => { const result = await chatRepo.sendMessage(input.content); if (Result.isErr(result)) { log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error }); throw result.error; } return { response: result.data, reply: result.data.blocked ? null : sendResponseToUiMessage(result.data), }; }); /** 翻历史(pagination)—— 不走 local-first,纯 server fetch */ export const loadMoreHistoryActor = fromPromise< { messages: UiMessage[]; hasMore: boolean; newOffset: number }, { offset: number } >(async ({ input }) => { const result = await chatRepo.getHistory(PAGE_SIZE, input.offset); if (Result.isErr(result)) { log.error("[chat-machine] loadMoreHistoryActor failed", { error: result.error }); throw result.error; } const page = localMessagesToUi(result.data.messages); return { messages: page, hasMore: page.length >= PAGE_SIZE, newOffset: input.offset + page.length, }; }); // ============================================================ // WebSocket: long-lived callback // ============================================================ /** * 长生命周期 WebSocket(仅 `vipUserSession` 期间 invoke) * - 启动:连 WS,事件 → sendBack 派回机器 * - 停止(cleanup):disconnect * - 游客不 invoke 此 actor(业务事实"游客不连 WS") * * 之所以用 fromCallback 而非 fromPromise: * - WS 是长连接,不是一次性 Promise * - fromCallback 提供 `sendBack` 回调,actor 内部事件 → 机器 * - cleanup 函数 → XState 在 parent state exit 时自动调用 */ export const chatWebSocketActor = fromCallback( ({ sendBack, input }) => { const ws = createChatWebSocket(input.token); activeChatWebSocket = ws; ws.onConnected = (userId) => { log.debug("[chat-machine] WS connected", { userId }); sendBack({ type: "ChatWebSocketConnected", userId }); }; ws.onSentence = (p) => { sendBack({ type: "ChatAISentenceReceived", index: p.index, text: p.text, total: p.total, done: p.done, }); }; ws.onImage = (imageUrl) => { sendBack({ type: "ChatImageReceived", imageUrl }); }; ws.onPaywallStatus = (p) => { sendBack({ type: "ChatPaywallStatusReceived", paywallTriggered: p.paywallTriggered, showUpgrade: p.showUpgrade, imageType: p.imageType, imageUrl: p.imageUrl, }); }; ws.onError = (msg) => { log.error("[chat-machine] WS error", { errorMessage: msg }); sendBack({ type: "ChatWebSocketError", errorMessage: msg }); }; ws.connect(); return () => { log.debug("[chat-machine] chatWebSocketActor cleanup"); if (activeChatWebSocket === ws) { activeChatWebSocket = null; } ws.disconnect(); }; }, ); // ============================================================ // WebSocket: per-send callback // ============================================================ /** * WS 发送消息(vipUserSession.sendingViaWs 期间 invoke) * - 走 `activeChatWebSocket.sendMessage(content)` * - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回 * - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready) * * 与 sendMessageHttpActor 的区别: * - sendMessageHttpActor: 一次发送 = 一次返回(HTTP response = AI reply) * - sendMessageWsActor: 一次发送 = 0 次直接返回(WS 异步流式回 reply,actor 本身不返 reply) * * 选 fromPromise 而非 fromCallback: * - 本 actor 是 fire-and-forget —— sync 内同步执行 ws.sendMessage,立即 resolve * - actor 本身没有"完成"语义(done 来自 WS 事件流,不来自这个 actor) * - 抛错走 onError 通道 * - fromPromise 的 input type 推断比 fromCallback 稳定(XState v5 已知坑) */ export const sendMessageWsActor = fromPromise( async ({ input }) => { const ws = getActiveChatWebSocket(); if (!ws) { log.error("[chat-machine] sendMessageWsActor no active WebSocket"); throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)"); } const ok = ws.sendMessage(input.content); if (!ok) { log.error("[chat-machine] sendMessageWsActor WebSocket not OPEN"); throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN"); } // resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回 }, ); // Re-export `UiMessage` type for the chat-machine.ts public API type UiMessage = import("@/data/dto/chat").UiMessage;