diff --git a/src/stores/chat/chat-auth-sync.tsx b/src/stores/chat/chat-auth-sync.tsx index 4e5edfa2..d397529d 100644 --- a/src/stores/chat/chat-auth-sync.tsx +++ b/src/stores/chat/chat-auth-sync.tsx @@ -58,9 +58,8 @@ export function ChatAuthSync() { const tokenR = await AuthStorage.getInstance().getLoginToken(); if (!cancelled && tokenR.success && tokenR.data) { chatDispatch({ - type: "ChatNonGuestLogin", + type: isVip ? "ChatVipLogin" : "ChatNonVipLogin", token: tokenR.data, - isVip, }); } })(); diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 9bbaf03a..066bb270 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -9,7 +9,8 @@ * * 鉴权生命周期事件(本轮新增): * - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(不连 WS) - * - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload) + * - `ChatNonVipLogin`:非 VIP 用户进入 /chat —— 拉服务器端首屏,不连 WS + * - `ChatVipLogin`:VIP 用户进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload) * - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息 * * 设计:所有WS / 历史 / 配额相关副作用全部通过派发事件给 chat 机器处理, @@ -18,7 +19,8 @@ export type ChatEvent = // 鉴权生命周期(登录态变化后由 ChatAuthSync 派) | { type: "ChatGuestLogin" } - | { type: "ChatNonGuestLogin"; token: string; isVip: boolean } + | { type: "ChatNonVipLogin"; token: string } + | { type: "ChatVipLogin"; token: string } | { type: "ChatLogout" } // 业务事件 | { type: "ChatSendMessage"; content: string } diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index d4b6197e..a70e119f 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -31,8 +31,8 @@ const log = new Logger("StoresChatChatMachineActors"); * 为什么用 module-level 而非 context.wsRef: * 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进 * XState context 会污染快照、警告 non-serializable - * 2) userSession 同一时间只可能有一个活跃 WS(parent-level invoke), - * module 单例的语义和"userSession 内一个活跃连接"一一对应 + * 2) vipUserSession 同一时间只可能有一个活跃 WS(parent-level invoke), + * module 单例的语义和"VIP 会话内一个活跃连接"一一对应 * 3) 不需要重构 ChatWebSocket 类的对外 API */ let activeChatWebSocket: ChatWebSocket | null = null; @@ -123,7 +123,7 @@ export const loadMoreHistoryActor = fromPromise< // WebSocket: long-lived callback // ============================================================ /** - * 长生命周期 WebSocket(仅 `userSession` 期间 invoke) + * 长生命周期 WebSocket(仅 `vipUserSession` 期间 invoke) * - 启动:连 WS,事件 → sendBack 派回机器 * - 停止(cleanup):disconnect * - 游客不 invoke 此 actor(业务事实"游客不连 WS") @@ -170,7 +170,7 @@ export const chatWebSocketActor = fromCallback( // WebSocket: per-send callback // ============================================================ /** - * WS 发送消息(userSession.sendingViaWs 期间 invoke) + * WS 发送消息(vipUserSession.sendingViaWs 期间 invoke) * - 走 `activeChatWebSocket.sendMessage(content)` * - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回 * - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready) diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 2892fd5e..4472bfbc 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -3,27 +3,30 @@ * * 鉴权解耦(事件驱动): * chat 机器不感知鉴权 / 不管 WebSocket —— 由 派生 loginStatus - * ChatAuthSync 只派 3 个事件: + * ChatAuthSync 派发登录态生命周期事件: * - `ChatGuestLogin` → 游客会话(断 WS = 不连) - * - `ChatNonGuestLogin { token }` → 非游客会话(连 WS) + * - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS) + * - `ChatVipLogin { token }` → VIP 用户会话(连 WS) * - `ChatLogout` → 登出(机器自动 cleanup WS actor) * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(parent):游客会话 —— 不 invoke WS - * - `userSession`(parent):非游客会话 —— invoke WS(持久) + * - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS + * - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久) * * init 双任务(核心重构): * - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组) - * - userSession.initializing:loadHistory + parent-level chatWebSocket 并行 + * - nonVipUserSession.initializing:loadHistory + * - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行 * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier) * * 消息发送路径(业务事实): * - guestSession:走 HTTP,并先检查本地游客配额 - * - userSession + VIP + WS 已连:走 WS,不受每日免费次数限制 - * - userSession + 非 VIP:走 HTTP,让后端返回 daily_limit blocked + * - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked + * - vipUserSession + WS 已连:走 WS,不受每日免费次数限制 * - `ChatWebSocketConnected` 事件 → `wsConnected = true` - * - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit) + * - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit) * * 配额: * - 游客:保留本地配额 guard,命中后不发送消息 @@ -75,31 +78,22 @@ export const chatMachine = setup({ actions: { startGuestSession: assign(() => ({ ...initialState, - isVip: false, })), - startUserSession: assign(({ event }) => { - if (event.type !== "ChatNonGuestLogin") return {}; - return { - ...initialState, - isVip: event.isVip, - }; - }), + startUserSession: assign(() => ({ + ...initialState, + })), clearChatSession: assign(() => ({ ...initialState, })), - appendUserMessage: assign(({ context, event }) => { + appendGuestUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; - // 两个额度同步更新:WS 已连不递减(实时会话不消耗),HTTP 必须减 - const newRemaining = context.wsConnected - ? context.guestRemainingQuota - : Math.max(0, context.guestRemainingQuota - 1); - const newTotal = context.wsConnected - ? context.guestTotalQuota - : Math.max(0, context.guestTotalQuota - 1); + // 游客消息发送成功前先乐观扣减本地日配额 + 总配额。 + const newRemaining = Math.max(0, context.guestRemainingQuota - 1); + const newTotal = Math.max(0, context.guestTotalQuota - 1); // 持久化两个额度( fire-and-forget,不 await —— assign 是 sync) // daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset) @@ -108,10 +102,10 @@ export const chatMachine = setup({ ChatStorage.getInstance().setGuestTotalQuota(newTotal); - log.debug("[chat-machine] appendUserMessage", { + log.debug("[chat-machine] appendGuestUserMessage", { contentLength: event.content.length, contentPreview: event.content.slice(0, 50), - quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)", + quotaMode: "guest http (decrement)", oldRemaining: context.guestRemainingQuota, newRemaining, oldTotal: context.guestTotalQuota, @@ -137,25 +131,47 @@ export const chatMachine = setup({ }; }), - appendUserImage: assign(({ context, event }) => { + appendUserMessage: assign(({ context, event }) => { + if (event.type !== "ChatSendMessage") return {}; + + const today = todayString(); + log.debug("[chat-machine] appendUserMessage", { + contentLength: event.content.length, + contentPreview: event.content.slice(0, 50), + wsConnected: context.wsConnected, + isReplyingAI: true, + }); + + return { + messages: [ + ...context.messages, + { + content: event.content, + isFromAI: false, + date: today, + }, + ], + isReplyingAI: true, + paywallTriggered: false, + paywallReason: null, + paywallDetail: null, + }; + }), + + appendGuestUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; - // 同 appendUserMessage:两个额度同步减 - const newRemaining = context.wsConnected - ? context.guestRemainingQuota - : Math.max(0, context.guestRemainingQuota - 1); - const newTotal = context.wsConnected - ? context.guestTotalQuota - : Math.max(0, context.guestTotalQuota - 1); + // 同 appendGuestUserMessage:游客两个额度同步减 + const newRemaining = Math.max(0, context.guestRemainingQuota - 1); + const newTotal = Math.max(0, context.guestTotalQuota - 1); const today = todayString(); void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); void ChatStorage.getInstance().setGuestTotalQuota(newTotal); - log.debug("[chat-machine] appendUserImage", { + log.debug("[chat-machine] appendGuestUserImage", { oldMessagesCount: context.messages.length, - wsConnected: context.wsConnected, - quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)", + quotaMode: "guest http (decrement)", oldRemaining: context.guestRemainingQuota, newRemaining, oldTotal: context.guestTotalQuota, @@ -180,6 +196,32 @@ export const chatMachine = setup({ }; }), + appendUserImage: assign(({ context, event }) => { + if (event.type !== "ChatSendImage") return {}; + + const today = todayString(); + log.debug("[chat-machine] appendUserImage", { + oldMessagesCount: context.messages.length, + wsConnected: context.wsConnected, + }); + + return { + messages: [ + ...context.messages, + { + content: "[Image]", + isFromAI: false, + date: today, + imageUrl: event.imageBase64, + }, + ], + isReplyingAI: true, + paywallTriggered: false, + paywallReason: null, + paywallDetail: null, + }; + }), + appendOrUpdateAISentence: assign(({ context, event }) => { if (event.type !== "ChatAISentenceReceived") return {}; const messages = [...context.messages]; @@ -237,8 +279,12 @@ export const chatMachine = setup({ target: "#chat.guestSession", actions: "startGuestSession", }, - ChatNonGuestLogin: { - target: "#chat.userSession", + ChatNonVipLogin: { + target: "#chat.nonVipUserSession", + actions: "startUserSession", + }, + ChatVipLogin: { + target: "#chat.vipUserSession", actions: "startUserSession", }, }, @@ -250,8 +296,12 @@ export const chatMachine = setup({ target: "#chat.idle", actions: "clearChatSession", }, - ChatNonGuestLogin: { - target: "#chat.userSession", + ChatNonVipLogin: { + target: "#chat.nonVipUserSession", + actions: "startUserSession", + }, + ChatVipLogin: { + target: "#chat.vipUserSession", actions: "startUserSession", }, }, @@ -324,7 +374,7 @@ export const chatMachine = setup({ }, // 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志) { - actions: "appendUserMessage", + actions: "appendGuestUserMessage", guard: ({ event }) => event.content.trim().length > 0, target: "sending", }, @@ -340,14 +390,11 @@ export const chatMachine = setup({ actions: "incrementQuotaExceeded", }, { - actions: "appendUserImage", + actions: "appendGuestUserImage", target: "sending", }, ], - // 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页 - ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, - ChatWebSocketError: { actions: "appendSocketErrorMessage" }, - ChatWebSocketConnected: { actions: "setWsConnected" }, + // 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, @@ -372,11 +419,110 @@ export const chatMachine = setup({ }, }, - userSession: { - // 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on + nonVipUserSession: { + on: { + ChatLogout: { + target: "#chat.idle", + actions: "clearChatSession", + }, + ChatGuestLogin: { + target: "#chat.guestSession", + actions: "startGuestSession", + }, + ChatNonVipLogin: { + target: "#chat.nonVipUserSession", + reenter: true, + actions: "startUserSession", + }, + ChatVipLogin: { + target: "#chat.vipUserSession", + actions: "startUserSession", + }, + }, + initial: "initializing", + states: { + initializing: { + invoke: { + src: "loadHistory", + onDone: { + target: "ready", + actions: assign(({ event }) => ({ + messages: event.output.messages, + isLoadingMore: false, + hasMore: event.output.hasMore, + historyOffset: event.output.newOffset, + historyLoaded: true, + })), + }, + onError: { + target: "ready", + actions: assign({ + isLoadingMore: false, + historyLoaded: true, + }), + }, + }, + }, + ready: { + on: { + ChatSendMessage: { + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + target: "sendingViaHttp", + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatLoadMoreHistory: { + target: "loadingMore", + }, + ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, + }, + }, + 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 }), + }, + }, + }, + }, + }, + + vipUserSession: { + // 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on // —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore) - // 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages, - // 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs) + // 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。 // // 子级 override 规则: // - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action) @@ -390,8 +536,12 @@ export const chatMachine = setup({ target: "#chat.guestSession", actions: "startGuestSession", }, - ChatNonGuestLogin: { - target: "#chat.userSession", + ChatNonVipLogin: { + target: "#chat.nonVipUserSession", + actions: "startUserSession", + }, + ChatVipLogin: { + target: "#chat.vipUserSession", reenter: true, actions: "startUserSession", }, @@ -405,7 +555,7 @@ export const chatMachine = setup({ // 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore) src: "chatWebSocket", input: ({ event }) => ({ - token: event.type === "ChatNonGuestLogin" ? event.token : "", + token: event.type === "ChatVipLogin" ? event.token : "", }), }, states: { @@ -445,9 +595,8 @@ export const chatMachine = setup({ // 内容非空;wsConnected=false 同理) ChatSendMessage: [ { - // VIP 用户保留 WS;非 VIP 走 HTTP,让后端 daily_limit 能返回 blocked。 + // VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。 guard: ({ context, event }) => - context.isVip && context.wsConnected && event.content.trim().length > 0, actions: "appendUserMessage", @@ -468,16 +617,16 @@ export const chatMachine = setup({ }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected - // 已上提到 userSession.on,子级不再声明 + // 已上提到 vipUserSession.on,子级不再声明 }, }, // WS 发送:invoke sendMessageWsActor 触发一次 send, - // 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence + // 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence // 当句尾 done: true → isReplyingAI = false → always 跳回 ready sendingViaWs: { on: { // 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs) - // 这个 child handler 替换 userSession.on 的 ChatWebSocketError(XState v5 不合并) + // 这个 child handler 替换 vipUserSession.on 的 ChatWebSocketError(XState v5 不合并) // 所以需要重复声明 action: "appendSocketErrorMessage" ChatWebSocketError: { target: "ready", diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index af76e714..7c28ed90 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -1,13 +1,11 @@ /** * Chat 状态机:State 形状 + 初始值 * - * 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。 - * - * 新增 `wsConnected: boolean`(仅 userSession 期间 true): - * - WS 已连 → 消息发送走 WS,不消耗次数 - * - WS 未连 → 消息发送走 HTTP,消耗次数 + * `wsConnected: boolean` 仅 VIP 会话期间有意义: + * - VIP + WS 已连 → 消息发送走 WS + * - 非 VIP / 游客 → 不连接 WS,消息走 HTTP * - `ChatWebSocketConnected` 事件 → true - * - `userSession.exit` → false(WS actor cleanup 时也会跑 exit) + * - VIP 会话 exit → false(WS actor cleanup 时也会跑 exit) */ import type { UiMessage } from "@/data/dto/chat"; @@ -15,14 +13,11 @@ export interface ChatState { messages: UiMessage[]; isReplyingAI: boolean; /** - * WebSocket 连接状态(仅 userSession 期间 true) - * - 决定消息发送路径(WS / HTTP)和是否消耗次数 - * - true:走 WS,不消耗次数 - * - false:走 HTTP,消耗次数 + * WebSocket 连接状态(仅 VIP 用户会话期间 true) + * - VIP true:走 WS + * - 非 VIP / 游客:固定 false,走 HTTP */ wsConnected: boolean; - /** 当前非游客用户是否 VIP;游客会话固定 false。 */ - isVip: boolean; /** 游客剩余配额(次/天)—— 仅游客业务展示用 */ guestRemainingQuota: number; @@ -37,11 +32,11 @@ export interface ChatState { historyOffset: number; /** 游客配额加载完成标志(仅 guestSession 期间) —— default false * - guestSession.initializing 走 always barrier:`quotaLoaded && historyLoaded` 才进 ready - * - userSession 永不为 true(不调 loadQuota)—— OK,不影响 barrier + * - user sessions 永不为 true(不调 loadQuota)—— OK,不影响 barrier */ quotaLoaded: boolean; /** history 加载完成标志(initial load —— local → network → save 跑完) - * - guestSession.initializing / userSession.initializing 走 always barrier + * - guestSession.initializing / user sessions initializing 走 always barrier * - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事) */ historyLoaded: boolean; @@ -51,7 +46,6 @@ export const initialState: ChatState = { messages: [], isReplyingAI: false, wsConnected: false, - isVip: false, guestRemainingQuota: 0, guestTotalQuota: 0, quotaExceededTrigger: 0,