From e9fc001a6f381ec3e57366055883086102a0c401 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 26 Jun 2026 18:35:26 +0800 Subject: [PATCH] refactor(chat): remove vip websocket session flow --- .../__tests__/chat-machine.helpers.test.ts | 1 - .../chat-machine.transitions.test.ts | 76 +--- src/stores/chat/chat-auth-sync.tsx | 11 +- src/stores/chat/chat-events.ts | 38 +- src/stores/chat/chat-machine.actors.ts | 134 +------ src/stores/chat/chat-machine.ts | 340 +----------------- src/stores/chat/chat-state.ts | 7 - 7 files changed, 40 insertions(+), 567 deletions(-) diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 9cb1ae19..e7f3655c 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -35,7 +35,6 @@ function makeChatState(overrides: Partial = {}): ChatState { messages: [], isReplyingAI: true, pendingReplyCount: 1, - wsConnected: false, upgradePromptVisible: false, upgradeReason: null, upgradeHint: null, diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 0577e815..5c63db98 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -83,7 +83,6 @@ function createTestChatMachine( response: makeChatSendResponse(), reply: null, })), - sendMessageWs: fromPromise(async () => {}), httpMessageQueue: fromCallback(({ receive, sendBack }) => { receive((event) => { if (event.type !== "ChatSendMessage") return; @@ -98,10 +97,6 @@ function createTestChatMachine( }); return () => undefined; }), - wsPreferredMessageQueue: fromCallback(({ receive }) => { - receive(() => undefined); - return () => undefined; - }), unlockPrivateMessage: fromPromise< UnlockPrivateOutput, { messageId: string } @@ -117,19 +112,12 @@ function createTestChatMachine( reason: "ok", }), })), - chatWebSocket: fromCallback(({ sendBack }) => { - sendBack({ - type: "ChatWebSocketConnected", - userId: "user-1", - }); - return () => undefined; - }), }, }); } describe("chatMachine transitions", () => { - it("keeps guest logout disabled while allowing upgrade to non-VIP login", async () => { + it("keeps guest logout disabled while allowing upgrade to user login", async () => { const actor = createActor(createTestChatMachine()).start(); actor.send({ type: "ChatGuestLogin" }); @@ -140,9 +128,9 @@ describe("chatMachine transitions", () => { actor.send({ type: "ChatLogout" }); expect(actor.getSnapshot().matches({ guestSession: "ready" })).toBe(true); - actor.send({ type: "ChatNonVipLogin", token: "token" }); + actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => - snapshot.matches({ nonVipUserSession: "ready" }), + snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatLogout" }); @@ -151,24 +139,16 @@ describe("chatMachine transitions", () => { actor.stop(); }); - it("enters VIP ready only after history and websocket are ready", async () => { + it("enters user ready after history is loaded", async () => { const actor = createActor(createTestChatMachine()).start(); - actor.send({ type: "ChatVipLogin", token: "token" }); + actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => - snapshot.matches({ vipUserSession: "ready" }), + snapshot.matches({ userSession: "ready" }), ); const snapshot = actor.getSnapshot(); expect(snapshot.context.historyLoaded).toBe(true); - expect(snapshot.context.wsConnected).toBe(true); - - actor.send({ type: "ChatNonVipLogin", token: "token" }); - await waitFor(actor, (nextSnapshot) => - nextSnapshot.matches({ nonVipUserSession: "ready" }), - ); - - expect(actor.getSnapshot().context.wsConnected).toBe(false); actor.stop(); }); @@ -176,23 +156,13 @@ describe("chatMachine transitions", () => { it("ignores guest login while an authenticated user session is active", async () => { const actor = createActor(createTestChatMachine()).start(); - actor.send({ type: "ChatNonVipLogin", token: "token" }); + actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => - snapshot.matches({ nonVipUserSession: "ready" }), + snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatGuestLogin" }); - expect(actor.getSnapshot().matches({ nonVipUserSession: "ready" })).toBe( - true, - ); - - actor.send({ type: "ChatVipLogin", token: "token" }); - await waitFor(actor, (snapshot) => - snapshot.matches({ vipUserSession: "ready" }), - ); - - actor.send({ type: "ChatGuestLogin" }); - expect(actor.getSnapshot().matches({ vipUserSession: "ready" })).toBe(true); + expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); actor.stop(); }); @@ -217,9 +187,9 @@ describe("chatMachine transitions", () => { }), ).start(); - actor.send({ type: "ChatNonVipLogin", token: "token" }); + actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => - snapshot.matches({ nonVipUserSession: "ready" }), + snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatUnlockPrivateMessage", messageId: "private-1" }); @@ -243,17 +213,15 @@ describe("chatMachine transitions", () => { it("allows multiple messages to be queued without leaving ready state", async () => { const actor = createActor(createTestChatMachine()).start(); - actor.send({ type: "ChatNonVipLogin", token: "token" }); + actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => - snapshot.matches({ nonVipUserSession: "ready" }), + snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatSendMessage", content: "hello" }); actor.send({ type: "ChatSendMessage", content: "still there?" }); - expect(actor.getSnapshot().matches({ nonVipUserSession: "ready" })).toBe( - true, - ); + expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); expect(actor.getSnapshot().context.messages).toMatchObject([ { content: "hello", isFromAI: false }, { content: "still there?", isFromAI: false }, @@ -293,7 +261,6 @@ describe("chatMachine transitions", () => { response: makeChatSendResponse(), reply: null, })), - sendMessageWs: fromPromise(async () => {}), httpMessageQueue: fromCallback(({ receive, sendBack }) => { const pending: string[] = []; receive((event) => { @@ -311,10 +278,6 @@ describe("chatMachine transitions", () => { }); return () => undefined; }), - wsPreferredMessageQueue: fromCallback(({ receive }) => { - receive(() => undefined); - return () => undefined; - }), unlockPrivateMessage: fromPromise< UnlockPrivateOutput, { messageId: string } @@ -330,20 +293,13 @@ describe("chatMachine transitions", () => { reason: "ok", }), })), - chatWebSocket: fromCallback(({ sendBack }) => { - sendBack({ - type: "ChatWebSocketConnected", - userId: "user-1", - }); - return () => undefined; - }), }, }); const actor = createActor(machine).start(); - actor.send({ type: "ChatNonVipLogin", token: "token" }); + actor.send({ type: "ChatUserLogin", token: "token" }); await waitFor(actor, (snapshot) => - snapshot.matches({ nonVipUserSession: "ready" }), + snapshot.matches({ userSession: "ready" }), ); actor.send({ type: "ChatSendMessage", content: "first" }); diff --git a/src/stores/chat/chat-auth-sync.tsx b/src/stores/chat/chat-auth-sync.tsx index d397529d..fe81d0cc 100644 --- a/src/stores/chat/chat-auth-sync.tsx +++ b/src/stores/chat/chat-auth-sync.tsx @@ -12,7 +12,6 @@ import { usePathname, useRouter } from "next/navigation"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { useAuthState } from "@/stores/auth/auth-context"; -import { useUserState } from "@/stores/user/user-context"; import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes"; import { useChatDispatch } from "./chat-context"; @@ -23,7 +22,6 @@ function isProtectedPath(pathname: string): boolean { export function ChatAuthSync() { const authState = useAuthState(); - const userState = useUserState(); const chatDispatch = useChatDispatch(); const pathname = usePathname(); const router = useRouter(); @@ -32,11 +30,7 @@ export function ChatAuthSync() { useEffect(() => { if (!authState.hasInitialized || authState.isLoading) return; - const isVip = userState.currentUser?.isVip === true; - const sessionKey = - authState.loginStatus === "notLoggedIn" || authState.loginStatus === "guest" - ? authState.loginStatus - : `${authState.loginStatus}:${isVip ? "vip" : "non-vip"}`; + const sessionKey = authState.loginStatus; if (prevSessionKeyRef.current === sessionKey) return; prevSessionKeyRef.current = sessionKey; @@ -58,7 +52,7 @@ export function ChatAuthSync() { const tokenR = await AuthStorage.getInstance().getLoginToken(); if (!cancelled && tokenR.success && tokenR.data) { chatDispatch({ - type: isVip ? "ChatVipLogin" : "ChatNonVipLogin", + type: "ChatUserLogin", token: tokenR.data, }); } @@ -74,7 +68,6 @@ export function ChatAuthSync() { chatDispatch, pathname, router, - userState.currentUser?.isVip, ]); return null; diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index da20fab5..9a1dd65b 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -8,19 +8,17 @@ * chat 机器不感知鉴权。 * * 鉴权生命周期事件(本轮新增): - * - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(不连 WS) - * - `ChatNonVipLogin`:非 VIP 用户进入 /chat —— 拉服务器端首屏,不连 WS - * - `ChatVipLogin`:VIP 用户进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload) - * - `ChatLogout`:正式登录用户登出 —— 断 WS + 清消息 + * - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏,不连 WS + * - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏,不连 WS + * - `ChatLogout`:正式登录用户登出 —— 清消息 * - * 设计:所有WS / 历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, - * chat-screen 不直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。 + * 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, + * chat-screen 不直接读 AuthStorage(除 token 取值)。 */ export type ChatEvent = // 鉴权生命周期(登录态变化后由 ChatAuthSync 派) | { type: "ChatGuestLogin" } - | { type: "ChatNonVipLogin"; token: string } - | { type: "ChatVipLogin"; token: string } + | { type: "ChatUserLogin"; token: string } | { type: "ChatLogout" } // 业务事件 | { type: "ChatSendMessage"; content: string } @@ -32,26 +30,4 @@ export type ChatEvent = type: "ChatQueuedHttpDone"; output: import("./chat-machine.helpers").HttpSendOutput; } - | { type: "ChatQueuedSendError"; content: string; errorMessage: string } - | { type: "ChatImageReceived"; url: string } - | { - type: "ChatPaywallStatusReceived"; - lockDetail: { - locked: boolean; - showContent: boolean; - showUpgrade: boolean; - reason: string | null; - hint: string | null; - detail: Record | null; - }; - } - // WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动") - | { - type: "ChatAISentenceReceived"; - index: number; - text: string; - total: number; - done: boolean; - } - | { type: "ChatWebSocketError"; errorMessage: string } - | { type: "ChatWebSocketConnected"; userId: string }; + | { type: "ChatQueuedSendError"; content: string; errorMessage: string }; diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 762f1198..944ed397 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -1,10 +1,9 @@ /** - * Chat 状态机:history / send / websocket / queue actors + * Chat 状态机:history / send / queue actors */ import { fromPromise, fromCallback } from "xstate"; -import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket"; import { MessageQueue } from "@/core/net/message-queue"; import type { ChatSendResponse } from "@/data/dto/chat"; import { Result, Logger } from "@/utils"; @@ -20,28 +19,6 @@ 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; -} - // ============================================================ // Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果) // ============================================================ @@ -131,111 +108,13 @@ export const unlockPrivateMessageActor = fromPromise< }; }); -// ============================================================ -// 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 = (url) => { - sendBack({ type: "ChatImageReceived", url }); - }; - ws.onPaywallStatus = (p) => { - sendBack({ - type: "ChatPaywallStatusReceived", - lockDetail: p, - }); - }; - 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 流回 - }, -); - export const httpMessageQueueActor = fromCallback( ({ sendBack, receive }) => { - return createMessageQueueActor("http", sendBack, receive); - }, -); - -export const wsPreferredMessageQueueActor = fromCallback( - ({ sendBack, receive }) => { - return createMessageQueueActor("wsPreferred", sendBack, receive); + return createMessageQueueActor(sendBack, receive); }, ); function createMessageQueueActor( - mode: "http" | "wsPreferred", sendBack: (event: ChatEvent) => void, receive: (listener: (event: ChatEvent) => void) => void, ): () => void { @@ -244,14 +123,6 @@ function createMessageQueueActor( queue.setConsumer(async (content) => { sendBack({ type: "ChatQueuedSendStarted" }); try { - if (mode === "wsPreferred") { - const ws = getActiveChatWebSocket(); - if (ws?.sendMessage(content)) return; - log.debug("[chat-machine] message queue fallback to HTTP", { - contentLength: content.length, - }); - } - const output = await sendMessageViaHttp(content); sendBack({ type: "ChatQueuedHttpDone", output }); } catch (error) { @@ -259,7 +130,6 @@ function createMessageQueueActor( error instanceof Error ? error.message : "Message send failed"; log.error("[chat-machine] message queue send failed", { error, - mode, }); sendBack({ type: "ChatQueuedSendError", diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 69373c0f..c6b5c744 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -5,34 +5,28 @@ * chat 机器不感知鉴权 / 不管 WebSocket —— 由 派生 loginStatus * ChatAuthSync 派发登录态生命周期事件: * - `ChatGuestLogin` → 游客会话(断 WS = 不连) - * - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS) - * - `ChatVipLogin { token }` → VIP 用户会话(连 WS) - * - `ChatLogout` → 正式登录用户登出(机器自动 cleanup WS actor) + * - `ChatUserLogin { token }` → 其他登录用户会话(不连 WS) + * - `ChatLogout` → 正式登录用户登出 * * 登录态流转约束: * - 未登录:可以进入游客登录 / 其他登录。 * - 游客登录:只可以升级为其他登录,不响应退出。 - * - 其他登录:可以退出,也可以在非 VIP / VIP 间切换,不响应游客登录。 + * - 其他登录:可以退出,不响应游客登录。 * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(parent):游客会话 —— 不 invoke WS - * - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS - * - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久) + * - `userSession`(parent):其他登录用户会话 —— 不 invoke WS * * init 任务: * - guestSession.initializing:loadHistory - * - nonVipUserSession.initializing:loadHistory - * - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行 + * - userSession.initializing:loadHistory * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier) * * 消息发送路径(业务事实): * - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态 - * - guestSession / nonVipUserSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准 - * - vipUserSession:队列优先走 WS,WS 不可用时 fallback HTTP + * - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准 * - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生 - * - `ChatWebSocketConnected` 事件 → `wsConnected = true` - * - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit) * * 配额: * - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。 @@ -50,17 +44,13 @@ import { applyHttpSendOutput, beginPendingReply, finishPendingReply, - normalizeLockDetail, } from "./chat-machine.helpers"; import { loadHistoryActor, sendMessageHttpActor, - sendMessageWsActor, loadMoreHistoryActor, - chatWebSocketActor, httpMessageQueueActor, unlockPrivateMessageActor, - wsPreferredMessageQueueActor, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); @@ -81,11 +71,8 @@ export const chatMachine = setup({ actors: { loadHistory: loadHistoryActor, sendMessageHttp: sendMessageHttpActor, - sendMessageWs: sendMessageWsActor, loadMoreHistory: loadMoreHistoryActor, - chatWebSocket: chatWebSocketActor, httpMessageQueue: httpMessageQueueActor, - wsPreferredMessageQueue: wsPreferredMessageQueueActor, unlockPrivateMessage: unlockPrivateMessageActor, }, actions: { @@ -137,7 +124,6 @@ export const chatMachine = setup({ log.debug("[chat-machine] appendUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), - wsConnected: context.wsConnected, isReplyingAI: context.isReplyingAI, }); @@ -189,7 +175,6 @@ export const chatMachine = setup({ const today = todayString(); log.debug("[chat-machine] appendUserImage", { oldMessagesCount: context.messages.length, - wsConnected: context.wsConnected, }); return { @@ -210,43 +195,6 @@ export const chatMachine = setup({ }; }), - 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, - }); - if (!event.done) { - return { - messages, - pendingReplyCount: Math.max(1, context.pendingReplyCount), - isReplyingAI: true, - }; - } - return { - messages, - ...finishPendingReply(context), - }; - }), - appendSocketErrorMessage: assign(({ context }) => { const messages = [ ...context.messages, @@ -266,61 +214,6 @@ export const chatMachine = setup({ return applyHttpSendOutput(context, event.output); }), - 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, - content: "", - imageUrl: event.url, - }; - } else { - messages.push({ - content: "", - isFromAI: true, - date: todayString(), - imageUrl: event.url, - }); - } - - log.debug("[chat-machine] appendAIImage", { - messagesCount: messages.length, - hasMergedIntoLastMessage: Boolean(last?.isFromAI && !last.imageUrl), - }); - - return { - messages, - upgradePromptVisible: false, - upgradeReason: null, - upgradeHint: null, - upgradeDetail: null, - }; - }), - - applyPaywallStatus: assign(({ event }) => { - if (event.type !== "ChatPaywallStatusReceived") return {}; - if (!event.lockDetail.showUpgrade) { - return { - upgradePromptVisible: false, - upgradeReason: null, - upgradeHint: null, - upgradeDetail: null, - }; - } - return { - upgradePromptVisible: true, - upgradeReason: - event.lockDetail.reason === "private_message" - ? "private_message" - : "image", - upgradeHint: event.lockDetail.hint, - upgradeDetail: normalizeLockDetail(event.lockDetail.detail), - }; - }), - setUnlockingPrivateMessage: assign(({ event }) => { if (event.type !== "ChatUnlockPrivateMessage") return {}; return { @@ -383,9 +276,6 @@ export const chatMachine = setup({ clearUnlockingPrivateMessage: assign({ unlockingPrivateMessageId: null, }), - - setWsConnected: assign({ wsConnected: true }), - clearWsConnected: assign({ wsConnected: false }), }, }).createMachine({ id: "chat", @@ -398,12 +288,8 @@ export const chatMachine = setup({ target: "#chat.guestSession", actions: "startGuestSession", }, - ChatNonVipLogin: { - target: "#chat.nonVipUserSession", - actions: "startUserSession", - }, - ChatVipLogin: { - target: "#chat.vipUserSession", + ChatUserLogin: { + target: "#chat.userSession", actions: "startUserSession", }, }, @@ -415,12 +301,8 @@ export const chatMachine = setup({ src: "httpMessageQueue", }, on: { - ChatNonVipLogin: { - target: "#chat.nonVipUserSession", - actions: "startUserSession", - }, - ChatVipLogin: { - target: "#chat.vipUserSession", + ChatUserLogin: { + target: "#chat.userSession", actions: "startUserSession", }, ChatQueuedSendStarted: { @@ -517,7 +399,7 @@ export const chatMachine = setup({ }, }, - nonVipUserSession: { + userSession: { invoke: { id: "messageQueue", src: "httpMessageQueue", @@ -527,15 +409,11 @@ export const chatMachine = setup({ target: "#chat.idle", actions: "clearChatSession", }, - ChatNonVipLogin: { - target: "#chat.nonVipUserSession", + ChatUserLogin: { + target: "#chat.userSession", reenter: true, actions: "startUserSession", }, - ChatVipLogin: { - target: "#chat.vipUserSession", - actions: "startUserSession", - }, ChatQueuedSendStarted: { actions: "markQueuedSendStarted", }, @@ -644,198 +522,6 @@ export const chatMachine = setup({ }, }, }, - - 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" }, - ChatQueuedSendStarted: { - actions: "markQueuedSendStarted", - }, - ChatQueuedHttpDone: { - actions: "applyQueuedHttpOutput", - }, - ChatQueuedSendError: { - actions: "appendSocketErrorMessage", - }, - }, - initial: "initializing", - exit: "clearWsConnected", - invoke: [ - { - // 父级:WS 长连接(一进来就连,跨 initializing/ready/loadingMore) - src: "chatWebSocket", - input: ({ event }) => ({ - token: event.type === "ChatVipLogin" ? event.token : "", - }), - }, - { - id: "messageQueue", - src: "wsPreferredMessageQueue", - }, - ], - 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: { - ChatSendMessage: { - guard: ({ event }) => event.content.trim().length > 0, - actions: ["appendUserMessage", "enqueueMessage"], - }, - 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", - }, - }, - }, - }, - }, }, }); diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index c8969ff5..54f727e6 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -4,12 +4,6 @@ export interface ChatState { messages: UiMessage[]; isReplyingAI: boolean; pendingReplyCount: number; - /** - * WebSocket 连接状态(仅 VIP 用户会话期间 true) - * - VIP true:走 WS - * - 非 VIP / 游客:固定 false,走 HTTP - */ - wsConnected: boolean; upgradePromptVisible: boolean; upgradeReason: "daily_limit" | "private_message" | "image" | null; upgradeHint: string | null; @@ -36,7 +30,6 @@ export const initialState: ChatState = { messages: [], isReplyingAI: false, pendingReplyCount: 0, - wsConnected: false, upgradePromptVisible: false, upgradeReason: null, upgradeHint: null,