import type { ChatLockDetailData, ChatSendResponse, } from "@/data/schemas/chat"; import { createClientUiMessageIdentity, createLegacyUiMessageIdentity, createRemoteUiMessageIdentity, type UiMessage, } from "@/stores/chat/ui-message"; import { todayString } from "@/utils/date"; /** * ChatMessage[] -> UiMessage[]. * Business fact: `role === "assistant"` means the message is from AI. */ export function localMessagesToUi( records: readonly { id?: string; type?: string; content: string; role: string; createdAt: string; audioUrl?: string | null; image?: { type: string | null; url: string | null }; lockDetail?: ChatLockDetailData; }[], ): UiMessage[] { const occurrences = new Map(); return records.map((m) => { const isFromAI = m.role === "assistant"; const identityBase = m.id ? `remote:${m.id}:${m.role}` : `legacy:${createLegacyFingerprint(m)}`; const occurrence = occurrences.get(identityBase) ?? 0; occurrences.set(identityBase, occurrence + 1); const identity = m.id ? createRemoteUiMessageIdentity(m.id, isFromAI, occurrence) : createLegacyUiMessageIdentity(identityBase, occurrence); return { ...identity, content: getAiMessageDisplayContent({ content: m.content, isFromAI, hasImage: Boolean(m.image?.url), }), isFromAI, date: messageDateFromCreatedAt(m.createdAt), ...(m.audioUrl && m.lockDetail?.locked !== true ? { audioUrl: m.audioUrl } : {}), ...deriveUiLockFields(m.lockDetail, m.image?.url), }; }); } /** * ChatSendResponse -> UiMessage. * Business fact: the backend response is the AI reply. */ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { return { ...(response.messageId ? createRemoteUiMessageIdentity(response.messageId, true) : createClientUiMessageIdentity("reply")), content: getAiMessageDisplayContent({ content: response.reply, isFromAI: true, hasImage: Boolean(response.image.url), }), isFromAI: true, date: todayString(new Date(response.timestamp)), ...(response.audioUrl && !response.lockDetail.locked ? { audioUrl: response.audioUrl } : {}), ...(response.commercialAction ? { commercialAction: response.commercialAction } : {}), ...deriveUiLockFields(response.lockDetail, response.image.url), }; } function createLegacyFingerprint(record: { type?: string; content: string; role: string; createdAt: string; audioUrl?: string | null; image?: { type: string | null; url: string | null }; lockDetail?: ChatLockDetailData; }): string { return JSON.stringify([ record.role, record.type ?? "text", record.content, record.createdAt, record.audioUrl ?? null, record.image?.type ?? null, record.image?.url ?? null, record.lockDetail?.locked ?? null, record.lockDetail?.reason ?? null, ]); } 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; }): string { return input.isFromAI && input.hasImage ? "" : input.content; } function deriveUiLockFields( lockDetail: ChatLockDetailData | undefined, imageUrl?: string | null, ): Partial { const lockReason = lockDetail?.reason ?? null; const isLocked = lockDetail?.locked === true; const isPrivateMessage = isLocked && lockReason === "private_message"; const isLockedImage = isLocked && (lockReason === "image_paywall" || lockReason === "image"); return { ...(imageUrl ? { imageUrl } : {}), ...(imageUrl && isLockedImage ? { imagePaywalled: true } : {}), ...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}), ...(isPrivateMessage ? { isPrivate: true } : {}), ...(isPrivateMessage ? { lockedPrivate: true } : {}), }; }