refactor(chat): reorganize helper functions and update imports

This commit is contained in:
2026-07-15 10:59:23 +08:00
parent 2b90f90ab0
commit ed3e800245
20 changed files with 27 additions and 29 deletions
+32
View File
@@ -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,
};
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./history";
export * from "./message-mappers";
export * from "./promotion";
export * from "./send-state";
export * from "./unlock";
+88
View File
@@ -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 } : {}),
};
}
+68
View File
@@ -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,
};
}
+92
View File
@@ -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,
};
}
+95
View File
@@ -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"
);
}