134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
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<string, number>();
|
|
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 }
|
|
: {}),
|
|
...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<UiMessage> {
|
|
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 } : {}),
|
|
};
|
|
}
|