57992bffac
- 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"
517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
/**
|
||
* 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 sendMessageHttpActor(HTTP 发送消息)
|
||
* - `loadingMore`:invoke loadMoreHistory(翻历史)
|
||
* - `userSession`(**parent**):非游客会话 —— **invoke WS**(持久)
|
||
* - `initializing`:invoke loadMoreHistory(拉服务器端首屏)
|
||
* - `ready`:用户聊天 + 翻历史
|
||
* - `sending`:invoke sendMessageHttpActor(HTTP 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 + createMachine(actions + 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;
|