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:
parent
90a6eb6209
commit
19a767b9dc
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue