refactor(chat): move WebSocket lifecycle into chat machine via fromCallback actor

- 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)
This commit is contained in:
2026-06-11 18:57:11 +08:00
parent 4b356f58f2
commit d55694a73e
3 changed files with 310 additions and 185 deletions
+42 -109
View File
@@ -5,13 +5,10 @@
* 原始 Dart: lib/ui/chat/chat_screen.dart172 行) * 原始 Dart: lib/ui/chat/chat_screen.dart172 行)
* *
* 职责(本文件): * 职责(本文件):
* - **订阅 auth 状态机**loginStatus)—— * - **订阅 auth 状态机**loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器
* - 派生 isGuest / 驱动 WS 生命周期 / 驱动初次加载(按 loginStatus 选 ChatInit vs ChatLoadMoreHistory * - 屏幕生命周期事件(init / visible / invisible
* - 驱动历史加载
* - 屏幕初始化(mount 时按 loginStatus 派初次加载)
* - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实) * - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实)
* - WebSocket 生命周期管理(基于 auth 状态机的 loginStatus * - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值
* - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏)
* *
* 子组件职责: * 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮) * - ChatHeader:顶部栏(游客 banner / 菜单按钮)
@@ -22,13 +19,13 @@
* - BrowserHintOverlay:浏览器提示 * - BrowserHintOverlay:浏览器提示
* - CharacterIntro:角色介绍卡片(首屏) * - CharacterIntro:角色介绍卡片(首屏)
* *
* **鉴权解耦** * **鉴权解耦****事件驱动**
* chat 机器**不**感知鉴权 —— isGuest 在此派生自 `auth.loginStatus === "guest"`。 * - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
* chat 机器****管 WebSocket 生命周期 —— 由此 useEffect 监听 loginStatus 驱动。 * - chat-screen ****派 3 个事件:
* * - `ChatGuestLogin` → 游客会话(**断** WS = 不连)
* **配额****游客)** * - `ChatNonGuestLogin { token }` → 非游客会话**** WS
* - 游客:派 ChatInit → readInitData 调 ChatStorage → onDone 条件赋 * - `ChatLogout` → 登出(机器自动 cleanup WS actor
* - 非游客:派 ChatLoadMoreHistory(无配额 init)—— "非游客无消息限制"业务事实 * - chat 机器**内部**用 fromCallback actor 完整管理 WS 生命周期
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; 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 { AuthStorage } from "@/data/storage/auth/auth_storage";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { GuestChatQuota } from "@/stores/chat"; import { GuestChatQuota } from "@/stores/chat";
import {
ChatWebSocket,
createChatWebSocket,
} from "@/core/net/chat-websocket";
import { MobileShell } from "@/app/_components/core/mobile-shell"; 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( function dispatchAuthLifecycle(
loginStatus: LoginStatus,
): Promise<string | null> {
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:都用业务 loginTokenOAuth sync 写下的)
const r = await storage.getLoginToken();
return r.success ? r.data : null;
}
/**
* 按 loginStatus 派初次加载事件(**仅**游客派 ChatInit —— 配额 init
*/
function dispatchInitialLoad(
loginStatus: LoginStatus, loginStatus: LoginStatus,
chatDispatch: ReturnType<typeof useChatDispatch>, chatDispatch: ReturnType<typeof useChatDispatch>,
): void { ): void {
if (loginStatus === "guest") { if (loginStatus === "guest") {
chatDispatch({ type: "ChatInit" }); // 拉本地消息 + 初始化配额 // 游客:派 ChatGuestLoginchat 机器**不**连 WS —— 业务事实"断 WS"
} else if (loginStatus !== "notLoggedIn") { chatDispatch({ type: "ChatGuestLogin" });
chatDispatch({ type: "ChatLoadMoreHistory" }); // 拉服务器端首屏(**无**配额 init) return;
} }
if (loginStatus === "notLoggedIn") {
// 登出:派 ChatLogoutchat 机器自动 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() { export function ChatScreen() {
@@ -103,92 +93,35 @@ export function ChatScreen() {
const [quotaExhausted, setQuotaExhausted] = useState(false); const [quotaExhausted, setQuotaExhausted] = useState(false);
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// mount 时按 loginStatus 派初次加载 // 屏幕生命周期 + 派初次鉴权生命周期事件
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
dispatchInitialLoad(authState.loginStatus, chatDispatch); chatDispatch({ type: "ChatScreenVisible" });
// eslint-disable-next-line react-hooks/exhaustive-deps dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
}, [chatDispatch]); return () => {
chatDispatch({ type: "ChatScreenInvisible" });
};
// 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的 // 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的
// "用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。 // "用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。
// loginStatus 后续变化由下方"副作用 ②" 统一处理。 // loginStatus 后续变化由下方"副作用 ②" 统一处理。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatDispatch]);
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// 核心副作用 loginStatus 变化 → 连/断 WebSocket // 副作用 loginStatus 变化 → 派对应鉴权生命周期事件
// ─────────────────────────────────────────────────────────────
const wsRef = useRef<ChatWebSocket | null>(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 变化 → 重新加载
// //
// 规则: // 规则:
// - 首次 mountprev === null)→ 由 mount useEffect 统一处理 // - 首次 mountprev === null)→ 由 mount useEffect 统一处理
// - "notLoggedIn" → 不加载 // - "notLoggedIn" → 派 ChatLogout
// - 登录态变化(guest→email 等)→ 重新加载 // - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin
// - 游客派 ChatInit(拉本地 + **初始化配额**)
// - 非游客派 ChatLoadMoreHistory(拉服务器端首屏,**无**配额 init)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
const prevLoginStatusRef = useRef<LoginStatus | null>(null); const prevLoginStatusRef = useRef<LoginStatus | null>(null);
useEffect(() => { useEffect(() => {
const prev = prevLoginStatusRef.current; const prev = prevLoginStatusRef.current;
prevLoginStatusRef.current = authState.loginStatus; prevLoginStatusRef.current = authState.loginStatus;
if (prev === null) return; // 首次 mount 由 mount useEffect 处理 if (prev === null) return; // 首次 mount 由 mount useEffect 处理
if (authState.loginStatus === "notLoggedIn") return; if (prev === authState.loginStatus) return; // 登录态**没**变
if (prev !== authState.loginStatus) { dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
dispatchInitialLoad(authState.loginStatus, chatDispatch);
}
}, [authState.loginStatus, chatDispatch]); }, [authState.loginStatus, chatDispatch]);
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
+17
View File
@@ -6,12 +6,29 @@
* 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已**删**。 * 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已**删**。
* 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅, * 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅,
* chat 机器**不**感知鉴权。 * chat 机器**不**感知鉴权。
*
* 鉴权生命周期事件(**本轮新增**):
* - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(**不**连 WS)
* - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + **连** WStoken 在 payload
* - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息
*
* 设计:**所有**WS / 历史 / 配额相关副作用**全部**通过派发事件给 chat 机器处理,
* chat-screen **不**直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
*/ */
export type ChatEvent = export type ChatEvent =
// 内部 UI 事件
| { type: "ChatInit" } | { type: "ChatInit" }
| { type: "ChatScreenVisible" }
| { type: "ChatScreenInvisible" }
// 鉴权生命周期(**用户**显式触发登录后由 chat-screen 派)
| { type: "ChatGuestLogin" }
| { type: "ChatNonGuestLogin"; token: string }
| { type: "ChatLogout" }
// 业务事件
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" } | { type: "ChatLoadMoreHistory" }
// WebSocket / AI 推句(**由** chat 机器内部 actor 派回 —— "机器**自**驱动"
| { | {
type: "ChatAISentenceReceived"; type: "ChatAISentenceReceived";
index: number; index: number;
+215 -40
View File
@@ -5,32 +5,49 @@
* *
* 本轮迁移范围: * 本轮迁移范围:
* ✅ HTTP 流程(init / send message / load more history * ✅ HTTP 流程(init / send message / load more history
* ✅ WebSocket 长生命周期(**由** chat 机器内 fromCallback actor 管)
* ✅ 上下文数据(messages / quota / 标志位) * ✅ 上下文数据(messages / quota / 标志位)
* ⏳ WebSocket 长生命周期 actorfromCallback)—— 下轮处理
* *
* 设计要点: * 设计要点:
* - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API
* - HTTP 操作用 `fromPromise` actor(一次性 Promise * - HTTP 操作用 `fromPromise` actor(一次性 Promise
* - WebSocket 用 `fromCallback` actor(长生命周期)—— **机器内**完整管理
* - chat-screen **不**直接管理 WS —— 只派 3 个鉴权生命周期事件
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
* *
* **鉴权解耦** * **鉴权解耦****事件驱动**
* chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。 * chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
* WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。 * 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` **仅**游客有意义 * - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义
* - 非游客"无消息限制"(业务事实)—— 永不被赋值 * - 非游客"无消息限制"(业务事实)—— 永不被赋值
* - chat-screen 派 `ChatInit` 时(**只**在 guest→ onDone 条件赋 * - `ChatGuestLogin` 触发 chatInitActor → onDone 条件赋
* - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op * - `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 type { UiMessage } from "@/models/chat/ui-message";
import { chatRepository } from "@/data/repositories/chat_repository"; import { chatRepository } from "@/data/repositories/chat_repository";
import type { IChatRepository } from "@/data/repositories/interfaces"; import type { IChatRepository } from "@/data/repositories/interfaces";
import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { createChatWebSocket } from "@/core/net/chat-websocket";
import { formatDate } from "@/utils/date"; import { formatDate } from "@/utils/date";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
@@ -48,8 +65,7 @@ export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state"; export { initialState } from "./chat-state";
export type { ChatEvent } from "./chat-events"; export type { ChatEvent } from "./chat-events";
// ============================================================ // ============================================================// Helpers
// Helpers
// ============================================================ // ============================================================
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -123,9 +139,9 @@ async function readInitData(): Promise<InitResult> {
}; };
} }
// ============================================================// Actors
// ============================================================ // ============================================================
// Actors // HTTP:一次性 Promise
// ============================================================
const chatInitActor = fromPromise<InitResult>(async () => readInitData()); const chatInitActor = fromPromise<InitResult>(async () => readInitData());
const sendMessageHttpActor = fromPromise< const sendMessageHttpActor = fromPromise<
@@ -158,6 +174,33 @@ const loadMoreHistoryActor = fromPromise<
}; };
}); });
// WebSocket:长生命周期(**由** chat 机器内 fromCallback actor 管)
// - 启动:连 WS,事件 → sendBack 派回机器
// - 停止(cleanup):disconnect
// - **游客**不** invoke 此 actor(业务事实"游客不连 WS"
const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
({ 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 // ============================================================// Machine
// ============================================================ // ============================================================
export const chatMachine = setup({ export const chatMachine = setup({
@@ -169,6 +212,7 @@ export const chatMachine = setup({
chatInit: chatInitActor, chatInit: chatInitActor,
sendMessageHttp: sendMessageHttpActor, sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor, loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor,
}, },
actions: { actions: {
appendUserMessage: assign(({ context, event }) => { appendUserMessage: assign(({ context, event }) => {
@@ -248,44 +292,39 @@ export const chatMachine = setup({
initial: "idle", initial: "idle",
context: initialState, context: initialState,
states: { states: {
// ─────────────────────────────────────────────────────────────
// 屏没挂 / 登出 / 跨态前
// ─────────────────────────────────────────────────────────────
idle: { idle: {
on: { on: {
ChatInit: "initializing", ChatGuestLogin: "guestSession",
ChatLoadMoreHistory: "loadingMoreHistory", ChatNonGuestLogin: "userSession",
ChatSendMessage: { // 兼容旧事件(**无**资源 —— 旧 chat-screen 还在派 ChatInit
guard: ({ event }) => event.content.trim().length > 0, ChatInit: "guestSession",
actions: "appendUserMessage", ChatLoadMoreHistory: "loadingMoreHistory", // 旧逻辑保留(翻历史用)
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence",
},
ChatWebSocketError: {
actions: "appendSocketErrorMessage",
},
ChatQuotaExceeded: {
actions: "incrementQuotaExceeded",
},
ChatWebSocketConnected: {},
}, },
}, },
// ─────────────────────────────────────────────────────────────
// 游客会话(**不** invoke WS —— "断 WS"业务事实)
// ─────────────────────────────────────────────────────────────
guestSession: {
initial: "initializing",
// guestSession **不** invoke chatWebSocket —— 业务事实"游客不连 WS"
states: {
initializing: { initializing: {
invoke: { invoke: {
src: "chatInit", src: "chatInit",
onDone: { onDone: {
target: "ready", target: "ready",
// **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制") // **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制")
// - 游客:messages + quota 都更新
// - 非游客:仅 messageslocal 空),quota 保持 default 0(不更新其值)
actions: assign(({ event }) => { actions: assign(({ event }) => {
const updates: Partial<ChatState> = { const updates: Partial<ChatState> = {
messages: event.output.localMessages, messages: event.output.localMessages,
}; };
if (event.output.isGuest) { if (event.output.isGuest) {
updates.guestRemainingQuota = event.output.guestRemainingQuota; updates.guestRemainingQuota =
event.output.guestRemainingQuota;
updates.guestTotalQuota = event.output.guestTotalQuota; updates.guestTotalQuota = event.output.guestTotalQuota;
} }
return updates; return updates;
@@ -296,7 +335,6 @@ export const chatMachine = setup({
}, },
}, },
}, },
ready: { ready: {
on: { on: {
ChatSendMessage: { ChatSendMessage: {
@@ -306,10 +344,11 @@ export const chatMachine = setup({
ChatSendImage: { ChatSendImage: {
actions: "appendUserImage", actions: "appendUserImage",
}, },
ChatLoadMoreHistory: "loadingMoreHistory", ChatLoadMoreHistory: "loadingMore",
ChatAuthStatusChanged: { ChatScreenInvisible: "idle", // 屏不可见 → idle
actions: "refreshAuthStatus", ChatLogout: "idle", // 登出 → idle
}, // 跨态(游客 → email
ChatNonGuestLogin: "userSession",
ChatAISentenceReceived: { ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence", actions: "appendOrUpdateAISentence",
}, },
@@ -322,8 +361,7 @@ export const chatMachine = setup({
ChatWebSocketConnected: {}, ChatWebSocketConnected: {},
}, },
}, },
loadingMore: {
loadingMoreHistory: {
invoke: { invoke: {
src: "loadMoreHistory", src: "loadMoreHistory",
input: ({ context }) => ({ offset: context.historyOffset }), input: ({ context }) => ({ offset: context.historyOffset }),
@@ -343,6 +381,143 @@ export const chatMachine = setup({
}, },
}, },
}, },
},
// ─────────────────────────────────────────────────────────────
// 非游客会话(**invoke WS** —— "连 WS"业务事实)
// chatWebSocket 在 parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续
// ─────────────────────────────────────────────────────────────
userSession: {
initial: "initializing",
invoke: {
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 }),
},
},
},
ready: {
on: {
ChatSendMessage: {
guard: ({ event }) => event.content.trim().length > 0,
actions: "appendUserMessage",
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatLoadMoreHistory: "loadingMore",
ChatScreenInvisible: "idle", // 屏不可见 → idlecleanup WS via parent state exit
ChatLogout: "idle", // 登出 → idlecleanup 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 }),
},
},
},
},
},
// ─────────────────────────────────────────────────────────────
// 旧 loadingMoreHistory state(兼容 ChatLoadMoreHistory 在 idle 时派的事件)
// ─────────────────────────────────────────────────────────────
loadingMoreHistory: {
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 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: {},
},
},
},
}); });
export type ChatMachine = typeof chatMachine; export type ChatMachine = typeof chatMachine;