314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import type { UiMessage } from "@/data/dto/chat";
|
||
import { chatRepository } from "@/data/repositories/chat_repository";
|
||
import type { IChatRepository } from "@/data/repositories/interfaces";
|
||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||
import { todayString, Result, Logger } from "@/utils";
|
||
|
||
import type { ChatState } from "./chat-state";
|
||
|
||
const log = new Logger("StoresChatChatMachineHelpers");
|
||
|
||
// ============================================================
|
||
// Constants
|
||
// ============================================================
|
||
/** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */
|
||
export const PAGE_SIZE = 50;
|
||
|
||
// ============================================================
|
||
// Repository injection
|
||
// ============================================================
|
||
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
|
||
export const chatRepo: IChatRepository = chatRepository;
|
||
|
||
// ============================================================
|
||
// 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),
|
||
}));
|
||
}
|
||
|
||
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 isPrivateMessage = lockDetail?.reason === "private_message";
|
||
return {
|
||
...(imageUrl ? { imageUrl } : {}),
|
||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
||
...(isPrivateMessage ? { isPrivate: true } : {}),
|
||
...(isPrivateMessage && lockDetail?.locked
|
||
? { lockedPrivate: true }
|
||
: {}),
|
||
...(isPrivateMessage && lockDetail?.hint
|
||
? { privateMessageHint: lockDetail.hint }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
export type HttpSendOutput = {
|
||
response: ChatSendResponse;
|
||
reply: UiMessage | null;
|
||
};
|
||
|
||
export function applyHttpSendOutput(
|
||
context: ChatState,
|
||
output: HttpSendOutput,
|
||
): Partial<ChatState> {
|
||
const { response, reply } = output;
|
||
|
||
const lockDetail = response.lockDetail;
|
||
const isMessageLimitBlocked =
|
||
lockDetail.locked &&
|
||
lockDetail.showUpgrade &&
|
||
lockDetail.reason === "daily_limit";
|
||
|
||
if (isMessageLimitBlocked) {
|
||
const lastMessage = context.messages[context.messages.length - 1];
|
||
const messages =
|
||
lastMessage && !lastMessage.isFromAI
|
||
? context.messages.slice(0, -1)
|
||
: context.messages;
|
||
|
||
return {
|
||
messages,
|
||
isReplyingAI: false,
|
||
upgradePromptVisible: true,
|
||
upgradeReason: "daily_limit",
|
||
upgradeHint: null,
|
||
upgradeDetail: normalizeLockDetail(lockDetail.detail),
|
||
};
|
||
}
|
||
|
||
if (!reply) {
|
||
return {
|
||
isReplyingAI: false,
|
||
};
|
||
}
|
||
|
||
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
|
||
return {
|
||
messages: [...context.messages, reply],
|
||
isReplyingAI: false,
|
||
upgradePromptVisible: false,
|
||
upgradeReason: null,
|
||
upgradeHint: null,
|
||
upgradeDetail: null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
messages: [...context.messages, reply],
|
||
isReplyingAI: false,
|
||
upgradePromptVisible: false,
|
||
upgradeReason: null,
|
||
upgradeHint: null,
|
||
upgradeDetail: null,
|
||
};
|
||
}
|
||
|
||
export function normalizeLockDetail(
|
||
detail: Record<string, unknown> | null,
|
||
): ChatState["upgradeDetail"] {
|
||
if (!detail) return null;
|
||
return {
|
||
...(typeof detail.type === "string" ? { type: detail.type } : {}),
|
||
...(typeof detail.usedToday === "number"
|
||
? { usedToday: detail.usedToday }
|
||
: {}),
|
||
...(typeof detail.usedTotal === "number"
|
||
? { usedTotal: detail.usedTotal }
|
||
: {}),
|
||
...(typeof detail.limit === "number" ? { limit: detail.limit } : {}),
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// 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 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,
|
||
});
|
||
|
||
// 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,
|
||
};
|
||
}
|