2025-02-06 17:32:29 +01:00
|
|
|
|
// coversations is stored in localStorage
|
|
|
|
|
|
// format: { [convId]: { id: string, lastModified: number, messages: [...] } }
|
|
|
|
|
|
|
|
|
|
|
|
import { CONFIG_DEFAULT } from '../Config';
|
2025-02-10 21:23:17 +01:00
|
|
|
|
import { Conversation, Message, TimingReport } from './types';
|
|
|
|
|
|
import Dexie, { Table } from 'dexie';
|
2025-02-06 17:32:29 +01:00
|
|
|
|
|
|
|
|
|
|
const event = new EventTarget();
|
|
|
|
|
|
|
|
|
|
|
|
type CallbackConversationChanged = (convId: string) => void;
|
|
|
|
|
|
let onConversationChangedHandlers: [
|
|
|
|
|
|
CallbackConversationChanged,
|
|
|
|
|
|
EventListener,
|
|
|
|
|
|
][] = [];
|
|
|
|
|
|
const dispatchConversationChange = (convId: string) => {
|
|
|
|
|
|
event.dispatchEvent(
|
|
|
|
|
|
new CustomEvent('conversationChange', { detail: { convId } })
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-02-10 21:23:17 +01:00
|
|
|
|
const db = new Dexie('LlamacppWebui') as Dexie & {
|
|
|
|
|
|
conversations: Table<Conversation>;
|
|
|
|
|
|
messages: Table<Message>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// https://dexie.org/docs/Version/Version.stores()
|
|
|
|
|
|
db.version(1).stores({
|
|
|
|
|
|
// Unlike SQL, you don’t need to specify all properties but only the one you wish to index.
|
|
|
|
|
|
conversations: '&id, lastModified',
|
|
|
|
|
|
messages: '&id, convId, [convId+id], timestamp',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-02-06 17:32:29 +01:00
|
|
|
|
// convId is a string prefixed with 'conv-'
|
|
|
|
|
|
const StorageUtils = {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* manage conversations
|
|
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
async getAllConversations(): Promise<Conversation[]> {
|
|
|
|
|
|
await migrationLStoIDB().catch(console.error); // noop if already migrated
|
|
|
|
|
|
return (await db.conversations.toArray()).sort(
|
|
|
|
|
|
(a, b) => b.lastModified - a.lastModified
|
|
|
|
|
|
);
|
2025-02-06 17:32:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
|
|
|
* can return null if convId does not exist
|
|
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
async getOneConversation(convId: string): Promise<Conversation | null> {
|
|
|
|
|
|
return (await db.conversations.where('id').equals(convId).first()) ?? null;
|
2025-02-06 17:32:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
2025-02-10 21:23:17 +01:00
|
|
|
|
* get all message nodes in a conversation
|
2025-02-06 17:32:29 +01:00
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
async getMessages(convId: string): Promise<Message[]> {
|
|
|
|
|
|
return await db.messages.where({ convId }).toArray();
|
2025-02-06 17:32:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
2025-02-10 21:23:17 +01:00
|
|
|
|
* use in conjunction with getMessages to filter messages by leafNodeId
|
|
|
|
|
|
* includeRoot: whether to include the root node in the result
|
|
|
|
|
|
* if node with leafNodeId does not exist, return the path with the latest timestamp
|
2025-02-06 17:32:29 +01:00
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
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) {
|
|
|
|
|
|
// if not found, we return the path with the latest timestamp
|
|
|
|
|
|
let latestTime = -1;
|
|
|
|
|
|
for (const msg of msgs) {
|
|
|
|
|
|
if (msg.timestamp > latestTime) {
|
|
|
|
|
|
startNode = msg;
|
|
|
|
|
|
latestTime = msg.timestamp;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// traverse the path from leafNodeId to root
|
|
|
|
|
|
// startNode can never be undefined here
|
|
|
|
|
|
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;
|
2025-02-06 17:32:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
/**
|
2025-02-10 21:23:17 +01:00
|
|
|
|
* create a new conversation with a default root node
|
2025-02-06 17:32:29 +01:00
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
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);
|
|
|
|
|
|
// create a root node
|
|
|
|
|
|
await db.messages.add({
|
|
|
|
|
|
id: msgId,
|
|
|
|
|
|
convId: conv.id,
|
|
|
|
|
|
type: 'root',
|
|
|
|
|
|
timestamp: now,
|
|
|
|
|
|
role: 'system',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
parent: -1,
|
|
|
|
|
|
children: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
return conv;
|
2025-02-06 17:32:29 +01:00
|
|
|
|
},
|
2025-05-08 15:37:29 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* update the name of a conversation
|
|
|
|
|
|
*/
|
|
|
|
|
|
async updateConversationName(convId: string, name: string): Promise<void> {
|
|
|
|
|
|
await db.conversations.update(convId, {
|
|
|
|
|
|
name,
|
|
|
|
|
|
lastModified: Date.now(),
|
|
|
|
|
|
});
|
|
|
|
|
|
dispatchConversationChange(convId);
|
|
|
|
|
|
},
|
2025-02-06 17:32:29 +01:00
|
|
|
|
/**
|
2025-02-10 21:23:17 +01:00
|
|
|
|
* if convId does not exist, throw an error
|
2025-02-06 17:32:29 +01:00
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
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();
|
|
|
|
|
|
// update the currNode of conversation
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
// update parent
|
|
|
|
|
|
await db.messages.update(parentNodeId, {
|
|
|
|
|
|
children: [...parentMsg.children, msg.id],
|
|
|
|
|
|
});
|
|
|
|
|
|
// create message
|
|
|
|
|
|
await db.messages.add({
|
|
|
|
|
|
...msg,
|
|
|
|
|
|
parent: parentNodeId,
|
|
|
|
|
|
children: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-02-06 17:32:29 +01:00
|
|
|
|
dispatchConversationChange(convId);
|
|
|
|
|
|
},
|
|
|
|
|
|
/**
|
2025-02-10 21:23:17 +01:00
|
|
|
|
* remove conversation by id
|
2025-02-06 17:32:29 +01:00
|
|
|
|
*/
|
2025-02-10 21:23:17 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2025-02-06 17:32:29 +01:00
|
|
|
|
dispatchConversationChange(convId);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// event listeners
|
|
|
|
|
|
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 = [];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// manage config
|
|
|
|
|
|
getConfig(): typeof CONFIG_DEFAULT {
|
|
|
|
|
|
const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
|
|
|
|
|
|
// to prevent breaking changes in the future, we always provide default value for missing keys
|
|
|
|
|
|
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;
|
2025-02-10 21:23:17 +01:00
|
|
|
|
|
|
|
|
|
|
// Migration from localStorage to IndexedDB
|
|
|
|
|
|
|
|
|
|
|
|
// these are old types, LS prefix stands for LocalStorage
|
|
|
|
|
|
interface LSConversation {
|
|
|
|
|
|
id: string; // format: `conv-{timestamp}`
|
|
|
|
|
|
lastModified: number; // timestamp from Date.now()
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|