From 38f060bbd85c4caf8fee3a9bd48f3a79c3e55a36 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 12 Jun 2026 11:20:19 +0800 Subject: [PATCH] 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 --- src/app/auth/components/auth-screen.tsx | 5 - src/app/chat/components/chat-input-bar.tsx | 7 + .../chat/components/text-bubble.module.css | 4 +- src/stores/chat/chat-machine.actors.ts | 197 +++++++ src/stores/chat/chat-machine.helpers.ts | 116 +++++ src/stores/chat/chat-machine.ts | 492 +++++++++--------- src/stores/chat/chat-state.ts | 15 +- 7 files changed, 583 insertions(+), 253 deletions(-) create mode 100644 src/stores/chat/chat-machine.actors.ts create mode 100644 src/stores/chat/chat-machine.helpers.ts diff --git a/src/app/auth/components/auth-screen.tsx b/src/app/auth/components/auth-screen.tsx index 12d836be..9828a01b 100644 --- a/src/app/auth/components/auth-screen.tsx +++ b/src/app/auth/components/auth-screen.tsx @@ -18,11 +18,6 @@ import { AuthBackground } from "./auth-background"; import { AuthPanel } from "./auth-panel"; export function AuthScreen() { - // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== - // 本组件只负责: - // 1. 渲染登录 UI - // 2. 邮箱登录成功 → 初始化 user store + navigate 到 /chat(业务层动作) - const state = useAuthState(); const authDispatch = useAuthDispatch(); const userDispatch = useUserDispatch(); diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index e444ffde..40bdb923 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -42,6 +42,13 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) { const handleSend = () => { if (!hasContent) return; + console.log("[chat-input-bar] handleSend", { + contentLength: input.length, + contentPreview: input.slice(0, 50), + hasContent, + disabled, + isFocused, + }); dispatch({ type: "ChatSendMessage", content: input }); setInput(""); textareaRef.current?.focus(); diff --git a/src/app/chat/components/text-bubble.module.css b/src/app/chat/components/text-bubble.module.css index c3af1c58..ede454a5 100644 --- a/src/app/chat/components/text-bubble.module.css +++ b/src/app/chat/components/text-bubble.module.css @@ -18,7 +18,7 @@ color: var(--color-text-foreground, #000); border-top-left-radius: 0; /* 气泡在头像侧的外侧留白 */ - margin-left: var(--spacing-4, 16px); + /* margin-left: var(--spacing-4, 16px); */ } .bubbleUser { @@ -30,5 +30,5 @@ color: #fff; border-top-right-radius: 0; /* 气泡在头像侧的外侧留白 */ - margin-right: var(--spacing-4, 16px); + /* margin-right: var(--spacing-4, 16px); */ } diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts new file mode 100644 index 00000000..c26c79c6 --- /dev/null +++ b/src/stores/chat/chat-machine.actors.ts @@ -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(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: "", + }); + 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: "", + }); + 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( + ({ 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: "", + }); + 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; diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts new file mode 100644 index 00000000..e8fb26ad --- /dev/null +++ b/src/stores/chat/chat-machine.helpers.ts @@ -0,0 +1,116 @@ +/** + * Chat 状态机:纯函数 + 数据加载 + * + * 从 `chat-machine.ts` 抽出的"helpers"段: + * - 翻页大小常量 + * - 仓库注入(接口类型 → 单例) + * - DTO ↔ UiMessage 映射(**纯函数** —— 可单测) + * - Result → number 映射(**纯函数** —— 可单测) + * - 初始数据装配(**仅**游客有意义 —— `chatInitActor` 调用) + * + * 设计目标: + * - helpers **无 XState 依赖**(可独立 import / 测试) + * - actors 依赖 helpers(import) + * - machine 依赖 actors(import)—— **不**直接依赖 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, + 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 { + 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, + 0, + ), + localMessages: + localResult && Result.isOk(localResult) && localResult.data + ? localMessagesToUi(localResult.data) + : [], + }; +} diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index a45037b9..9dbb7e96 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -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 + createMachine(actions + states) + * - `chat-machine.helpers.ts` —— 纯函数 + 数据加载 + * - `chat-machine.actors.ts` —— 4 个 XState actor(含全链日志) + * + * **调试日志**(保留 —— 用户要求"不要将其删除"): + * - 命名约定:`[]` 前缀标识来源 + * - 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, - 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 { - 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, - 0, - ), - localMessages: - localResult && Result.isOk(localResult) && localResult.data - ? localMessagesToUi(localResult.data) - : [], - }; -} - -// ============================================================// Actors -// ============================================================ -// HTTP:一次性 Promise -const chatInitActor = fromPromise(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( - ({ 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 = { 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", // 登出 → idle(cleanup 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" }, }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index cbb2029b..e7bbbcaa 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -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` → false(WS 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,