Files
cozsweet-frontend-nextjs/src/stores/chat/chat-machine.helpers.ts
T

314 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
// ============================================================
/**
* 拉 history 流程(每步都记日志 —— 用户要求"不要删除"
* 1. 读 localchatRepo.getLocalMessages)→ 返 localMessages
* 2. 读 networkchatRepo.getHistory)→ 返 networkMessages
* 3. 用 network 覆盖 localchatRepo.saveMessagesToLocal)→ local 被同步
*
* 返回 network 最终 messagesauthorititative),不返 localUI 不需要过渡态)
*
* 保留日志(前缀 `[chat-machine]` 与 chat-machine.actors.ts 对齐)
*/
export async function readAndSyncHistory(): Promise<{
messages: UiMessage[]; // network 最终 messagesauthorititative
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 welcomenetwork 权威为空 = 用户从未发过消息)
const finalMessages =
networkUi.length === 0 ? [greetingMessage] : networkUi;
if (finalMessages[0] === greetingMessage) {
log.debug("[chat-machine] loadHistory EMPTY → prepend greeting");
}
return {
messages: finalMessages, // authoritativeempty 时塞 greeting
hasMore: finalMessages.length >= PAGE_SIZE,
newOffset: finalMessages.length,
localOverwritten,
localCount: localMessages.length,
networkCount: networkUi.length,
};
}