import type { UiMessage } from "@/data/dto/chat"; import { chatRepository } from "@/data/repositories/chat_repository"; import type { IChatRepository } from "@/data/repositories/interfaces"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { todayString, Result, Logger } from "@/utils"; import type { ChatState } from "./chat-state"; 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<{ id?: string; type?: string; content: string; role: string; createdAt: string; audioUrl?: string | null; image?: { type: string | null; url: string | null }; lockDetail?: { locked: boolean; showContent: boolean; showUpgrade: boolean; reason: string | null; hint: string | null; detail: Record | null; }; }>, ): UiMessage[] { return records.map((m) => ({ ...(m.id ? { id: m.id } : {}), content: getAiMessageDisplayContent({ content: m.content, isFromAI: m.role === "assistant", hasImage: Boolean(m.image?.url), lockDetail: m.lockDetail, }), isFromAI: m.role === "assistant", date: messageDateFromCreatedAt(m.createdAt), ...(m.audioUrl ? { audioUrl: m.audioUrl } : {}), ...deriveUiLockFields(m.lockDetail, m.image?.url), })); } function messageDateFromCreatedAt(createdAt: string): string { const parsed = new Date(createdAt); if (Number.isNaN(parsed.getTime())) return todayString(); return todayString(parsed); } function getAiMessageDisplayContent(input: { content: string; isFromAI: boolean; hasImage?: boolean; lockDetail?: { showContent: boolean; reason: string | null; }; }): string { if (input.lockDetail?.showContent === false) return ""; return input.isFromAI && input.hasImage ? "" : input.content; } /** * ChatSendResponse → UiMessage(纯函数) * - 业务事实:后端响应就是 AI 的回复 * - 用后端 `timestamp`(不用本地 time)—— 多设备/时区一致 */ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { return { ...(response.messageId ? { id: response.messageId } : {}), content: getAiMessageDisplayContent({ content: response.reply, isFromAI: true, hasImage: Boolean(response.image.url), lockDetail: response.lockDetail, }), isFromAI: true, date: todayString(new Date(response.timestamp)), ...(response.audioUrl ? { audioUrl: response.audioUrl } : {}), ...deriveUiLockFields(response.lockDetail, response.image.url), }; } function deriveUiLockFields( lockDetail: | { locked: boolean; showContent: boolean; showUpgrade: boolean; reason: string | null; hint: string | null; } | undefined, imageUrl?: string | null, ): Partial { const isPrivateMessage = lockDetail?.reason === "private_message"; return { ...(imageUrl ? { imageUrl } : {}), ...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}), ...(isPrivateMessage ? { isPrivate: true } : {}), ...(isPrivateMessage && lockDetail?.locked ? { lockedPrivate: true } : {}), ...(isPrivateMessage && lockDetail?.hint ? { privateMessageHint: lockDetail.hint } : {}), }; } export type HttpSendOutput = { response: ChatSendResponse; reply: UiMessage | null; }; export function applyHttpSendOutput( context: ChatState, output: HttpSendOutput, ): Partial { const { response, reply } = output; const lockDetail = response.lockDetail; const isMessageLimitBlocked = lockDetail.locked && lockDetail.showUpgrade && lockDetail.reason === "daily_limit"; if (isMessageLimitBlocked) { const lastMessage = context.messages[context.messages.length - 1]; const messages = lastMessage && !lastMessage.isFromAI ? context.messages.slice(0, -1) : context.messages; return { messages, isReplyingAI: false, upgradePromptVisible: true, upgradeReason: "daily_limit", upgradeHint: null, upgradeDetail: normalizeLockDetail(lockDetail.detail), }; } if (!reply) { return { isReplyingAI: false, }; } if (lockDetail.showUpgrade && lockDetail.reason === "private_message") { return { messages: [...context.messages, reply], isReplyingAI: false, upgradePromptVisible: false, upgradeReason: null, upgradeHint: null, upgradeDetail: null, }; } return { messages: [...context.messages, reply], isReplyingAI: false, upgradePromptVisible: false, upgradeReason: null, upgradeHint: null, upgradeDetail: null, }; } export function normalizeLockDetail( detail: Record | null, ): ChatState["upgradeDetail"] { if (!detail) return null; return { ...(typeof detail.type === "string" ? { type: detail.type } : {}), ...(typeof detail.usedToday === "number" ? { usedToday: detail.usedToday } : {}), ...(typeof detail.usedTotal === "number" ? { usedTotal: detail.usedTotal } : {}), ...(typeof detail.limit === "number" ? { limit: detail.limit } : {}), }; } // ============================================================ // 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: todayString(), }; // 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, }; }