/** * 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() 中(确保类型推断正确) */ import { setup, fromPromise, assign } from "xstate"; import type { UiMessage } from "@/models/chat/ui-message"; import { chatRepository } from "@/data/repositories/chat_repository"; 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"; // ============================================================ // Context // ============================================================ export interface ChatContext { messages: UiMessage[]; isReplyingAI: boolean; isGuest: boolean; guestRemainingQuota: number; guestTotalQuota: number; quotaExceededTrigger: number; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; } const initialContext: ChatContext = { messages: [], isReplyingAI: false, isGuest: true, guestRemainingQuota: 40, guestTotalQuota: 50, quotaExceededTrigger: 0, isLoadingMore: false, hasMore: true, historyOffset: 0, }; /** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */ export const GuestChatQuota = { warningThreshold: 5, quotaExhausted: 0, } as const; // ============================================================ // Events // ============================================================ export type ChatEvent = | { type: "ChatInit" } | { type: "ChatScreenVisible" } | { type: "ChatScreenInvisible" } | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } | { type: "ChatLoadMoreHistory" } | { type: "ChatAISentenceReceived"; index: number; text: string; total: number; done: boolean; } | { type: "ChatWebSocketError"; errorMessage: string } | { type: "ChatWebSocketConnected"; userId: string } | { type: "ChatQuotaExceeded" } | { type: "ChatAuthStatusChanged" }; // ============================================================ // 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; } interface InitResult { localMessages: UiMessage[]; isGuest: boolean; guestRemainingQuota: number; guestTotalQuota: number; } 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 ? chatRepository.getLocalMessages() : Promise.resolve(null), ]); return { isGuest, guestRemainingQuota: mapQuotaResult( dailyResult as Result<{ remaining: number } | null>, 40, ), guestTotalQuota: mapTotalQuotaResult( totalResult as Result, 50, ), 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 chatRepository.sendMessage(input.content); if (Result.isErr(result)) throw result.error; // 拉取最新本地历史 const local = await chatRepository.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 chatRepository.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 ChatContext, 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, }; }), appendUserImage: assign(({ context, event }) => { if (event.type !== "ChatSendImage") return {}; return { messages: [ ...context.messages, { content: "[Image]", isFromAI: false, date: formatDate(), imageUrl: event.imageBase64, }, ], isReplyingAI: true, }; }), 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 }; }), refreshAuthStatus: assign(() => ({ isGuest: !AuthStorage.getInstance().hasLoginToken(), })), incrementQuotaExceeded: assign(({ context }) => ({ quotaExceededTrigger: context.quotaExceededTrigger + 1, })), }, }).createMachine({ id: "chat", initial: "idle", context: initialContext, states: { idle: { on: { ChatInit: "initializing", ChatScreenVisible: "ready", ChatLoadMoreHistory: "loadingMoreHistory", ChatSendMessage: { guard: ({ event }) => event.content.trim().length > 0, actions: "appendUserMessage", }, ChatSendImage: { actions: "appendUserImage", }, ChatAuthStatusChanged: { actions: "refreshAuthStatus", }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence", }, ChatWebSocketError: { actions: "appendSocketErrorMessage", }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded", }, ChatWebSocketConnected: {}, }, }, initializing: { invoke: { src: "chatInit", onDone: { target: "ready", actions: assign({ messages: ({ event }) => event.output.localMessages, isGuest: ({ event }) => event.output.isGuest, guestRemainingQuota: ({ event }) => event.output.guestRemainingQuota, guestTotalQuota: ({ event }) => event.output.guestTotalQuota, }), }, onError: { target: "ready", }, }, }, ready: { on: { ChatSendMessage: { guard: ({ event }) => event.content.trim().length > 0, actions: "appendUserMessage", }, ChatSendImage: { actions: "appendUserImage", }, ChatLoadMoreHistory: "loadingMoreHistory", ChatScreenInvisible: "idle", ChatAuthStatusChanged: { actions: "refreshAuthStatus", }, 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;