/** * Chat 状态机(XState v5) * * 登录态流转约束: * - 未登录:可以进入游客登录 / 其他登录。 * - 游客登录:只可以升级为其他登录,不响应退出。 * - 其他登录:可以退出,不响应游客登录 / 重复其他登录。 * * 状态结构(parent state 模式): * - `idle`:屏没挂 / 登出 * - `guestSession`(parent):游客会话 * - `userSession`(parent):其他登录用户会话 * * init 任务: * - guestSession.initializing:loadHistory * - userSession.initializing:loadHistory * - 每个任务都是独立 actor,不互相等待(除非 `always` barrier) * * 消息发送路径(业务事实): * - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态 * - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准 * - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生 * * 发送能力: * - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。 * - 后端返回 `cannotSendReason="insufficient_credits"` 时展示充值引导。 * */ import { setup, assign, sendTo } from "xstate"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput, applyHistoryLoadedOutput, applyNetworkHistoryLoadedOutput, countLockedHistoryMessages, shouldAutoUnlockHistory, shouldPromptUnlockHistory, } from "./chat-machine.helpers"; import { chatHistoryActors } from "./chat-history-flow"; import { chatMediaActions } from "./chat-media-flow"; import { chatSendActions, chatSendActors } from "./chat-send-flow"; import { chatUnlockActions, chatUnlockActors } from "./chat-unlock-flow"; // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { ChatState } from "./chat-state"; export { initialState } from "./chat-state"; export type { ChatEvent } from "./chat-events"; // ============================================================ // Machine // ============================================================ export const chatMachine = setup({ types: { context: {} as ChatState, events: {} as ChatEvent, }, actors: { ...chatHistoryActors, ...chatSendActors, ...chatUnlockActors, }, actions: { ...chatSendActions, ...chatMediaActions, ...chatUnlockActions, enqueueMessage: sendTo("messageQueue", ({ event }) => event), startGuestSession: assign(() => ({ ...initialState, })), startUserSession: assign(() => ({ ...initialState, })), clearChatSession: assign(() => ({ ...initialState, })), applyLocalHistoryLoaded: assign(({ event }) => { if (event.type !== "ChatLocalHistoryLoaded") return {}; return applyHistoryLoadedOutput(event.output); }), applyNetworkHistoryLoaded: assign(({ context, event }) => { if (event.type !== "ChatNetworkHistoryLoaded") return {}; return applyNetworkHistoryLoadedOutput(context, event.output); }), applyNetworkHistoryLoadedAndClearPayment: assign(({ context, event }) => { if (event.type !== "ChatNetworkHistoryLoaded") return {}; return { ...applyNetworkHistoryLoadedOutput(context, event.output), isLoadingMore: false, paymentUnlockPending: false, }; }), showUnlockHistoryPromptFromNetwork: assign(({ context, event }) => { if (event.type !== "ChatNetworkHistoryLoaded") return {}; const nextHistory = applyNetworkHistoryLoadedOutput( context, event.output, ); return { ...nextHistory, isLoadingMore: false, paymentUnlockPending: false, unlockHistoryPromptVisible: true, lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages), unlockHistoryError: null, }; }), markHistoryLoadFailed: assign({ isLoadingMore: false, historyLoaded: true, }), }, }).createMachine({ id: "chat", initial: "idle", context: initialState, states: { idle: { on: { ChatGuestLogin: { target: "#chat.guestSession", actions: "startGuestSession", }, ChatUserLogin: { target: "#chat.userSession", actions: "startUserSession", }, }, }, guestSession: { invoke: [ { id: "messageQueue", src: "httpMessageQueue", }, { id: "loadHistory", src: "loadHistory", }, ], on: { ChatUserLogin: { target: "#chat.userSession", actions: "startUserSession", }, ChatNetworkHistoryLoaded: { actions: "applyNetworkHistoryLoaded", }, ChatHistoryLoadFailed: { actions: "markHistoryLoadFailed", }, ChatQueuedSendStarted: { actions: "markQueuedSendStarted", }, ChatQueuedHttpDone: { actions: "applyQueuedHttpOutput", }, ChatQueuedSendError: { actions: "appendQueuedSendErrorMessage", }, }, initial: "initializing", states: { initializing: { on: { ChatLocalHistoryLoaded: { target: "ready", actions: "applyLocalHistoryLoaded", }, ChatNetworkHistoryLoaded: { target: "ready", actions: "applyNetworkHistoryLoaded", }, ChatHistoryLoadFailed: { target: "ready", actions: "markHistoryLoadFailed", }, }, }, ready: { on: { ChatSendMessage: { actions: ["appendGuestUserMessage", "enqueueMessage"], guard: ({ context, event }) => context.canSendMessage && event.content.trim().length > 0, }, ChatSendImage: { actions: "appendGuestUserImage", target: "sending", }, // 游客不支持翻页历史,发送消息统一走 HTTP 队列。 }, }, sending: { 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 }), }, }, }, }, }, userSession: { invoke: [ { id: "messageQueue", src: "httpMessageQueue", }, { id: "loadHistory", src: "loadHistory", }, ], on: { ChatLogout: { target: "#chat.idle", actions: "clearChatSession", }, ChatNetworkHistoryLoaded: [ { guard: ({ context, event }) => event.type === "ChatNetworkHistoryLoaded" && context.paymentUnlockPending && shouldAutoUnlockHistory(event.output.messages), target: ".unlockingHistory", actions: [ "applyNetworkHistoryLoadedAndClearPayment", "markUnlockHistoryStarted", ], }, { guard: ({ context, event }) => event.type === "ChatNetworkHistoryLoaded" && context.paymentUnlockPending && shouldPromptUnlockHistory(event.output.messages), target: ".ready", actions: "showUnlockHistoryPromptFromNetwork", }, { guard: ({ context }) => !context.isUnlockingHistory && !context.isUnlockingMessage, actions: "applyNetworkHistoryLoaded", }, ], ChatHistoryLoadFailed: { actions: "markHistoryLoadFailed", }, ChatQueuedSendStarted: { actions: "markQueuedSendStarted", }, ChatQueuedHttpDone: { actions: "applyQueuedHttpOutput", }, ChatQueuedSendError: { actions: "appendQueuedSendErrorMessage", }, ChatPaymentSucceeded: [ { guard: ({ context }) => context.historyLoaded && shouldAutoUnlockHistory(context.messages), target: ".unlockingHistory", actions: ["clearUpgradePrompt", "markUnlockHistoryStarted"], }, { guard: ({ context }) => context.historyLoaded && shouldPromptUnlockHistory(context.messages), actions: ["clearUpgradePrompt", "showUnlockHistoryPrompt"], }, { guard: ({ context }) => !context.historyLoaded, actions: ["clearUpgradePrompt", "markPaymentUnlockPending"], }, { guard: ({ context }) => context.historyLoaded, actions: ["clearUpgradePrompt", "dismissUnlockHistoryPrompt"], }, ], ChatUnlockHistoryConfirmed: { target: ".unlockingHistory", actions: "markUnlockHistoryStarted", }, ChatUnlockHistoryDismissed: { actions: "dismissUnlockHistoryPrompt", }, ChatUnlockPaywallNavigationConsumed: { actions: "clearUnlockPaywallRequest", }, }, initial: "initializing", states: { initializing: { on: { ChatLocalHistoryLoaded: { target: "ready", actions: "applyLocalHistoryLoaded", }, ChatNetworkHistoryLoaded: [ { guard: ({ context, event }) => event.type === "ChatNetworkHistoryLoaded" && context.paymentUnlockPending && shouldAutoUnlockHistory(event.output.messages), target: "unlockingHistory", actions: [ "applyNetworkHistoryLoadedAndClearPayment", "markUnlockHistoryStarted", ], }, { guard: ({ context, event }) => event.type === "ChatNetworkHistoryLoaded" && context.paymentUnlockPending && shouldPromptUnlockHistory(event.output.messages), target: "ready", actions: "showUnlockHistoryPromptFromNetwork", }, { target: "ready", actions: "applyNetworkHistoryLoadedAndClearPayment", }, ], ChatHistoryLoadFailed: { target: "ready", actions: "markHistoryLoadFailed", }, }, }, ready: { on: { ChatSendMessage: { guard: ({ context, event }) => context.canSendMessage && event.content.trim().length > 0, actions: ["appendUserMessage", "enqueueMessage"], }, ChatSendImage: { actions: "appendUserImage", }, ChatLoadMoreHistory: { target: "loadingMore", }, ChatUnlockMessageRequested: { guard: ({ event }) => event.messageId.trim().length > 0, target: "unlockingMessage", actions: "markUnlockMessageStarted", }, }, }, 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 }), }, }, }, unlockingHistory: { invoke: { id: "unlockHistory", src: "unlockHistory", onDone: { target: "ready", actions: assign(({ event }) => { const lockedHistoryCount = countLockedHistoryMessages( event.output.messages, ); const insufficientBalance = !event.output.unlocked && event.output.reason === "insufficient_balance"; return { messages: event.output.messages, hasMore: event.output.hasMore, historyOffset: event.output.newOffset, historyLoaded: true, paymentUnlockPending: false, unlockHistoryPromptVisible: insufficientBalance, lockedHistoryCount, isUnlockingHistory: false, unlockHistoryError: insufficientBalance ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` : null, }; }), }, onError: { target: "ready", actions: "markUnlockHistoryFailed", }, }, }, unlockingMessage: { invoke: { id: "unlockMessage", src: "unlockMessage", input: ({ context }) => ({ messageId: context.unlockingMessageId ?? "", }), onDone: [ { guard: ({ event }) => event.output.response.unlocked, target: "ready", actions: "applyUnlockMessageSucceeded", }, { target: "ready", actions: "requestUnlockPaymentFromOutput", }, ], onError: { target: "ready", actions: "requestUnlockPaymentFromError", }, }, }, }, }, }, }); export type ChatMachine = typeof chatMachine;