feat: chat session synchronization (#4592)

* refactor: include chatId parameter in generateChatTitle function

* refactor: handle remote chat session deletion

* feat: implement session synchronization and message fetching

* refactor(service): improve session synchronization logic and message fetching

- Made `sync` property private for better encapsulation.
- Added checks to prevent unnecessary updates for already synced sessions.
- Enhanced session creation with additional timestamps for better tracking.

* refactor(service): skip fetching messages if local session is up-to-date

* refactor(service): enhance session management options in ensureSession and createSession
This commit is contained in:
Whitewater 2025-10-24 14:23:57 +08:00 committed by GitHub
parent 90a6eb6209
commit 19a767b9dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 94 additions and 20 deletions

View File

@ -1,4 +1,4 @@
import type { AIChatMessage, AIChatSession } from "@follow-app/client-sdk"
import type { AIChatMessage, AIChatSession, ListSessionsQuery } from "@follow-app/client-sdk"
import { followApi } from "../../lib/api-client"
import { queryClient } from "../../lib/query-client"
@ -13,17 +13,83 @@ const MAX_PAGES = 10
* Service for syncing AI chat session messages from remote API into local DB.
*/
class AIChatSessionServiceStatic {
private sync = false
/**
* List sessions from backend and ensure local DB has sessions and unseen messages.
* This does NOT mark sessions as seen on the server (non-destructive sync).
*
* Returns a small summary for observability.
*/
async syncSessionsAndMessagesFromServer(filters?: ListSessionsQuery): Promise<{
sessions: number
messages: number
failures: number
}> {
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
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),
})
// 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
}
}
return { sessions: sessions.length, messages: totalMessages, failures }
}
/**
* Fetch messages for a chat session from the remote API and persist (upsert) them locally.
* Returns the normalized BizUIMessage list that was persisted.
*
* Defensive in extracting the message array because the SDK response shape may evolve.
*/
async fetchAndPersistMessages(session: AIChatSession): Promise<BizUIMessage[]> {
const unseenMessages = await this.fetchUnseenRemoteMessages(session.chatId, session.lastSeenAt)
async fetchAndPersistMessages(session: AIChatSession): Promise<void> {
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
return
}
const unseenMessages = await this.fetchUnseenRemoteMessages(
session.chatId,
new Date(session.lastSeenAt),
)
const normalized = unseenMessages.map(this.normalizeRemoteMessage)
await AIPersistService.ensureSession(session.chatId, { title: session.title })
await AIPersistService.ensureSession(session.chatId, {
title: session.title,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
})
await AIPersistService.upsertMessages(session.chatId, normalized)
await followApi.aiChatSessions.markSeen({
@ -37,7 +103,6 @@ class AIChatSessionServiceStatic {
queryClient.invalidateQueries({ queryKey: aiChatSessionKeys.lists }),
queryClient.invalidateQueries({ queryKey: aiChatSessionKeys.unread }),
])
return normalized
}
/**
@ -50,10 +115,9 @@ class AIChatSessionServiceStatic {
*/
private async fetchUnseenRemoteMessages(
chatId: string,
lastSeenAt: string,
lastSeenAt: Date,
): Promise<AIChatMessage[]> {
const allMessages: AIChatMessage[] = []
const lastSeenAtDate = new Date(lastSeenAt)
let before: string | undefined
for (let page = 0; page < MAX_PAGES; page++) {
const resp = await followApi.aiChatSessions.messages.get(
@ -64,7 +128,7 @@ class AIChatSessionServiceStatic {
allMessages.push(...batch)
const { nextBefore } = data
if (!nextBefore || new Date(nextBefore) <= lastSeenAtDate) {
if (!nextBefore || new Date(nextBefore) <= lastSeenAt) {
break
}
before = nextBefore
@ -80,15 +144,12 @@ class AIChatSessionServiceStatic {
private normalizeRemoteMessage = (msg: AIChatMessage): BizUIMessage => {
const metadata =
msg.metadata && typeof msg.metadata === "object" ? (msg.metadata as BizUIMetadata) : undefined
const normalizedParts = msg.messageParts.filter(
(i) => i.type === "text",
) satisfies BizUIMessage["parts"]
return {
id: msg.id,
role: msg.role satisfies BizUIMessage["role"],
parts: normalizedParts,
// Remove this comment once @follow-app/client-sdk updated
// @ts-expect-error TODO fix message part types
parts: msg.messageParts,
metadata,
createdAt: new Date(msg.createdAt),
}

View File

@ -2,6 +2,7 @@ import { useCallback, useState } 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"
@ -14,6 +15,7 @@ export const useChatHistory = () => {
const { t } = getI18n()
try {
await AIChatSessionService.syncSessionsAndMessagesFromServer()
const result = await AIPersistService.getChatSessions()
const sessions: ChatSession[] = result.map((row) => ({
chatId: row.chatId,

View File

@ -5,6 +5,7 @@ import { aiChatMessagesTable, aiChatTable } from "@follow/database/schemas/index
import { asc, count, eq, inArray, sql } from "drizzle-orm"
import { getI18n } from "~/i18n"
import { followClient } from "~/lib/api-client"
import { AI_CHAT_SPECIAL_ID_PREFIX } from "../constants"
import type { BizUIMessage, BizUIMessagePart } from "../store/types"
@ -260,7 +261,10 @@ class AIPersistServiceStatic {
/**
* Ensure session exists (idempotent operation)
*/
async ensureSession(chatId: string, options: { title?: string } = {}): Promise<void> {
async ensureSession(
chatId: string,
options: { title?: string; createdAt?: Date; updatedAt?: Date } = {},
): Promise<void> {
const cachedExists = this.getSessionExistsFromCache(chatId)
const shouldCheckDb = cachedExists !== true || options.title
@ -300,13 +304,16 @@ class AIPersistServiceStatic {
this.markSessionExists(chatId, true)
}
async createSession(chatId: string, options: { title?: string } = {}) {
async createSession(
chatId: string,
options: { title?: string; createdAt?: Date; updatedAt?: Date } = {},
) {
const now = new Date()
await db.insert(aiChatTable).values({
chatId,
title: this.resolveSessionTitle(chatId, options.title, { createdAt: now, updatedAt: now }),
createdAt: now,
updatedAt: now,
createdAt: options.createdAt ?? now,
updatedAt: options.updatedAt ?? now,
})
// Mark session as existing in cache
this.markSessionExists(chatId, true)
@ -410,6 +417,9 @@ class AIPersistServiceStatic {
await db.delete(aiChatTable).where(eq(aiChatTable.chatId, chatId))
// Clear session from cache
this.clearSessionCache(chatId)
await followClient.api.aiChatSessions.delete({ chatId }).catch((error) => {
console.error("Failed to delete remote chat session:", error)
})
}
async updateSessionTitle(chatId: string, title: string) {

View File

@ -3,7 +3,7 @@ import { followClient } from "~/lib/api-client"
import { AIPersistService } from "../services"
import type { SendingUIMessage } from "../store/types"
export const generateChatTitle = async (messages: SendingUIMessage[]) => {
export const generateChatTitle = async (chatId: string, messages: SendingUIMessage[]) => {
const relevantMessages = messages.map((msg) => {
let content = ""
if (msg.parts && Array.isArray(msg.parts)) {
@ -29,6 +29,7 @@ export const generateChatTitle = async (messages: SendingUIMessage[]) => {
const response = await followClient.api.ai
.summaryTitle({
chatId,
messages: relevantMessages,
})
.catch((error) => {
@ -59,7 +60,7 @@ export const generateAndUpdateChatTitle = async (
return null
}
const title = await generateChatTitle(messages)
const title = await generateChatTitle(chatId, messages)
if (title && chatId) {
try {