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:
@@ -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;
|
||||
|
||||
+250
-75
@@ -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<InitResult> {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================// Actors
|
||||
// ============================================================
|
||||
// Actors
|
||||
// ============================================================
|
||||
// HTTP:一次性 Promise
|
||||
const chatInitActor = fromPromise<InitResult>(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<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
|
||||
// ============================================================
|
||||
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<ChatState> = {
|
||||
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<ChatState> = {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user