feat(media): implement caching for chat media and update components to use cached media
This commit is contained in:
@@ -30,6 +30,13 @@
|
|||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nativePaywallImage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.paywallScrim {
|
.paywallScrim {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
/**
|
/**
|
||||||
* FullscreenImageViewer 全屏图片查看器
|
* FullscreenImageViewer 全屏图片查看器
|
||||||
*
|
*
|
||||||
@@ -13,9 +14,11 @@ import { ChevronLeft } from "lucide-react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
||||||
import styles from "./fullscreen-image-viewer.module.css";
|
import styles from "./fullscreen-image-viewer.module.css";
|
||||||
|
|
||||||
export interface FullscreenImageViewerProps {
|
export interface FullscreenImageViewerProps {
|
||||||
|
messageId?: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
onUnlockImagePaywall?: () => void;
|
onUnlockImagePaywall?: () => void;
|
||||||
@@ -23,11 +26,18 @@ export interface FullscreenImageViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FullscreenImageViewer({
|
export function FullscreenImageViewer({
|
||||||
|
messageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
onClose,
|
onClose,
|
||||||
}: FullscreenImageViewerProps) {
|
}: FullscreenImageViewerProps) {
|
||||||
|
const { mediaUrl } = useCachedChatMediaUrl({
|
||||||
|
messageId,
|
||||||
|
remoteUrl: imageUrl,
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -36,9 +46,13 @@ export function FullscreenImageViewer({
|
|||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const src = imageUrl.startsWith("data:") || imageUrl.startsWith("http")
|
const sourceUrl = mediaUrl || imageUrl;
|
||||||
? imageUrl
|
const src = sourceUrl.startsWith("data:") ||
|
||||||
: `data:image/png;base64,${imageUrl}`;
|
sourceUrl.startsWith("http") ||
|
||||||
|
sourceUrl.startsWith("blob:")
|
||||||
|
? sourceUrl
|
||||||
|
: `data:image/png;base64,${sourceUrl}`;
|
||||||
|
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
|
||||||
|
|
||||||
if (imagePaywalled) {
|
if (imagePaywalled) {
|
||||||
return (
|
return (
|
||||||
@@ -47,6 +61,13 @@ export function FullscreenImageViewer({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Locked fullscreen image"
|
aria-label="Locked fullscreen image"
|
||||||
>
|
>
|
||||||
|
{shouldUseNativeImage ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -54,6 +75,7 @@ export function FullscreenImageViewer({
|
|||||||
sizes="(max-width: 540px) 100vw, 540px"
|
sizes="(max-width: 540px) 100vw, 540px"
|
||||||
className={styles.paywallImage}
|
className={styles.paywallImage}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div className={styles.paywallScrim} aria-hidden="true" />
|
<div className={styles.paywallScrim} aria-hidden="true" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,6 +103,18 @@ export function FullscreenImageViewer({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Fullscreen image"
|
aria-label="Fullscreen image"
|
||||||
>
|
>
|
||||||
|
{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
|
<Image
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -93,6 +127,11 @@ export function FullscreenImageViewer({
|
|||||||
'<div class="error">🖼</div>';
|
'<div class="error">🖼</div>';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBrowserLocalImageSrc(src: string): boolean {
|
||||||
|
return src.startsWith("blob:") || src.startsWith("data:");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
/**
|
/**
|
||||||
* ImageBubble 图片气泡
|
* ImageBubble 图片气泡
|
||||||
*
|
*
|
||||||
@@ -13,6 +14,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { ROUTE_BUILDERS } from "@/router/routes";
|
import { ROUTE_BUILDERS } from "@/router/routes";
|
||||||
|
|
||||||
|
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
||||||
import styles from "./image-bubble.module.css";
|
import styles from "./image-bubble.module.css";
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
@@ -28,9 +30,15 @@ export function ImageBubble({
|
|||||||
}: ImageBubbleProps) {
|
}: ImageBubbleProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState(false);
|
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 canOpen = Boolean(messageId);
|
||||||
|
const shouldUseNativeImage = isBrowserLocalImageSrc(src);
|
||||||
|
|
||||||
const openImage = () => {
|
const openImage = () => {
|
||||||
if (!messageId) return;
|
if (!messageId) return;
|
||||||
@@ -54,6 +62,13 @@ export function ImageBubble({
|
|||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className={styles.errorFallback}>🖼</div>
|
<div className={styles.errorFallback}>🖼</div>
|
||||||
|
) : shouldUseNativeImage ? (
|
||||||
|
<img
|
||||||
|
className={styles.image}
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
@@ -80,3 +95,7 @@ function decodeBase64Image(uri: string): string {
|
|||||||
// 裸 base64 字符串 → 包装成 data URI(PNG 兜底)
|
// 裸 base64 字符串 → 包装成 data URI(PNG 兜底)
|
||||||
return `data:image/png;base64,${uri}`;
|
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 ? (
|
{hasAudio && audioUrl ? (
|
||||||
<VoiceBubble
|
<VoiceBubble
|
||||||
|
messageId={messageId}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
isFromAI={isFromAI}
|
isFromAI={isFromAI}
|
||||||
locked={isLockedVoiceMessage}
|
locked={isLockedVoiceMessage}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { LockKeyhole, Pause, Play } from "lucide-react";
|
import { LockKeyhole, Pause, Play } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useCachedChatMediaUrl } from "../hooks/use-cached-chat-media-url";
|
||||||
import styles from "./voice-bubble.module.css";
|
import styles from "./voice-bubble.module.css";
|
||||||
|
|
||||||
export interface VoiceBubbleProps {
|
export interface VoiceBubbleProps {
|
||||||
|
messageId?: string;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
@@ -15,6 +17,7 @@ export interface VoiceBubbleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceBubble({
|
export function VoiceBubble({
|
||||||
|
messageId,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
locked = false,
|
locked = false,
|
||||||
@@ -25,6 +28,11 @@ export function VoiceBubble({
|
|||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const { mediaUrl } = useCachedChatMediaUrl({
|
||||||
|
messageId,
|
||||||
|
remoteUrl: audioUrl,
|
||||||
|
kind: "audio",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -117,7 +125,7 @@ export function VoiceBubble({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!locked ? <audio ref={audioRef} src={audioUrl} preload="metadata" /> : null}
|
{!locked ? <audio ref={audioRef} src={mediaUrl} preload="metadata" /> : null}
|
||||||
</div>
|
</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">
|
<MobileShell background="#000000">
|
||||||
<FullscreenImageViewer
|
<FullscreenImageViewer
|
||||||
|
messageId={messageId}
|
||||||
imageUrl={message.imageUrl ?? ""}
|
imageUrl={message.imageUrl ?? ""}
|
||||||
imagePaywalled={message.imagePaywalled === true}
|
imagePaywalled={message.imagePaywalled === true}
|
||||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||||
|
|||||||
@@ -10,15 +10,30 @@ import {
|
|||||||
UnlockPrivateRequest,
|
UnlockPrivateRequest,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import { Result } from "@/utils";
|
import { AuthStorage } from "@/data/storage/auth";
|
||||||
import type { IChatRepository } from "@/data/repositories/interfaces";
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
|
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";
|
import { createLazySingleton } from "./lazy_singleton";
|
||||||
|
|
||||||
|
const log = new Logger("DataRepositoriesChatRepository");
|
||||||
|
|
||||||
export class ChatRepository implements IChatRepository {
|
export class ChatRepository implements IChatRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly api: ChatApi,
|
private readonly api: ChatApi,
|
||||||
private readonly localStorage: LocalChatStorage,
|
private readonly localStorage: LocalChatStorage,
|
||||||
|
private readonly mediaStorage: LocalChatMediaStorage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ============ 远程操作 ============
|
// ============ 远程操作 ============
|
||||||
@@ -144,6 +159,92 @@ export class ChatRepository implements IChatRepository {
|
|||||||
return this._mapLocalStorageResult(this.localStorage.getMessageCount());
|
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=""。 */
|
/** ChatMessage → LocalMessage:丢弃 wire 字段,注入 storage 层的 sessionId=""。 */
|
||||||
@@ -185,6 +286,55 @@ export class ChatRepository implements IChatRepository {
|
|||||||
): Promise<Result<T>> {
|
): Promise<Result<T>> {
|
||||||
return await promise;
|
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(
|
new ChatRepository(
|
||||||
chatApi,
|
chatApi,
|
||||||
LocalChatStorage.getInstance(),
|
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,
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} 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 {
|
export interface IChatRepository {
|
||||||
/** 发送一条消息。 */
|
/** 发送一条消息。 */
|
||||||
@@ -58,4 +71,24 @@ export interface IChatRepository {
|
|||||||
|
|
||||||
/** 获取本地消息数量。 */
|
/** 获取本地消息数量。 */
|
||||||
getLocalMessageCount(): Promise<Result<number>>;
|
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>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
export * from "./chat_storage";
|
export * from "./chat_storage";
|
||||||
export * from "./local_chat_db";
|
export * from "./local_chat_db";
|
||||||
export * from "./local_chat_storage";
|
export * from "./local_chat_storage";
|
||||||
|
export * from "./local_chat_media_storage";
|
||||||
export * from "./local_message";
|
export * from "./local_message";
|
||||||
|
|||||||
@@ -32,13 +32,34 @@ export interface LocalMessageRow {
|
|||||||
sessionId: string;
|
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 {
|
export class LocalChatDB extends Dexie {
|
||||||
messages!: Table<LocalMessageRow, number>;
|
messages!: Table<LocalMessageRow, number>;
|
||||||
|
media!: Table<LocalChatMediaRow, string>;
|
||||||
|
|
||||||
constructor(dbName: string = "cozsweet-chat") {
|
constructor(dbName: string = "cozsweet-chat") {
|
||||||
super(dbName);
|
super(dbName);
|
||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
messages: "++dbId",
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -156,6 +156,7 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
|
void chatRepo.prefetchMediaForSendResponse(result.data);
|
||||||
const isMessageLimit =
|
const isMessageLimit =
|
||||||
result.data.lockDetail.locked &&
|
result.data.lockDetail.locked &&
|
||||||
result.data.lockDetail.showUpgrade &&
|
result.data.lockDetail.showUpgrade &&
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export async function readAndSyncHistory(): Promise<{
|
|||||||
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
||||||
count: networkUi.length,
|
count: networkUi.length,
|
||||||
});
|
});
|
||||||
|
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
|
||||||
|
|
||||||
// 3. 用 network 覆盖 local
|
// 3. 用 network 覆盖 local
|
||||||
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
|
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
|
||||||
|
|||||||
Reference in New Issue
Block a user