Files
cozsweet-frontend-nextjs/src/stores/chat/chat-machine.ts
T
admin 57992bffac chore(chat): clean up legacy state machine and fix bubble alignment
- Remove redundant `loadingMoreHistory` and `ready` states from chat machine, consolidating history loading flow
- Add `align-self: flex-start` to chat row container for proper alignment
- Clean up commented-out margin rules in text bubble styles
- Update deploy script GIT_REMOTE from "server" to "test"
2026-06-12 11:59:34 +08:00

517 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
*
* 设计要点:
* - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API
* - HTTP 操作用 `fromPromise` actor(一次性 Promise
* - WebSocket 用 `fromCallback` actor(长生命周期)—— **机器内**完整管理
* - chat-screen **不**直接管理 WS —— 只派 3 个鉴权生命周期事件
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
*
* **鉴权解耦**(**事件驱动**):
* chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
* chat-screen **只**派 3 个事件:
* - `ChatGuestLogin` → 游客会话(**断** WS = 不连)
* - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS)
* - `ChatLogout` → 登出(机器自动 cleanup WS actor
*
* **状态结构**parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`**parent**):游客会话 —— **不** invoke WS
* - `initializing`invoke chatInit(拉本地 + 初始化配额)
* - `ready`:用户聊天 + 翻历史
* - `sending`invoke sendMessageHttpActorHTTP 发送消息)
* - `loadingMore`invoke loadMoreHistory(翻历史)
* - `userSession`**parent**):非游客会话 —— **invoke WS**(持久)
* - `initializing`invoke loadMoreHistory(拉服务器端首屏)
* - `ready`:用户聊天 + 翻历史
* - `sending`invoke sendMessageHttpActorHTTP fallback —— WS 真发**未** wire
* - `loadingMore`invoke loadMoreHistory(翻历史)
* WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/sending/loadingMore 持续
*
* **消息发送路径**(**业务事实**):
* - `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 已连时**不**递减
*
* **文件结构**
* - `chat-machine.ts`(本文件)—— **只**含 setup + createMachineactions + states
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载
* - `chat-machine.actors.ts` —— 4 个 XState actor(含全链日志)
*
* **调试日志**(保留 —— 用户要求"不要将其删除"):
* - 命名约定:`[<file>]` 前缀标识来源
* - 3 actor **全链日志**ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到
*/
import { setup, assign } from "xstate";
import { formatDate } from "@/utils/date";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
chatInitActor,
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: {
chatInit: chatInitActor,
sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor,
},
actions: {
appendUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
const newQuota = context.wsConnected
? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1);
console.log("[chat-machine] appendUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
oldMessagesCount: context.messages.length,
newMessagesCount: context.messages.length + 1,
wsConnected: context.wsConnected,
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
oldQuota: context.guestRemainingQuota,
newQuota,
isReplyingAI: true,
});
return {
messages: [
...context.messages,
{
content: event.content,
isFromAI: false,
date: formatDate(),
},
],
isReplyingAI: true,
guestRemainingQuota: newQuota,
};
}),
appendUserImage: assign(({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
const newQuota = context.wsConnected
? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1);
console.log("[chat-machine] appendUserImage", {
oldMessagesCount: context.messages.length,
wsConnected: context.wsConnected,
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
oldQuota: context.guestRemainingQuota,
newQuota,
});
return {
messages: [
...context.messages,
{
content: "[Image]",
isFromAI: false,
date: formatDate(),
imageUrl: event.imageBase64,
},
],
isReplyingAI: true,
guestRemainingQuota: newQuota,
};
}),
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 }),
logSendMessageReceived: ({ event, context }) =>
console.log("[chat-machine] SendMessage received", {
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
isReplyingAI: context.isReplyingAI,
wsConnected: context.wsConnected,
currentMessagesCount: context.messages.length,
}),
logSendImageReceived: ({ event, context }) =>
console.log("[chat-machine] SendImage received", {
hasImageBase64: event.type === "ChatSendImage" ? !!event.imageBase64 : false,
wsConnected: context.wsConnected,
}),
logLoadMoreHistoryReceived: ({ context }) =>
console.log("[chat-machine] LoadMoreHistory received", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
},
}).createMachine({
id: "chat",
initial: "idle",
context: initialState,
states: {
idle: {
on: {
ChatGuestLogin: "#chat.guestSession",
ChatNonGuestLogin: "#chat.userSession",
},
},
guestSession: {
initial: "initializing",
states: {
initializing: {
invoke: {
src: "chatInit",
onDone: {
target: "ready",
actions: assign(({ event }) => {
const updates: Partial<ChatState> = {
messages: event.output.localMessages,
};
if (event.output.isGuest) {
updates.guestRemainingQuota = event.output.guestRemainingQuota;
updates.guestTotalQuota = event.output.guestTotalQuota;
}
console.log("[chat-machine] guestSession.initializing.onDone", {
messagesCount: event.output.localMessages.length,
isGuest: event.output.isGuest,
guestRemainingQuota: updates.guestRemainingQuota ?? 0,
guestTotalQuota: updates.guestTotalQuota ?? 0,
});
return updates;
}),
},
onError: {
target: "ready",
actions: ({ event }) =>
console.error("[chat-machine] guestSession.initializing.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
},
},
},
ready: {
on: {
ChatSendMessage: {
actions: ["logSendMessageReceived", "appendUserMessage"],
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
ChatSendImage: {
actions: ["logSendImageReceived", "appendUserImage"],
},
ChatLoadMoreHistory: {
actions: [
"logLoadMoreHistoryReceived",
({ context }) =>
console.log("[chat-machine] guestSession.ready.ChatLoadMoreHistory received", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
],
target: "loadingMore",
},
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
sending: {
entry: ({ event }) =>
console.log("[chat-machine] → guestSession.sending", {
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "",
}),
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,
})),
({ context, event }) =>
console.log("[chat-machine] guestSession.sending.onDone", {
newReplyContentLength: event.output.reply.content.length,
totalMessagesCount: context.messages.length + 1,
isReplyingAI: false,
}),
],
},
onError: {
target: "ready",
actions: [
assign({ isReplyingAI: false }),
({ event }) =>
console.error("[chat-machine] guestSession.sending.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
loadingMore: {
entry: ({ context }) =>
console.log("[chat-machine] → guestSession.loadingMore", { offset: context.historyOffset }),
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,
})),
({ event }) =>
console.log("[chat-machine] guestSession.loadingMore.onDone", {
newMessagesCount: event.output.messages.length,
newOffset: event.output.newOffset,
hasMore: event.output.hasMore,
}),
],
},
onError: {
target: "ready",
actions: [
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] guestSession.loadingMore.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
},
},
userSession: {
initial: "initializing",
exit: "clearWsConnected",
invoke: {
src: "chatWebSocket",
input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? event.token : "",
}),
},
states: {
initializing: {
invoke: {
src: "loadMoreHistory",
input: ({ context }) => ({ offset: context.historyOffset }),
onDone: {
target: "ready",
actions: [
assign(({ context, event }) => ({
messages: event.output.messages,
isLoadingMore: false,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
})),
({ event }) =>
console.log("[chat-machine] userSession.initializing.onDone", {
newMessagesCount: event.output.messages.length,
newOffset: event.output.newOffset,
hasMore: event.output.hasMore,
}),
],
},
onError: {
target: "ready",
actions: [
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] userSession.initializing.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
ready: {
on: {
ChatSendMessage: {
actions: ["logSendMessageReceived", "appendUserMessage"],
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
ChatSendImage: {
actions: ["logSendImageReceived", "appendUserImage"],
},
ChatLoadMoreHistory: {
actions: [
"logLoadMoreHistoryReceived",
({ context }) =>
console.log("[chat-machine] userSession.ready.ChatLoadMoreHistory received", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
],
target: "loadingMore",
},
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
sending: {
entry: ({ event }) =>
console.log("[chat-machine] → userSession.sending", {
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "",
wsConnected: true,
}),
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,
})),
({ context, event }) =>
console.log("[chat-machine] userSession.sending.onDone", {
newReplyContentLength: event.output.reply.content.length,
totalMessagesCount: context.messages.length + 1,
isReplyingAI: false,
}),
],
},
onError: {
target: "ready",
actions: [
assign({ isReplyingAI: false }),
({ event }) =>
console.error("[chat-machine] userSession.sending.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
loadingMore: {
entry: ({ context }) =>
console.log("[chat-machine] → userSession.loadingMore", { offset: context.historyOffset }),
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,
})),
({ event }) =>
console.log("[chat-machine] userSession.loadingMore.onDone", {
newMessagesCount: event.output.messages.length,
newOffset: event.output.newOffset,
hasMore: event.output.hasMore,
}),
],
},
onError: {
target: "ready",
actions: [
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] userSession.loadingMore.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
},
},
},
});
export type ChatMachine = typeof chatMachine;