Files
cozsweet-frontend-nextjs/src/stores/chat/chat-machine.ts
T

473 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Chat 状态机(XState v5
*
* 登录态流转约束:
* - 未登录:可以进入游客登录 / 其他登录。
* - 游客登录:只可以升级为其他登录,不响应退出。
* - 其他登录:可以退出,不响应游客登录 / 重复其他登录。
*
* 状态结构(parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`parent):游客会话
* - `userSession`parent):其他登录用户会话
*
* init 任务:
* - guestSession.initializingloadHistory
* - userSession.initializingloadHistory
* - 每个任务都是独立 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;