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