Files
cozsweet-frontend-nextjs/src/stores/chat/chat-history-sync.ts
T
Codex 02f6964484
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
feat(chat): render commercial actions and persist greetings
2026-07-23 14:54:23 +08:00

213 lines
6.0 KiB
TypeScript

import type { UiMessage } from "@/stores/chat/ui-message";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { todayString } from "@/utils/date";
import { isAbortError } from "@/utils/abort";
import { getCharacterErrorCode } from "@/data/services/api";
import { CHAT_HISTORY_LIMIT } from "./helper/history";
import { localMessagesToUi } from "./helper/message-mappers";
const log = new Logger("StoresChatChatHistorySync");
export type ReadAndSyncHistoryOutput = {
/** Network-authoritative messages. Empty network history includes a UI greeting. */
messages: UiMessage[];
/** Display identities present in the local snapshot before the request. */
localDisplayIds: readonly string[];
/** True when network history was written back to local storage. */
localOverwritten: boolean;
localCount: number;
networkCount: number;
total: number;
limit: number;
};
export type LocalHistorySnapshotOutput = {
/** Local cached messages. Empty local history includes a UI greeting. */
messages: UiMessage[];
localCount: number;
};
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
export function createGreetingMessage(
characterId: string,
content: string,
): UiMessage {
return {
displayId: `greeting:${encodeURIComponent(characterId)}`,
content,
isFromAI: true,
date: todayString(),
isSynthetic: true,
};
}
export async function resolveHistoryCacheIdentity(
characterId: string,
): Promise<string | null> {
const result = await resolveChatConversationKey(characterId);
return Result.isOk(result) ? result.data : null;
}
export async function readLocalHistorySnapshot(
cacheIdentity: string | null,
characterId: string,
emptyChatGreeting: string,
): Promise<LocalHistorySnapshotOutput> {
const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage(
characterId,
emptyChatGreeting,
);
const localResult = cacheIdentity
? await chatRepo.getLocalMessages(cacheIdentity)
: null;
const localMessages =
localResult && Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [];
log.debug("[chat-machine] loadHistory LOCAL DONE", {
count: localMessages.length,
});
const snapshotMessages =
localMessages.length === 0 ? [greetingMessage] : localMessages;
if (snapshotMessages[0] === greetingMessage) {
log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting (local)");
}
return {
messages: snapshotMessages,
localCount: localMessages.length,
};
}
export async function syncNetworkHistory(
characterId: string,
localCount: number,
localDisplayIds: readonly string[],
cacheIdentity: string | null,
emptyChatGreeting: string,
signal?: AbortSignal,
): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage(
characterId,
emptyChatGreeting,
);
const openingResult = await chatRepo.saveOpeningMessage(
characterId,
emptyChatGreeting,
{ signal },
);
if (Result.isErr(openingResult)) {
if (isAbortError(openingResult.error)) throw openingResult.error;
log.warn("[chat-machine] opening message persistence skipped", {
characterId,
error: openingResult.error,
});
}
signal?.throwIfAborted();
const networkResult = await chatRepo.getHistory(
characterId,
CHAT_HISTORY_LIMIT,
0,
{ signal },
);
if (Result.isErr(networkResult)) {
if (isAbortError(networkResult.error)) throw networkResult.error;
if (getCharacterErrorCode(networkResult.error)) throw networkResult.error;
log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error,
});
return null;
}
signal?.throwIfAborted();
const networkUi = localMessagesToUi(networkResult.data.messages);
log.debug("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
if (cacheIdentity) {
void chatRepo.prefetchMediaForMessages(
networkResult.data.messages,
characterId,
cacheIdentity,
);
}
const saveResult = cacheIdentity
? await chatRepo.saveMessagesToLocal(
networkResult.data.messages,
cacheIdentity,
)
: null;
const localOverwritten = saveResult !== null && 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,
localDisplayIds,
localOverwritten,
localCount,
networkCount: networkUi.length,
total: networkResult.data.total,
limit: networkResult.data.limit,
};
}
/**
* 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(
characterId: string,
emptyChatGreeting: string,
signal?: AbortSignal,
): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot(
cacheIdentity,
characterId,
emptyChatGreeting,
);
const networkSnapshot = await syncNetworkHistory(
characterId,
localSnapshot.localCount,
localSnapshot.messages.map((message) => message.displayId),
cacheIdentity,
emptyChatGreeting,
signal,
);
if (networkSnapshot) return networkSnapshot;
return {
messages: localSnapshot.messages,
localDisplayIds: localSnapshot.messages.map(
(message) => message.displayId,
),
localOverwritten: false,
localCount: localSnapshot.localCount,
networkCount: 0,
total: 0,
limit: CHAT_HISTORY_LIMIT,
};
}