213 lines
6.0 KiB
TypeScript
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,
|
|
};
|
|
}
|