| |
| |
|
|
| 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'); |
| }); |
| } |
|
|