refactor(chat): tighten media cache boundaries
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export type ChatMediaKind = "image" | "audio";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user