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

519 lines
19 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 只派 3 个事件:
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
* - `ChatLogout` → 登出(机器自动 cleanup WS actor
*
* 状态结构(parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`parent):游客会话 —— 不 invoke WS
* - `userSession`parent):非游客会话 —— invoke WS(持久)
*
* init 双任务(核心重构):
* - guestSession.initializingloadQuota + loadHistory 并行(`invoke: [...]` 数组)
* - userSession.initializingloadHistory + parent-level chatWebSocket 并行
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
*
* 消息发送路径(业务事实):
* - `wsConnected: true`(仅 userSession 期间)→ 走 WS,不消耗次数(仅配额规则)
* - `wsConnected: false` → 走 HTTP,消耗次数(仅游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
* - `userSession.exit` action → `wsConnected = false`WS actor cleanup 时也会跑 exit
*
* 配额(仅游客):
* - 非游客"无消息限制"(业务事实)—— 永不被赋值
* - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时不递减
*
*/
import { setup, assign } from "xstate";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { formatDate, todayString, Logger } from "@/utils";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
loadQuotaActor,
loadHistoryActor,
sendMessageHttpActor,
sendMessageWsActor,
loadMoreHistoryActor,
chatWebSocketActor,
} from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine");
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
export const GuestChatQuota = {
warningThreshold: 5,
quotaExhausted: 0,
} as const;
// 重新导出 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: {
loadQuota: loadQuotaActor,
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
sendMessageWs: sendMessageWsActor,
loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor,
},
actions: {
appendUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
// 两个额度同步更新:WS 已连不递减(实时会话不消耗),HTTP 必须减
const newRemaining = context.wsConnected
? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1);
const newTotal = context.wsConnected
? context.guestTotalQuota
: Math.max(0, context.guestTotalQuota - 1);
// 持久化两个额度( fire-and-forget,不 await —— assign 是 sync
// daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset
const today = todayString();
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
log.debug("[chat-machine] appendUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
oldRemaining: context.guestRemainingQuota,
newRemaining,
oldTotal: context.guestTotalQuota,
newTotal,
isReplyingAI: true,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: today,
},
],
isReplyingAI: true,
guestRemainingQuota: newRemaining,
guestTotalQuota: newTotal,
};
}),
appendUserImage: assign(({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
// 同 appendUserMessage:两个额度同步减
const newRemaining = context.wsConnected
? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1);
const newTotal = context.wsConnected
? context.guestTotalQuota
: Math.max(0, context.guestTotalQuota - 1);
const today = todayString();
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
log.debug("[chat-machine] appendUserImage", {
oldMessagesCount: context.messages.length,
wsConnected: context.wsConnected,
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
oldRemaining: context.guestRemainingQuota,
newRemaining,
oldTotal: context.guestTotalQuota,
newTotal,
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: today,
imageUrl: event.imageBase64,
},
],
isReplyingAI: true,
guestRemainingQuota: newRemaining,
guestTotalQuota: newTotal,
};
}),
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: formatDate(),
});
} 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: formatDate(),
},
];
return { messages, isReplyingAI: false };
}),
incrementQuotaExceeded: assign(({ context }) => ({
quotaExceededTrigger: context.quotaExceededTrigger + 1,
})),
setWsConnected: assign({ wsConnected: true }),
clearWsConnected: assign({ wsConnected: false }),
},
}).createMachine({
id: "chat",
initial: "idle",
context: initialState,
states: {
idle: {
on: {
ChatGuestLogin: "#chat.guestSession",
ChatNonGuestLogin: "#chat.userSession",
},
},
// ========================================================================
// guestSession(游客会话 —— 不连 WS,2 个 init 任务并行)
// ========================================================================
guestSession: {
on: {
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
},
initial: "initializing",
states: {
initializing: {
// "always" barrier:两个任务都完成才进 ready
always: [
{
target: "ready",
guard: ({ context }) => context.quotaLoaded && context.historyLoaded,
},
],
invoke: [
// 任务 1:加载配额(fromPromise,返结果 + onDone 设 flag
{
id: "loadQuota",
src: "loadQuota",
onDone: {
actions: assign(({ event }) => ({
guestRemainingQuota: event.output.remaining,
guestTotalQuota: event.output.total,
quotaLoaded: true,
})),
},
onError: {
// 失败也标 loaded,不卡 init(游客可能 quota 服务不可用,让 UI 进 ready)
actions: assign({
quotaLoaded: true,
}),
},
},
// 任务 2:拉 historyfromPromise,返回最终 network messages
{
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: {
// 配额检查 + 发送消息(3 条 guard,按顺序匹配)
// 1. 总配额(guestTotalQuota)不足 → 触发 quota exceeded dialog,不发送
// 2. 每日配额(guestRemainingQuota)不足 → 同上
// 3. 两个配额都 OK + 内容非空 → 发送 + 减配额
ChatSendMessage: [
// 1. 总配额 不足(guestTotalQuota <= 0 → 触发 quota exceeded dialog
{
guard: ({ context }) => context.guestTotalQuota <= 0,
actions: "incrementQuotaExceeded",
},
// 2. 每日配额 不足(guestRemainingQuota <= 0 → 触发 quota exceeded dialog
{
guard: ({ context }) => context.guestRemainingQuota <= 0,
actions: "incrementQuotaExceeded",
},
// 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志)
{
actions: "appendUserMessage",
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
],
// 配额检查 + 发送图片(同样 3 条 guard,不检查 "内容空"
ChatSendImage: [
{
guard: ({ context }) => context.guestTotalQuota <= 0,
actions: "incrementQuotaExceeded",
},
{
guard: ({ context }) => context.guestRemainingQuota <= 0,
actions: "incrementQuotaExceeded",
},
{
actions: "appendUserImage",
target: "sending",
},
],
// 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
sending: {
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) => ({
messages: [...context.messages, event.output.reply],
isReplyingAI: false,
})),
},
onError: {
target: "ready",
actions: assign({ isReplyingAI: false }),
},
},
},
// 删除 loadingMore(游客无翻页)
},
},
// ========================================================================
// userSession(非游客会话 —— WS 父级 + history 子级)
// ========================================================================
userSession: {
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on
// —— 任何 child stateinitializing / ready / sendingViaWs / sendingViaHttp / loadingMore
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages
// 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs
//
// 子级 override 规则:
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action
// —— XState v5: child handler 替换 parent;复制 action
on: {
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
ChatNonGuestLogin: {
target: "#chat.userSession",
reenter: true,
},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
},
initial: "initializing",
exit: "clearWsConnected",
invoke: {
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore
src: "chatWebSocket",
input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? 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: [
{
// WS 已连 + 内容非空 → 走 WSsendingViaWs 等 AI 流式回 reply
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",
},
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
// 已上提到 userSession.on,子级不再声明
},
},
// WS 发送:invoke sendMessageWsActor 触发一次 send
// 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
sendingViaWs: {
on: {
// 流式回包中 WS 出错(mid-stream → 加错误泡 + 跳回 ready(不卡在 sendingViaWs
// 这个 child handler 替换 userSession.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 }) => ({
messages: [...context.messages, event.output.reply],
isReplyingAI: false,
})),
},
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;