refactor(stores): tighten chat and payment boundaries
This commit is contained in:
@@ -3,10 +3,10 @@ import { fromPromise } from "xstate";
|
||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { Logger, Result } from "@/utils";
|
||||
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
localMessagesToUi,
|
||||
readAndSyncHistory,
|
||||
} from "./chat-machine.helpers";
|
||||
|
||||
const log = new Logger("StoresChatChatHistoryFlow");
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { Logger, Result, todayString } from "@/utils";
|
||||
|
||||
import { localMessagesToUi, PAGE_SIZE } from "./chat-machine.helpers";
|
||||
|
||||
const log = new Logger("StoresChatChatHistorySync");
|
||||
|
||||
export type ReadAndSyncHistoryOutput = {
|
||||
/** Network-authoritative messages. Empty network history includes a UI greeting. */
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
/** True when network history was written back to local storage. */
|
||||
localOverwritten: boolean;
|
||||
localCount: number;
|
||||
networkCount: number;
|
||||
};
|
||||
|
||||
function createGreetingMessage(): UiMessage {
|
||||
return {
|
||||
content:
|
||||
"You're here! Facebook is so restrictive, " +
|
||||
"there were things I couldn't say there. " +
|
||||
"Finally I can relax. How was your day out?",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(): Promise<ReadAndSyncHistoryOutput> {
|
||||
const chatRepo = getChatRepository();
|
||||
const greetingMessage = createGreetingMessage();
|
||||
|
||||
const localResult = await chatRepo.getLocalMessages();
|
||||
const localMessages =
|
||||
Result.isOk(localResult) && localResult.data
|
||||
? localMessagesToUi(localResult.data)
|
||||
: [];
|
||||
log.debug("[chat-machine] loadHistory LOCAL DONE", {
|
||||
count: localMessages.length,
|
||||
});
|
||||
|
||||
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
|
||||
if (Result.isErr(networkResult)) {
|
||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||
error: networkResult.error,
|
||||
});
|
||||
|
||||
const fallbackMessages =
|
||||
localMessages.length === 0 ? [greetingMessage] : localMessages;
|
||||
if (fallbackMessages[0] === greetingMessage) {
|
||||
log.debug(
|
||||
"[chat-machine] loadHistory EMPTY -> prepend greeting (network-failed path)",
|
||||
);
|
||||
}
|
||||
return {
|
||||
messages: fallbackMessages,
|
||||
hasMore: false,
|
||||
newOffset: fallbackMessages.length,
|
||||
localOverwritten: false,
|
||||
localCount: localMessages.length,
|
||||
networkCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const networkUi = localMessagesToUi(networkResult.data.messages);
|
||||
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
||||
count: networkUi.length,
|
||||
});
|
||||
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
|
||||
|
||||
const saveResult = await chatRepo.saveMessagesToLocal(
|
||||
networkResult.data.messages,
|
||||
);
|
||||
const localOverwritten = 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,
|
||||
hasMore: finalMessages.length >= PAGE_SIZE,
|
||||
newOffset: finalMessages.length,
|
||||
localOverwritten,
|
||||
localCount: localMessages.length,
|
||||
networkCount: networkUi.length,
|
||||
};
|
||||
}
|
||||
@@ -1,285 +1,13 @@
|
||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||
import { todayString, Result, Logger } from "@/utils";
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
import type { ChatState, ChatUpgradeReason } from "./chat-state";
|
||||
import type { ChatState } from "./chat-state";
|
||||
|
||||
const log = new Logger("StoresChatChatMachineHelpers");
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
/** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */
|
||||
// History pagination size used by the history actors.
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
// ============================================================
|
||||
// DTO ↔ UiMessage 映射
|
||||
// ============================================================
|
||||
/**
|
||||
* ChatMessage[] → UiMessage[](纯函数)
|
||||
* - 与 Dart `LocalMessage → ChatMessage` 等价
|
||||
* - 业务事实:`role === "assistant"` → `isFromAI: true`
|
||||
*/
|
||||
export function localMessagesToUi(
|
||||
records: ReadonlyArray<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
audioUrl?: string | null;
|
||||
image?: { type: string | null; url: string | null };
|
||||
lockDetail?: {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
};
|
||||
}>,
|
||||
): UiMessage[] {
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
hasImage: Boolean(m.image?.url),
|
||||
lockDetail: m.lockDetail,
|
||||
}),
|
||||
isFromAI: m.role === "assistant",
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.audioUrl ? { audioUrl: m.audioUrl } : {}),
|
||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||
}));
|
||||
}
|
||||
|
||||
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
|
||||
return messages.filter(isUnlockableLockedMessage).length;
|
||||
}
|
||||
|
||||
function isUnlockableLockedMessage(message: UiMessage): boolean {
|
||||
if (!message.isFromAI || message.locked !== true) return false;
|
||||
if (message.imagePaywalled === true) return true;
|
||||
return (
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
lockDetail?: {
|
||||
showContent: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
}): string {
|
||||
if (input.lockDetail?.showContent === false) return "";
|
||||
return input.isFromAI && input.hasImage ? "" : input.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatSendResponse → UiMessage(纯函数)
|
||||
* - 业务事实:后端响应就是 AI 的回复
|
||||
* - 用后端 `timestamp`(不用本地 time)—— 多设备/时区一致
|
||||
*/
|
||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
return {
|
||||
...(response.messageId ? { id: response.messageId } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: response.reply,
|
||||
isFromAI: true,
|
||||
hasImage: Boolean(response.image.url),
|
||||
lockDetail: response.lockDetail,
|
||||
}),
|
||||
isFromAI: true,
|
||||
date: todayString(new Date(response.timestamp)),
|
||||
...(response.audioUrl ? { audioUrl: response.audioUrl } : {}),
|
||||
...deriveUiLockFields(response.lockDetail, response.image.url),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveUiLockFields(
|
||||
lockDetail:
|
||||
| {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
}
|
||||
| undefined,
|
||||
imageUrl?: string | null,
|
||||
): Partial<UiMessage> {
|
||||
const lockReason = lockDetail?.reason ?? null;
|
||||
const isPrivateMessage = lockReason === "private_message";
|
||||
const isVoiceMessage = lockReason === "voice_message";
|
||||
return {
|
||||
...(imageUrl ? { imageUrl } : {}),
|
||||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
||||
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
|
||||
...(isPrivateMessage ? { isPrivate: true } : {}),
|
||||
...(isPrivateMessage && lockDetail?.locked
|
||||
? { lockedPrivate: true }
|
||||
: {}),
|
||||
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
|
||||
? { privateMessageHint: lockDetail.hint }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type HttpSendOutput = {
|
||||
response: ChatSendResponse;
|
||||
reply: UiMessage | null;
|
||||
};
|
||||
|
||||
export interface UnlockMessageOutput {
|
||||
messageId: string;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
export function applySingleUnlockOutput(
|
||||
messages: readonly UiMessage[],
|
||||
output: UnlockMessageOutput,
|
||||
): UiMessage[] {
|
||||
if (!output.response.unlocked) return [...messages];
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.id !== output.messageId) return message;
|
||||
|
||||
const nextContent =
|
||||
output.response.content.length > 0
|
||||
? output.response.content
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: nextContent,
|
||||
locked: output.response.lockDetail.locked,
|
||||
lockReason: output.response.lockDetail.reason,
|
||||
imagePaywalled: message.imageUrl
|
||||
? output.response.lockDetail.locked &&
|
||||
output.response.lockDetail.showUpgrade
|
||||
: undefined,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: 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 lockDetail = response.lockDetail;
|
||||
const isWeeklyLimitBlocked =
|
||||
lockDetail.locked &&
|
||||
lockDetail.showUpgrade &&
|
||||
lockDetail.reason === "weekly_limit";
|
||||
const isInsufficientCredits =
|
||||
response.canSendMessage === false &&
|
||||
response.cannotSendReason === "insufficient_credits";
|
||||
const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked
|
||||
? "weekly_limit"
|
||||
: 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,
|
||||
};
|
||||
}
|
||||
|
||||
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
function getSendCapabilityState(
|
||||
response: ChatSendResponse,
|
||||
): Pick<
|
||||
ChatState,
|
||||
| "canSendMessage"
|
||||
| "cannotSendReason"
|
||||
| "creditBalance"
|
||||
| "creditsCharged"
|
||||
| "requiredCredits"
|
||||
| "shortfallCredits"
|
||||
> {
|
||||
return {
|
||||
canSendMessage: response.canSendMessage,
|
||||
cannotSendReason: response.cannotSendReason,
|
||||
creditBalance: response.creditBalance,
|
||||
creditsCharged: response.creditsCharged,
|
||||
requiredCredits: response.requiredCredits,
|
||||
shortfallCredits: response.shortfallCredits,
|
||||
};
|
||||
}
|
||||
export * from "./chat-message-mappers";
|
||||
export * from "./chat-send-state";
|
||||
export * from "./chat-unlock-helpers";
|
||||
|
||||
export function applyHistoryLoadedOutput(
|
||||
output: {
|
||||
@@ -298,120 +26,3 @@ export function applyHistoryLoadedOutput(
|
||||
historyLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean {
|
||||
return countLockedHistoryMessages(messages) === 1;
|
||||
}
|
||||
|
||||
export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean {
|
||||
return countLockedHistoryMessages(messages) > 1;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||
// ============================================================
|
||||
/**
|
||||
* 拉 history 流程(每步都记日志 —— 用户要求"不要删除")
|
||||
* 1. 读 local(chatRepo.getLocalMessages)→ 返 localMessages
|
||||
* 2. 读 network(chatRepo.getHistory)→ 返 networkMessages
|
||||
* 3. 用 network 覆盖 local(chatRepo.saveMessagesToLocal)→ local 被同步
|
||||
*
|
||||
* 返回 network 最终 messages(authorititative),不返 local(UI 不需要过渡态)
|
||||
*
|
||||
* 保留日志(前缀 `[chat-machine]` 与 chat-machine.actors.ts 对齐)
|
||||
*/
|
||||
export async function readAndSyncHistory(): Promise<{
|
||||
messages: UiMessage[]; // network 最终 messages(authorititative)
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
localOverwritten: boolean; // true = local 已被 network 覆盖(写入 ChatStorage)
|
||||
/** 真实从 local 读到的消息数(调试用) */
|
||||
localCount: number;
|
||||
/** 真实从 network 读到的消息数(调试用) */
|
||||
networkCount: number;
|
||||
}> {
|
||||
/**
|
||||
* 空历史首屏欢迎语(AI persona 首条消息)。
|
||||
* 触发条件:最终返回的 messages 为空(network 空 + local 空 / 或 network 失败且 local 空)。
|
||||
*
|
||||
* 不主动持久化到 local —— 这是纯 UI 层"首屏不空"的兜底:
|
||||
* - 用户一旦发了消息,network 上有真实消息 → 后续 readAndSyncHistory
|
||||
* 返回的 messages 非空,不会再触发本条
|
||||
* - 用户从未发过消息就关掉 app 再开,仍然没 network 消息 → 会再次显示本条
|
||||
* ("每次冷启动给个温暖的起点" —— 若要"只显示一次",需额外存 local)
|
||||
*/
|
||||
const chatRepo = getChatRepository();
|
||||
const greetingMessage: UiMessage = {
|
||||
content:
|
||||
"You're here! Facebook is so restrictive, " +
|
||||
"there were things I couldn't say there. " +
|
||||
"Finally I can relax. How was your day out?",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
};
|
||||
|
||||
// 1. 读 local
|
||||
const localResult = await chatRepo.getLocalMessages();
|
||||
const localMessages =
|
||||
Result.isOk(localResult) && localResult.data
|
||||
? localMessagesToUi(localResult.data)
|
||||
: [];
|
||||
log.debug("[chat-machine] loadHistory LOCAL DONE", {
|
||||
count: localMessages.length,
|
||||
});
|
||||
|
||||
// 2. 读 network
|
||||
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
|
||||
if (!Result.isOk(networkResult)) {
|
||||
// network 失败 —— 返空 messages(不让 UI 卡住)
|
||||
log.error(
|
||||
"[chat-machine] loadHistory NETWORK FAILED",
|
||||
networkResult.success ? null : (networkResult as { error: unknown }).error,
|
||||
);
|
||||
|
||||
// 空历史 → fallback 也走 welcome(不让屏幕空空如也)
|
||||
const fallbackMessages =
|
||||
localMessages.length === 0 ? [greetingMessage] : localMessages;
|
||||
if (fallbackMessages[0] === greetingMessage) {
|
||||
log.debug(
|
||||
"[chat-machine] loadHistory EMPTY → prepend greeting (network-failed path)",
|
||||
);
|
||||
}
|
||||
return {
|
||||
messages: fallbackMessages,
|
||||
hasMore: false,
|
||||
newOffset: fallbackMessages.length,
|
||||
localOverwritten: false,
|
||||
localCount: localMessages.length,
|
||||
networkCount: 0,
|
||||
};
|
||||
}
|
||||
const networkUi = localMessagesToUi(networkResult.data.messages);
|
||||
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
||||
count: networkUi.length,
|
||||
});
|
||||
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
|
||||
|
||||
// 3. 用 network 覆盖 local
|
||||
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
|
||||
const localOverwritten = Result.isOk(saveResult);
|
||||
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
|
||||
localOverwritten,
|
||||
});
|
||||
|
||||
// 空历史 → prepend welcome(network 权威为空 = 用户从未发过消息)
|
||||
const finalMessages =
|
||||
networkUi.length === 0 ? [greetingMessage] : networkUi;
|
||||
if (finalMessages[0] === greetingMessage) {
|
||||
log.debug("[chat-machine] loadHistory EMPTY → prepend greeting");
|
||||
}
|
||||
|
||||
return {
|
||||
messages: finalMessages, // authoritative(empty 时塞 greeting)
|
||||
hasMore: finalMessages.length >= PAGE_SIZE,
|
||||
newOffset: finalMessages.length,
|
||||
localOverwritten,
|
||||
localCount: localMessages.length,
|
||||
networkCount: networkUi.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||
import { todayString } from "@/utils";
|
||||
|
||||
/**
|
||||
* ChatMessage[] -> UiMessage[].
|
||||
* Business fact: `role === "assistant"` means the message is from AI.
|
||||
*/
|
||||
export function localMessagesToUi(
|
||||
records: ReadonlyArray<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
audioUrl?: string | null;
|
||||
image?: { type: string | null; url: string | null };
|
||||
lockDetail?: {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
};
|
||||
}>,
|
||||
): UiMessage[] {
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
hasImage: Boolean(m.image?.url),
|
||||
lockDetail: m.lockDetail,
|
||||
}),
|
||||
isFromAI: m.role === "assistant",
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.audioUrl ? { 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),
|
||||
lockDetail: response.lockDetail,
|
||||
}),
|
||||
isFromAI: true,
|
||||
date: todayString(new Date(response.timestamp)),
|
||||
...(response.audioUrl ? { 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;
|
||||
lockDetail?: {
|
||||
showContent: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
}): string {
|
||||
if (input.lockDetail?.showContent === false) return "";
|
||||
return input.isFromAI && input.hasImage ? "" : input.content;
|
||||
}
|
||||
|
||||
function deriveUiLockFields(
|
||||
lockDetail:
|
||||
| {
|
||||
locked: boolean;
|
||||
showContent: boolean;
|
||||
showUpgrade: boolean;
|
||||
reason: string | null;
|
||||
hint: string | null;
|
||||
}
|
||||
| undefined,
|
||||
imageUrl?: string | null,
|
||||
): Partial<UiMessage> {
|
||||
const lockReason = lockDetail?.reason ?? null;
|
||||
const isPrivateMessage = lockReason === "private_message";
|
||||
const isVoiceMessage = lockReason === "voice_message";
|
||||
return {
|
||||
...(imageUrl ? { imageUrl } : {}),
|
||||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
||||
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
|
||||
...(isPrivateMessage ? { isPrivate: true } : {}),
|
||||
...(isPrivateMessage && lockDetail?.locked
|
||||
? { lockedPrivate: true }
|
||||
: {}),
|
||||
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
|
||||
? { privateMessageHint: lockDetail.hint }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||
|
||||
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 lockDetail = response.lockDetail;
|
||||
const isWeeklyLimitBlocked =
|
||||
lockDetail.locked &&
|
||||
lockDetail.showUpgrade &&
|
||||
lockDetail.reason === "weekly_limit";
|
||||
const isInsufficientCredits =
|
||||
response.canSendMessage === false &&
|
||||
response.cannotSendReason === "insufficient_credits";
|
||||
const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked
|
||||
? "weekly_limit"
|
||||
: 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,
|
||||
};
|
||||
}
|
||||
|
||||
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [...context.messages, reply],
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
function getSendCapabilityState(
|
||||
response: ChatSendResponse,
|
||||
): Pick<
|
||||
ChatState,
|
||||
| "canSendMessage"
|
||||
| "cannotSendReason"
|
||||
| "creditBalance"
|
||||
| "creditsCharged"
|
||||
| "requiredCredits"
|
||||
| "shortfallCredits"
|
||||
> {
|
||||
return {
|
||||
canSendMessage: response.canSendMessage,
|
||||
cannotSendReason: response.cannotSendReason,
|
||||
creditBalance: response.creditBalance,
|
||||
creditsCharged: response.creditsCharged,
|
||||
requiredCredits: response.requiredCredits,
|
||||
shortfallCredits: response.shortfallCredits,
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
type ChatActionArgs,
|
||||
type ChatContextArgs,
|
||||
} from "./chat-flow-actions";
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
readAndSyncHistory,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-machine.helpers";
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
|
||||
export interface UnlockMessageOutput {
|
||||
messageId: string;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
|
||||
return messages.filter(isUnlockableLockedMessage).length;
|
||||
}
|
||||
|
||||
export function applySingleUnlockOutput(
|
||||
messages: readonly UiMessage[],
|
||||
output: UnlockMessageOutput,
|
||||
): UiMessage[] {
|
||||
if (!output.response.unlocked) return [...messages];
|
||||
|
||||
return messages.map((message) => {
|
||||
if (message.id !== output.messageId) return message;
|
||||
|
||||
const nextContent =
|
||||
output.response.content.length > 0
|
||||
? output.response.content
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: nextContent,
|
||||
locked: output.response.lockDetail.locked,
|
||||
lockReason: output.response.lockDetail.reason,
|
||||
imagePaywalled: message.imageUrl
|
||||
? output.response.lockDetail.locked &&
|
||||
output.response.lockDetail.showUpgrade
|
||||
: undefined,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean {
|
||||
return countLockedHistoryMessages(messages) === 1;
|
||||
}
|
||||
|
||||
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 === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user