diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 462e6928..d113e0ad 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -35,6 +35,7 @@ import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation- import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap"; +import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync"; const chatShellStyle = { "--chat-message-font-size": "clamp(16px, 3.333vw, 18px)", @@ -47,6 +48,11 @@ export function ChatScreen() { const state = useChatState(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); + useSplashLatestMessageSync({ + historyLoaded: state.historyLoaded, + loginStatus: authState.loginStatus, + messages: state.historyMessages, + }); const isPromotionBootstrapReady = useChatPromotionBootstrap(); const visibleMessages = isPromotionBootstrapReady ? state.messages diff --git a/src/app/chat/hooks/use-splash-latest-message-sync.ts b/src/app/chat/hooks/use-splash-latest-message-sync.ts new file mode 100644 index 00000000..088f2bd9 --- /dev/null +++ b/src/app/chat/hooks/use-splash-latest-message-sync.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import type { LoginStatus } from "@/data/dto/auth"; +import type { UiMessage } from "@/data/dto/chat"; +import { resolveSplashLatestMessageCacheIdentity } from "@/lib/chat/splash_latest_message"; +import { getLatestSplashMessagePreview } from "@/lib/chat/splash_latest_message_preview"; +import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider"; + +export interface UseSplashLatestMessageSyncInput { + historyLoaded: boolean; + loginStatus: LoginStatus; + messages: readonly UiMessage[]; +} + +export function useSplashLatestMessageSync({ + historyLoaded, + loginStatus, + messages, +}: UseSplashLatestMessageSyncInput): void { + const cache = useSplashLatestMessageCache(); + const hasObservedInitialSnapshotRef = useRef(false); + const identityPromiseRef = useRef | null>(null); + + useEffect(() => { + hasObservedInitialSnapshotRef.current = false; + identityPromiseRef.current = null; + }, [loginStatus]); + + useEffect(() => { + if (!historyLoaded) return; + + if (!hasObservedInitialSnapshotRef.current) { + hasObservedInitialSnapshotRef.current = true; + return; + } + + const preview = getLatestSplashMessagePreview(messages); + let cancelled = false; + + identityPromiseRef.current ??= resolveSplashLatestMessageCacheIdentity(); + void identityPromiseRef.current.then((identity) => { + if (!cancelled && identity) { + cache.set(identity, preview); + } + }); + + return () => { + cancelled = true; + }; + }, [cache, historyLoaded, messages]); +} diff --git a/src/app/splash/__tests__/splash-latest-message.helpers.test.ts b/src/app/splash/__tests__/splash-latest-message.helpers.test.ts index 2d92d792..12ce1421 100644 --- a/src/app/splash/__tests__/splash-latest-message.helpers.test.ts +++ b/src/app/splash/__tests__/splash-latest-message.helpers.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message_preview"; +import { + getLatestSplashMessagePreview, + getSplashLatestMessagePreview, +} from "@/lib/chat/splash_latest_message_preview"; describe("getSplashLatestMessagePreview", () => { it("uses text content for text messages", () => { @@ -34,6 +37,15 @@ describe("getSplashLatestMessagePreview", () => { ).toBe("[图片]"); }); + it("uses image placeholder for a mapped UI image message", () => { + expect( + getSplashLatestMessagePreview({ + content: "", + imageUrl: "https://example.com/image.jpg", + }), + ).toBe("[图片]"); + }); + it("uses voice placeholder for voice messages", () => { expect( getSplashLatestMessagePreview({ @@ -54,6 +66,35 @@ describe("getSplashLatestMessagePreview", () => { ).toBe("[语音]"); }); + it("uses media placeholders for mapped UI lock reasons", () => { + expect( + getSplashLatestMessagePreview({ + content: "", + lockReason: "voice_message", + }), + ).toBe("[语音]"); + expect( + getSplashLatestMessagePreview({ + content: "", + lockReason: "image_paywall", + }), + ).toBe("[图片]"); + }); + + it("skips synthetic messages when selecting the latest preview", () => { + expect( + getLatestSplashMessagePreview([ + { content: "Real message" }, + { content: "Greeting", isSynthetic: true }, + ]), + ).toBe("Real message"); + expect( + getLatestSplashMessagePreview([ + { content: "Greeting", isSynthetic: true }, + ]), + ).toBeNull(); + }); + it("falls back when the latest message has no displayable content", () => { expect( getSplashLatestMessagePreview({ diff --git a/src/app/splash/hooks/use-splash-latest-message.ts b/src/app/splash/hooks/use-splash-latest-message.ts index 81cce74c..51a711da 100644 --- a/src/app/splash/hooks/use-splash-latest-message.ts +++ b/src/app/splash/hooks/use-splash-latest-message.ts @@ -3,7 +3,8 @@ import { useEffect, useState } from "react"; import type { LoginStatus } from "@/data/dto/auth"; -import { fetchSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; +import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; +import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider"; import { Logger, Result } from "@/utils"; const log = new Logger("SplashLatestMessage"); @@ -30,6 +31,7 @@ export function useSplashLatestMessage({ isAuthLoading, loginStatus, }: UseSplashLatestMessageInput): UseSplashLatestMessageOutput { + const cache = useSplashLatestMessageCache(); const [state, setState] = useState({ key: "", loaded: false, @@ -45,7 +47,7 @@ export function useSplashLatestMessage({ let cancelled = false; const loadLatestMessage = async () => { - const result = await fetchSplashLatestMessagePreview(); + const result = await loadSplashLatestMessagePreview({ cache }); if (cancelled) return; if (Result.isErr(result)) { @@ -69,7 +71,7 @@ export function useSplashLatestMessage({ return () => { cancelled = true; }; - }, [requestKey, shouldLoad]); + }, [cache, requestKey, shouldLoad]); if (!shouldLoad) return { message: null, isLoading: false }; if (state.key !== requestKey || !state.loaded) { diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts index 647ac3d2..76715b5f 100644 --- a/src/data/dto/chat/ui_message.ts +++ b/src/data/dto/chat/ui_message.ts @@ -13,6 +13,8 @@ export const UiMessageSchema = z.object({ isFromAI: z.boolean(), /** 日期分隔条使用的本地日期(YYYY-MM-DD) */ date: z.string(), + /** 前端生成的辅助消息,不应作为服务端聊天预览。 */ + isSynthetic: z.boolean().optional(), /** 图片 URL(base64 data URL 或 http URL) */ imageUrl: z.string().optional(), /** true = 图片可在列表展示,但全屏查看时需要会员解锁 */ diff --git a/src/lib/chat/__tests__/splash_latest_message.test.ts b/src/lib/chat/__tests__/splash_latest_message.test.ts index 7f905b87..5b20a655 100644 --- a/src/lib/chat/__tests__/splash_latest_message.test.ts +++ b/src/lib/chat/__tests__/splash_latest_message.test.ts @@ -8,7 +8,11 @@ vi.mock("@/data/repositories/chat_repository", () => ({ getChatRepository: () => ({ getHistory: getHistoryMock }), })); -import { fetchSplashLatestMessagePreview } from "../splash_latest_message"; +import { + fetchSplashLatestMessagePreview, + loadSplashLatestMessagePreview, +} from "../splash_latest_message"; +import { createSplashLatestMessageCache } from "../splash_latest_message_cache"; describe("fetchSplashLatestMessagePreview", () => { beforeEach(() => { @@ -39,3 +43,87 @@ describe("fetchSplashLatestMessagePreview", () => { expect(Result.isOk(result) && result.data).toBeNull(); }); }); + +describe("loadSplashLatestMessagePreview", () => { + it("reuses a successful result for the same identity", async () => { + const cache = createSplashLatestMessageCache(); + const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Hello again")); + const resolveIdentity = vi.fn().mockResolvedValue(Result.ok("user:1")); + + const first = await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity, + }); + const second = await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity, + }); + + expect(Result.isOk(first) && first.data).toBe("Hello again"); + expect(Result.isOk(second) && second.data).toBe("Hello again"); + expect(fetchPreview).toHaveBeenCalledTimes(1); + }); + + it("treats a cached empty history as a cache hit", async () => { + const cache = createSplashLatestMessageCache(); + cache.set("user:empty", null); + const fetchPreview = vi.fn(); + + const result = await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity: vi + .fn() + .mockResolvedValue(Result.ok("user:empty")), + }); + + expect(Result.isOk(result) && result.data).toBeNull(); + expect(fetchPreview).not.toHaveBeenCalled(); + }); + + it("does not cache failed requests", async () => { + const cache = createSplashLatestMessageCache(); + const fetchPreview = vi + .fn() + .mockResolvedValueOnce(Result.err(new Error("offline"))) + .mockResolvedValueOnce(Result.ok("Recovered")); + const resolveIdentity = vi.fn().mockResolvedValue(Result.ok("user:1")); + + await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity, + }); + const retry = await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity, + }); + + expect(Result.isOk(retry) && retry.data).toBe("Recovered"); + expect(fetchPreview).toHaveBeenCalledTimes(2); + }); + + it("bypasses the cache when the identity is unavailable", async () => { + const cache = createSplashLatestMessageCache(); + const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Network")); + const resolveIdentity = vi + .fn() + .mockResolvedValue(Result.err(new Error("missing identity"))); + + await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity, + }); + await loadSplashLatestMessagePreview({ + cache, + fetchPreview, + resolveIdentity, + }); + + expect(fetchPreview).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/chat/__tests__/splash_latest_message_cache.test.ts b/src/lib/chat/__tests__/splash_latest_message_cache.test.ts new file mode 100644 index 00000000..81046971 --- /dev/null +++ b/src/lib/chat/__tests__/splash_latest_message_cache.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { createSplashLatestMessageCache } from "../splash_latest_message_cache"; + +describe("splash latest message cache", () => { + it("isolates previews by chat identity", () => { + const cache = createSplashLatestMessageCache(); + + cache.set("user:1", "First user"); + cache.set("user:2", "Second user"); + + expect(cache.get("user:1")?.message).toBe("First user"); + expect(cache.get("user:2")?.message).toBe("Second user"); + expect(cache.get("user:3")).toBeUndefined(); + }); + + it("clears all session previews", () => { + const cache = createSplashLatestMessageCache(); + cache.set("user:1", null); + + cache.clear(); + + expect(cache.get("user:1")).toBeUndefined(); + }); +}); diff --git a/src/lib/chat/splash_latest_message.ts b/src/lib/chat/splash_latest_message.ts index e5b350b6..c505aa14 100644 --- a/src/lib/chat/splash_latest_message.ts +++ b/src/lib/chat/splash_latest_message.ts @@ -1,9 +1,47 @@ "use client"; import { getChatRepository } from "@/data/repositories/chat_repository"; +import { + resolveChatCacheOwnerKey, + type ChatCacheIdentityResolver, +} from "@/data/repositories/chat_cache_identity"; import { Result, type Result as ResultT } from "@/utils"; -import { getSplashLatestMessagePreview } from "./splash_latest_message_preview"; +import type { SplashLatestMessageCache } from "./splash_latest_message_cache"; +import { getLatestSplashMessagePreview } from "./splash_latest_message_preview"; + +export interface LoadSplashLatestMessagePreviewInput { + cache: SplashLatestMessageCache; + fetchPreview?: () => Promise>; + resolveIdentity?: ChatCacheIdentityResolver; +} + +export async function resolveSplashLatestMessageCacheIdentity(): Promise< + string | null +> { + const result = await resolveChatCacheOwnerKey(); + return Result.isOk(result) ? result.data : null; +} + +export async function loadSplashLatestMessagePreview({ + cache, + fetchPreview = fetchSplashLatestMessagePreview, + resolveIdentity = resolveChatCacheOwnerKey, +}: LoadSplashLatestMessagePreviewInput): Promise> { + const identityResult = await resolveIdentity(); + const identity = Result.isOk(identityResult) ? identityResult.data : null; + + if (identity) { + const cached = cache.get(identity); + if (cached) return Result.ok(cached.message); + } + + const result = await fetchPreview(); + if (identity && Result.isOk(result)) { + cache.set(identity, result.data); + } + return result; +} export async function fetchSplashLatestMessagePreview(): Promise< ResultT @@ -11,6 +49,5 @@ export async function fetchSplashLatestMessagePreview(): Promise< const result = await getChatRepository().getHistory(1, 0); if (Result.isErr(result)) return Result.err(result.error); - const latestMessage = result.data.messages.at(-1) ?? null; - return Result.ok(getSplashLatestMessagePreview(latestMessage)); + return Result.ok(getLatestSplashMessagePreview(result.data.messages)); } diff --git a/src/lib/chat/splash_latest_message_cache.ts b/src/lib/chat/splash_latest_message_cache.ts new file mode 100644 index 00000000..80e1412b --- /dev/null +++ b/src/lib/chat/splash_latest_message_cache.ts @@ -0,0 +1,19 @@ +export interface SplashLatestMessageCacheEntry { + message: string | null; +} + +export interface SplashLatestMessageCache { + clear(): void; + get(identity: string): SplashLatestMessageCacheEntry | undefined; + set(identity: string, message: string | null): void; +} + +export function createSplashLatestMessageCache(): SplashLatestMessageCache { + const entries = new Map(); + + return { + clear: () => entries.clear(), + get: (identity) => entries.get(identity), + set: (identity, message) => entries.set(identity, { message }), + }; +} diff --git a/src/lib/chat/splash_latest_message_preview.ts b/src/lib/chat/splash_latest_message_preview.ts index ca145bec..983cbedb 100644 --- a/src/lib/chat/splash_latest_message_preview.ts +++ b/src/lib/chat/splash_latest_message_preview.ts @@ -2,6 +2,9 @@ export interface SplashLatestMessageSource { type?: string | null; content?: string | null; audioUrl?: string | null; + imageUrl?: string | null; + isSynthetic?: boolean; + lockReason?: string | null; image?: { type?: string | null; url?: string | null; @@ -18,7 +21,7 @@ const FALLBACK_PREVIEW_TEXT = "[消息]"; export function getSplashLatestMessagePreview( message: SplashLatestMessageSource | null | undefined, ): string | null { - if (!message) return null; + if (!message || message.isSynthetic) return null; if (isVoiceMessage(message)) return VOICE_PREVIEW_TEXT; if (isImageMessage(message)) return IMAGE_PREVIEW_TEXT; @@ -27,13 +30,25 @@ export function getSplashLatestMessagePreview( return content.length > 0 ? content : FALLBACK_PREVIEW_TEXT; } +export function getLatestSplashMessagePreview( + messages: readonly SplashLatestMessageSource[], +): string | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message && !message.isSynthetic) { + return getSplashLatestMessagePreview(message); + } + } + return null; +} + function isVoiceMessage(message: SplashLatestMessageSource): boolean { const type = normalizeMessageType(message.type); return ( type === "voice" || type === "audio" || hasValue(message.audioUrl) || - message.lockDetail?.reason === "voice_message" + getLockReason(message) === "voice_message" ); } @@ -41,12 +56,18 @@ function isImageMessage(message: SplashLatestMessageSource): boolean { const type = normalizeMessageType(message.type); return ( type === "image" || + hasValue(message.imageUrl) || hasValue(message.image?.url) || hasValue(message.image?.type) || - message.lockDetail?.reason === "image" + getLockReason(message) === "image" || + getLockReason(message) === "image_paywall" ); } +function getLockReason(message: SplashLatestMessageSource): string | null { + return message.lockReason ?? message.lockDetail?.reason ?? null; +} + function normalizeMessageType(value: string | null | undefined): string { return value?.trim().toLowerCase() ?? ""; } diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index b488a0c2..57d93d0c 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -3,12 +3,13 @@ /** * 根级 Client Providers 包装 * - * 这里只保留跨路由的 Auth、User 和 viewport Provider。Chat、Payment、 - * PrivateRoom 状态机由对应路由 layout 按需挂载。 + * 这里只保留跨路由的 Auth、User、消息预览和 viewport Provider。Chat、 + * Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。 */ import type { ReactNode } from "react"; +import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { AppNavigationGuard } from "@/router/app-navigation-guard"; import { AuthProvider } from "@/stores/auth/auth-context"; @@ -28,11 +29,13 @@ export function RootProviders({ children }: RootProvidersProps) { - - - {children} -
- + + + + {children} +
+ + ); diff --git a/src/providers/splash-latest-message-provider.tsx b/src/providers/splash-latest-message-provider.tsx new file mode 100644 index 00000000..90cdda9c --- /dev/null +++ b/src/providers/splash-latest-message-provider.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + createContext, + type ReactNode, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { + createSplashLatestMessageCache, + type SplashLatestMessageCache, +} from "@/lib/chat/splash_latest_message_cache"; +import { useAuthState } from "@/stores/auth/auth-context"; + +const SplashLatestMessageCacheContext = + createContext(null); + +export function SplashLatestMessageProvider({ + children, +}: { + children: ReactNode; +}) { + const [cache] = useState(createSplashLatestMessageCache); + + return ( + + + {children} + + ); +} + +export function useSplashLatestMessageCache(): SplashLatestMessageCache { + const cache = useContext(SplashLatestMessageCacheContext); + if (!cache) { + throw new Error( + "useSplashLatestMessageCache must be used within SplashLatestMessageProvider.", + ); + } + return cache; +} + +function SplashLatestMessageAuthSync({ + cache, +}: { + cache: SplashLatestMessageCache; +}) { + const authState = useAuthState(); + const previousLoginStatusRef = useRef(null); + + useEffect(() => { + if (!authState.hasInitialized || authState.isLoading) return; + + const previousLoginStatus = previousLoginStatusRef.current; + if ( + previousLoginStatus !== null && + previousLoginStatus !== authState.loginStatus + ) { + cache.clear(); + } + previousLoginStatusRef.current = authState.loginStatus; + }, [ + authState.hasInitialized, + authState.isLoading, + authState.loginStatus, + cache, + ]); + + return null; +} diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index 85870c03..d706e5b8 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -35,6 +35,7 @@ export function createGreetingMessage(): UiMessage { "Finally I can relax. How was your day out?", isFromAI: true, date: todayString(), + isSynthetic: true, }; }