import type { UiMessage } from "@/stores/chat/ui-message"; import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity"; import { loadChatRepository } from "@/data/repositories/chat_repository_loader"; import { Logger } from "@/utils/logger"; import { Result } from "@/utils/result"; import { todayString } from "@/utils/date"; import { isAbortError } from "@/utils/abort"; import { getCharacterErrorCode } from "@/data/services/api"; import { CHAT_HISTORY_LIMIT } from "./helper/history"; import { localMessagesToUi } from "./helper/message-mappers"; const log = new Logger("StoresChatChatHistorySync"); export type ReadAndSyncHistoryOutput = { /** Network-authoritative messages. Empty network history includes a UI greeting. */ messages: UiMessage[]; /** Display identities present in the local snapshot before the request. */ localDisplayIds: readonly string[]; /** True when network history was written back to local storage. */ localOverwritten: boolean; localCount: number; networkCount: number; total: number; limit: number; }; export type LocalHistorySnapshotOutput = { /** Local cached messages. Empty local history includes a UI greeting. */ messages: UiMessage[]; localCount: number; }; export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput; export function createGreetingMessage( characterId: string, content: string, ): UiMessage { return { displayId: `greeting:${encodeURIComponent(characterId)}`, content, isFromAI: true, date: todayString(), isSynthetic: true, }; } export async function resolveHistoryCacheIdentity( characterId: string, ): Promise { const result = await resolveChatConversationKey(characterId); return Result.isOk(result) ? result.data : null; } export async function readLocalHistorySnapshot( cacheIdentity: string | null, characterId: string, emptyChatGreeting: string, ): Promise { const chatRepo = await loadChatRepository(); const greetingMessage = createGreetingMessage( characterId, emptyChatGreeting, ); const localResult = cacheIdentity ? await chatRepo.getLocalMessages(cacheIdentity) : null; const localMessages = localResult && Result.isOk(localResult) && localResult.data ? localMessagesToUi(localResult.data) : []; log.debug("[chat-machine] loadHistory LOCAL DONE", { count: localMessages.length, }); const snapshotMessages = localMessages.length === 0 ? [greetingMessage] : localMessages; if (snapshotMessages[0] === greetingMessage) { log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting (local)"); } return { messages: snapshotMessages, localCount: localMessages.length, }; } export async function syncNetworkHistory( characterId: string, localCount: number, localDisplayIds: readonly string[], cacheIdentity: string | null, emptyChatGreeting: string, signal?: AbortSignal, ): Promise { const chatRepo = await loadChatRepository(); const greetingMessage = createGreetingMessage( characterId, emptyChatGreeting, ); const openingResult = await chatRepo.saveOpeningMessage( characterId, emptyChatGreeting, { signal }, ); if (Result.isErr(openingResult)) { if (isAbortError(openingResult.error)) throw openingResult.error; log.warn("[chat-machine] opening message persistence skipped", { characterId, error: openingResult.error, }); } signal?.throwIfAborted(); const networkResult = await chatRepo.getHistory( characterId, CHAT_HISTORY_LIMIT, 0, { signal }, ); if (Result.isErr(networkResult)) { if (isAbortError(networkResult.error)) throw networkResult.error; if (getCharacterErrorCode(networkResult.error)) throw networkResult.error; log.error("[chat-machine] loadHistory NETWORK FAILED", { error: networkResult.error, }); return null; } signal?.throwIfAborted(); const networkUi = localMessagesToUi(networkResult.data.messages); log.debug("[chat-machine] loadHistory NETWORK DONE", { count: networkUi.length, }); if (cacheIdentity) { void chatRepo.prefetchMediaForMessages( networkResult.data.messages, characterId, cacheIdentity, ); } const saveResult = cacheIdentity ? await chatRepo.saveMessagesToLocal( networkResult.data.messages, cacheIdentity, ) : null; const localOverwritten = saveResult !== null && Result.isOk(saveResult); log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", { localOverwritten, }); const finalMessages = networkUi.length === 0 ? [greetingMessage] : networkUi; if (finalMessages[0] === greetingMessage) { log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting"); } return { messages: finalMessages, localDisplayIds, localOverwritten, localCount, networkCount: networkUi.length, total: networkResult.data.total, limit: networkResult.data.limit, }; } /** * History sync flow: * 1. Read local history for fallback. * 2. Read network history as the authoritative source. * 3. Overwrite local history with network data. */ export async function readAndSyncHistory( characterId: string, emptyChatGreeting: string, signal?: AbortSignal, ): Promise { const cacheIdentity = await resolveHistoryCacheIdentity(characterId); const localSnapshot = await readLocalHistorySnapshot( cacheIdentity, characterId, emptyChatGreeting, ); const networkSnapshot = await syncNetworkHistory( characterId, localSnapshot.localCount, localSnapshot.messages.map((message) => message.displayId), cacheIdentity, emptyChatGreeting, signal, ); if (networkSnapshot) return networkSnapshot; return { messages: localSnapshot.messages, localDisplayIds: localSnapshot.messages.map( (message) => message.displayId, ), localOverwritten: false, localCount: localSnapshot.localCount, networkCount: 0, total: 0, limit: CHAT_HISTORY_LIMIT, }; }