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

795 lines
25 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
*
* 鉴权解耦(事件驱动):
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
* ChatAuthSync 派发登录态生命周期事件:
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
* - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS)
* - `ChatVipLogin { token }` → VIP 用户会话(连 WS
* - `ChatLogout` → 正式登录用户登出(机器自动 cleanup WS actor
*
* 登录态流转约束:
* - 未登录:可以进入游客登录 / 其他登录。
* - 游客登录:只可以升级为其他登录,不响应退出。
* - 其他登录:可以退出,也可以在非 VIP / VIP 间切换,不响应游客登录。
*
* 状态结构(parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`parent):游客会话 —— 不 invoke WS
* - `nonVipUserSession`parent):非 VIP 用户会话 —— 不 invoke WS
* - `vipUserSession`parent):VIP 用户会话 —— invoke WS(持久)
*
* init 任务:
* - guestSession.initializingloadHistory
* - nonVipUserSession.initializingloadHistory
* - vipUserSession.initializingloadHistory + parent-level chatWebSocket 并行
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
*
* 消息发送路径(业务事实):
* - guestSession:走 HTTP,消息数量限制以后端 `lockDetail` 响应为准
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit lockDetail
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
* - `vipUserSession.exit` action → `wsConnected = false`WS actor cleanup 时也会跑 exit
*
* 配额:
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
* - 后端返回 `lockDetail.reason="daily_limit"` 且需要升级时展示会员引导。
*
*/
import { setup, assign } from "xstate";
import { todayString, Logger } from "@/utils";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
applyHttpSendOutput,
normalizeLockDetail,
} from "./chat-machine.helpers";
import {
loadHistoryActor,
sendMessageHttpActor,
sendMessageWsActor,
loadMoreHistoryActor,
chatWebSocketActor,
unlockPrivateMessageActor,
} from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine");
// 重新导出 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: {
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
sendMessageWs: sendMessageWsActor,
loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor,
unlockPrivateMessage: unlockPrivateMessageActor,
},
actions: {
startGuestSession: assign(() => ({
...initialState,
})),
startUserSession: assign(() => ({
...initialState,
})),
clearChatSession: assign(() => ({
...initialState,
})),
appendGuestUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
const today = todayString();
log.debug("[chat-machine] appendGuestUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: "server controlled",
isReplyingAI: true,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
isReplyingAI: true,
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}),
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,
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}),
appendGuestUserImage: assign(({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
const today = todayString();
log.debug("[chat-machine] appendGuestUserImage", {
oldMessagesCount: context.messages.length,
quotaMode: "server controlled",
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
isReplyingAI: true,
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}),
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,
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}),
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,
});
return { messages, isReplyingAI: !event.done };
}),
appendSocketErrorMessage: assign(({ context }) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, isReplyingAI: false };
}),
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 {
unlockingPrivateMessageId: event.messageId,
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}),
applyUnlockPrivateOutput: assign(({ context, event }) => {
if (!("output" in event)) return {};
const output = event.output as {
messageId: string;
response: import("@/data/dto/chat").UnlockPrivateResponse;
};
const { messageId, response } = output;
if (response.unlocked && response.content != null) {
return {
messages: context.messages.map((message) =>
message.id === messageId
? {
...message,
content: response.content ?? message.content,
locked: false,
lockedPrivate: false,
privateMessageHint: null,
isPrivate: message.isPrivate ?? true,
}
: message,
),
unlockingPrivateMessageId: null,
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}
if (response.showUpgrade) {
return {
unlockingPrivateMessageId: null,
upgradePromptVisible: true,
upgradeReason: "private_message",
upgradeHint: null,
upgradeDetail: {
usedToday: response.privateUsedToday,
limit: response.privateFreeLimit,
},
};
}
return {
unlockingPrivateMessageId: null,
};
}),
clearUnlockingPrivateMessage: assign({
unlockingPrivateMessageId: null,
}),
setWsConnected: assign({ wsConnected: true }),
clearWsConnected: assign({ wsConnected: false }),
},
}).createMachine({
id: "chat",
initial: "idle",
context: initialState,
states: {
idle: {
on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatNonVipLogin: {
target: "#chat.nonVipUserSession",
actions: "startUserSession",
},
ChatVipLogin: {
target: "#chat.vipUserSession",
actions: "startUserSession",
},
},
},
guestSession: {
on: {
ChatNonVipLogin: {
target: "#chat.nonVipUserSession",
actions: "startUserSession",
},
ChatVipLogin: {
target: "#chat.vipUserSession",
actions: "startUserSession",
},
},
initial: "initializing",
states: {
initializing: {
// 本地游客额度逻辑已停用:只等待历史加载完成。
always: [
{
target: "ready",
guard: ({ context }) => context.historyLoaded,
},
],
invoke: {
id: "loadHistory",
src: "loadHistory",
onDone: {
actions: assign(({ event }) => ({
messages: event.output.messages,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
})),
},
onError: {
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
actions: assign({
historyLoaded: true,
}),
},
},
},
ready: {
on: {
ChatSendMessage: {
actions: "appendGuestUserMessage",
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
ChatSendImage: {
actions: "appendGuestUserImage",
target: "sending",
},
ChatUnlockPrivateMessage: {
actions: "setUnlockingPrivateMessage",
target: "unlockingPrivate",
},
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
},
},
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 }),
},
},
},
unlockingPrivate: {
invoke: {
src: "unlockPrivateMessage",
input: ({ event }) => ({
messageId:
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
}),
onDone: {
target: "ready",
actions: "applyUnlockPrivateOutput",
},
onError: {
target: "ready",
actions: "clearUnlockingPrivateMessage",
},
},
},
},
},
nonVipUserSession: {
on: {
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",
},
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",
},
ChatUnlockPrivateMessage: {
actions: "setUnlockingPrivateMessage",
target: "unlockingPrivate",
},
},
},
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",
},
},
},
},
},
vipUserSession: {
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on
// —— 任何 child stateinitializing / 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" },
},
initial: "initializing",
exit: "clearWsConnected",
invoke: {
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore
src: "chatWebSocket",
input: ({ event }) => ({
token: event.type === "ChatVipLogin" ? event.token : "",
}),
},
states: {
initializing: {
// barrierWS 连上 + 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: {
// 发送消息:wsConnected → 走 WS(流式回 reply),否则 → 走 HTTP(一次性回 reply
// 两个 branch 都有 "content 非空" guard(仅作 fallback 兜底:wsConnected=true 也需
// 内容非空;wsConnected=false 同理)
ChatSendMessage: [
{
// VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。
guard: ({ context, event }) =>
context.wsConnected &&
event.content.trim().length > 0,
actions: "appendUserMessage",
target: "sendingViaWs",
},
{
// WS 未连(或 fallback + 内容非空 → 走 HTTPsendingViaHttp 一次性回 reply
guard: ({ event }) => event.content.trim().length > 0,
actions: "appendUserMessage",
target: "sendingViaHttp",
},
],
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 的 ChatWebSocketErrorXState 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",
},
},
},
},
},
},
});
export type ChatMachine = typeof chatMachine;