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
+6
View File
@@ -40,6 +40,10 @@ interface ChatState {
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
/** 游客配额加载完成标**志****仅** guestSession 期间) —— UI 可**用**此**显**示 loading */
quotaLoaded: boolean;
/** history 初始加**载**完成标**志**local → network → save 跑**完** —— UI 可**用**此**显**示 loading */
historyLoaded: boolean;
}
const ChatStateCtx = createContext<ChatState | null>(null);
@@ -63,6 +67,8 @@ export function ChatProvider({ children }: ChatProviderProps) {
isLoadingMore: state.context.isLoadingMore,
hasMore: state.context.hasMore,
historyOffset: state.context.historyOffset,
quotaLoaded: state.context.quotaLoaded,
historyLoaded: state.context.historyLoaded,
}),
[state],
);
-2
View File
@@ -16,8 +16,6 @@
* chat-screen **不**直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
*/
export type ChatEvent =
// 内部 UI 事件
| { type: "ChatInit" }
// 鉴权生命周期(**用户**显式触发登录后由 chat-screen 派)
| { type: "ChatGuestLogin" }
| { type: "ChatNonGuestLogin"; token: string }
+56 -9
View File
@@ -2,9 +2,10 @@
* Chat 状态机:4 个 XState actor
*
* 从 `chat-machine.ts` 抽出的"Actors"段:
* - `chatInitActor`fromPromise**仅**游客有意义)
* - `loadQuotaActor`fromPromise**仅**游客有意义 —— 拉**日**配 + 总**配**
* - `loadHistoryActor`fromPromise —— local → network → save network to local 3 **步**
* - `sendMessageHttpActor`fromPromise**已** wire —— "一次发送 = 一次返回"
* - `loadMoreHistoryActor`fromPromise
* - `loadMoreHistoryActor`fromPromise —— 翻**页****不**走 local-first
* - `chatWebSocketActor`fromCallback,长生命周期 —— **仅** userSession 期间 invoke
*
* 设计:
@@ -23,17 +24,62 @@ import {
PAGE_SIZE,
chatRepo,
localMessagesToUi,
readInitData,
readAndSyncHistory,
readGuestQuota,
sendResponseToUiMessage,
type InitResult,
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
// ============================================================// HTTP: one-shot Promise
// ============================================================
/** 拉初始数据(****游客有意义) */
export const chatInitActor = fromPromise<InitResult>(async () => readInitData());
// Init 任务 1:拉游客配额(fromPromise**** local
// ============================================================
/**
* 拉游客**日**配 + 总**配****仅** guestSession 期间 invoke
* - 失**败**返 `{ remaining: 0, total: 0 }`**不**抛,**让** UI **还**是**能**进** ready
* - onDone 在 state machine **里**赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true
*/
export const loadQuotaActor = fromPromise<{
remaining: number;
total: number;
}>(async () => {
console.log("[chat-machine] loadQuotaActor ENTRY");
const result = await readGuestQuota();
console.log("[chat-machine] loadQuotaActor DONE", result);
return result;
});
// ============================================================
// Init 任务 2:拉 history 3 步流(fromPromise**返**回**最**终**结**果**
// ============================================================
/**
* local → network → save network to local 3 步流(**返**回** network **最**终** messages**
* - 选**方**案 AfromPromise):UI **不**会**看**到** local 中**间**态****直**接**显**示** network **结**果**
* - 内部 3 步**走** helper `readAndSyncHistory`**日**志**全**在** helper **里**
* - 失**败**返 local**退**而**求**其**次**
*/
export const loadHistoryActor = fromPromise<{
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
localOverwritten: boolean;
localCount: number;
networkCount: number;
}>(async () => {
console.log("[chat-machine] loadHistoryActor ENTRY");
const result = await readAndSyncHistory();
console.log("[chat-machine] loadHistoryActor DONE", {
finalCount: result.messages.length,
localCount: result.localCount,
networkCount: result.networkCount,
hasMore: result.hasMore,
localOverwritten: result.localOverwritten,
});
return result;
});
// ============================================================
// HTTP: one-shot Promise
// ============================================================
/**
* HTTP 发送消息(**一次发送 = 一次返回**)
* - 后端响应**就**是 AI 回复(`ChatSendResponse.reply: string`
@@ -76,7 +122,7 @@ export const sendMessageHttpActor = fromPromise<
return { reply };
});
/** 翻历史(pagination */
/** 翻历史(pagination—— **不**走 local-first**纯** server fetch */
export const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
@@ -123,7 +169,8 @@ export const loadMoreHistoryActor = fromPromise<
};
});
// ============================================================// WebSocket: long-lived callback
// ============================================================
// WebSocket: long-lived callback
// ============================================================
/**
* 长生命周期 WebSocket**仅** `userSession` 期间 invoke
+99 -35
View File
@@ -6,34 +6,42 @@
* - 仓库注入(接口类型 → 单例)
* - DTO ↔ UiMessage 映射(**纯函数** —— 可单测)
* - Result → number 映射(**纯函数** —— 可单测)
* - 初始数据装配(****游客有意义 —— `chatInitActor` 调用)
* - 2 ****独立 init 任务**的**数据加载**
* - `readGuestQuota()` —— 游客**日**配 + 总**配****仅**游客有意义,**纯** ChatStorage
* - `readAndSyncHistory()` —— local → network → save network to local 3 **步**
*
* 设计目标:
* - helpers **无 XState 依赖**(可独立 import / 测试)
* - actors 依赖 helpersimport
* - machine 依赖 actorsimport)—— **不**直接依赖 helpers
*
* 历史:
* - `readInitData` + `InitResult` 已**删****被** 2 **个**独立 helper **替**换**
* - `AuthStorage` import **已**删****只** `readInitData` 用过,**移**到** `chatInit` actor **上**层**调**用**
*/
import type { UiMessage } from "@/models/chat/ui-message";
import { chatRepository } from "@/data/repositories/chat_repository";
import type { IChatRepository } from "@/data/repositories/interfaces";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { formatDate } from "@/utils/date";
import { Result } from "@/utils/result";
// ============================================================// Constants
// ============================================================
// Constants
// ============================================================
/** 翻历史每页大小(**仅** `loadMoreHistoryActor` 用) */
export const PAGE_SIZE = 20;
// ============================================================// Repository injection
// ============================================================
// Repository injection
// ============================================================
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
export const chatRepo: IChatRepository = chatRepository;
// ============================================================// DTO ↔ UiMessage 映射
// ============================================================
// DTO ↔ UiMessage 映射
// ============================================================
/**
* ChatMessage[] → UiMessage[]**纯函数**
@@ -67,7 +75,8 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
};
}
// ============================================================// Result → number 映射
// ============================================================
// Result → number 映射
// ============================================================
/** 日配额 Result → number**纯函数** */
export function mapQuotaResult(
@@ -87,45 +96,100 @@ export function mapTotalQuotaResult(
return fallback;
}
// ============================================================// Initial data
// ============================================================
export interface InitResult {
localMessages: UiMessage[];
isGuest: boolean;
guestRemainingQuota: number;
guestTotalQuota: number;
}
// Init 任务 1:游客配额(**纯** local)—— `loadQuotaActor` 调
// ============================================================
/**
* 读取初始数据(`chatInitActor` 调用
*
* - `isGuest` 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额"
* —— **不**是鉴权信号。鉴权判断已上提到 chat-screen 派生。
* - 配额 fallback 0 —— 非游客路径用不到;onDone 会**条件**赋。
* 读取游客配额(**仅** guestSession 有意义
* - 游客**日**配 + 总**配** 两**项**都**从** ChatStorage 拉(**纯** local**无**网络)
* - 失**败**返 fallback 0**不**抛)—— **让** UI **还**是**能**进** ready
*/
export async function readInitData(): Promise<InitResult> {
export async function readGuestQuota(): Promise<{
remaining: number;
total: number;
}> {
const chatStorage = ChatStorage.getInstance();
const isGuest = !AuthStorage.getInstance().hasLoginToken();
const [dailyResult, totalResult, localResult] = await Promise.all([
const [dailyResult, totalResult] = await Promise.all([
chatStorage.getGuestDailyChatQuota(),
chatStorage.getGuestTotalQuota(),
isGuest ? chatRepo.getLocalMessages() : Promise.resolve(null),
]);
return {
isGuest,
guestRemainingQuota: mapQuotaResult(
remaining: mapQuotaResult(
dailyResult as Result<{ remaining: number } | null>,
0,
),
guestTotalQuota: mapTotalQuotaResult(
totalResult as Result<number | null>,
0,
),
localMessages:
localResult && Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [],
total: mapTotalQuotaResult(totalResult as Result<number | null>, 0),
};
}
// ============================================================
// Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
// ============================================================
/**
* 拉 history **流**程(**每**步**都**记**日**志** —— 用户要求"**不**要**删**除**"
* 1. **读** localchatRepo.getLocalMessages)→ **返** localMessages
* 2. **读** networkchatRepo.getHistory)→ **返** networkMessages
* 3. **用** network **覆**盖** localchatRepo.saveMessagesToLocal)→ local **被**同步
*
* **返**回** network **最**终** messages**authorititative),**不**返** localUI **不**需**要**过渡**态**
*
* **保**留**日**志****前**缀 `[chat-machine]` 与 chat-machine.actors.ts **对**齐**
*/
export async function readAndSyncHistory(): Promise<{
messages: UiMessage[]; // network **最**终** messagesauthorititative
hasMore: boolean;
newOffset: number;
localOverwritten: boolean; // true = local **已**被 network **覆**盖****写**入** ChatStorage
/** 真实从 local 读到的消息数(**调**试**用**) */
localCount: number;
/** 真实从 network 读到的消息数(**调**试**用**) */
networkCount: number;
}> {
// 1. **读** local
const localResult = await chatRepo.getLocalMessages();
const localMessages =
Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [];
console.log("[chat-machine] loadHistory LOCAL DONE", {
count: localMessages.length,
});
// 2. **读** network
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
if (!Result.isOk(networkResult)) {
// network 失**败** —— **返**空 messages**不**让 UI 卡**住**
console.error(
"[chat-machine] loadHistory NETWORK FAILED",
networkResult.success ? null : (networkResult as { error: unknown }).error,
);
return {
messages: localMessages, // **退**而**求**其**次**:返 local**不**然**屏**幕**空**
hasMore: false,
newOffset: 0,
localOverwritten: false,
localCount: localMessages.length,
networkCount: 0,
};
}
const networkUi = localMessagesToUi(networkResult.data.messages);
console.log("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
// 3. **用** network **覆**盖** local"**再**用**网**络**数**据**覆**盖**本**地**数**据"**这**句**的**字**面**实**现**
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
const localOverwritten = Result.isOk(saveResult);
console.log("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
localOverwritten,
});
return {
messages: networkUi, // authorititative
hasMore: networkUi.length >= PAGE_SIZE,
newOffset: networkUi.length,
localOverwritten,
localCount: localMessages.length,
networkCount: networkUi.length,
};
}
+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,
}),
},
},
},
+12
View File
@@ -39,6 +39,16 @@ export interface ChatState {
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
/** 游客配额加载完成标**志****仅** guestSession 期间) —— default false
* - guestSession.initializing **走** always barrier`quotaLoaded && historyLoaded` **才**进** ready
* - userSession **永不为** true**不**调 loadQuota)—— OK**不**影响 barrier
*/
quotaLoaded: boolean;
/** history 加**载**完成标**志**initial load —— local → network → save 跑**完**
* - guestSession.initializing / userSession.initializing 走 always barrier
* - **不**被 `loadMoreHistoryActor` 设**置****翻**页**是**另**一**码**事**
*/
historyLoaded: boolean;
}
export const initialState: ChatState = {
@@ -51,4 +61,6 @@ export const initialState: ChatState = {
isLoadingMore: false,
hasMore: true,
historyOffset: 0,
quotaLoaded: false,
historyLoaded: false,
};