refactor(chat): extract XState actors into chat-machine.actors.ts

- Split chat state machine actors (chatInit, sendMessageHttp, loadMoreHistory, chatWebSocket) into a dedicated module
- Add comprehensive console logging across the chat state machine for full trace debugging
- Comment out margin on text bubble CSS for layout adjustment
- Add debug logging to chat input bar send handler
- Clean up redundant comments in auth-screen component
This commit is contained in:
2026-06-12 11:20:19 +08:00
parent 6394c8ba7b
commit 38f060bbd8
7 changed files with 583 additions and 253 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* Chat 状态机:4 个 XState actor
*
* 从 `chat-machine.ts` 抽出的"Actors"段:
* - `chatInitActor`fromPromise**仅**游客有意义)
* - `sendMessageHttpActor`fromPromise**已定义但**未 wire —— 消息**不**真发后端)
* - `loadMoreHistoryActor`fromPromise
* - `chatWebSocketActor`fromCallback,长生命周期 —— **仅** userSession 期间 invoke
*
* 设计:
* - 依赖 `chat-machine.helpers.ts`(纯函数 + 数据加载)
* - 依赖 `createChatWebSocket``@/core/net/chat-websocket`
* - 保留**全链日志**(用户要求"不要将其删除")
* - 命名约定:`[chat-machine]` 前缀 —— 与 `chat-machine.ts` 的日志对齐
*/
import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket } from "@/core/net/chat-websocket";
import { Result } from "@/utils/result";
import {
PAGE_SIZE,
chatRepo,
localMessagesToUi,
readInitData,
type InitResult,
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
// ============================================================// HTTP: one-shot Promise
// ============================================================
/** 拉初始数据(**仅**游客有意义) */
export const chatInitActor = fromPromise<InitResult>(async () => readInitData());
/**
* HTTP 发送消息(**已定义但未** wire —— 消息**不**真发后端)
* - 任何 state 转移**未** invoke 此 actor
* - 保留全链日志以便排查
*/
export const sendMessageHttpActor = fromPromise<
{ messages: UiMessage[] },
{ content: string }
>(async ({ input, self }) => {
console.log("[chat-machine] sendMessageHttpActor ENTRY", {
contentLength: input.content.length,
contentPreview: input.content.slice(0, 50),
selfId: self.id,
selfPath: "<actor path>",
});
console.warn(
"[chat-machine] sendMessageHttpActor invoked —— actor 已定义但**未** invoke(消息**不**真发后端)",
{ contentLength: input.content.length, contentPreview: input.content.slice(0, 50) },
);
console.log("[chat-machine] sendMessageHttpActor calling chatRepo.sendMessage");
const result = await chatRepo.sendMessage(input.content);
console.log("[chat-machine] sendMessageHttpActor chatRepo.sendMessage DONE", {
success: result.success,
error: result.success ? null : Result.isErr(result) ? result.error : null,
});
if (Result.isErr(result)) {
console.error("[chat-machine] sendMessageHttpActor result isErr, throwing");
throw result.error;
}
console.log("[chat-machine] sendMessageHttpActor fetching local messages");
const local = await chatRepo.getLocalMessages();
console.log("[chat-machine] sendMessageHttpActor local messages DONE", {
success: local.success,
messagesCount: local.success && local.data ? local.data.length : 0,
});
if (Result.isOk(local) && local.data) {
const messages = localMessagesToUi(local.data);
console.log("[chat-machine] sendMessageHttpActor done", { messagesCount: messages.length });
return { messages };
}
return { messages: [] };
});
/** 翻历史(pagination */
export const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
>(async ({ input, self }) => {
console.log("[chat-machine] loadMoreHistoryActor ENTRY", {
inputOffset: input.offset,
pageSize: PAGE_SIZE,
selfPath: "<actor path>",
});
console.log("[chat-machine] loadMoreHistoryActor calling chatRepo.getHistory", {
pageSize: PAGE_SIZE,
offset: input.offset,
});
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
console.log("[chat-machine] loadMoreHistoryActor chatRepo.getHistory DONE", {
success: result.success,
error: result.success ? null : Result.isErr(result) ? result.error : null,
responseType: result.success ? typeof result.data : "err",
});
if (Result.isErr(result)) {
console.error("[chat-machine] loadMoreHistoryActor result isErr, throwing", {
error: result.error,
});
throw result.error;
}
console.log("[chat-machine] loadMoreHistoryActor result data", {
rawMessagesCount: result.data.messages.length,
});
const page = localMessagesToUi(result.data.messages);
console.log("[chat-machine] loadMoreHistoryActor AFTER UiMessage mapping", {
uiMessagesCount: page.length,
});
const hasMore = page.length >= PAGE_SIZE;
const newOffset = input.offset + page.length;
console.log("[chat-machine] loadMoreHistoryActor result", {
pageSize: page.length,
hasMore,
newOffset,
});
return {
messages: page,
hasMore,
newOffset,
};
});
// ============================================================// WebSocket: long-lived callback
// ============================================================
/**
* 长生命周期 WebSocket**仅** `userSession` 期间 invoke
* - 启动:连 WS,事件 → sendBack 派回机器
* - 停止(cleanup):disconnect
* - **游客**不** invoke 此 actor(业务事实"游客不连 WS"
*
* 之所以用 fromCallback 而非 fromPromise
* - WS 是长连接,**不**是一次性 Promise
* - fromCallback 提供 `sendBack` 回调,actor 内部事件 → 机器
* - cleanup 函数 → XState 在 parent state exit 时自动调用
*/
export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
({ sendBack, input, self }) => {
console.log("[chat-machine] chatWebSocketActor ENTRY", {
hasToken: !!input.token,
tokenLength: input.token?.length ?? 0,
tokenPrefix: input.token?.slice(0, 10) ?? "EMPTY",
selfPath: "<actor path>",
});
console.log("[chat-machine] chatWebSocketActor creating ChatWebSocket instance");
const ws = createChatWebSocket(input.token);
console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
wsType: ws.constructor.name,
});
ws.onConnected = (userId) => {
console.log("[chat-machine] WS onConnected", {
userId,
userIdPrefix: userId?.slice(0, 8) ?? "EMPTY",
});
sendBack({ type: "ChatWebSocketConnected", userId });
};
ws.onSentence = (p) => {
console.log("[chat-machine] WS onSentence", {
index: p.index,
total: p.total,
done: p.done,
textLength: p.text.length,
textPreview: p.text.slice(0, 50),
textSuffix: p.text.slice(-20),
});
sendBack({
type: "ChatAISentenceReceived",
index: p.index,
text: p.text,
total: p.total,
done: p.done,
});
};
ws.onError = (msg) => {
console.error("[chat-machine] WS onError", {
errorMessage: msg,
errorLength: msg?.length ?? 0,
});
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
};
console.log("[chat-machine] chatWebSocketActor calling ws.connect()");
ws.connect();
console.log("[chat-machine] chatWebSocketActor ws.connect() called");
return () => {
console.log(
"[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)",
);
ws.disconnect();
};
},
);
// Re-export `UiMessage` type for the chat-machine.ts public API
type UiMessage = import("@/models/chat/ui-message").UiMessage;
+116
View File
@@ -0,0 +1,116 @@
/**
* Chat 状态机:纯函数 + 数据加载
*
* 从 `chat-machine.ts` 抽出的"helpers"段:
* - 翻页大小常量
* - 仓库注入(接口类型 → 单例)
* - DTO ↔ UiMessage 映射(**纯函数** —— 可单测)
* - Result → number 映射(**纯函数** —— 可单测)
* - 初始数据装配(**仅**游客有意义 —— `chatInitActor` 调用)
*
* 设计目标:
* - helpers **无 XState 依赖**(可独立 import / 测试)
* - actors 依赖 helpersimport
* - machine 依赖 actorsimport)—— **不**直接依赖 helpers
*/
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 { Result } from "@/utils/result";
// ============================================================// Constants
// ============================================================
/** 翻历史每页大小(**仅** `loadMoreHistoryActor` 用) */
export const PAGE_SIZE = 20;
// ============================================================// Repository injection
// ============================================================
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
export const chatRepo: IChatRepository = chatRepository;
// ============================================================// DTO ↔ UiMessage 映射
// ============================================================
/**
* ChatMessage[] → UiMessage[]**纯函数**
* - 与 Dart `LocalMessage → ChatMessage` 等价
* - 业务事实:`role === "assistant"` → `isFromAI: true`
*/
export function localMessagesToUi(
records: ReadonlyArray<{
content: string;
role: string;
createdAt: string;
}>,
): UiMessage[] {
return records.map((m) => ({
content: m.content,
isFromAI: m.role === "assistant",
date: m.createdAt,
}));
}
// ============================================================// Result → number 映射
// ============================================================
/** 日配额 Result → number**纯函数** */
export function mapQuotaResult(
r: Result<{ remaining: number } | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data.remaining;
return fallback;
}
/** 总配额 Result → number**纯函数** */
export function mapTotalQuotaResult(
r: Result<number | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data;
return fallback;
}
// ============================================================// Initial data
// ============================================================
export interface InitResult {
localMessages: UiMessage[];
isGuest: boolean;
guestRemainingQuota: number;
guestTotalQuota: number;
}
/**
* 读取初始数据(`chatInitActor` 调用)
*
* - `isGuest` 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额"
* —— **不**是鉴权信号。鉴权判断已上提到 chat-screen 派生。
* - 配额 fallback 0 —— 非游客路径用不到;onDone 会**条件**赋。
*/
export async function readInitData(): Promise<InitResult> {
const chatStorage = ChatStorage.getInstance();
const isGuest = !AuthStorage.getInstance().hasLoginToken();
const [dailyResult, totalResult, localResult] = await Promise.all([
chatStorage.getGuestDailyChatQuota(),
chatStorage.getGuestTotalQuota(),
isGuest ? chatRepo.getLocalMessages() : Promise.resolve(null),
]);
return {
isGuest,
guestRemainingQuota: 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)
: [],
};
}
+247 -245
View File
@@ -34,25 +34,38 @@
* - `loadingMore`invoke loadMoreHistory(翻历史)
* WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/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
*
* **配额(**仅**游客)**
* - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义
* - 非游客"无消息限制"(业务事实)—— 永不被赋值
* - `ChatGuestLogin` 触发 chatInitActor → onDone 条件赋
* - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op
* - `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, fromPromise, fromCallback, assign } from "xstate";
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 { createChatWebSocket } from "@/core/net/chat-websocket";
import { setup, assign } from "xstate";
import { formatDate } from "@/utils/date";
import { Result } from "@/utils/result";
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 = {
@@ -65,143 +78,8 @@ export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
export type { ChatEvent } from "./chat-events";
// ============================================================// Helpers
// ============================================================
const PAGE_SIZE = 20;
function localMessagesToUi(
records: ReadonlyArray<{
content: string;
role: string;
createdAt: string;
}>,
): UiMessage[] {
return records.map((m) => ({
content: m.content,
isFromAI: m.role === "assistant",
date: m.createdAt,
}));
}
function mapQuotaResult(
r: Result<{ remaining: number } | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data.remaining;
return fallback;
}
function mapTotalQuotaResult(
r: Result<number | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data;
return fallback;
}
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
const chatRepo: IChatRepository = chatRepository;
interface InitResult {
localMessages: UiMessage[];
isGuest: boolean;
guestRemainingQuota: number;
guestTotalQuota: number;
}
async function readInitData(): Promise<InitResult> {
const chatStorage = ChatStorage.getInstance();
// isGuest 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额" —— **不**是鉴权信号
// 鉴权判断已上提到 chat-screen 派生
const isGuest = !AuthStorage.getInstance().hasLoginToken();
const [dailyResult, totalResult, localResult] = await Promise.all([
chatStorage.getGuestDailyChatQuota(),
chatStorage.getGuestTotalQuota(),
isGuest ? chatRepo.getLocalMessages() : Promise.resolve(null),
]);
return {
isGuest,
// fallback 0 —— 非游客路径用不到;onDone 会**条件**赋
guestRemainingQuota: 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)
: [],
};
}
// ============================================================// Actors
// ============================================================
// HTTP:一次性 Promise
const chatInitActor = fromPromise<InitResult>(async () => readInitData());
const sendMessageHttpActor = fromPromise<
{ messages: UiMessage[] },
{ content: string }
>(async ({ input }) => {
const result = await chatRepo.sendMessage(input.content);
if (Result.isErr(result)) throw result.error;
// 拉取最新本地历史
const local = await chatRepo.getLocalMessages();
if (Result.isOk(local) && local.data) {
return { messages: localMessagesToUi(local.data) };
}
return { messages: [] };
});
const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
>(async ({ input }) => {
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
if (Result.isErr(result)) throw result.error;
const page = localMessagesToUi(result.data.messages);
return {
messages: page,
hasMore: page.length >= PAGE_SIZE,
newOffset: input.offset + page.length,
};
});
// WebSocket:长生命周期(**由** chat 机器内 fromCallback actor 管)
// - 启动:连 WS,事件 → sendBack 派回机器
// - 停止(cleanup):disconnect
// - **游客**不** invoke 此 actor(业务事实"游客不连 WS"
const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
({ sendBack, input }) => {
const ws = createChatWebSocket(input.token);
ws.onConnected = (userId) => {
sendBack({ type: "ChatWebSocketConnected", userId });
};
ws.onSentence = (p) => {
sendBack({
type: "ChatAISentenceReceived",
index: p.index,
text: p.text,
total: p.total,
done: p.done,
});
};
ws.onError = (msg) => {
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
};
ws.connect();
return () => ws.disconnect();
},
);
// ============================================================// Machine
// Machine
// ============================================================
export const chatMachine = setup({
types: {
@@ -217,6 +95,20 @@ export const chatMachine = setup({
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,
@@ -227,13 +119,22 @@ export const chatMachine = setup({
},
],
isReplyingAI: true,
// 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制")
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
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,
@@ -245,8 +146,7 @@ export const chatMachine = setup({
},
],
isReplyingAI: true,
// 游客减配额;非游客 no-op
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
guestRemainingQuota: newQuota,
};
}),
@@ -268,6 +168,12 @@ export const chatMachine = setup({
};
}
}
console.log("[chat-machine] appendOrUpdateAISentence", {
index: event.index,
total: event.total,
done: event.done,
messagesCount: messages.length,
});
return { messages, isReplyingAI: !event.done };
}),
@@ -286,109 +192,159 @@ export const chatMachine = setup({
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",
ChatLoadMoreHistory: "loadingMoreHistory", // 旧逻辑保留(翻历史用)
ChatLoadMoreHistory: {
actions: [
"logLoadMoreHistoryReceived",
({ context }) =>
console.log("[chat-machine] idle.ChatLoadMoreHistory received (but no UI dispatcher)", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
isLoadingMore: context.isLoadingMore,
}),
],
target: "loadingMoreHistory",
},
},
},
// ─────────────────────────────────────────────────────────────
// 游客会话(**不** invoke WS —— "断 WS"业务事实)
// ─────────────────────────────────────────────────────────────
guestSession: {
initial: "initializing",
// guestSession **不** invoke chatWebSocket —— 业务事实"游客不连 WS"
states: {
initializing: {
invoke: {
src: "chatInit",
onDone: {
target: "ready",
// **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制")
actions: assign(({ event }) => {
const updates: Partial<ChatState> = {
messages: event.output.localMessages,
};
if (event.output.isGuest) {
updates.guestRemainingQuota =
event.output.guestRemainingQuota;
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,
actions: "appendUserMessage",
},
ChatSendImage: {
actions: "appendUserImage",
actions: ["logSendImageReceived", "appendUserImage"],
},
ChatLoadMoreHistory: "loadingMore",
ChatLogout: "#chat.idle", // 登出 → idle
// 跨态(游客 → email
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",
},
ChatQuotaExceeded: {
actions: "incrementQuotaExceeded",
},
ChatWebSocketConnected: {},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
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,
})),
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 }),
actions: [
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] guestSession.loadingMore.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
},
},
// ─────────────────────────────────────────────────────────────
// 非游客会话(**invoke WS** —— "连 WS"业务事实)
// chatWebSocket 在 parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续
// ─────────────────────────────────────────────────────────────
userSession: {
initial: "initializing",
exit: "clearWsConnected",
invoke: {
src: "chatWebSocket",
// event 是触发 userSession 状态的事件(ChatNonGuestLogin from idle / ChatGuestLogin from guestSession
input: ({ event }) => ({
token: event.type === "ChatNonGuestLogin" ? event.token : "",
}),
@@ -400,116 +356,162 @@ export const chatMachine = setup({
input: ({ context }) => ({ offset: context.historyOffset }),
onDone: {
target: "ready",
actions: assign(({ event }) => ({
messages: event.output.messages,
isLoadingMore: false,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
})),
actions: [
assign(({ 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 }),
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,
actions: "appendUserMessage",
},
ChatSendImage: {
actions: "appendUserImage",
actions: ["logSendImageReceived", "appendUserImage"],
},
ChatLoadMoreHistory: "loadingMore",
ChatLogout: "#chat.idle", // 登出 → idlecleanup WS
// 跨态(email → 游客)
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",
},
ChatQuotaExceeded: {
actions: "incrementQuotaExceeded",
},
ChatWebSocketConnected: {},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
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,
})),
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 }),
actions: [
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] userSession.loadingMore.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
},
},
// ─────────────────────────────────────────────────────────────
// 旧 loadingMoreHistory state(兼容 ChatLoadMoreHistory 在 idle 时派的事件)
// ─────────────────────────────────────────────────────────────
loadingMoreHistory: {
entry: ({ context }) =>
console.log("[chat-machine] → loadingMoreHistory (legacy)", { 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,
})),
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] loadingMoreHistory.onDone", {
newMessagesCount: event.output.messages.length,
newOffset: event.output.newOffset,
hasMore: event.output.hasMore,
}),
],
},
onError: {
target: "ready",
actions: assign({ isLoadingMore: false }),
actions: [
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] loadingMoreHistory.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
},
},
},
// ─────────────────────────────────────────────────────────────
// 旧 ready state(兼容 ChatScreenVisible 事件)—— **不** invoke WS
// (**实际**不会走到这里 —— guestSession.ready / userSession.ready 是真 ready
// ─────────────────────────────────────────────────────────────
ready: {
on: {
ChatSendMessage: {
actions: ["logSendMessageReceived", "appendUserMessage"],
guard: ({ event }) => event.content.trim().length > 0,
actions: "appendUserMessage",
},
ChatSendImage: {
actions: "appendUserImage",
actions: ["logSendImageReceived", "appendUserImage"],
},
ChatLoadMoreHistory: {
actions: [
"logLoadMoreHistoryReceived",
({ context }) =>
console.log("[chat-machine] ready.ChatLoadMoreHistory received (legacy ready)", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
],
target: "loadingMoreHistory",
},
ChatLoadMoreHistory: "loadingMoreHistory",
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
ChatNonGuestLogin: "#chat.userSession",
ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence",
},
ChatWebSocketError: {
actions: "appendSocketErrorMessage",
},
ChatQuotaExceeded: {
actions: "incrementQuotaExceeded",
},
ChatWebSocketConnected: {},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
},
+14 -1
View File
@@ -9,6 +9,12 @@
* - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋
* - 非游客:**永不被赋值**(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op
* - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤
*
* 新增 `wsConnected: boolean`**仅** userSession 期间 true):
* - **WS 已连** → 消息发送走 WS,**不**消耗次数
* - **WS 未连** → 消息发送走 HTTP,消耗次数
* - `ChatWebSocketConnected` 事件 → true
* - `userSession.exit` → falseWS actor cleanup 时**也**会跑 exit
*/
import type { UiMessage } from "@/models/chat/ui-message";
@@ -16,7 +22,13 @@ export interface ChatState {
messages: UiMessage[];
isReplyingAI: boolean;
/**
* 游客剩余配额(次/天)—— **仅**游客业务展示用
* WebSocket 连接状态(**仅** userSession 期间 true
* - 决定消息发送**路径**WS / HTTP)和**是否消耗**次数
* - true:走 WS**不**消耗次数
* - false:走 HTTP,消耗次数
*/
wsConnected: boolean;
/** 游客剩余配额(次/天)—— **仅**游客业务展示用
* - default 0 = "未初始化" / "已耗尽"(同码,靠 `isGuest` 区分)
* - 非游客:永不被赋值(业务事实"无消息限制")
*/
@@ -32,6 +44,7 @@ export interface ChatState {
export const initialState: ChatState = {
messages: [],
isReplyingAI: false,
wsConnected: false,
guestRemainingQuota: 0,
guestTotalQuota: 0,
quotaExceededTrigger: 0,