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 { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
import { Logger, Result } from "@/utils";
|
import { Logger, Result } from "@/utils";
|
||||||
|
|
||||||
|
import { readAndSyncHistory } from "./chat-history-sync";
|
||||||
import {
|
import {
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
localMessagesToUi,
|
localMessagesToUi,
|
||||||
readAndSyncHistory,
|
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatHistoryFlow");
|
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 type { UiMessage } 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 { ChatState, ChatUpgradeReason } from "./chat-state";
|
import type { ChatState } from "./chat-state";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachineHelpers");
|
// History pagination size used by the history actors.
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Constants
|
|
||||||
// ============================================================
|
|
||||||
/** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */
|
|
||||||
export const PAGE_SIZE = 50;
|
export const PAGE_SIZE = 50;
|
||||||
|
|
||||||
// ============================================================
|
export * from "./chat-message-mappers";
|
||||||
// DTO ↔ UiMessage 映射
|
export * from "./chat-send-state";
|
||||||
// ============================================================
|
export * from "./chat-unlock-helpers";
|
||||||
/**
|
|
||||||
* 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 function applyHistoryLoadedOutput(
|
export function applyHistoryLoadedOutput(
|
||||||
output: {
|
output: {
|
||||||
@@ -298,120 +26,3 @@ export function applyHistoryLoadedOutput(
|
|||||||
historyLoaded: true,
|
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 ChatActionArgs,
|
||||||
type ChatContextArgs,
|
type ChatContextArgs,
|
||||||
} from "./chat-flow-actions";
|
} from "./chat-flow-actions";
|
||||||
|
import { readAndSyncHistory } from "./chat-history-sync";
|
||||||
import {
|
import {
|
||||||
applySingleUnlockOutput,
|
applySingleUnlockOutput,
|
||||||
countLockedHistoryMessages,
|
countLockedHistoryMessages,
|
||||||
readAndSyncHistory,
|
|
||||||
type UnlockMessageOutput,
|
type UnlockMessageOutput,
|
||||||
} from "./chat-machine.helpers";
|
} 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,45 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hydratePlansState(
|
||||||
|
plans: PaymentPlan[],
|
||||||
|
selectedPlanId: string,
|
||||||
|
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
|
||||||
|
const nextSelectedPlanId = selectedPlanId || getDefaultPlanId(plans);
|
||||||
|
return {
|
||||||
|
plans,
|
||||||
|
selectedPlanId: nextSelectedPlanId,
|
||||||
|
autoRenew: defaultAutoRenewForPlan(nextSelectedPlanId, plans),
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshPlansState(
|
||||||
|
plans: PaymentPlan[],
|
||||||
|
selectedPlanId: string,
|
||||||
|
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
|
||||||
|
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId)
|
||||||
|
? selectedPlanId
|
||||||
|
: getDefaultPlanId(plans);
|
||||||
|
return {
|
||||||
|
plans,
|
||||||
|
selectedPlanId: nextSelectedPlanId,
|
||||||
|
autoRenew: defaultAutoRenewForPlan(nextSelectedPlanId, plans),
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPlanState(
|
||||||
|
planId: string,
|
||||||
|
plans: readonly PaymentPlan[],
|
||||||
|
): Partial<PaymentState> {
|
||||||
|
return {
|
||||||
|
...resetOrderState(),
|
||||||
|
selectedPlanId: planId,
|
||||||
|
autoRenew: defaultAutoRenewForPlan(planId, plans),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resetOrderState(): Partial<PaymentState> {
|
export function resetOrderState(): Partial<PaymentState> {
|
||||||
return {
|
return {
|
||||||
currentOrderId: null,
|
currentOrderId: null,
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
refreshPaymentPlansActor,
|
refreshPaymentPlansActor,
|
||||||
} from "./payment-machine.actors";
|
} from "./payment-machine.actors";
|
||||||
import {
|
import {
|
||||||
defaultAutoRenewForPlan,
|
|
||||||
getDefaultPlanId,
|
|
||||||
hasOrderPollingTimedOut,
|
hasOrderPollingTimedOut,
|
||||||
|
hydratePlansState,
|
||||||
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
||||||
|
refreshPlansState,
|
||||||
resetOrderState,
|
resetOrderState,
|
||||||
|
selectPlanState,
|
||||||
toPaymentErrorMessage,
|
toPaymentErrorMessage,
|
||||||
} from "./payment-machine.helpers";
|
} from "./payment-machine.helpers";
|
||||||
|
|
||||||
@@ -58,14 +59,7 @@ export const paymentMachine = setup({
|
|||||||
target: "refreshingPlans",
|
target: "refreshingPlans",
|
||||||
actions: assign(({ context, event }) => {
|
actions: assign(({ context, event }) => {
|
||||||
const plans = event.output?.plans ?? [];
|
const plans = event.output?.plans ?? [];
|
||||||
const selectedPlanId =
|
return hydratePlansState(plans, context.selectedPlanId);
|
||||||
context.selectedPlanId || getDefaultPlanId(plans);
|
|
||||||
return {
|
|
||||||
plans,
|
|
||||||
selectedPlanId,
|
|
||||||
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
|
|
||||||
errorMessage: null,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,14 +79,7 @@ export const paymentMachine = setup({
|
|||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ context, event }) => {
|
actions: assign(({ context, event }) => {
|
||||||
const plans = event.output.plans;
|
const plans = event.output.plans;
|
||||||
const selectedPlanId =
|
return hydratePlansState(plans, context.selectedPlanId);
|
||||||
context.selectedPlanId || getDefaultPlanId(plans);
|
|
||||||
return {
|
|
||||||
plans,
|
|
||||||
selectedPlanId,
|
|
||||||
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
|
|
||||||
errorMessage: null,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
@@ -111,16 +98,7 @@ export const paymentMachine = setup({
|
|||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ context, event }) => {
|
actions: assign(({ context, event }) => {
|
||||||
const plans = event.output.plans;
|
const plans = event.output.plans;
|
||||||
const selectedPlanId =
|
return refreshPlansState(plans, context.selectedPlanId);
|
||||||
plans.some((plan) => plan.planId === context.selectedPlanId)
|
|
||||||
? context.selectedPlanId
|
|
||||||
: getDefaultPlanId(plans);
|
|
||||||
return {
|
|
||||||
plans,
|
|
||||||
selectedPlanId,
|
|
||||||
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
|
|
||||||
errorMessage: null,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
@@ -137,9 +115,7 @@ export const paymentMachine = setup({
|
|||||||
PaymentInit: "refreshingPlans",
|
PaymentInit: "refreshingPlans",
|
||||||
PaymentPlanSelected: {
|
PaymentPlanSelected: {
|
||||||
actions: assign(({ context, event }) => ({
|
actions: assign(({ context, event }) => ({
|
||||||
...resetOrderState(),
|
...selectPlanState(event.planId, context.plans),
|
||||||
selectedPlanId: event.planId,
|
|
||||||
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
PaymentPayChannelChanged: {
|
PaymentPayChannelChanged: {
|
||||||
@@ -283,9 +259,7 @@ export const paymentMachine = setup({
|
|||||||
PaymentPlanSelected: {
|
PaymentPlanSelected: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign(({ context, event }) => ({
|
actions: assign(({ context, event }) => ({
|
||||||
...resetOrderState(),
|
...selectPlanState(event.planId, context.plans),
|
||||||
selectedPlanId: event.planId,
|
|
||||||
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
PaymentPayChannelChanged: {
|
PaymentPayChannelChanged: {
|
||||||
|
|||||||
Reference in New Issue
Block a user