From 4814eca73d13af4c27afbc05af5c1a9e0e9763cc Mon Sep 17 00:00:00 2001 From: Viacheslav Slinko Date: Wed, 29 Jan 2025 13:37:58 +0300 Subject: [PATCH] feat: sync sessions via backend --- src/renderer/App.tsx | 2 + src/renderer/components/SessionList.tsx | 5 +- src/renderer/hooks/useLoadSessions.ts | 15 +++++ src/renderer/packages/sync-sessions.ts | 40 +++++++++++++ src/renderer/storage/StoreStorage.ts | 1 + src/renderer/stores/atoms.ts | 34 +++++++---- src/renderer/stores/sessionActions.ts | 78 ++++++++++++++++--------- src/shared/types.ts | 5 ++ 8 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 src/renderer/hooks/useLoadSessions.ts create mode 100644 src/renderer/packages/sync-sessions.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6f324ba4..e740333f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -17,6 +17,7 @@ import { useAtom, useAtomValue } from 'jotai' import * as atoms from './stores/atoms' import Sidebar from './Sidebar' import * as premiumActions from './stores/premiumActions' +import { useLoadSessions } from './hooks/useLoadSessions' function Main() { const spellCheck = useAtomValue(atoms.spellCheckAtom) @@ -53,6 +54,7 @@ function Main() { } export default function App() { + useLoadSessions() useI18nEffect() premiumActions.useAutoValidate() useSystemLanguageWhenInit() diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 4607cb4f..322c9f97 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -52,7 +52,10 @@ export default function SessionList(props: Props) { const oldIndex = sortedSessions.findIndex(s => s.id === activeId) const newIndex = sortedSessions.findIndex(s => s.id === overId) const newReversed = arrayMove(sortedSessions, oldIndex, newIndex) - setSessions(atoms.sortSessions(newReversed)) + setSessions({ + ts: Date.now(), + sessions: atoms.sortSessions(newReversed), + }) } } return ( diff --git a/src/renderer/hooks/useLoadSessions.ts b/src/renderer/hooks/useLoadSessions.ts new file mode 100644 index 00000000..84e10a6c --- /dev/null +++ b/src/renderer/hooks/useLoadSessions.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react' +import { loadSessionsDump } from '@/packages/sync-sessions' +import { replaceSessionsFromBackend } from '@/stores/sessionActions' + +export function useLoadSessions() { + useEffect(() => { + ;(async () => { + try { + replaceSessionsFromBackend(await loadSessionsDump()) + } catch (e) { + console.error(e) + } + })() + }, []) +} diff --git a/src/renderer/packages/sync-sessions.ts b/src/renderer/packages/sync-sessions.ts new file mode 100644 index 00000000..7dd642f2 --- /dev/null +++ b/src/renderer/packages/sync-sessions.ts @@ -0,0 +1,40 @@ +import { ofetch } from 'ofetch' +import { SessionsDump } from 'src/shared/types' + +export async function loadSessionsDump() { + return await ofetch('http://localhost:8080/api/chats-history', { + method: 'GET', + retry: 3, + headers: { + authorization: `Bearer 634fc0dee42cf0e8bb3e85de634db34e`, + }, + }) +} + +async function saveSessionsToBackend(dump: SessionsDump) { + try { + await fetch('http://localhost:8080/api/chats-history', { + method: 'PUT', + headers: { + 'content-type': 'application/json', + authorization: `Bearer 634fc0dee42cf0e8bb3e85de634db34e`, + }, + body: JSON.stringify(dump), + }) + } catch (e) { + console.error(e) + } +} + +let saveTimer: ReturnType | undefined +let scheduledDumpTs = 0 + +export function scheduleSaveSessionsToBackend(dump: SessionsDump) { + if (dump.ts <= scheduledDumpTs) { + return + } + + scheduledDumpTs = dump.ts + clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveSessionsToBackend(dump), 500) +} diff --git a/src/renderer/storage/StoreStorage.ts b/src/renderer/storage/StoreStorage.ts index a3a0be9a..970c5469 100644 --- a/src/renderer/storage/StoreStorage.ts +++ b/src/renderer/storage/StoreStorage.ts @@ -4,6 +4,7 @@ import platform from '@/packages/platform' export enum StorageKey { ChatSessions = 'chat-sessions', + ChatSessionsTs = 'chat-sessions-ts', Configs = 'configs', Settings = 'settings', MyCopilots = 'myCopilots', diff --git a/src/renderer/stores/atoms.ts b/src/renderer/stores/atoms.ts index b1dbf79c..147b0381 100644 --- a/src/renderer/stores/atoms.ts +++ b/src/renderer/stores/atoms.ts @@ -1,11 +1,11 @@ import { atom, SetStateAction } from 'jotai' -import { Session, Toast, Settings, CopilotDetail, Message, SettingWindowTab -} from '../../shared/types' +import { Session, Toast, Settings, CopilotDetail, Message, SettingWindowTab, SessionsDump } from '../../shared/types' import { selectAtom, atomWithStorage } from 'jotai/utils' import { focusAtom } from 'jotai-optics' import * as defaults from '../../shared/defaults' import storage, { StorageKey } from '../storage' import platform from '../packages/platform' +import { scheduleSaveSessionsToBackend } from '../packages/sync-sessions' const _settingsAtom = atomWithStorage(StorageKey.Settings, defaults.settings(), storage) export const settingsAtom = atom( @@ -43,6 +43,7 @@ export const myCopilotsAtom = atomWithStorage(StorageKey.MyCopi // sessions +const _sessionsTsAtom = atomWithStorage(StorageKey.ChatSessionsTs, 0, storage) const _sessionsAtom = atomWithStorage(StorageKey.ChatSessions, [], storage) export const sessionsAtom = atom( (get) => { @@ -50,19 +51,30 @@ export const sessionsAtom = atom( if (sessions.length === 0) { sessions = defaults.sessions() } - return sessions + return { + ts: get(_sessionsTsAtom), + sessions, + } }, - (get, set, update: SetStateAction) => { - const sessions = get(_sessionsAtom) - let newSessions = typeof update === 'function' ? update(sessions) : update - if (newSessions.length === 0) { - newSessions = defaults.sessions() + (get, set, update: SetStateAction) => { + let newDump = + typeof update === 'function' ? update({ ts: get(_sessionsTsAtom), sessions: get(_sessionsAtom) }) : update + + if (newDump.sessions.length === 0) { + newDump = { + ts: Date.now(), + sessions: defaults.sessions(), + } } - set(_sessionsAtom, newSessions) + + set(_sessionsTsAtom, newDump.ts) + set(_sessionsAtom, newDump.sessions) + + scheduleSaveSessionsToBackend(newDump) } ) export const sortedSessionsAtom = atom((get) => { - return sortSessions(get(sessionsAtom)) + return sortSessions(get(sessionsAtom).sessions) }) export function sortSessions(sessions: Session[]): Session[] { @@ -86,7 +98,7 @@ export const currentSessionIdAtom = atom( export const currentSessionAtom = atom((get) => { const id = get(currentSessionIdAtom) - const sessions = get(sessionsAtom) + const { sessions } = get(sessionsAtom) let current = sessions.find((session) => session.id === id) if (!current) { return sessions[sessions.length - 1] // fallback to the last session diff --git a/src/renderer/stores/sessionActions.ts b/src/renderer/stores/sessionActions.ts index 558f43ba..067b12b0 100644 --- a/src/renderer/stores/sessionActions.ts +++ b/src/renderer/stores/sessionActions.ts @@ -1,10 +1,5 @@ import { getDefaultStore } from 'jotai' -import { - Settings, - createMessage, - Message, - Session, -} from '../../shared/types' +import { Settings, createMessage, Message, Session, SessionsDump } from '../../shared/types' import * as atoms from './atoms' import * as promptFormat from '../packages/prompts' import * as Sentry from '@sentry/react' @@ -18,35 +13,41 @@ import { throttle } from 'lodash' import { countWord } from '@/packages/word-count' import { estimateTokensFromMessages } from '@/packages/token' import * as settingActions from './settingActions' +import { scheduleSaveSessionsToBackend } from '@/packages/sync-sessions' export function create(newSession: Session) { const store = getDefaultStore() - store.set(atoms.sessionsAtom, (sessions) => [...sessions, newSession]) + store.set(atoms.sessionsAtom, ({ sessions }) => ({ + ts: Date.now(), + sessions: [...sessions, newSession], + })) switchCurrentSession(newSession.id) } export function modify(update: Session) { const store = getDefaultStore() - store.set(atoms.sessionsAtom, (sessions) => - sessions.map((s) => { + store.set(atoms.sessionsAtom, ({ sessions }) => ({ + ts: Date.now(), + sessions: sessions.map((s) => { if (s.id === update.id) { return update } return s - }) - ) + }), + })) } export function modifyName(sessionId: string, name: string) { const store = getDefaultStore() - store.set(atoms.sessionsAtom, (sessions) => - sessions.map((s) => { + store.set(atoms.sessionsAtom, ({ sessions }) => ({ + ts: Date.now(), + sessions: sessions.map((s) => { if (s.id === sessionId) { return { ...s, name, threadName: name } } return s - }) - ) + }), + })) } export function createEmpty(type: 'chat') { @@ -66,7 +67,10 @@ export function switchCurrentSession(sessionId: string) { export function remove(session: Session) { const store = getDefaultStore() - store.set(atoms.sessionsAtom, (sessions) => sessions.filter((s) => s.id !== session.id)) + store.set(atoms.sessionsAtom, ({ sessions }) => ({ + ts: Date.now(), + sessions: sessions.filter((s) => s.id !== session.id), + })) } export function clear(sessionId: string) { @@ -84,20 +88,20 @@ export async function copy(source: Session) { const store = getDefaultStore() const newSession = { ...source } newSession.id = uuidv4() - store.set(atoms.sessionsAtom, (sessions) => { + store.set(atoms.sessionsAtom, ({ sessions }) => { let originIndex = sessions.findIndex((s) => s.id === source.id) if (originIndex < 0) { originIndex = 0 } const newSessions = [...sessions] newSessions.splice(originIndex + 1, 0, newSession) - return newSessions + return { ts: Date.now(), sessions: newSessions } }) } export function getSession(sessionId: string) { const store = getDefaultStore() - const sessions = store.get(atoms.sessionsAtom) + const { sessions } = store.get(atoms.sessionsAtom) return sessions.find((s) => s.id === sessionId) } @@ -105,8 +109,9 @@ export function insertMessage(sessionId: string, msg: Message) { const store = getDefaultStore() msg.wordCount = countWord(msg.content) msg.tokenCount = estimateTokensFromMessages([msg]) - store.set(atoms.sessionsAtom, (sessions) => - sessions.map((s) => { + store.set(atoms.sessionsAtom, ({ sessions }) => ({ + ts: Date.now(), + sessions: sessions.map((s) => { if (s.id === sessionId) { const newMessages = [...s.messages] newMessages.push(msg) @@ -116,8 +121,8 @@ export function insertMessage(sessionId: string, msg: Message) { } } return s - }) - ) + }), + })) } export function modifyMessage(sessionId: string, updated: Message, refreshCounting?: boolean) { @@ -139,15 +144,16 @@ export function modifyMessage(sessionId: string, updated: Message, refreshCounti return m }) } - store.set(atoms.sessionsAtom, (sessions) => - sessions.map((s) => { + store.set(atoms.sessionsAtom, ({ sessions }) => ({ + ts: Date.now(), + sessions: sessions.map((s) => { if (s.id !== sessionId) { return s } s.messages = handle(s.messages) return { ...s } - }) - ) + }), + })) } export async function submitNewUserMessage(params: { @@ -329,11 +335,27 @@ export function initEmptyChatSession(): Session { } } -export function getSessions() { +export function replaceSessionsFromBackend(newDump: SessionsDump) { + const store = getDefaultStore() + const currentDump = store.get(atoms.sessionsAtom) + + if (newDump.ts > currentDump.ts) { + store.set(atoms.sessionsAtom, newDump) + } else if (newDump.ts < currentDump.ts) { + scheduleSaveSessionsToBackend(currentDump) + } +} + +export function getSessionsDump() { const store = getDefaultStore() return store.get(atoms.sessionsAtom) } +export function getSessions() { + const store = getDefaultStore() + return store.get(atoms.sessionsAtom).sessions +} + export function getSortedSessions() { const store = getDefaultStore() return store.get(atoms.sortedSessionsAtom) diff --git a/src/shared/types.ts b/src/shared/types.ts index fe07c9f6..feb9e4f5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,11 @@ export interface Session { copilotId?: string } +export interface SessionsDump { + ts: number + sessions: Session[] +} + export function createMessage(role: MessageRole = MessageRoleEnum.User, content: string = ''): Message { return { id: uuidv4(),