refactor(chat): reorganize helper functions and update imports
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
import type { ChatState } from "../chat-state";
|
||||
|
||||
// The initial history request remains bounded even though pagination is disabled.
|
||||
export const CHAT_HISTORY_LIMIT = 50;
|
||||
|
||||
export function applyHistoryLoadedOutput(
|
||||
output: {
|
||||
messages: UiMessage[];
|
||||
},
|
||||
): Pick<ChatState, "messages" | "historyLoaded"> {
|
||||
return {
|
||||
messages: output.messages,
|
||||
historyLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyNetworkHistoryLoadedOutput(
|
||||
context: ChatState,
|
||||
output: {
|
||||
messages: UiMessage[];
|
||||
localCount: number;
|
||||
},
|
||||
): Pick<ChatState, "messages" | "historyLoaded"> {
|
||||
const localSnapshotSize = output.localCount === 0 ? 1 : output.localCount;
|
||||
const optimisticTail = context.messages.slice(localSnapshotSize);
|
||||
return {
|
||||
messages: [...output.messages, ...optimisticTail],
|
||||
historyLoaded: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./history";
|
||||
export * from "./message-mappers";
|
||||
export * from "./promotion";
|
||||
export * from "./send-state";
|
||||
export * from "./unlock";
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type ChatSendResponse, type UiMessage } from "@/data/dto/chat";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
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[] {
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
hasImage: Boolean(m.image?.url),
|
||||
}),
|
||||
isFromAI: m.role === "assistant",
|
||||
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 ? { id: response.messageId } : {}),
|
||||
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 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import { todayString } from "@/utils/date";
|
||||
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
type UnlockMessageOutput,
|
||||
} from "./unlock";
|
||||
|
||||
const PROMOTION_COPY = {
|
||||
voice: "I left a voice message for you. Unlock it to listen.",
|
||||
image: "I sent you a private photo. Unlock it to view.",
|
||||
private: "I have something private to tell you. Unlock it to read.",
|
||||
} as const;
|
||||
|
||||
export interface ChatPromotionState {
|
||||
session: PendingChatPromotion;
|
||||
message: UiMessage;
|
||||
}
|
||||
|
||||
export function createChatPromotionState(
|
||||
session: PendingChatPromotion,
|
||||
messageId?: string,
|
||||
): ChatPromotionState {
|
||||
const isImage = session.promotionType === "image";
|
||||
const isPrivate = session.promotionType === "private";
|
||||
|
||||
return {
|
||||
session,
|
||||
message: {
|
||||
id: messageId || `promotion:${session.clientLockId}`,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
locked: true,
|
||||
lockReason: session.lockType,
|
||||
imagePaywalled: isImage ? true : undefined,
|
||||
lockedPrivate: isPrivate ? true : undefined,
|
||||
privateMessageHint: PROMOTION_COPY[session.promotionType],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPromotionMessage(
|
||||
messages: readonly UiMessage[],
|
||||
promotion: ChatPromotionState | null,
|
||||
): UiMessage[] {
|
||||
if (!promotion) return [...messages];
|
||||
return [
|
||||
...messages.filter((message) => message.id !== promotion.message.id),
|
||||
promotion.message,
|
||||
];
|
||||
}
|
||||
|
||||
export function applyPromotionUnlockOutput(
|
||||
promotion: ChatPromotionState | null,
|
||||
output: UnlockMessageOutput,
|
||||
): ChatPromotionState | null {
|
||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
||||
return promotion;
|
||||
}
|
||||
return {
|
||||
...promotion,
|
||||
message:
|
||||
applySingleUnlockOutput([promotion.message], output)[0] ??
|
||||
promotion.message,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type ChatSendResponse, type UiMessage } from "@/data/dto/chat";
|
||||
|
||||
import type { ChatState, ChatUpgradeReason } from "../chat-state";
|
||||
|
||||
export type HttpSendOutput = {
|
||||
response: ChatSendResponse;
|
||||
reply: UiMessage | null;
|
||||
};
|
||||
|
||||
export function beginPendingReply(context: ChatState): Pick<
|
||||
ChatState,
|
||||
"isReplyingAI" | "pendingReplyCount"
|
||||
> {
|
||||
return {
|
||||
pendingReplyCount: context.pendingReplyCount + 1,
|
||||
isReplyingAI: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function finishPendingReply(context: ChatState): Pick<
|
||||
ChatState,
|
||||
"isReplyingAI" | "pendingReplyCount"
|
||||
> {
|
||||
const pendingReplyCount = Math.max(0, context.pendingReplyCount - 1);
|
||||
return {
|
||||
pendingReplyCount,
|
||||
isReplyingAI: pendingReplyCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyHttpSendOutput(
|
||||
context: ChatState,
|
||||
output: HttpSendOutput,
|
||||
): Partial<ChatState> {
|
||||
const { response, reply } = output;
|
||||
|
||||
const isInsufficientCredits = response.canSendMessage === false;
|
||||
const upgradeReason: ChatUpgradeReason | null = isInsufficientCredits
|
||||
? "insufficient_credits"
|
||||
: null;
|
||||
const sendCapabilityState = getSendCapabilityState(response);
|
||||
|
||||
if (upgradeReason) {
|
||||
const lastMessage = context.messages[context.messages.length - 1];
|
||||
const messages =
|
||||
!reply && lastMessage && !lastMessage.isFromAI
|
||||
? context.messages.slice(0, -1)
|
||||
: context.messages;
|
||||
|
||||
return {
|
||||
messages: reply ? [...messages, reply] : messages,
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
return {
|
||||
...finishPendingReply(context),
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
function getSendCapabilityState(
|
||||
response: ChatSendResponse,
|
||||
): Pick<
|
||||
ChatState,
|
||||
| "canSendMessage"
|
||||
| "creditBalance"
|
||||
| "creditsCharged"
|
||||
| "requiredCredits"
|
||||
| "shortfallCredits"
|
||||
> {
|
||||
return {
|
||||
canSendMessage: response.canSendMessage,
|
||||
creditBalance: response.creditBalance,
|
||||
creditsCharged: response.creditsCharged,
|
||||
requiredCredits: response.requiredCredits,
|
||||
shortfallCredits: response.shortfallCredits,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
export interface UnlockMessageRequest {
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface UnlockMessageOutput {
|
||||
displayMessageId: string;
|
||||
request: UnlockMessageRequest;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
|
||||
return messages.filter(isUnlockableLockedMessage).length;
|
||||
}
|
||||
|
||||
export function applySingleUnlockOutput(
|
||||
messages: readonly UiMessage[],
|
||||
output: UnlockMessageOutput,
|
||||
): UiMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (!shouldApplySingleUnlock(message, output.displayMessageId)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const resolvedId = output.response.messageId || message.id;
|
||||
if (!output.response.unlocked) {
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedImageUrl =
|
||||
output.response.image.url ?? message.imageUrl;
|
||||
const resolvedContent =
|
||||
message.lockReason === "private_message"
|
||||
? output.response.content || message.content
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
content: resolvedContent,
|
||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||
imageUrl: resolvedImageUrl,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
imagePaywalled: resolvedImageUrl ? false : undefined,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getUnlockedAudioUrl(
|
||||
message: UiMessage,
|
||||
response: UnlockPrivateResponse,
|
||||
): string | undefined {
|
||||
if (message.lockReason !== "voice_message") return message.audioUrl;
|
||||
if (response.audioUrl.length === 0) return message.audioUrl;
|
||||
return response.audioUrl;
|
||||
}
|
||||
|
||||
function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean {
|
||||
if (message.id !== messageId) return false;
|
||||
if (!message.isFromAI) return false;
|
||||
if (message.locked !== true) return false;
|
||||
return (
|
||||
message.imagePaywalled === true ||
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean {
|
||||
return countLockedHistoryMessages(messages) > 1;
|
||||
}
|
||||
|
||||
function isUnlockableLockedMessage(message: UiMessage): boolean {
|
||||
if (!message.isFromAI || message.locked !== true) return false;
|
||||
if (message.imagePaywalled === true) return true;
|
||||
return (
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user