chore(chat): refactor chat init into separate quota and history actors

Split `chatInitActor` into `loadQuotaActor` (guest quota fetch) and `loadHistoryActor` (local → network → save sync), and add `quotaLoaded` / `historyLoaded` state flags so the UI can display loading status during the new local-first history hydration flow. Also drop the now-unused `ChatInit` event and pipe Next.js stdout/stderr to a persistent log file in the post-receive hook for easier troubleshooting.
This commit is contained in:
2026-06-15 16:21:40 +08:00
parent cfc6de5a8c
commit 1f3980d461
8 changed files with 296 additions and 152 deletions
+120 -104
View File
@@ -18,17 +18,27 @@
* **状态结构**parent state 模式):
* - `idle`:屏没挂 / 登出
* - `guestSession`**parent**):游客会话 —— **不** invoke WS
* - `initializing`invoke chatInit(拉本地 + 初始化配额)
* - `ready`:用户聊天 + 翻历史
* - `initializing`**双** invoke + `always` barrier
* - `loadQuotaActor`**纯** localChatStorage **日**配 + 总**配**
* - `loadHistoryActor`local → network → save 3 步)
* - 2 **个** `quotaLoaded` / `historyLoaded` **都** true **才**进** ready
* - `ready`:用户聊天 + 看消息
* - `sending`invoke sendMessageHttpActorHTTP 发送消息)
* - `loadingMore`invoke loadMoreHistory(翻历史
* - ~~`loadingMore`~~**已**删**(游客**无**服**务**端** history**不**支持翻**页**
* - `userSession`**parent**):非游客会话 —— **invoke WS**(持久)
* - `initializing`invoke loadMoreHistory(拉服务器端首屏
* - `ready`:用户聊天 + 翻历史
* - 父级 invoke `chatWebSocket`**一**进**来****就**连
* - `initializing`invoke `loadHistory` + `always` barrier
* - WS **已**连** + history **加**载**完**成** → ready
* - `ready`:用户聊天 + 看消息
* - `sending`invoke sendMessageHttpActorHTTP fallback —— WS 真发**未** wire
* - `loadingMore`invoke loadMoreHistory翻历史
* - `loadingMore`invoke loadMoreHistory**翻**页****保**留**
* WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/sending/loadingMore 持续
*
* **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
@@ -41,12 +51,12 @@
*
* **文件结构**
* - `chat-machine.ts`(本文件)—— **只**含 setup + createMachineactions + states
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载
* - `chat-machine.actors.ts` —— 4 个 XState actor含全链日志
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载readGuestQuota + readAndSyncHistory
* - `chat-machine.actors.ts` —— 5 个 XState actorloadQuota + loadHistory + sendMessage + loadMoreHistory + chatWebSocket
*
* **调试日志**(保留 —— 用户要求"不要将其删除"):
* - 命名约定:`[<file>]` 前缀标识来源
* - 3 actor **全链日志**ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到
* - 4 actor **全链日志**ENTRY / API / DONE / 关键决策)—— 排查 actor **没**被调时**一眼**看到
*/
import { setup, assign } from "xstate";
@@ -56,7 +66,8 @@ import { formatDate } from "@/utils/date";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import {
chatInitActor,
loadQuotaActor,
loadHistoryActor,
sendMessageHttpActor,
loadMoreHistoryActor,
chatWebSocketActor,
@@ -82,7 +93,8 @@ export const chatMachine = setup({
events: {} as ChatEvent,
},
actors: {
chatInit: chatInitActor,
loadQuota: loadQuotaActor,
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
loadMoreHistory: loadMoreHistoryActor,
chatWebSocket: chatWebSocketActor,
@@ -223,39 +235,76 @@ export const chatMachine = setup({
},
},
// ========================================================================
// guestSession(游客会话 —— **不**连 WS2 **个** init 任务**并**行**
// ========================================================================
guestSession: {
initial: "initializing",
states: {
initializing: {
invoke: {
src: "chatInit",
onDone: {
// "always" barrier:两**个**任务**都**完**成**才**进** ready
always: [
{
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;
}),
guard: ({ context }) => context.quotaLoaded && context.historyLoaded,
},
onError: {
target: "ready",
actions: ({ event }) =>
console.error("[chat-machine] guestSession.initializing.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
],
invoke: [
// 任务 1:加载配额(fromPromise**返**结果 + onDone 设 flag
{
id: "loadQuota",
src: "loadQuota",
onDone: {
actions: assign(({ event }) => {
console.log("[chat-machine] guestSession.initializing.loadQuota.onDone", {
remaining: event.output.remaining,
total: event.output.total,
});
return {
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 }) => {
console.log(
"[chat-machine] guestSession.initializing.loadHistory.onDone",
{
finalCount: event.output.messages.length,
hasMore: event.output.hasMore,
newOffset: event.output.newOffset,
localOverwritten: event.output.localOverwritten,
},
);
return {
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: {
@@ -267,17 +316,7 @@ export const chatMachine = setup({
ChatSendImage: {
actions: ["logSendImageReceived", "appendUserImage"],
},
ChatLoadMoreHistory: {
actions: [
"logLoadMoreHistoryReceived",
({ context }) =>
console.log("[chat-machine] guestSession.ready.ChatLoadMoreHistory received", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
],
target: "loadingMore",
},
// **删**除 ChatLoadMoreHistory handler —— 游客**无**服**务**端** history**不**支**持**翻**页**
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
@@ -324,48 +363,18 @@ export const chatMachine = setup({
},
},
},
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),
}),
],
},
},
},
// **删**除 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 : "",
@@ -373,35 +382,42 @@ export const chatMachine = setup({
},
states: {
initializing: {
invoke: {
src: "loadMoreHistory",
input: ({ context }) => ({ offset: context.historyOffset }),
onDone: {
// barrierWS **连**上** + history **加**载**完**成**才**进** ready
always: [
{
target: "ready",
actions: [
assign(({ context, event }) => ({
guard: ({ context }) => context.wsConnected && context.historyLoaded,
},
],
invoke: {
// 任务 2:拉 history**只**调**用** loadHistoryActor**不**再**用** loadMoreHistoryActor
src: "loadHistory",
onDone: {
actions: assign(({ event }) => {
console.log(
"[chat-machine] userSession.initializing.loadHistory.onDone",
{
finalCount: event.output.messages.length,
hasMore: event.output.hasMore,
newOffset: event.output.newOffset,
localOverwritten: event.output.localOverwritten,
},
);
return {
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,
}),
],
historyLoaded: true,
};
}),
},
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),
}),
],
// 失败**也**标 loaded**不**卡 init
actions: assign({
isLoadingMore: false,
historyLoaded: true,
}),
},
},
},