Files
cozsweet-frontend-nextjs/src/stores/chat/chat-machine.ts
T
admin 9ffa30cc03 style: remove markdown bold syntax from code comments
Remove `**...**` emphasis markers from inline comments and JSDoc blocks across
auth and chat components, machine mappers, and quota helpers. These are
plain code comments, not rendered markdown, so the bold syntax was noise.

Files touched:
- src/app/auth/components/auth-screen.tsx
- src/app/chat/components/chat-header.tsx
- src/app/chat/components/chat-screen.tsx
- chat machine mapper / quota helpers

No functional or behavioral changes.
2026-06-16 14:06:31 +08:00

446 lines
16 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 —— 由 chat-screen 派生 loginStatus
* chat-screen 只派 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 } from "@/utils/date";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
loadQuotaActor,
loadHistoryActor,
sendMessageHttpActor,
loadMoreHistoryActor,
chatWebSocketActor,
} from "./chat-machine.actors";
/** 游客配额阈值(与 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,
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 = formatDate();
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
console.log("[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 = formatDate();
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
console.log("[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}`,
};
}
}
console.log("[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: {
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,不支持翻页
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
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: {
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: {
ChatSendMessage: {
actions: "appendUserMessage",
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
ChatSendImage: {
actions: "appendUserImage",
},
ChatLoadMoreHistory: {
target: "loadingMore",
},
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
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: {
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;