/** * Chat 状态机(XState v5) * * 原始 Dart: lib/ui/chat/bloc/chat bloc + chat state + chat event * * 本轮迁移范围: * ✅ HTTP 流程(init / send message / load more history) * ✅ 上下文数据(messages / quota / 标志位) * ⏳ WebSocket 长生命周期 actor(fromCallback)—— 下轮处理 * * 设计要点: * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API * - HTTP 操作用 `fromPromise` actor(一次性 Promise) * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 * - actions 全部 inline 在 setup() 中(确保类型推断正确) * * **鉴权解耦**: * chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。 * WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。 * * **配额(**仅**游客)**: * - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义 * - 非游客"无消息限制"(业务事实)—— 永不被赋值 * - chat-screen 派 `ChatInit` 时(**只**在 guest)→ onDone 条件赋 * - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op */ import { setup, fromPromise, 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 { formatDate } from "@/utils/date"; import { Result } from "@/utils/result"; import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; /** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */ export const GuestChatQuota = { warningThreshold: 5, quotaExhausted: 0, } as const; // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 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 // ============================================================ 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, }; }); // ============================================================// Machine // ============================================================ export const chatMachine = setup({ types: { context: {} as ChatState, events: {} as ChatEvent, }, actors: { chatInit: chatInitActor, sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, }, actions: { appendUserMessage: assign(({ context, event }) => { if (event.type !== "ChatSendMessage") return {}; return { messages: [ ...context.messages, { content: event.content, isFromAI: false, date: formatDate(), }, ], isReplyingAI: true, // 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制") guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1), }; }), appendUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; return { messages: [ ...context.messages, { content: "[Image]", isFromAI: false, date: formatDate(), imageUrl: event.imageBase64, }, ], isReplyingAI: true, // 游客减配额;非游客 no-op guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1), }; }), appendOrUpdateAISentence: assign(({ context, event }) => { if (event.type !== "ChatAISentenceReceived") return {}; const messages = [...context.messages]; if (event.index === 0) { messages.push({ content: event.text, isFromAI: true, date: formatDate(), }); } else { const last = messages[messages.length - 1]; if (last && last.isFromAI) { messages[messages.length - 1] = { ...last, content: `${last.content} ${event.text}`, }; } } return { messages, isReplyingAI: !event.done }; }), appendSocketErrorMessage: assign(({ context }) => { const messages = [ ...context.messages, { content: "Something went wrong. Try sending again?", isFromAI: true, date: formatDate(), }, ]; return { messages, isReplyingAI: false }; }), incrementQuotaExceeded: assign(({ context }) => ({ quotaExceededTrigger: context.quotaExceededTrigger + 1, })), }, }).createMachine({ id: "chat", initial: "idle", context: initialState, states: { idle: { on: { ChatInit: "initializing", ChatScreenVisible: "ready", ChatLoadMoreHistory: "loadingMoreHistory", ChatSendMessage: { guard: ({ event }) => event.content.trim().length > 0, actions: "appendUserMessage", }, ChatSendImage: { actions: "appendUserImage", }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence", }, ChatWebSocketError: { actions: "appendSocketErrorMessage", }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded", }, ChatWebSocketConnected: {}, }, }, initializing: { invoke: { src: "chatInit", onDone: { target: "ready", // **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制") // - 游客:messages + quota 都更新 // - 非游客:仅 messages(local 空),quota 保持 default 0(不更新其值) actions: assign(({ event }) => { const updates: Partial = { messages: event.output.localMessages, }; if (event.output.isGuest) { updates.guestRemainingQuota = event.output.guestRemainingQuota; updates.guestTotalQuota = event.output.guestTotalQuota; } return updates; }), }, onError: { target: "ready", }, }, }, ready: { on: { ChatSendMessage: { guard: ({ event }) => event.content.trim().length > 0, actions: "appendUserMessage", }, ChatSendImage: { actions: "appendUserImage", }, ChatLoadMoreHistory: "loadingMoreHistory", ChatScreenInvisible: "idle", ChatAISentenceReceived: { actions: "appendOrUpdateAISentence", }, ChatWebSocketError: { actions: "appendSocketErrorMessage", }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded", }, ChatWebSocketConnected: {}, }, }, loadingMoreHistory: { 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, })), }, onError: { target: "ready", actions: assign({ isLoadingMore: false }), }, }, }, }, }); export type ChatMachine = typeof chatMachine;