refactor(chat): split machine and repository flows
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import { ChatMessage } from "@/data/dto/chat";
|
||||
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
export class ChatLocalMessageStore {
|
||||
constructor(private readonly localStorage: LocalChatStorage) {}
|
||||
|
||||
async markMessageUnlocked(
|
||||
messageId: string,
|
||||
content: string,
|
||||
lockDetail?: ChatLockDetailData,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
const localResult = await this.getMessages();
|
||||
if (Result.isErr(localResult)) {
|
||||
throw localResult.error;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const updatedMessages = localResult.data.map((message) => {
|
||||
if (message.id !== messageId) return message;
|
||||
changed = true;
|
||||
return ChatMessage.from({
|
||||
...message.toJson(),
|
||||
content,
|
||||
lockDetail: lockDetail ?? {
|
||||
...message.lockDetail,
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
reason: null,
|
||||
hint: null,
|
||||
detail: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
const saveResult = await this.saveMessages(updatedMessages);
|
||||
if (Result.isErr(saveResult)) {
|
||||
throw saveResult.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveMessage(message: ChatMessage): Promise<Result<void>> {
|
||||
return this.localStorage.saveMessage(this._chatToLocal(message));
|
||||
}
|
||||
|
||||
async saveMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
): Promise<Result<void>> {
|
||||
const cleared = await this.localStorage.clearAll();
|
||||
if (!cleared.success) {
|
||||
return Result.err(cleared.error);
|
||||
}
|
||||
return this.localStorage.saveMessages(
|
||||
messages.map((message) => this._chatToLocal(message)),
|
||||
);
|
||||
}
|
||||
|
||||
async getMessages(): Promise<Result<ChatMessage[]>> {
|
||||
const result = await this.localStorage.getAllMessages();
|
||||
if (!result.success) {
|
||||
return Result.err(result.error);
|
||||
}
|
||||
return Result.ok(result.data.map((message) => this._localToChat(message)));
|
||||
}
|
||||
|
||||
async clearMessages(): Promise<Result<void>> {
|
||||
return this.localStorage.clearAll();
|
||||
}
|
||||
|
||||
async getMessageCount(): Promise<Result<number>> {
|
||||
return this.localStorage.getMessageCount();
|
||||
}
|
||||
|
||||
private _chatToLocal(message: ChatMessage): LocalMessage {
|
||||
return LocalMessage.from({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
audioUrl: message.audioUrl,
|
||||
image: message.image,
|
||||
lockDetail: message.lockDetail,
|
||||
sessionId: "",
|
||||
});
|
||||
}
|
||||
|
||||
private _localToChat(local: LocalMessage): ChatMessage {
|
||||
return ChatMessage.from({
|
||||
id: local.id,
|
||||
role: local.role,
|
||||
type: local.type,
|
||||
content: local.content,
|
||||
createdAt: local.createdAt,
|
||||
audioUrl: local.audioUrl,
|
||||
image: local.image,
|
||||
lockDetail: local.lockDetail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
|
||||
import {
|
||||
LocalChatMediaStorage,
|
||||
type LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage";
|
||||
import { Logger, Result } from "@/utils";
|
||||
import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
} from "@/data/repositories/interfaces";
|
||||
|
||||
import {
|
||||
buildChatMediaCacheKey,
|
||||
fallbackChatMediaMimeType,
|
||||
getMessageMediaTargets,
|
||||
getSendResponseMediaTargets,
|
||||
isCacheableRemoteChatMediaUrl,
|
||||
uniqueMediaTargets,
|
||||
} from "./chat_media_cache_helpers";
|
||||
|
||||
const log = new Logger("DataRepositoriesChatMediaCacheCoordinator");
|
||||
|
||||
export class ChatMediaCacheCoordinator {
|
||||
constructor(private readonly mediaStorage: LocalChatMediaStorage) {}
|
||||
|
||||
async getCachedMedia(
|
||||
input: ChatMediaLookupInput,
|
||||
): Promise<Result<LocalChatMediaRow | null>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
||||
for (const ownerKey of ownerKeys) {
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
if (result.data) return result.data;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return Result.wrap(async () => {
|
||||
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
|
||||
throw new Error(`Unsupported media url: ${input.remoteUrl}`);
|
||||
}
|
||||
|
||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
||||
const ownerKey = ownerKeys[0] ?? "anonymous";
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
for (const lookupOwnerKey of ownerKeys) {
|
||||
const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input);
|
||||
const existingResult = await this.mediaStorage.getMedia(lookupCacheKey);
|
||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||
if (existingResult.data) return existingResult.data;
|
||||
}
|
||||
|
||||
const response = await fetch(input.remoteUrl, {
|
||||
method: "GET",
|
||||
cache: "force-cache",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Media download failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (blob.size <= 0) {
|
||||
throw new Error("Media download returned an empty blob.");
|
||||
}
|
||||
|
||||
const saveResult = await this.mediaStorage.saveMedia({
|
||||
cacheKey,
|
||||
ownerKey,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
remoteUrl: input.remoteUrl,
|
||||
blob,
|
||||
mimeType:
|
||||
blob.type ||
|
||||
response.headers.get("content-type") ||
|
||||
fallbackChatMediaMimeType(input.kind),
|
||||
});
|
||||
if (Result.isErr(saveResult)) throw saveResult.error;
|
||||
void requestBrowserPersistentStorageOnce();
|
||||
|
||||
const savedResult = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(savedResult)) throw savedResult.error;
|
||||
if (!savedResult.data) {
|
||||
throw new Error("Cached media could not be read after save.");
|
||||
}
|
||||
return savedResult.data;
|
||||
});
|
||||
}
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(
|
||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||
);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
||||
}
|
||||
|
||||
private async _prefetchMediaTargets(
|
||||
targets: readonly CacheRemoteChatMediaInput[],
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
for (const target of uniqueMediaTargets(targets)) {
|
||||
const result = await this.cacheRemoteMedia(target);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn("[chat-media] prefetch failed", {
|
||||
messageId: target.messageId,
|
||||
kind: target.kind,
|
||||
remoteUrl: target.remoteUrl,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _resolveMediaOwnerKeys(): Promise<string[]> {
|
||||
const ownerKeys: string[] = [];
|
||||
const userIdResult = await UserStorage.getInstance().getUserId();
|
||||
if (
|
||||
Result.isOk(userIdResult) &&
|
||||
userIdResult.data !== null &&
|
||||
userIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`user:${userIdResult.data}`);
|
||||
}
|
||||
|
||||
const deviceIdResult = await AuthStorage.getInstance().getDeviceId();
|
||||
if (
|
||||
Result.isOk(deviceIdResult) &&
|
||||
deviceIdResult.data !== null &&
|
||||
deviceIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`device:${deviceIdResult.data}`);
|
||||
}
|
||||
|
||||
ownerKeys.push("anonymous");
|
||||
return Array.from(new Set(ownerKeys));
|
||||
}
|
||||
|
||||
private _buildMediaCacheKey(
|
||||
ownerKey: string,
|
||||
input: ChatMediaLookupInput,
|
||||
): string {
|
||||
return buildChatMediaCacheKey({
|
||||
ownerKey,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatSendResponse,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
export class ChatRemoteDataSource {
|
||||
constructor(private readonly api: ChatApi) {}
|
||||
|
||||
async sendMessage(
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const request = SendMessageRequest.from({
|
||||
message,
|
||||
image: options?.image ?? "",
|
||||
useWebSocket: options?.useWebSocket ?? false,
|
||||
});
|
||||
return await this.api.sendMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<Result<ChatHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.getHistory(limit, offset));
|
||||
}
|
||||
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
||||
);
|
||||
}
|
||||
|
||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.unlockHistory());
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { ChatApi, chatApi } from "@/data/services/api";
|
||||
import {
|
||||
import type {
|
||||
ChatHistoryResponse,
|
||||
ChatMessage,
|
||||
ChatSendResponse,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Logger, Result } from "@/utils";
|
||||
import { chatApi } from "@/data/services/api";
|
||||
import {
|
||||
LocalChatMediaStorage,
|
||||
LocalChatStorage,
|
||||
type LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import {
|
||||
LocalChatMediaStorage,
|
||||
LocalChatStorage,
|
||||
LocalMessage,
|
||||
type LocalChatMediaRow,
|
||||
} from "@/data/storage/chat";
|
||||
import {
|
||||
buildChatMediaCacheKey,
|
||||
fallbackChatMediaMimeType,
|
||||
getMessageMediaTargets,
|
||||
getSendResponseMediaTargets,
|
||||
isCacheableRemoteChatMediaUrl,
|
||||
uniqueMediaTargets,
|
||||
} from "./chat_media_cache_helpers";
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage";
|
||||
import type { Result } from "@/utils";
|
||||
|
||||
const log = new Logger("DataRepositoriesChatRepository");
|
||||
import { ChatLocalMessageStore } from "./chat_local_message_store";
|
||||
import { ChatMediaCacheCoordinator } from "./chat_media_cache_coordinator";
|
||||
import { ChatRemoteDataSource } from "./chat_remote_data_source";
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
export class ChatRepository implements IChatRepository {
|
||||
constructor(
|
||||
private readonly api: ChatApi,
|
||||
private readonly localStorage: LocalChatStorage,
|
||||
private readonly mediaStorage: LocalChatMediaStorage,
|
||||
private readonly remote: ChatRemoteDataSource,
|
||||
private readonly localMessages: ChatLocalMessageStore,
|
||||
private readonly mediaCache: ChatMediaCacheCoordinator,
|
||||
) {}
|
||||
|
||||
// ============ 远程操作 ============
|
||||
@@ -52,14 +40,7 @@ export class ChatRepository implements IChatRepository {
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const request = SendMessageRequest.from({
|
||||
message,
|
||||
image: options?.image ?? "",
|
||||
useWebSocket: options?.useWebSocket ?? false,
|
||||
});
|
||||
return await this.api.sendMessage(request);
|
||||
});
|
||||
return this.remote.sendMessage(message, options);
|
||||
}
|
||||
|
||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||
@@ -67,21 +48,19 @@ export class ChatRepository implements IChatRepository {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<Result<ChatHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.getHistory(limit, offset));
|
||||
return this.remote.getHistory(limit, offset);
|
||||
}
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
||||
);
|
||||
return this.remote.unlockPrivateMessage(messageId);
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.unlockHistory());
|
||||
return this.remote.unlockHistory();
|
||||
}
|
||||
|
||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||
@@ -90,47 +69,18 @@ export class ChatRepository implements IChatRepository {
|
||||
content: string,
|
||||
lockDetail?: ChatLockDetailData,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
const localResult = await this.getLocalMessages();
|
||||
if (Result.isErr(localResult)) {
|
||||
throw localResult.error;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const updatedMessages = localResult.data.map((message) => {
|
||||
if (message.id !== messageId) return message;
|
||||
changed = true;
|
||||
return ChatMessage.from({
|
||||
...message.toJson(),
|
||||
content,
|
||||
lockDetail: lockDetail ?? {
|
||||
...message.lockDetail,
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
reason: null,
|
||||
hint: null,
|
||||
detail: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
const saveResult = await this.saveMessagesToLocal(updatedMessages);
|
||||
if (Result.isErr(saveResult)) {
|
||||
throw saveResult.error;
|
||||
}
|
||||
});
|
||||
return this.localMessages.markMessageUnlocked(
|
||||
messageId,
|
||||
content,
|
||||
lockDetail,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 本地(Dexie)操作 ============
|
||||
|
||||
/** 把一条消息写入本地存储。 */
|
||||
async saveMessageToLocal(message: ChatMessage): Promise<Result<void>> {
|
||||
return this._mapLocalStorageResult(
|
||||
this.localStorage.saveMessage(this._chatToLocal(message)),
|
||||
);
|
||||
return this.localMessages.saveMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,27 +90,17 @@ export class ChatRepository implements IChatRepository {
|
||||
async saveMessagesToLocal(
|
||||
messages: readonly ChatMessage[],
|
||||
): Promise<Result<void>> {
|
||||
const cleared = await this.localStorage.clearAll();
|
||||
if (!cleared.success) {
|
||||
return Result.err(cleared.error);
|
||||
}
|
||||
return this._mapLocalStorageResult(
|
||||
this.localStorage.saveMessages(messages.map((m) => this._chatToLocal(m))),
|
||||
);
|
||||
return this.localMessages.saveMessages(messages);
|
||||
}
|
||||
|
||||
/** 读取所有本地消息,按 dbId 升序。 */
|
||||
async getLocalMessages(): Promise<Result<ChatMessage[]>> {
|
||||
const result = await this.localStorage.getAllMessages();
|
||||
if (!result.success) {
|
||||
return Result.err(result.error);
|
||||
}
|
||||
return Result.ok(result.data.map((lm) => this._localToChat(lm)));
|
||||
return this.localMessages.getMessages();
|
||||
}
|
||||
|
||||
/** 清空本地消息。 */
|
||||
async clearLocalMessages(): Promise<Result<void>> {
|
||||
return this._mapLocalStorageResult(this.localStorage.clearAll());
|
||||
return this.localMessages.clearMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +108,7 @@ export class ChatRepository implements IChatRepository {
|
||||
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
||||
*/
|
||||
async getLocalMessageCount(): Promise<Result<number>> {
|
||||
return this._mapLocalStorageResult(this.localStorage.getMessageCount());
|
||||
return this.localMessages.getMessageCount();
|
||||
}
|
||||
|
||||
// ============ 本地媒体缓存 ============
|
||||
@@ -176,182 +116,25 @@ export class ChatRepository implements IChatRepository {
|
||||
async getCachedMedia(
|
||||
input: ChatMediaLookupInput,
|
||||
): Promise<Result<LocalChatMediaRow | null>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
||||
for (const ownerKey of ownerKeys) {
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
if (result.data) return result.data;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return this.mediaCache.getCachedMedia(input);
|
||||
}
|
||||
|
||||
async cacheRemoteMedia(
|
||||
input: CacheRemoteChatMediaInput,
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return Result.wrap(async () => {
|
||||
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
|
||||
throw new Error(`Unsupported media url: ${input.remoteUrl}`);
|
||||
}
|
||||
|
||||
const ownerKeys = await this._resolveMediaOwnerKeys();
|
||||
const ownerKey = ownerKeys[0] ?? "anonymous";
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
for (const lookupOwnerKey of ownerKeys) {
|
||||
const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input);
|
||||
const existingResult = await this.mediaStorage.getMedia(lookupCacheKey);
|
||||
if (Result.isErr(existingResult)) throw existingResult.error;
|
||||
if (existingResult.data) return existingResult.data;
|
||||
}
|
||||
|
||||
const response = await fetch(input.remoteUrl, {
|
||||
method: "GET",
|
||||
cache: "force-cache",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Media download failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (blob.size <= 0) {
|
||||
throw new Error("Media download returned an empty blob.");
|
||||
}
|
||||
|
||||
const saveResult = await this.mediaStorage.saveMedia({
|
||||
cacheKey,
|
||||
ownerKey,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
remoteUrl: input.remoteUrl,
|
||||
blob,
|
||||
mimeType:
|
||||
blob.type ||
|
||||
response.headers.get("content-type") ||
|
||||
fallbackChatMediaMimeType(input.kind),
|
||||
});
|
||||
if (Result.isErr(saveResult)) throw saveResult.error;
|
||||
void requestBrowserPersistentStorageOnce();
|
||||
|
||||
const savedResult = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(savedResult)) throw savedResult.error;
|
||||
if (!savedResult.data) {
|
||||
throw new Error("Cached media could not be read after save.");
|
||||
}
|
||||
return savedResult.data;
|
||||
});
|
||||
return this.mediaCache.cacheRemoteMedia(input);
|
||||
}
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(
|
||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||
);
|
||||
return this.mediaCache.prefetchMediaForMessages(messages);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
||||
}
|
||||
|
||||
// ============ 私有助手 ============
|
||||
|
||||
/** ChatMessage → LocalMessage:丢弃 wire 字段,注入 storage 层的 sessionId=""。 */
|
||||
private _chatToLocal(message: ChatMessage): LocalMessage {
|
||||
return LocalMessage.from({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
audioUrl: message.audioUrl,
|
||||
image: message.image,
|
||||
lockDetail: message.lockDetail,
|
||||
sessionId: "",
|
||||
});
|
||||
}
|
||||
|
||||
/** LocalMessage → ChatMessage:丢弃 storage 层的 sessionId。 */
|
||||
private _localToChat(local: LocalMessage): ChatMessage {
|
||||
return ChatMessage.from({
|
||||
id: local.id,
|
||||
role: local.role,
|
||||
type: local.type,
|
||||
content: local.content,
|
||||
createdAt: local.createdAt,
|
||||
audioUrl: local.audioUrl,
|
||||
image: local.image,
|
||||
lockDetail: local.lockDetail,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 storage 层 `Result<T>` 透传:仓库与 storage 现在共享同一全局
|
||||
* `Result<T>`(来自 `@/utils/result`),不需要任何形状转换。
|
||||
* 该方法保留仅为未来万一又出现差异化时方便快速插入适配。
|
||||
*/
|
||||
private async _mapLocalStorageResult<T>(
|
||||
promise: Promise<Result<T>>,
|
||||
): Promise<Result<T>> {
|
||||
return await promise;
|
||||
}
|
||||
|
||||
private async _prefetchMediaTargets(
|
||||
targets: readonly CacheRemoteChatMediaInput[],
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
for (const target of uniqueMediaTargets(targets)) {
|
||||
const result = await this.cacheRemoteMedia(target);
|
||||
if (Result.isErr(result)) {
|
||||
log.warn("[chat-media] prefetch failed", {
|
||||
messageId: target.messageId,
|
||||
kind: target.kind,
|
||||
remoteUrl: target.remoteUrl,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _resolveMediaOwnerKeys(): Promise<string[]> {
|
||||
const ownerKeys: string[] = [];
|
||||
const userIdResult = await UserStorage.getInstance().getUserId();
|
||||
if (
|
||||
Result.isOk(userIdResult) &&
|
||||
userIdResult.data !== null &&
|
||||
userIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`user:${userIdResult.data}`);
|
||||
}
|
||||
|
||||
const deviceIdResult = await AuthStorage.getInstance().getDeviceId();
|
||||
if (
|
||||
Result.isOk(deviceIdResult) &&
|
||||
deviceIdResult.data !== null &&
|
||||
deviceIdResult.data.length > 0
|
||||
) {
|
||||
ownerKeys.push(`device:${deviceIdResult.data}`);
|
||||
}
|
||||
|
||||
ownerKeys.push("anonymous");
|
||||
return Array.from(new Set(ownerKeys));
|
||||
}
|
||||
|
||||
private _buildMediaCacheKey(
|
||||
ownerKey: string,
|
||||
input: ChatMediaLookupInput,
|
||||
): string {
|
||||
return buildChatMediaCacheKey({
|
||||
ownerKey,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
});
|
||||
return this.mediaCache.prefetchMediaForSendResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,8 +142,8 @@ export class ChatRepository implements IChatRepository {
|
||||
export const getChatRepository = createLazySingleton<IChatRepository>(
|
||||
() =>
|
||||
new ChatRepository(
|
||||
chatApi,
|
||||
LocalChatStorage.getInstance(),
|
||||
LocalChatMediaStorage.getInstance(),
|
||||
new ChatRemoteDataSource(chatApi),
|
||||
new ChatLocalMessageStore(LocalChatStorage.getInstance()),
|
||||
new ChatMediaCacheCoordinator(LocalChatMediaStorage.getInstance()),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user