From 18e9c941e0b1959bbd34d1b28cfd2d97945a5d8f Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 27 Oct 2025 21:49:16 +0800 Subject: [PATCH] feat(ai-chat): integrate session hydration and enhance chat history loading logic - Added `hydrateSessionsFromLocalDb` to initialize app for session management. - Updated `ChatHistoryDropdown` to conditionally render loading state based on session length. - Refactored `useChatHistory` to utilize the AI chat session store for state management. - Enhanced `AIChatSessionService` to include session synchronization and error handling. - Exported store actions from `ai-chat-session` module for better state management. Signed-off-by: Innei --- .../layer/renderer/src/initialize/index.ts | 2 + .../src/modules/ai-chat-session/index.ts | 1 + .../src/modules/ai-chat-session/service.ts | 125 +++++++++++++----- .../src/modules/ai-chat-session/store.ts | 75 +++++++++++ .../layouts/ChatHistoryDropdown.tsx | 2 +- .../modules/ai-chat/hooks/useChatHistory.ts | 53 ++++---- 6 files changed, 200 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/ai-chat-session/store.ts diff --git a/apps/desktop/layer/renderer/src/initialize/index.ts b/apps/desktop/layer/renderer/src/initialize/index.ts index 9d4a5aab7..1e7fd4c95 100644 --- a/apps/desktop/layer/renderer/src/initialize/index.ts +++ b/apps/desktop/layer/renderer/src/initialize/index.ts @@ -7,6 +7,7 @@ import { repository } from "@pkg" import { enableMapSet } from "immer" import { initI18n } from "~/i18n" +import { hydrateSessionsFromLocalDb } from "~/modules/ai-chat-session" import { settingSyncQueue } from "~/modules/settings/helper/sync-queue" import { ElectronCloseEvent, ElectronShowEvent } from "~/providers/invalidate-query-provider" @@ -55,6 +56,7 @@ export const initializeApp = async () => { initializeDayjs() registerHistoryStack() + hydrateSessionsFromLocalDb() // Set Environment document.documentElement.dataset.buildType = ELECTRON_BUILD ? "electron" : "web" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat-session/index.ts b/apps/desktop/layer/renderer/src/modules/ai-chat-session/index.ts index 639903a38..87023a121 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat-session/index.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat-session/index.ts @@ -1,2 +1,3 @@ export * from "./query" export * from "./service" +export * from "./store" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat-session/service.ts b/apps/desktop/layer/renderer/src/modules/ai-chat-session/service.ts index 6e8ab923f..de31f764b 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat-session/service.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat-session/service.ts @@ -1,10 +1,13 @@ import type { AIChatMessage, AIChatSession, ListSessionsQuery } from "@follow-app/client-sdk" +import { getI18n } from "~/i18n" + import { followApi } from "../../lib/api-client" import { queryClient } from "../../lib/query-client" import { AIPersistService } from "../ai-chat/services" import type { BizUIMessage, BizUIMetadata } from "../ai-chat/store/types" import { aiChatSessionKeys } from "./query" +import { aiChatSessionStoreActions } from "./store" // Hard cap on pagination to prevent excessive API calls while keeping initial sync fast. const MAX_PAGES = 10 @@ -29,42 +32,99 @@ class AIChatSessionServiceStatic { if (this.sync) { return { sessions: 0, messages: 0, failures: 0 } } + this.sync = true - const res = await followApi.aiChatSessions.list(filters) - const sessions = res.data - let totalMessages = 0 - let failures = 0 + aiChatSessionStoreActions.setSyncing(true) + aiChatSessionStoreActions.clearError() - for (const session of sessions) { - const dbSession = await AIPersistService.getChatSession(session.chatId) - const lastUpdatedAt = dbSession ? dbSession.updatedAt : new Date(0) - if (lastUpdatedAt >= new Date(session.updatedAt)) { - // If local session is already up-to-date, skip fetching messages - continue - } - try { - // Ensure session exists locally first - await AIPersistService.ensureSession(session.chatId, { - title: session.title, - createdAt: new Date(session.createdAt), - updatedAt: new Date(session.updatedAt), - }) + await this.loadSessionsFromDb().catch((error) => { + console.error("syncSessionsAndMessagesFromServer: failed to load local sessions", error) + }) - // Fetch unseen messages (newer than lastSeenAt) without changing read state remotely - const unseenMessages = await this.fetchUnseenRemoteMessages(session.chatId, lastUpdatedAt) - if (unseenMessages.length === 0) continue - - const normalized = unseenMessages.map(this.normalizeRemoteMessage) - await AIPersistService.upsertMessages(session.chatId, normalized) - totalMessages += normalized.length - } catch (err) { - // Keep going even if a single session fails - console.error("syncSessionsAndMessagesFromServer: failed for session", session.chatId, err) - failures += 1 - } + let summary: { sessions: number; messages: number; failures: number } = { + sessions: 0, + messages: 0, + failures: 0, } - return { sessions: sessions.length, messages: totalMessages, failures } + + try { + const res = await followApi.aiChatSessions.list(filters) + const sessions = res.data + + let totalMessages = 0 + let failures = 0 + + await Promise.allSettled( + sessions.map((session) => + this.processSessionSync(session) + .then((count) => { + totalMessages += count + }) + .catch((err) => { + console.error( + "syncSessionsAndMessagesFromServer: failed for session", + session.chatId, + err, + ) + failures += 1 + }), + ), + ) + + summary = { sessions: sessions.length, messages: totalMessages, failures } + + await this.loadSessionsFromDb() + aiChatSessionStoreActions.setStats(summary) + aiChatSessionStoreActions.setLastSyncedAt(new Date()) + return summary + } catch (error) { + aiChatSessionStoreActions.setError( + error instanceof Error ? error.message : "Failed to sync chat sessions", + ) + throw error + } finally { + aiChatSessionStoreActions.setSyncing(false) + this.sync = false + } + } + + private async processSessionSync(session: AIChatSession): Promise { + const dbSession = await AIPersistService.getChatSession(session.chatId) + const lastUpdatedAt = dbSession ? dbSession.updatedAt : new Date(0) + if (lastUpdatedAt >= new Date(session.updatedAt)) { + return 0 + } + + await AIPersistService.ensureSession(session.chatId, { + title: session.title, + createdAt: new Date(session.createdAt), + updatedAt: new Date(session.updatedAt), + }) + + const unseenMessages = await this.fetchUnseenRemoteMessages(session.chatId, lastUpdatedAt) + if (unseenMessages.length === 0) { + return 0 + } + + const normalized = unseenMessages.map(this.normalizeRemoteMessage) + await AIPersistService.upsertMessages(session.chatId, normalized) + + return normalized.length + } + + async loadSessionsFromDb() { + const { t } = getI18n() + const rows = await AIPersistService.getChatSessions() + const normalized = rows.map((row) => ({ + chatId: row.chatId, + title: row.title || t("ai:common.new_chat"), + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + messageCount: row.messageCount, + })) + aiChatSessionStoreActions.setSessions(normalized) + return normalized } /** * Fetch messages for a chat session from the remote API and persist (upsert) them locally. @@ -97,6 +157,9 @@ class AIChatSessionServiceStatic { lastSeenAt: new Date().toISOString(), }) + await this.loadSessionsFromDb() + aiChatSessionStoreActions.setLastSyncedAt(new Date()) + // Invalidate related queries so UI updates outside of hook-based mutation flows Promise.all([ queryClient.invalidateQueries({ queryKey: aiChatSessionKeys.detail(session.chatId) }), diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat-session/store.ts b/apps/desktop/layer/renderer/src/modules/ai-chat-session/store.ts new file mode 100644 index 000000000..17db9f2b5 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat-session/store.ts @@ -0,0 +1,75 @@ +import { createWithEqualityFn } from "zustand/traditional" + +import type { ChatSession } from "../ai-chat/types/ChatSession" +import { AIChatSessionService } from "./service" + +export interface AIChatSessionSyncStats { + sessions: number + messages: number + failures: number +} + +export interface AIChatSessionViewModelState { + sessions: ChatSession[] + isLoading: boolean + isSyncing: boolean + stats: AIChatSessionSyncStats + lastSyncedAt?: Date + error?: string +} + +const createInitialStats = (): AIChatSessionSyncStats => ({ + sessions: 0, + messages: 0, + failures: 0, +}) + +export const useAIChatSessionStore = createWithEqualityFn(() => ({ + sessions: [], + isLoading: false, + isSyncing: false, + stats: createInitialStats(), + lastSyncedAt: undefined, + error: undefined, +})) + +const { setState } = useAIChatSessionStore + +export const aiChatSessionStoreActions = { + setSessions: (sessions: ChatSession[]) => + setState({ + sessions, + }), + setLoading: (isLoading: boolean) => + setState({ + isLoading, + }), + setSyncing: (isSyncing: boolean) => + setState({ + isSyncing, + }), + setStats: (stats: AIChatSessionSyncStats) => + setState({ + stats: { ...stats }, + }), + resetStats: () => + setState({ + stats: createInitialStats(), + }), + setLastSyncedAt: (lastSyncedAt?: Date) => + setState({ + lastSyncedAt, + }), + setError: (error?: string) => + setState({ + error, + }), + clearError: () => + setState({ + error: undefined, + }), +} + +export const hydrateSessionsFromLocalDb = async () => { + return AIChatSessionService.loadSessionsFromDb() +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx index 46b9414e3..f0e8a61bb 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHistoryDropdown.tsx @@ -95,7 +95,7 @@ export const ChatHistoryDropdown = ({ {triggerElement || defaultTrigger} - {loading ? ( + {loading && sessions.length === 0 ? (
diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useChatHistory.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useChatHistory.ts index 08944686d..237826a00 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useChatHistory.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useChatHistory.ts @@ -1,41 +1,42 @@ -import { useCallback, useState } from "react" +import { useCallback, useMemo } from "react" -import { getI18n } from "~/i18n" -import { AIPersistService } from "~/modules/ai-chat/services/index" import { AIChatSessionService } from "~/modules/ai-chat-session/service" - -import type { ChatSession } from "../types/ChatSession" +import { aiChatSessionStoreActions, useAIChatSessionStore } from "~/modules/ai-chat-session/store" export const useChatHistory = () => { - const [sessions, setSessions] = useState([]) - const [loading, setLoading] = useState(false) + const state = useAIChatSessionStore((s) => s) + + const { sessions } = state + const loading = state.isLoading || state.isSyncing const loadHistory = useCallback(async () => { - setLoading(true) + if (loading) return + + aiChatSessionStoreActions.setLoading(true) + aiChatSessionStoreActions.clearError() - const { t } = getI18n() try { - await AIChatSessionService.syncSessionsAndMessagesFromServer() - const result = await AIPersistService.getChatSessions() - const sessions: ChatSession[] = result.map((row) => ({ - chatId: row.chatId, - title: row.title || t("ai:common.new_chat"), - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - messageCount: row.messageCount, - })) + aiChatSessionStoreActions.setSyncing(true) - setSessions(sessions) + await AIChatSessionService.syncSessionsAndMessagesFromServer() } catch (error) { console.error("Failed to load chat history:", error) + aiChatSessionStoreActions.setError(error instanceof Error ? error.message : "Unknown error") } finally { - setLoading(false) + aiChatSessionStoreActions.setSyncing(false) + aiChatSessionStoreActions.setLoading(false) } - }, []) + }, [loading]) - return { - sessions, - loading, - loadHistory, - } + return useMemo( + () => ({ + sessions, + loading, + loadHistory, + stats: state.stats, + lastSyncedAt: state.lastSyncedAt, + error: state.error, + }), + [sessions, loading, loadHistory, state.stats, state.lastSyncedAt, state.error], + ) }