feat(media): implement caching for chat media and update components to use cached media

This commit is contained in:
2026-06-30 15:59:33 +08:00
parent 78d8aae22e
commit ccd2d6bd48
15 changed files with 591 additions and 42 deletions
@@ -30,6 +30,13 @@
transform: scale(1.08);
}
.nativePaywallImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.paywallScrim {
position: absolute;
inset: 0;
@@ -1,4 +1,5 @@
"use client";
/* eslint-disable @next/next/no-img-element */
/**
* FullscreenImageViewer 全屏图片查看器
*
@@ -13,9 +14,11 @@ import { ChevronLeft } from "lucide-react";
import Image from "next/image";
import { useEffect } from "react";
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps {
messageId?: string;
imageUrl: string;
imagePaywalled?: boolean;
onUnlockImagePaywall?: () => void;
@@ -23,11 +26,18 @@ export interface FullscreenImageViewerProps {
}
export function FullscreenImageViewer({
messageId,
imageUrl,
imagePaywalled = false,
onUnlockImagePaywall,
onClose,
}: FullscreenImageViewerProps) {
const { mediaUrl } = useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -36,9 +46,13 @@ export function FullscreenImageViewer({
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
const src = imageUrl.startsWith("data:") || imageUrl.startsWith("http")
? imageUrl
: `data:image/png;base64,${imageUrl}`;
const sourceUrl = mediaUrl || imageUrl;
const src = sourceUrl.startsWith("data:") ||
sourceUrl.startsWith("http") ||
sourceUrl.startsWith("blob:")
? sourceUrl
: `data:image/png;base64,${sourceUrl}`;
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
if (imagePaywalled) {
return (
@@ -47,13 +61,21 @@ export function FullscreenImageViewer({
role="dialog"
aria-label="Locked fullscreen image"
>
<Image
src={src}
alt=""
fill
sizes="(max-width: 540px) 100vw, 540px"
className={styles.paywallImage}
/>
{shouldUseNativeImage ? (
<img
src={src}
alt=""
className={`${styles.nativePaywallImage} ${styles.paywallImage}`}
/>
) : (
<Image
src={src}
alt=""
fill
sizes="(max-width: 540px) 100vw, 540px"
className={styles.paywallImage}
/>
)}
<div className={styles.paywallScrim} aria-hidden="true" />
<button
type="button"
@@ -81,18 +103,35 @@ export function FullscreenImageViewer({
role="dialog"
aria-label="Fullscreen image"
>
<Image
src={src}
alt=""
width={800}
height={800}
className={styles.viewerImage}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
(e.currentTarget.parentElement as HTMLElement).innerHTML =
'<div class="error">🖼</div>';
}}
/>
{shouldUseNativeImage ? (
<img
src={src}
alt=""
className={styles.viewerImage}
onError={(e) => {
e.currentTarget.style.display = "none";
(e.currentTarget.parentElement as HTMLElement).innerHTML =
'<div class="error">🖼</div>';
}}
/>
) : (
<Image
src={src}
alt=""
width={800}
height={800}
className={styles.viewerImage}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
(e.currentTarget.parentElement as HTMLElement).innerHTML =
'<div class="error">🖼</div>';
}}
/>
)}
</div>
);
}
function isBrowserLocalImageSrc(src: string): boolean {
return src.startsWith("blob:") || src.startsWith("data:");
}
+20 -1
View File
@@ -1,4 +1,5 @@
"use client";
/* eslint-disable @next/next/no-img-element */
/**
* ImageBubble 图片气泡
*
@@ -13,6 +14,7 @@ import { useState } from "react";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
@@ -28,9 +30,15 @@ export function ImageBubble({
}: ImageBubbleProps) {
const router = useRouter();
const [error, setError] = useState(false);
const { mediaUrl } = useCachedChatMediaUrl({
messageId,
remoteUrl: imageUrl,
kind: "image",
});
const src = decodeBase64Image(imageUrl);
const src = decodeBase64Image(mediaUrl || imageUrl);
const canOpen = Boolean(messageId);
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
const openImage = () => {
if (!messageId) return;
@@ -54,6 +62,13 @@ export function ImageBubble({
>
{error ? (
<div className={styles.errorFallback}>🖼</div>
) : shouldUseNativeImage ? (
<img
className={styles.image}
src={src}
alt=""
onError={() => setError(true)}
/>
) : (
<Image
className={styles.image}
@@ -80,3 +95,7 @@ function decodeBase64Image(uri: string): string {
// 裸 base64 字符串 → 包装成 data URIPNG 兜底)
return `data:image/png;base64,${uri}`;
}
function isBrowserLocalImageSrc(src: string): boolean {
return src.startsWith("blob:") || src.startsWith("data:");
}
@@ -73,6 +73,7 @@ export function MessageContent({
)}
{hasAudio && audioUrl ? (
<VoiceBubble
messageId={messageId}
audioUrl={audioUrl}
isFromAI={isFromAI}
locked={isLockedVoiceMessage}
+9 -1
View File
@@ -3,9 +3,11 @@
import { LockKeyhole, Pause, Play } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
import styles from "./voice-bubble.module.css";
export interface VoiceBubbleProps {
messageId?: string;
audioUrl: string;
isFromAI: boolean;
locked?: boolean;
@@ -15,6 +17,7 @@ export interface VoiceBubbleProps {
}
export function VoiceBubble({
messageId,
audioUrl,
isFromAI,
locked = false,
@@ -25,6 +28,11 @@ export function VoiceBubble({
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasError, setHasError] = useState(false);
const { mediaUrl } = useCachedChatMediaUrl({
messageId,
remoteUrl: audioUrl,
kind: "audio",
});
useEffect(() => {
const audio = audioRef.current;
@@ -117,7 +125,7 @@ export function VoiceBubble({
</>
) : null}
</div>
{!locked ? <audio ref={audioRef} src={audioUrl} preload="metadata" /> : null}
{!locked ? <audio ref={audioRef} src={mediaUrl} preload="metadata" /> : null}
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
import type { LocalChatMediaKind } from "@/data/storage/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Result } from "@/utils";
export interface UseCachedChatMediaUrlInput {
messageId?: string | null;
remoteUrl?: string | null;
kind: LocalChatMediaKind;
}
export interface UseCachedChatMediaUrlOutput {
mediaUrl: string;
isUsingCachedMedia: boolean;
}
interface CachedMediaUrlState {
key: string;
objectUrl: string;
}
export function useCachedChatMediaUrl({
messageId,
remoteUrl,
kind,
}: UseCachedChatMediaUrlInput): UseCachedChatMediaUrlOutput {
const [cachedState, setCachedState] = useState<CachedMediaUrlState | null>(
null,
);
const fallbackUrl = remoteUrl ?? "";
const stateKey =
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
useEffect(() => {
if (!messageId || !remoteUrl || !isCacheableRemoteUrl(remoteUrl)) {
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const applyBlob = (blob: Blob) => {
const nextObjectUrl = URL.createObjectURL(blob);
objectUrl = nextObjectUrl;
if (cancelled) {
URL.revokeObjectURL(nextObjectUrl);
return;
}
setCachedState((previous) => {
if (previous) URL.revokeObjectURL(previous.objectUrl);
return {
key: stateKey,
objectUrl: nextObjectUrl,
};
});
};
const resolveCachedMedia = async () => {
const chatRepo = getChatRepository();
const cachedResult = await chatRepo.getCachedMedia({ messageId, kind });
if (cancelled) return;
if (Result.isOk(cachedResult) && cachedResult.data) {
applyBlob(cachedResult.data.blob);
return;
}
const cacheResult = await chatRepo.cacheRemoteMedia({
messageId,
kind,
remoteUrl,
});
if (cancelled) return;
if (Result.isOk(cacheResult)) {
applyBlob(cacheResult.data.blob);
}
};
void resolveCachedMedia();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [kind, messageId, remoteUrl, stateKey]);
if (cachedState?.key === stateKey) {
return {
mediaUrl: cachedState.objectUrl,
isUsingCachedMedia: true,
};
}
return {
mediaUrl: fallbackUrl,
isUsingCachedMedia: false,
};
}
function isCacheableRemoteUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
@@ -76,6 +76,7 @@ export function ChatImageViewerScreen({
<>
<MobileShell background="#000000">
<FullscreenImageViewer
messageId={messageId}
imageUrl={message.imageUrl ?? ""}
imagePaywalled={message.imagePaywalled === true}
onUnlockImagePaywall={handleUnlockImagePaywall}
+241 -3
View File
@@ -10,15 +10,30 @@ import {
UnlockPrivateRequest,
UnlockPrivateResponse,
} from "@/data/dto/chat";
import { Result } from "@/utils";
import type { IChatRepository } from "@/data/repositories/interfaces";
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
import { AuthStorage } from "@/data/storage/auth";
import { UserStorage } from "@/data/storage/user/user_storage";
import { Logger, Result } from "@/utils";
import type {
CacheRemoteChatMediaInput,
ChatMediaLookupInput,
IChatRepository,
} from "@/data/repositories/interfaces";
import {
LocalChatMediaStorage,
LocalChatStorage,
LocalMessage,
type LocalChatMediaKind,
type LocalChatMediaRow,
} from "@/data/storage/chat";
import { createLazySingleton } from "./lazy_singleton";
const log = new Logger("DataRepositoriesChatRepository");
export class ChatRepository implements IChatRepository {
constructor(
private readonly api: ChatApi,
private readonly localStorage: LocalChatStorage,
private readonly mediaStorage: LocalChatMediaStorage,
) {}
// ============ 远程操作 ============
@@ -144,6 +159,92 @@ export class ChatRepository implements IChatRepository {
return this._mapLocalStorageResult(this.localStorage.getMessageCount());
}
// ============ 本地媒体缓存 ============
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 (!isCacheableRemoteUrl(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") || fallbackMimeType(input.kind),
});
if (Result.isErr(saveResult)) throw saveResult.error;
void requestPersistentStorage();
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));
}
// ============ 私有助手 ============
/** ChatMessage → LocalMessage:丢弃 wire 字段,注入 storage 层的 sessionId=""。 */
@@ -185,6 +286,55 @@ export class ChatRepository implements IChatRepository {
): 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 `${ownerKey}:${input.messageId}:${input.kind}`;
}
}
/** 全局懒单例。 */
@@ -193,5 +343,93 @@ export const getChatRepository = createLazySingleton<IChatRepository>(
new ChatRepository(
chatApi,
LocalChatStorage.getInstance(),
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 });
}
}
@@ -19,6 +19,19 @@ import type {
UnlockHistoryResponse,
UnlockPrivateResponse,
} from "@/data/dto/chat";
import type {
LocalChatMediaKind,
LocalChatMediaRow,
} from "@/data/storage/chat";
export interface ChatMediaLookupInput {
messageId: string;
kind: LocalChatMediaKind;
}
export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
remoteUrl: string;
}
export interface IChatRepository {
/** 发送一条消息。 */
@@ -58,4 +71,24 @@ export interface IChatRepository {
/** 获取本地消息数量。 */
getLocalMessageCount(): Promise<Result<number>>;
/** 获取本地缓存的图片 / 音频 Blob。 */
getCachedMedia(
input: ChatMediaLookupInput,
): Promise<Result<LocalChatMediaRow | null>>;
/** 下载并缓存远程图片 / 音频 Blob。 */
cacheRemoteMedia(
input: CacheRemoteChatMediaInput,
): Promise<Result<LocalChatMediaRow>>;
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
prefetchMediaForMessages(
messages: readonly ChatMessage[],
): Promise<Result<void>>;
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
prefetchMediaForSendResponse(
response: ChatSendResponse,
): Promise<Result<void>>;
}
+1
View File
@@ -5,4 +5,5 @@
export * from "./chat_storage";
export * from "./local_chat_db";
export * from "./local_chat_storage";
export * from "./local_chat_media_storage";
export * from "./local_message";
+21
View File
@@ -32,13 +32,34 @@ export interface LocalMessageRow {
sessionId: string;
}
export type LocalChatMediaKind = "image" | "audio";
export interface LocalChatMediaRow {
cacheKey: string;
ownerKey: string;
messageId: string;
kind: LocalChatMediaKind;
remoteUrl: string;
blob: Blob;
mimeType: string;
byteSize: number;
createdAt: number;
updatedAt: number;
lastAccessedAt: number;
}
export class LocalChatDB extends Dexie {
messages!: Table<LocalMessageRow, number>;
media!: Table<LocalChatMediaRow, string>;
constructor(dbName: string = "cozsweet-chat") {
super(dbName);
this.version(1).stores({
messages: "++dbId",
});
this.version(2).stores({
messages: "++dbId",
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
});
}
}
@@ -0,0 +1,88 @@
"use client";
import { Result, type Result as ResultT } from "@/utils";
import {
LocalChatDB,
type LocalChatMediaKind,
type LocalChatMediaRow,
} from "./local_chat_db";
export type { LocalChatMediaKind, LocalChatMediaRow };
export interface SaveLocalChatMediaInput {
cacheKey: string;
ownerKey: string;
messageId: string;
kind: LocalChatMediaKind;
remoteUrl: string;
blob: Blob;
mimeType: string;
}
export class LocalChatMediaStorage {
private readonly db: LocalChatDB;
private static _instance: LocalChatMediaStorage | null = null;
constructor(db?: LocalChatDB) {
this.db = db ?? new LocalChatDB();
}
static getInstance(): LocalChatMediaStorage {
if (!LocalChatMediaStorage._instance) {
LocalChatMediaStorage._instance = new LocalChatMediaStorage();
}
return LocalChatMediaStorage._instance;
}
/** @internal */
static _resetForTests(): void {
LocalChatMediaStorage._instance = null;
}
async getMedia(cacheKey: string): Promise<ResultT<LocalChatMediaRow | null>> {
try {
const row = (await this.db.media.get(cacheKey)) ?? null;
if (row) {
await this.db.media.update(cacheKey, {
lastAccessedAt: Date.now(),
});
}
return Result.ok(row);
} catch (e) {
return Result.err(e);
}
}
async saveMedia(input: SaveLocalChatMediaInput): Promise<ResultT<void>> {
try {
const now = Date.now();
const existing = await this.db.media.get(input.cacheKey);
await this.db.media.put({
cacheKey: input.cacheKey,
ownerKey: input.ownerKey,
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
blob: input.blob,
mimeType: input.mimeType,
byteSize: input.blob.size,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastAccessedAt: now,
});
return Result.ok(undefined);
} catch (e) {
return Result.err(e);
}
}
async clearAll(): Promise<ResultT<void>> {
try {
await this.db.media.clear();
return Result.ok(undefined);
} catch (e) {
return Result.err(e);
}
}
}
-15
View File
@@ -1,15 +0,0 @@
"use client";
/**
* 登出(NextAuth 流程触发器)
*
* 调用 `signOut()` 清除 NextAuth session cookie 并重定向。
* 不做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。
*
*/
import { signOut } from "next-auth/react";
export class Logout {
async signOut(): Promise<void> {
await signOut();
}
}
+1
View File
@@ -156,6 +156,7 @@ async function sendMessageViaHttp(content: string): Promise<{
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
throw result.error;
}
void chatRepo.prefetchMediaForSendResponse(result.data);
const isMessageLimit =
result.data.lockDetail.locked &&
result.data.lockDetail.showUpgrade &&
+1
View File
@@ -356,6 +356,7 @@ export async function readAndSyncHistory(): Promise<{
log.debug("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
// 3. 用 network 覆盖 local
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);