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:
@@ -116,8 +116,10 @@ stop_existing_next() {
|
||||
# 6. launch_next —— 后**台**启 `pnpm run start`,捕 PID
|
||||
# ============================================================
|
||||
launch_next() {
|
||||
# next stdout/stderr 接**到** logs/next-server.log(**追**加**模式**,**保**留**历**史** log **用**于排**查**)
|
||||
NEXT_LOG_FILE="$REPO_TOPLEVEL/logs/next-server.log"
|
||||
echo "=== start LAUNCH @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE"
|
||||
nohup pnpm run start > /dev/null 2>&1 &
|
||||
nohup pnpm run start >> "$NEXT_LOG_FILE" 2>&1 &
|
||||
START_PID=$!
|
||||
echo "=== start PID=$START_PID @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* - QuotaDialog:配额告警弹窗(**仅**游客)
|
||||
* - PwaInstallOverlay:PWA 安装提示触发器
|
||||
* - BrowserHintOverlay:浏览器提示
|
||||
* - CharacterIntro:角色介绍卡片(首屏)
|
||||
*
|
||||
* **鉴权解耦**(**事件驱动**):
|
||||
* - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* chat-screen **不**直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
|
||||
*/
|
||||
export type ChatEvent =
|
||||
// 内部 UI 事件
|
||||
| { type: "ChatInit" }
|
||||
// 鉴权生命周期(**用户**显式触发登录后由 chat-screen 派)
|
||||
| { type: "ChatGuestLogin" }
|
||||
| { type: "ChatNonGuestLogin"; token: string }
|
||||
|
||||
@@ -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**)
|
||||
* - 选**方**案 A(fromPromise):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)
|
||||
|
||||
@@ -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 依赖 helpers(import)
|
||||
* - machine 依赖 actors(import)—— **不**直接依赖 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 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||
// ============================================================
|
||||
/**
|
||||
* 拉 history **流**程(**每**步**都**记**日**志** —— 用户要求"**不**要**删**除**")
|
||||
* 1. **读** local(chatRepo.getLocalMessages)→ **返** localMessages
|
||||
* 2. **读** network(chatRepo.getHistory)→ **返** networkMessages
|
||||
* 3. **用** network **覆**盖** local(chatRepo.saveMessagesToLocal)→ local **被**同步
|
||||
*
|
||||
* **返**回** network **最**终** messages**(authorititative),**不**返** local(UI **不**需**要**过渡**态**)
|
||||
*
|
||||
* **保**留**日**志**(**前**缀 `[chat-machine]` 与 chat-machine.actors.ts **对**齐**)
|
||||
*/
|
||||
export async function readAndSyncHistory(): Promise<{
|
||||
messages: UiMessage[]; // network **最**终** messages(authorititative)
|
||||
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
@@ -18,17 +18,27 @@
|
||||
* **状态结构**(parent state 模式):
|
||||
* - `idle`:屏没挂 / 登出
|
||||
* - `guestSession`(**parent**):游客会话 —— **不** invoke WS
|
||||
* - `initializing`:invoke chatInit(拉本地 + 初始化配额)
|
||||
* - `ready`:用户聊天 + 翻历史
|
||||
* - `initializing`:**双** invoke + `always` barrier
|
||||
* - `loadQuotaActor`(**纯** local,ChatStorage **日**配 + 总**配**)
|
||||
* - `loadHistoryActor`(local → network → save 3 步)
|
||||
* - 2 **个** `quotaLoaded` / `historyLoaded` **都** true **才**进** ready
|
||||
* - `ready`:用户聊天 + 看消息
|
||||
* - `sending`:invoke sendMessageHttpActor(HTTP 发送消息)
|
||||
* - `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 sendMessageHttpActor(HTTP fallback —— WS 真发**未** wire)
|
||||
* - `loadingMore`:invoke loadMoreHistory(翻历史)
|
||||
* - `loadingMore`:invoke loadMoreHistory(**翻**页**,**保**留**)
|
||||
* WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/sending/loadingMore 持续
|
||||
*
|
||||
* **init 双任务**(**核**心**重**构**):
|
||||
* - guestSession.initializing:loadQuota + loadHistory **并**行**(`invoke: [...]` **数**组**)
|
||||
* - userSession.initializing:loadHistory + 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 + createMachine(actions + states)
|
||||
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载
|
||||
* - `chat-machine.actors.ts` —— 4 个 XState actor(含全链日志)
|
||||
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载(readGuestQuota + readAndSyncHistory)
|
||||
* - `chat-machine.actors.ts` —— 5 个 XState actor(loadQuota + 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(游客会话 —— **不**连 WS,2 **个** 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:拉 history(fromPromise,**返**回**最**终** 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: {
|
||||
// barrier:WS **连**上** + 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user