473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
/**
|
||
* 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;
|