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

422 lines
12 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 机器不感知鉴权 —— 由 <ChatAuthSync /> 派生 loginStatus
* ChatAuthSync 派发登录态生命周期事件:
* - `ChatGuestLogin` → 游客会话
* - `ChatUserLogin { token }` → 其他登录用户会话
* - `ChatLogout` → 正式登录用户登出
*
* 登录态流转约束:
* - 未登录:可以进入游客登录 / 其他登录。
* - 游客登录:只可以升级为其他登录,不响应退出。
* - 其他登录:可以退出,不响应游客登录。
*
* 状态结构(parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`parent):游客会话
* - `userSession`parent):其他登录用户会话
*
* init 任务:
* - guestSession.initializingloadHistory
* - userSession.initializingloadHistory
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
*
* 消息发送路径(业务事实):
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
* - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
*
* 配额:
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
* - 后端返回 `lockDetail.reason="daily_limit"` 且需要升级时展示会员引导。
*
*/
import { setup, assign, sendTo } from "xstate";
import { todayString, Logger } from "@/utils";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
applyHttpSendOutput,
beginPendingReply,
finishPendingReply,
} from "./chat-machine.helpers";
import {
loadHistoryActor,
sendMessageHttpActor,
loadMoreHistoryActor,
httpMessageQueueActor,
} 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,
loadMoreHistory: loadMoreHistoryActor,
httpMessageQueue: httpMessageQueueActor,
},
actions: {
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
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: context.isReplyingAI,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
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),
isReplyingAI: context.isReplyingAI,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
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,
},
],
...beginPendingReply(context),
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,
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
...beginPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
upgradeHint: null,
upgradeDetail: null,
};
}),
appendQueuedSendErrorMessage: assign(({ context }) => {
const messages = [
...context.messages,
{
content: "Something went wrong. Try sending again?",
isFromAI: true,
date: todayString(),
},
];
return { messages, ...finishPendingReply(context) };
}),
markQueuedSendStarted: assign(({ context }) => beginPendingReply(context)),
applyQueuedHttpOutput: assign(({ context, event }) => {
if (event.type !== "ChatQueuedHttpDone") return {};
return applyHttpSendOutput(context, event.output);
}),
},
}).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",
},
on: {
ChatUserLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
},
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", "enqueueMessage"],
guard: ({ event }) => 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",
},
on: {
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",
},
ChatUserLogin: {
target: "#chat.userSession",
reenter: true,
actions: "startUserSession",
},
ChatQueuedSendStarted: {
actions: "markQueuedSendStarted",
},
ChatQueuedHttpDone: {
actions: "applyQueuedHttpOutput",
},
ChatQueuedSendError: {
actions: "appendQueuedSendErrorMessage",
},
},
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", "enqueueMessage"],
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatLoadMoreHistory: {
target: "loadingMore",
},
},
},
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 }),
},
},
},
},
},
},
});
export type ChatMachine = typeof chatMachine;