From d55694a73e630afa142493142ec7fead172f28aa Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 11 Jun 2026 18:57:11 +0800 Subject: [PATCH] refactor(chat): move WebSocket lifecycle into chat machine via fromCallback actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat-screen no longer manages WebSocket directly; it now dispatches three auth lifecycle events (ChatGuestLogin, ChatNonGuestLogin, ChatLogout) derived from auth.loginStatus - chat-machine internally owns the WebSocket long-lived actor via fromCallback, completing the previously deferred migration scope - removed ChatWebSocket import, getWsToken helper, and direct WS effect from chat-screen; renamed dispatchInitialLoad to dispatchAuthLifecycle to reflect the new responsibility - decouples auth from chat machine via event-driven boundary: machine stays unaware of loginStatus, guest semantics live at the dispatch layer (guest → skip WS, non-guest → connect with token) --- src/app/chat/components/chat-screen.tsx | 153 ++++------- src/stores/chat/chat-events.ts | 17 ++ src/stores/chat/chat-machine.ts | 325 ++++++++++++++++++------ 3 files changed, 310 insertions(+), 185 deletions(-) diff --git a/src/app/chat/components/chat-screen.tsx b/src/app/chat/components/chat-screen.tsx index a80ee95f..b94505de 100644 --- a/src/app/chat/components/chat-screen.tsx +++ b/src/app/chat/components/chat-screen.tsx @@ -5,13 +5,10 @@ * 原始 Dart: lib/ui/chat/chat_screen.dart(172 行) * * 职责(本文件): - * - **订阅 auth 状态机**(loginStatus)—— - * - 派生 isGuest / 驱动 WS 生命周期 / 驱动初次加载(按 loginStatus 选 ChatInit vs ChatLoadMoreHistory) - * - 驱动历史加载 - * - 屏幕初始化(mount 时按 loginStatus 派初次加载) + * - **订阅 auth 状态机**(loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器 + * - 屏幕生命周期事件(init / visible / invisible) * - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实) - * - WebSocket 生命周期管理(基于 auth 状态机的 loginStatus) - * - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏) + * - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值) * * 子组件职责: * - ChatHeader:顶部栏(游客 banner / 菜单按钮) @@ -22,13 +19,13 @@ * - BrowserHintOverlay:浏览器提示 * - CharacterIntro:角色介绍卡片(首屏) * - * **鉴权解耦**: - * chat 机器**不**感知鉴权 —— isGuest 在此派生自 `auth.loginStatus === "guest"`。 - * chat 机器**不**管 WebSocket 生命周期 —— 由此 useEffect 监听 loginStatus 驱动。 - * - * **配额(**仅**游客)**: - * - 游客:派 ChatInit → readInitData 调 ChatStorage → onDone 条件赋 - * - 非游客:派 ChatLoadMoreHistory(无配额 init)—— "非游客无消息限制"业务事实 + * **鉴权解耦**(**事件驱动**): + * - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus + * - chat-screen **只**派 3 个事件: + * - `ChatGuestLogin` → 游客会话(**断** WS = 不连) + * - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS) + * - `ChatLogout` → 登出(机器自动 cleanup WS actor) + * - chat 机器**内部**用 fromCallback actor 完整管理 WS 生命周期 */ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; @@ -38,10 +35,6 @@ import type { LoginStatus } from "@/models/auth/login-status"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { GuestChatQuota } from "@/stores/chat"; -import { - ChatWebSocket, - createChatWebSocket, -} from "@/core/net/chat-websocket"; import { MobileShell } from "@/app/_components/core/mobile-shell"; @@ -61,34 +54,31 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean { } /** - * 拿 WebSocket token —— 按 loginStatus 选 loginToken 或 guestToken + * 按 loginStatus 派鉴权生命周期事件(chat 机器**不**感知 loginStatus —— 这层转换在此) + * + * 设计:**不**直接管理 WS / **不**读 AuthStorage(除取 token) */ -async function getWsToken( - loginStatus: LoginStatus, -): Promise { - if (loginStatus === "notLoggedIn") return null; - const storage = AuthStorage.getInstance(); - if (loginStatus === "guest") { - const r = await storage.getGuestToken(); - return r.success ? r.data : null; - } - // email / google / facebook / apple:都用业务 loginToken(OAuth sync 写下的) - const r = await storage.getLoginToken(); - return r.success ? r.data : null; -} - -/** - * 按 loginStatus 派初次加载事件(**仅**游客派 ChatInit —— 配额 init) - */ -function dispatchInitialLoad( +function dispatchAuthLifecycle( loginStatus: LoginStatus, chatDispatch: ReturnType, ): void { if (loginStatus === "guest") { - chatDispatch({ type: "ChatInit" }); // 拉本地消息 + 初始化配额 - } else if (loginStatus !== "notLoggedIn") { - chatDispatch({ type: "ChatLoadMoreHistory" }); // 拉服务器端首屏(**无**配额 init) + // 游客:派 ChatGuestLogin(chat 机器**不**连 WS —— 业务事实"断 WS") + chatDispatch({ type: "ChatGuestLogin" }); + return; } + if (loginStatus === "notLoggedIn") { + // 登出:派 ChatLogout(chat 机器自动 cleanup WS actor + 清消息) + chatDispatch({ type: "ChatLogout" }); + return; + } + // 非游客:拿 loginToken → 派 ChatNonGuestLogin { token } + void (async () => { + const tokenR = await AuthStorage.getInstance().getLoginToken(); + if (tokenR.success && tokenR.data) { + chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data }); + } + })(); } export function ChatScreen() { @@ -103,92 +93,35 @@ export function ChatScreen() { const [quotaExhausted, setQuotaExhausted] = useState(false); // ───────────────────────────────────────────────────────────── - // mount 时按 loginStatus 派初次加载 + // 屏幕生命周期 + 派初次鉴权生命周期事件 // ───────────────────────────────────────────────────────────── useEffect(() => { - dispatchInitialLoad(authState.loginStatus, chatDispatch); + chatDispatch({ type: "ChatScreenVisible" }); + dispatchAuthLifecycle(authState.loginStatus, chatDispatch); + return () => { + chatDispatch({ type: "ChatScreenInvisible" }); + }; + // 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的 + // ("用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。 + // loginStatus 后续变化由下方"副作用 ②" 统一处理。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatDispatch]); - // 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的 - // ("用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。 - // loginStatus 后续变化由下方"副作用 ②" 统一处理。 // ───────────────────────────────────────────────────────────── - // 核心副作用 ①:loginStatus 变化 → 连/断 WebSocket - // ───────────────────────────────────────────────────────────── - const wsRef = useRef(null); - - useEffect(() => { - let cancelled = false; - - void (async () => { - if (authState.loginStatus === "notLoggedIn") { - // 登出 / 未登录:断开 WS - if (wsRef.current) { - wsRef.current.disconnect(); - wsRef.current = null; - } - return; - } - - // 已登录:拿 token → 连 WS - const token = await getWsToken(authState.loginStatus); - if (cancelled || !token) return; - - // 清理旧连接(如有) - if (wsRef.current) { - wsRef.current.disconnect(); - wsRef.current = null; - } - - const ws = createChatWebSocket(token); - ws.onConnected = (userId) => { - chatDispatch({ type: "ChatWebSocketConnected", userId }); - }; - ws.onSentence = (p) => { - chatDispatch({ - type: "ChatAISentenceReceived", - index: p.index, - text: p.text, - total: p.total, - done: p.done, - }); - }; - ws.onError = (msg) => { - chatDispatch({ type: "ChatWebSocketError", errorMessage: msg }); - }; - ws.connect(); - wsRef.current = ws; - })(); - - return () => { - cancelled = true; - if (wsRef.current) { - wsRef.current.disconnect(); - wsRef.current = null; - } - }; - }, [authState.loginStatus, chatDispatch]); - - // ───────────────────────────────────────────────────────────── - // 副作用 ②:loginStatus 变化 → 重新加载 + // 副作用 ②:loginStatus 变化 → 派对应鉴权生命周期事件 // // 规则: // - 首次 mount(prev === null)→ 由 mount useEffect 统一处理 - // - "notLoggedIn" → 不加载 - // - 登录态变化(guest→email 等)→ 重新加载 - // - 游客派 ChatInit(拉本地 + **初始化配额**) - // - 非游客派 ChatLoadMoreHistory(拉服务器端首屏,**无**配额 init) + // - "notLoggedIn" → 派 ChatLogout + // - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin // ───────────────────────────────────────────────────────────── const prevLoginStatusRef = useRef(null); useEffect(() => { const prev = prevLoginStatusRef.current; prevLoginStatusRef.current = authState.loginStatus; if (prev === null) return; // 首次 mount 由 mount useEffect 处理 - if (authState.loginStatus === "notLoggedIn") return; - if (prev !== authState.loginStatus) { - dispatchInitialLoad(authState.loginStatus, chatDispatch); - } + if (prev === authState.loginStatus) return; // 登录态**没**变 + dispatchAuthLifecycle(authState.loginStatus, chatDispatch); }, [authState.loginStatus, chatDispatch]); // ───────────────────────────────────────────────────────────── diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index f0cbf33c..acbd55cb 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -6,12 +6,29 @@ * 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已**删**。 * 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅, * chat 机器**不**感知鉴权。 + * + * 鉴权生命周期事件(**本轮新增**): + * - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(**不**连 WS) + * - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + **连** WS(token 在 payload) + * - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息 + * + * 设计:**所有**WS / 历史 / 配额相关副作用**全部**通过派发事件给 chat 机器处理, + * chat-screen **不**直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。 */ export type ChatEvent = + // 内部 UI 事件 | { type: "ChatInit" } + | { type: "ChatScreenVisible" } + | { type: "ChatScreenInvisible" } + // 鉴权生命周期(**用户**显式触发登录后由 chat-screen 派) + | { type: "ChatGuestLogin" } + | { type: "ChatNonGuestLogin"; token: string } + | { type: "ChatLogout" } + // 业务事件 | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } | { type: "ChatLoadMoreHistory" } + // WebSocket / AI 推句(**由** chat 机器内部 actor 派回 —— "机器**自**驱动") | { type: "ChatAISentenceReceived"; index: number; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 00243226..bd2a2c94 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -5,32 +5,49 @@ * * 本轮迁移范围: * ✅ HTTP 流程(init / send message / load more history) + * ✅ WebSocket 长生命周期(**由** chat 机器内 fromCallback actor 管) * ✅ 上下文数据(messages / quota / 标志位) - * ⏳ WebSocket 长生命周期 actor(fromCallback)—— 下轮处理 * * 设计要点: * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API * - HTTP 操作用 `fromPromise` actor(一次性 Promise) + * - WebSocket 用 `fromCallback` actor(长生命周期)—— **机器内**完整管理 + * - chat-screen **不**直接管理 WS —— 只派 3 个鉴权生命周期事件 * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 - * - actions 全部 inline 在 setup() 中(确保类型推断正确) * - * **鉴权解耦**: - * chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。 - * WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。 + * **鉴权解耦**(**事件驱动**): + * 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`:用户聊天 + 翻历史 + * - `loadingMore`:invoke loadMoreHistory(翻历史) + * - `userSession`(**parent**):非游客会话 —— **invoke WS**(持久) + * - `initializing`:invoke loadMoreHistory(拉服务器端首屏) + * - `ready`:用户聊天 + 翻历史 + * - `loadingMore`:invoke loadMoreHistory(翻历史) + * WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续 * * **配额(**仅**游客)**: * - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义 * - 非游客"无消息限制"(业务事实)—— 永不被赋值 - * - chat-screen 派 `ChatInit` 时(**只**在 guest)→ onDone 条件赋 + * - `ChatGuestLogin` 触发 chatInitActor → onDone 条件赋 * - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op */ -import { setup, fromPromise, assign } from "xstate"; +import { setup, fromPromise, fromCallback, assign } from "xstate"; 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 { createChatWebSocket } from "@/core/net/chat-websocket"; import { formatDate } from "@/utils/date"; import { Result } from "@/utils/result"; @@ -48,8 +65,7 @@ export type { ChatState } from "./chat-state"; export { initialState } from "./chat-state"; export type { ChatEvent } from "./chat-events"; -// ============================================================ -// Helpers +// ============================================================// Helpers // ============================================================ const PAGE_SIZE = 20; @@ -123,9 +139,9 @@ async function readInitData(): Promise { }; } +// ============================================================// Actors // ============================================================ -// Actors -// ============================================================ +// HTTP:一次性 Promise const chatInitActor = fromPromise(async () => readInitData()); const sendMessageHttpActor = fromPromise< @@ -158,6 +174,33 @@ const loadMoreHistoryActor = fromPromise< }; }); +// WebSocket:长生命周期(**由** chat 机器内 fromCallback actor 管) +// - 启动:连 WS,事件 → sendBack 派回机器 +// - 停止(cleanup):disconnect +// - **游客**不** invoke 此 actor(业务事实"游客不连 WS") +const chatWebSocketActor = fromCallback( + ({ sendBack, input }) => { + const ws = createChatWebSocket(input.token); + ws.onConnected = (userId) => { + sendBack({ type: "ChatWebSocketConnected", userId }); + }; + ws.onSentence = (p) => { + sendBack({ + type: "ChatAISentenceReceived", + index: p.index, + text: p.text, + total: p.total, + done: p.done, + }); + }; + ws.onError = (msg) => { + sendBack({ type: "ChatWebSocketError", errorMessage: msg }); + }; + ws.connect(); + return () => ws.disconnect(); + }, +); + // ============================================================// Machine // ============================================================ export const chatMachine = setup({ @@ -169,6 +212,7 @@ export const chatMachine = setup({ chatInit: chatInitActor, sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, + chatWebSocket: chatWebSocketActor, }, actions: { appendUserMessage: assign(({ context, event }) => { @@ -248,81 +292,181 @@ export const chatMachine = setup({ initial: "idle", context: initialState, states: { + // ───────────────────────────────────────────────────────────── + // 屏没挂 / 登出 / 跨态前 + // ───────────────────────────────────────────────────────────── idle: { on: { - ChatInit: "initializing", - ChatLoadMoreHistory: "loadingMoreHistory", - ChatSendMessage: { - guard: ({ event }) => event.content.trim().length > 0, - actions: "appendUserMessage", - }, - ChatSendImage: { - actions: "appendUserImage", - }, - ChatAISentenceReceived: { - actions: "appendOrUpdateAISentence", - }, - ChatWebSocketError: { - actions: "appendSocketErrorMessage", - }, - ChatQuotaExceeded: { - actions: "incrementQuotaExceeded", - }, - ChatWebSocketConnected: {}, + ChatGuestLogin: "guestSession", + ChatNonGuestLogin: "userSession", + // 兼容旧事件(**无**资源 —— 旧 chat-screen 还在派 ChatInit) + ChatInit: "guestSession", + ChatLoadMoreHistory: "loadingMoreHistory", // 旧逻辑保留(翻历史用) }, }, - initializing: { + // ───────────────────────────────────────────────────────────── + // 游客会话(**不** invoke WS —— "断 WS"业务事实) + // ───────────────────────────────────────────────────────────── + guestSession: { + initial: "initializing", + // guestSession **不** invoke chatWebSocket —— 业务事实"游客不连 WS" + states: { + initializing: { + invoke: { + src: "chatInit", + onDone: { + target: "ready", + // **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制") + actions: assign(({ event }) => { + const updates: Partial = { + messages: event.output.localMessages, + }; + if (event.output.isGuest) { + updates.guestRemainingQuota = + event.output.guestRemainingQuota; + updates.guestTotalQuota = event.output.guestTotalQuota; + } + return updates; + }), + }, + onError: { + target: "ready", + }, + }, + }, + ready: { + on: { + ChatSendMessage: { + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatLoadMoreHistory: "loadingMore", + ChatScreenInvisible: "idle", // 屏不可见 → idle + ChatLogout: "idle", // 登出 → idle + // 跨态(游客 → email) + ChatNonGuestLogin: "userSession", + ChatAISentenceReceived: { + actions: "appendOrUpdateAISentence", + }, + ChatWebSocketError: { + actions: "appendSocketErrorMessage", + }, + ChatQuotaExceeded: { + actions: "incrementQuotaExceeded", + }, + ChatWebSocketConnected: {}, + }, + }, + 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 }), + }, + }, + }, + }, + }, + + // ───────────────────────────────────────────────────────────── + // 非游客会话(**invoke WS** —— "连 WS"业务事实) + // chatWebSocket 在 parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续 + // ───────────────────────────────────────────────────────────── + userSession: { + initial: "initializing", invoke: { - src: "chatInit", - onDone: { - target: "ready", - // **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制") - // - 游客:messages + quota 都更新 - // - 非游客:仅 messages(local 空),quota 保持 default 0(不更新其值) - actions: assign(({ event }) => { - const updates: Partial = { - messages: event.output.localMessages, - }; - if (event.output.isGuest) { - updates.guestRemainingQuota = event.output.guestRemainingQuota; - updates.guestTotalQuota = event.output.guestTotalQuota; - } - return updates; - }), + src: "chatWebSocket", + // event 是触发 userSession 状态的事件(ChatNonGuestLogin from idle / ChatGuestLogin from guestSession) + input: ({ event }) => ({ + token: event.type === "ChatNonGuestLogin" ? event.token : "", + }), + }, + states: { + initializing: { + invoke: { + src: "loadMoreHistory", + input: ({ context }) => ({ offset: context.historyOffset }), + onDone: { + target: "ready", + actions: assign(({ event }) => ({ + messages: event.output.messages, + isLoadingMore: false, + hasMore: event.output.hasMore, + historyOffset: event.output.newOffset, + })), + }, + onError: { + target: "ready", + actions: assign({ isLoadingMore: false }), + }, + }, }, - onError: { - target: "ready", + ready: { + on: { + ChatSendMessage: { + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatLoadMoreHistory: "loadingMore", + ChatScreenInvisible: "idle", // 屏不可见 → idle(cleanup WS via parent state exit) + ChatLogout: "idle", // 登出 → idle(cleanup WS) + // 跨态(email → 游客) + ChatGuestLogin: "guestSession", + ChatAISentenceReceived: { + actions: "appendOrUpdateAISentence", + }, + ChatWebSocketError: { + actions: "appendSocketErrorMessage", + }, + ChatQuotaExceeded: { + actions: "incrementQuotaExceeded", + }, + ChatWebSocketConnected: {}, + }, + }, + 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 }), + }, + }, }, }, }, - ready: { - on: { - ChatSendMessage: { - guard: ({ event }) => event.content.trim().length > 0, - actions: "appendUserMessage", - }, - ChatSendImage: { - actions: "appendUserImage", - }, - ChatLoadMoreHistory: "loadingMoreHistory", - ChatAuthStatusChanged: { - actions: "refreshAuthStatus", - }, - ChatAISentenceReceived: { - actions: "appendOrUpdateAISentence", - }, - ChatWebSocketError: { - actions: "appendSocketErrorMessage", - }, - ChatQuotaExceeded: { - actions: "incrementQuotaExceeded", - }, - ChatWebSocketConnected: {}, - }, - }, - + // ───────────────────────────────────────────────────────────── + // 旧 loadingMoreHistory state(兼容 ChatLoadMoreHistory 在 idle 时派的事件) + // ───────────────────────────────────────────────────────────── loadingMoreHistory: { invoke: { src: "loadMoreHistory", @@ -342,6 +486,37 @@ export const chatMachine = setup({ }, }, }, + + // ───────────────────────────────────────────────────────────── + // 旧 ready state(兼容 ChatScreenVisible 事件)—— **不** invoke WS + // (**实际**不会走到这里 —— guestSession.ready / userSession.ready 是真 ready) + // ───────────────────────────────────────────────────────────── + ready: { + on: { + ChatSendMessage: { + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatLoadMoreHistory: "loadingMoreHistory", + ChatScreenInvisible: "idle", + ChatLogout: "idle", + ChatGuestLogin: "guestSession", + ChatNonGuestLogin: "userSession", + ChatAISentenceReceived: { + actions: "appendOrUpdateAISentence", + }, + ChatWebSocketError: { + actions: "appendSocketErrorMessage", + }, + ChatQuotaExceeded: { + actions: "incrementQuotaExceeded", + }, + ChatWebSocketConnected: {}, + }, + }, }, });