| | |
| | |
| |
|
| | import { CONFIG_DEFAULT } from '../Config'; |
| | import { Conversation, Message, TimingReport } from './types'; |
| | import Dexie, { Table } from 'dexie'; |
| |
|
| | const event = new EventTarget(); |
| |
|
| | type CallbackConversationChanged = (convId: string) => void; |
| | let onConversationChangedHandlers: [ |
| | CallbackConversationChanged, |
| | EventListener, |
| | ][] = []; |
| | const dispatchConversationChange = (convId: string) => { |
| | event.dispatchEvent( |
| | new CustomEvent('conversationChange', { detail: { convId } }) |
| | ); |
| | }; |
| |
|
| | const db = new Dexie('LlamacppWebui') as Dexie & { |
| | conversations: Table<Conversation>; |
| | messages: Table<Message>; |
| | }; |
| |
|
| | |
| | db.version(1).stores({ |
| | |
| | conversations: '&id, lastModified', |
| | messages: '&id, convId, [convId+id], timestamp', |
| | }); |
| |
|
| | |
| | const StorageUtils = { |
| | |
| | |
| | |
| | async getAllConversations(): Promise<Conversation[]> { |
| | await migrationLStoIDB().catch(console.error); |
| | return (await db.conversations.toArray()).sort( |
| | (a, b) => b.lastModified - a.lastModified |
| | ); |
| | }, |
| | |
| | |
| | |
| | async getOneConversation(convId: string): Promise<Conversation | null> { |
| | return (await db.conversations.where('id').equals(convId).first()) ?? null; |
| | }, |
| | |
| | |
| | |
| | async getMessages(convId: string): Promise<Message[]> { |
| | return await db.messages.where({ convId }).toArray(); |
| | }, |
| | |
| | |
| | |
| | |
| | |
| | filterByLeafNodeId( |
| | msgs: Readonly<Message[]>, |
| | leafNodeId: Message['id'], |
| | includeRoot: boolean |
| | ): Readonly<Message[]> { |
| | const res: Message[] = []; |
| | const nodeMap = new Map<Message['id'], Message>(); |
| | for (const msg of msgs) { |
| | nodeMap.set(msg.id, msg); |
| | } |
| | let startNode: Message | undefined = nodeMap.get(leafNodeId); |
| | if (!startNode) { |
| | |
| | let latestTime = -1; |
| | for (const msg of msgs) { |
| | if (msg.timestamp > latestTime) { |
| | startNode = msg; |
| | latestTime = msg.timestamp; |
| | } |
| | } |
| | } |
| | |
| | |
| | let currNode: Message | undefined = startNode; |
| | while (currNode) { |
| | if (currNode.type !== 'root' || (currNode.type === 'root' && includeRoot)) |
| | res.push(currNode); |
| | currNode = nodeMap.get(currNode.parent ?? -1); |
| | } |
| | res.sort((a, b) => a.timestamp - b.timestamp); |
| | return res; |
| | }, |
| | |
| | |
| | |
| | async createConversation(name: string): Promise<Conversation> { |
| | const now = Date.now(); |
| | const msgId = now; |
| | const conv: Conversation = { |
| | id: `conv-${now}`, |
| | lastModified: now, |
| | currNode: msgId, |
| | name, |
| | }; |
| | await db.conversations.add(conv); |
| | |
| | await db.messages.add({ |
| | id: msgId, |
| | convId: conv.id, |
| | type: 'root', |
| | timestamp: now, |
| | role: 'system', |
| | content: '', |
| | parent: -1, |
| | children: [], |
| | }); |
| | return conv; |
| | }, |
| | |
| | |
| | |
| | async appendMsg( |
| | msg: Exclude<Message, 'parent' | 'children'>, |
| | parentNodeId: Message['id'] |
| | ): Promise<void> { |
| | if (msg.content === null) return; |
| | const { convId } = msg; |
| | await db.transaction('rw', db.conversations, db.messages, async () => { |
| | const conv = await StorageUtils.getOneConversation(convId); |
| | const parentMsg = await db.messages |
| | .where({ convId, id: parentNodeId }) |
| | .first(); |
| | |
| | if (!conv) { |
| | throw new Error(`Conversation ${convId} does not exist`); |
| | } |
| | if (!parentMsg) { |
| | throw new Error( |
| | `Parent message ID ${parentNodeId} does not exist in conversation ${convId}` |
| | ); |
| | } |
| | await db.conversations.update(convId, { |
| | lastModified: Date.now(), |
| | currNode: msg.id, |
| | }); |
| | |
| | await db.messages.update(parentNodeId, { |
| | children: [...parentMsg.children, msg.id], |
| | }); |
| | |
| | await db.messages.add({ |
| | ...msg, |
| | parent: parentNodeId, |
| | children: [], |
| | }); |
| | }); |
| | dispatchConversationChange(convId); |
| | }, |
| | |
| | |
| | |
| | async remove(convId: string): Promise<void> { |
| | await db.transaction('rw', db.conversations, db.messages, async () => { |
| | await db.conversations.delete(convId); |
| | await db.messages.where({ convId }).delete(); |
| | }); |
| | dispatchConversationChange(convId); |
| | }, |
| |
|
| | |
| | onConversationChanged(callback: CallbackConversationChanged) { |
| | const fn = (e: Event) => callback((e as CustomEvent).detail.convId); |
| | onConversationChangedHandlers.push([callback, fn]); |
| | event.addEventListener('conversationChange', fn); |
| | }, |
| | offConversationChanged(callback: CallbackConversationChanged) { |
| | const fn = onConversationChangedHandlers.find(([cb, _]) => cb === callback); |
| | if (fn) { |
| | event.removeEventListener('conversationChange', fn[1]); |
| | } |
| | onConversationChangedHandlers = []; |
| | }, |
| |
|
| | |
| | getConfig(): typeof CONFIG_DEFAULT { |
| | const savedVal = JSON.parse(localStorage.getItem('config') || '{}'); |
| | |
| | return { |
| | ...CONFIG_DEFAULT, |
| | ...savedVal, |
| | }; |
| | }, |
| | setConfig(config: typeof CONFIG_DEFAULT) { |
| | localStorage.setItem('config', JSON.stringify(config)); |
| | }, |
| | getTheme(): string { |
| | return localStorage.getItem('theme') || 'auto'; |
| | }, |
| | setTheme(theme: string) { |
| | if (theme === 'auto') { |
| | localStorage.removeItem('theme'); |
| | } else { |
| | localStorage.setItem('theme', theme); |
| | } |
| | }, |
| | }; |
| |
|
| | export default StorageUtils; |
| |
|
| | |
| |
|
| | |
| | interface LSConversation { |
| | id: string; |
| | lastModified: number; |
| | messages: LSMessage[]; |
| | } |
| | interface LSMessage { |
| | id: number; |
| | role: 'user' | 'assistant' | 'system'; |
| | content: string; |
| | timings?: TimingReport; |
| | } |
| | async function migrationLStoIDB() { |
| | if (localStorage.getItem('migratedToIDB')) return; |
| | const res: LSConversation[] = []; |
| | for (const key in localStorage) { |
| | if (key.startsWith('conv-')) { |
| | res.push(JSON.parse(localStorage.getItem(key) ?? '{}')); |
| | } |
| | } |
| | if (res.length === 0) return; |
| | await db.transaction('rw', db.conversations, db.messages, async () => { |
| | let migratedCount = 0; |
| | for (const conv of res) { |
| | const { id: convId, lastModified, messages } = conv; |
| | const firstMsg = messages[0]; |
| | const lastMsg = messages.at(-1); |
| | if (messages.length < 2 || !firstMsg || !lastMsg) { |
| | console.log( |
| | `Skipping conversation ${convId} with ${messages.length} messages` |
| | ); |
| | continue; |
| | } |
| | const name = firstMsg.content ?? '(no messages)'; |
| | await db.conversations.add({ |
| | id: convId, |
| | lastModified, |
| | currNode: lastMsg.id, |
| | name, |
| | }); |
| | const rootId = messages[0].id - 2; |
| | await db.messages.add({ |
| | id: rootId, |
| | convId: convId, |
| | type: 'root', |
| | timestamp: rootId, |
| | role: 'system', |
| | content: '', |
| | parent: -1, |
| | children: [firstMsg.id], |
| | }); |
| | for (let i = 0; i < messages.length; i++) { |
| | const msg = messages[i]; |
| | await db.messages.add({ |
| | ...msg, |
| | type: 'text', |
| | convId: convId, |
| | timestamp: msg.id, |
| | parent: i === 0 ? rootId : messages[i - 1].id, |
| | children: i === messages.length - 1 ? [] : [messages[i + 1].id], |
| | }); |
| | } |
| | migratedCount++; |
| | console.log( |
| | `Migrated conversation ${convId} with ${messages.length} messages` |
| | ); |
| | } |
| | console.log( |
| | `Migrated ${migratedCount} conversations from localStorage to IndexedDB` |
| | ); |
| | localStorage.setItem('migratedToIDB', '1'); |
| | }); |
| | } |
| |
|