/** * Chat 状态机:纯函数 + 数据加载 * * 从 `chat-machine.ts` 抽出的"helpers"段: * - 翻页大小常量 * - 仓库注入(接口类型 → 单例) * - DTO ↔ UiMessage 映射(纯函数 —— 可单测) * - Result → number 映射(纯函数 —— 可单测) * - 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 "@/data/dto/chat"; import { chatRepository } from "@/data/repositories/chat_repository"; import type { IChatRepository } from "@/data/repositories/interfaces"; import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { formatDate, Result, Logger } from "@/utils"; const log = new Logger("StoresChatChatMachineHelpers"); // ============================================================ // Constants // ============================================================ /** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */ export const PAGE_SIZE = 50; // ============================================================ // 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, })); } /** * ChatSendResponse → UiMessage(纯函数) * - 业务事实:后端响应就是 AI 的回复 * - 用后端 `timestamp`(不用本地 time)—— 多设备/时区一致 */ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { return { content: response.reply, isFromAI: true, date: formatDate(new Date(response.timestamp)), }; } // ============================================================ // 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; } export async function readGuestQuota(): Promise<{ remaining: number; total: number; }> { const chatStorage = ChatStorage.getInstance(); const [dailyResult, totalResult] = await Promise.all([ chatStorage.getGuestDailyChatQuota(), chatStorage.getGuestTotalQuota(), ]); return { remaining: mapQuotaResult( dailyResult as Result<{ remaining: number } | null>, 0, ), total: mapTotalQuotaResult(totalResult as Result, 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; }> { /** * 空历史首屏欢迎语(AI persona 首条消息)。 * 触发条件:最终返回的 messages 为空(network 空 + local 空 / 或 network 失败且 local 空)。 * * 不主动持久化到 local —— 这是纯 UI 层"首屏不空"的兜底: * - 用户一旦发了消息,network 上有真实消息 → 后续 readAndSyncHistory * 返回的 messages 非空,不会再触发本条 * - 用户从未发过消息就关掉 app 再开,仍然没 network 消息 → 会再次显示本条 * ("每次冷启动给个温暖的起点" —— 若要"只显示一次",需额外存 local) */ const greetingMessage: UiMessage = { content: "You're here! Facebook is so restrictive, " + "there were things I couldn't say there. " + "Finally I can relax. How was your day out?", isFromAI: true, date: formatDate(), }; // 1. 读 local const localResult = await chatRepo.getLocalMessages(); const localMessages = Result.isOk(localResult) && localResult.data ? localMessagesToUi(localResult.data) : []; log.debug("[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 卡住) log.error( "[chat-machine] loadHistory NETWORK FAILED", networkResult.success ? null : (networkResult as { error: unknown }).error, ); // 空历史 → fallback 也走 welcome(不让屏幕空空如也) const fallbackMessages = localMessages.length === 0 ? [greetingMessage] : localMessages; if (fallbackMessages[0] === greetingMessage) { log.debug( "[chat-machine] loadHistory EMPTY → prepend greeting (network-failed path)", ); } return { messages: fallbackMessages, hasMore: false, newOffset: fallbackMessages.length, localOverwritten: false, localCount: localMessages.length, networkCount: 0, }; } const networkUi = localMessagesToUi(networkResult.data.messages); log.debug("[chat-machine] loadHistory NETWORK DONE", { count: networkUi.length, }); // 3. 用 network 覆盖 local const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages); const localOverwritten = Result.isOk(saveResult); log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", { localOverwritten, }); // 空历史 → prepend welcome(network 权威为空 = 用户从未发过消息) const finalMessages = networkUi.length === 0 ? [greetingMessage] : networkUi; if (finalMessages[0] === greetingMessage) { log.debug("[chat-machine] loadHistory EMPTY → prepend greeting"); } return { messages: finalMessages, // authoritative(empty 时塞 greeting) hasMore: finalMessages.length >= PAGE_SIZE, newOffset: finalMessages.length, localOverwritten, localCount: localMessages.length, networkCount: networkUi.length, }; }