refactor(chat): tighten media cache boundaries

This commit is contained in:
2026-06-30 16:12:02 +08:00
parent ccd2d6bd48
commit 9cff16e2d9
17 changed files with 388 additions and 146 deletions
+1
View File
@@ -0,0 +1 @@
export type ChatMediaKind = "image" | "audio";
+1
View File
@@ -4,6 +4,7 @@
export * from "./chat_history_response";
export * from "./chat_list_item";
export * from "./chat_media";
export * from "./chat_message";
export * from "./chat_send_response";
export * from "./chat_sync_data";
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
import {
buildChatMediaCacheKey,
fallbackChatMediaMimeType,
getMessageMediaTargets,
getSendResponseMediaTargets,
uniqueMediaTargets,
} from "../chat_media_cache_helpers";
function makeMessage(
overrides: Partial<Parameters<typeof ChatMessage.from>[0]> = {},
): ChatMessage {
return ChatMessage.from({
id: "msg-1",
role: "assistant",
type: "image",
content: "",
createdAt: "2026-06-30T00:00:00.000Z",
audioUrl: null,
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
}
function makeResponse(
overrides: Partial<Parameters<typeof ChatSendResponse.from>[0]> = {},
): ChatSendResponse {
return ChatSendResponse.from({
reply: "",
audioUrl: "",
messageId: "msg-1",
isGuest: false,
timestamp: 1782356425363,
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
}
describe("chat media cache helpers", () => {
it("builds owner-scoped media cache keys", () => {
expect(
buildChatMediaCacheKey({
ownerKey: "user:u1",
messageId: "msg-1",
kind: "image",
}),
).toBe("user:u1:msg-1:image");
});
it("extracts cacheable media targets from history messages", () => {
expect(
getMessageMediaTargets(
makeMessage({
audioUrl: "https://example.com/a.mp3",
image: { type: "jpg", url: "https://example.com/a.jpg" },
}),
),
).toEqual([
{
messageId: "msg-1",
kind: "image",
remoteUrl: "https://example.com/a.jpg",
},
{
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://example.com/a.mp3",
},
]);
});
it("ignores empty message ids and non-remote media urls", () => {
expect(
getSendResponseMediaTargets(
makeResponse({
messageId: "",
audioUrl: "https://example.com/a.mp3",
image: { type: "png", url: "data:image/png;base64,abc" },
}),
),
).toEqual([]);
});
it("deduplicates media targets", () => {
expect(
uniqueMediaTargets([
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
]),
).toEqual([
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
]);
});
it("provides fallback mime types", () => {
expect(fallbackChatMediaMimeType("image")).toBe("image/*");
expect(fallbackChatMediaMimeType("audio")).toBe("audio/mpeg");
});
});
@@ -0,0 +1,89 @@
import type {
ChatMediaKind,
ChatMessage,
ChatSendResponse,
} from "@/data/dto/chat";
import type { CacheRemoteChatMediaInput } from "@/data/repositories/interfaces";
import { isCacheableRemoteChatMediaUrl } from "@/lib/chat/chat_media_url";
export { isCacheableRemoteChatMediaUrl };
export function buildChatMediaCacheKey(input: {
ownerKey: string;
messageId: string;
kind: ChatMediaKind;
}): string {
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
}
export function getMessageMediaTargets(
message: ChatMessage,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
messageId: message.id,
kind: "image",
remoteUrl: message.image.url,
});
pushMediaTarget(targets, {
messageId: message.id,
kind: "audio",
remoteUrl: message.audioUrl,
});
return targets;
}
export function getSendResponseMediaTargets(
response: ChatSendResponse,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
messageId: response.messageId,
kind: "image",
remoteUrl: response.image.url,
});
pushMediaTarget(targets, {
messageId: response.messageId,
kind: "audio",
remoteUrl: response.audioUrl,
});
return targets;
}
export function fallbackChatMediaMimeType(kind: ChatMediaKind): string {
return kind === "image" ? "image/*" : "audio/mpeg";
}
export function uniqueMediaTargets(
targets: readonly CacheRemoteChatMediaInput[],
): CacheRemoteChatMediaInput[] {
const seen = new Set<string>();
return targets.filter((target) => {
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function pushMediaTarget(
targets: CacheRemoteChatMediaInput[],
input: {
messageId: string;
kind: ChatMediaKind;
remoteUrl: string | null;
},
): void {
if (input.messageId.length === 0) return;
if (
!input.remoteUrl ||
!isCacheableRemoteChatMediaUrl(input.remoteUrl)
) {
return;
}
targets.push({
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
});
}
+19 -92
View File
@@ -22,10 +22,18 @@ import {
LocalChatMediaStorage,
LocalChatStorage,
LocalMessage,
type LocalChatMediaKind,
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";
const log = new Logger("DataRepositoriesChatRepository");
@@ -180,7 +188,7 @@ export class ChatRepository implements IChatRepository {
input: CacheRemoteChatMediaInput,
): Promise<Result<LocalChatMediaRow>> {
return Result.wrap(async () => {
if (!isCacheableRemoteUrl(input.remoteUrl)) {
if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) {
throw new Error(`Unsupported media url: ${input.remoteUrl}`);
}
@@ -217,10 +225,12 @@ export class ChatRepository implements IChatRepository {
remoteUrl: input.remoteUrl,
blob,
mimeType:
blob.type || response.headers.get("content-type") || fallbackMimeType(input.kind),
blob.type ||
response.headers.get("content-type") ||
fallbackChatMediaMimeType(input.kind),
});
if (Result.isErr(saveResult)) throw saveResult.error;
void requestPersistentStorage();
void requestBrowserPersistentStorageOnce();
const savedResult = await this.mediaStorage.getMedia(cacheKey);
if (Result.isErr(savedResult)) throw savedResult.error;
@@ -333,7 +343,11 @@ export class ChatRepository implements IChatRepository {
ownerKey: string,
input: ChatMediaLookupInput,
): string {
return `${ownerKey}:${input.messageId}:${input.kind}`;
return buildChatMediaCacheKey({
ownerKey,
messageId: input.messageId,
kind: input.kind,
});
}
}
@@ -346,90 +360,3 @@ export const getChatRepository = createLazySingleton<IChatRepository>(
LocalChatMediaStorage.getInstance(),
),
);
function getMessageMediaTargets(
message: ChatMessage,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
messageId: message.id,
kind: "image",
remoteUrl: message.image.url,
});
pushMediaTarget(targets, {
messageId: message.id,
kind: "audio",
remoteUrl: message.audioUrl,
});
return targets;
}
function getSendResponseMediaTargets(
response: ChatSendResponse,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
messageId: response.messageId,
kind: "image",
remoteUrl: response.image.url,
});
pushMediaTarget(targets, {
messageId: response.messageId,
kind: "audio",
remoteUrl: response.audioUrl,
});
return targets;
}
function pushMediaTarget(
targets: CacheRemoteChatMediaInput[],
input: {
messageId: string;
kind: LocalChatMediaKind;
remoteUrl: string | null;
},
): void {
if (input.messageId.length === 0) return;
if (!input.remoteUrl || !isCacheableRemoteUrl(input.remoteUrl)) return;
targets.push({
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
});
}
function isCacheableRemoteUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
function fallbackMimeType(kind: LocalChatMediaKind): string {
return kind === "image" ? "image/*" : "audio/mpeg";
}
function uniqueMediaTargets(
targets: readonly CacheRemoteChatMediaInput[],
): CacheRemoteChatMediaInput[] {
const seen = new Set<string>();
return targets.filter((target) => {
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
let storagePersistenceRequested = false;
async function requestPersistentStorage(): Promise<void> {
if (storagePersistenceRequested) return;
storagePersistenceRequested = true;
if (typeof navigator === "undefined") return;
if (!navigator.storage?.persist) return;
try {
const persisted = await navigator.storage.persist();
log.debug("[chat-media] persistent storage requested", { persisted });
} catch (error) {
log.debug("[chat-media] persistent storage request skipped", { error });
}
}
@@ -14,19 +14,17 @@
import type { Result } from "@/utils";
import type {
ChatHistoryResponse,
ChatMediaKind,
ChatMessage,
ChatSendResponse,
UnlockHistoryResponse,
UnlockPrivateResponse,
} from "@/data/dto/chat";
import type {
LocalChatMediaKind,
LocalChatMediaRow,
} from "@/data/storage/chat";
import type { LocalChatMediaRow } from "@/data/storage/chat";
export interface ChatMediaLookupInput {
messageId: string;
kind: LocalChatMediaKind;
kind: ChatMediaKind;
}
export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
+2 -3
View File
@@ -12,6 +12,7 @@
*/
import Dexie, { type Table } from "dexie";
import type { ChatMediaKind } from "@/data/dto/chat";
import type {
ChatImageData,
ChatLockDetailData,
@@ -32,13 +33,11 @@ export interface LocalMessageRow {
sessionId: string;
}
export type LocalChatMediaKind = "image" | "audio";
export interface LocalChatMediaRow {
cacheKey: string;
ownerKey: string;
messageId: string;
kind: LocalChatMediaKind;
kind: ChatMediaKind;
remoteUrl: string;
blob: Blob;
mimeType: string;
@@ -1,20 +1,20 @@
"use client";
import { Result, type Result as ResultT } from "@/utils";
import type { ChatMediaKind } from "@/data/dto/chat";
import {
LocalChatDB,
type LocalChatMediaKind,
type LocalChatMediaRow,
} from "./local_chat_db";
export type { LocalChatMediaKind, LocalChatMediaRow };
export type { LocalChatMediaRow };
export interface SaveLocalChatMediaInput {
cacheKey: string;
ownerKey: string;
messageId: string;
kind: LocalChatMediaKind;
kind: ChatMediaKind;
remoteUrl: string;
blob: Blob;
mimeType: string;