diff --git a/public/demo-conversation.json b/public/demo-conversation.json
index 338b4ae..f93ed11 100644
--- a/public/demo-conversation.json
+++ b/public/demo-conversation.json
@@ -1,33 +1,83 @@
-{
- "demo": true,
- "id": "conv-1734086746930",
- "lastModified": 1734087548943,
- "messages": [
- {
- "id": 1734086764521,
- "role": "user",
- "content": "this is a demo conversation, used in dev mode"
- },
- {
- "id": 1734087548327,
- "role": "assistant",
- "content": "This is the formula:\n\n$\\frac{e^{x_i}}{\\sum_{j=1}^{n}e^{x_j}}$\n\nGiven an input vector \\(\\mathbf{x} = [x_1, x_2, \\ldots, x_n]\\)\n\n\\[\ny_i = \\frac{e^{x_i}}{\\sum_{j=1}^n e^{x_j}}\n\\]\n\n$2x + y = z$\n\nCode block latex:\n```latex\n\\frac{e^{x_i}}{\\sum_{j=1}^{n}e^{x_j}}\n```\n\nTest dollar sign: $1234 $4567\n\nInvalid latex syntax: $E = mc^$ and $$E = mc^$$",
- "timings": {
- "prompt_n": 1,
- "prompt_ms": 28.923,
- "predicted_n": 25,
- "predicted_ms": 573.016
+[
+ {
+ "table": "conversations",
+ "rows": [
+ {
+ "id": "conv-1734086746930",
+ "lastModified": 1734087548943,
+ "currNode": 1755372088111,
+ "name": "Technical Demo"
}
- },
- {
- "id": 1734087548328,
- "role": "user",
- "content": "this is a demo conversation, used in dev mode"
- },
- {
- "id": 1734087548329,
- "role": "assistant",
- "content": "Code block:\n```js\nconsole.log('hello world')\n```\n```sh\nls -la /dev\n```"
- }
- ]
-}
+ ]
+ },
+ {
+ "table": "messages",
+ "rows": [
+ {
+ "id": 1734086746930,
+ "convId": "conv-1734086746930",
+ "type": "root",
+ "timestamp": 1734086746930,
+ "role": "system",
+ "content": "",
+ "parent": -1,
+ "children": [
+ 1734086764521,
+ 1734087548328
+ ]
+ },
+ {
+ "id": 1734086764521,
+ "timestamp": 1734086764521,
+ "type": "text",
+ "convId": "conv-1734086746930",
+ "role": "user",
+ "content": "A LaTeX block demo conversation",
+ "parent": 1734086746930,
+ "children": [
+ 1734087548327
+ ]
+ },
+ {
+ "id": 1734087548327,
+ "timestamp": 1734087548327,
+ "type": "text",
+ "convId": "conv-1734086746930",
+ "model": "gpt-3.5-turbo",
+ "role": "assistant",
+ "content": "This is the formula:\n\n$\\frac{e^{x_i}}{\\sum_{j=1}^{n}e^{x_j}}$\n\nGiven an input vector \\(\\mathbf{x} = [x_1, x_2, \\ldots, x_n]\\)\n\n\\[\ny_i = \\frac{e^{x_i}}{\\sum_{j=1}^n e^{x_j}}\n\\]\n\n$2x + y = z$\n\nCode block latex:\n```latex\n\\frac{e^{x_i}}{\\sum_{j=1}^{n}e^{x_j}}\n```\n\nTest dollar sign: $1234 $4567\n\nInvalid latex syntax: $E = mc^$ and $$E = mc^$$",
+ "parent": 1734086764521,
+ "children": [],
+ "timings": {
+ "prompt_n": 1,
+ "prompt_ms": 28.923,
+ "predicted_n": 25,
+ "predicted_ms": 573.016
+ }
+ },
+ {
+ "id": 1734087548328,
+ "timestamp": 1734087548328,
+ "type": "text",
+ "convId": "conv-1734086746930",
+ "role": "user",
+ "content": "A code block demo conversation",
+ "parent": 1734086746930,
+ "children": [
+ 1734087548329
+ ]
+ },
+ {
+ "id": 1734087548329,
+ "timestamp": 1734087548329,
+ "type": "text",
+ "convId": "conv-1734086746930",
+ "model": "gpt-3.5-turbo",
+ "role": "assistant",
+ "content": "Code block:\n```js\nconsole.log('hello world')\n```\n```sh\nls -la /dev\n```",
+ "parent": 1734087548328,
+ "children": []
+ }
+ ]
+ }
+]
diff --git a/src/components/SettingDialog.tsx b/src/components/SettingDialog.tsx
index 88be876..479e37b 100644
--- a/src/components/SettingDialog.tsx
+++ b/src/components/SettingDialog.tsx
@@ -1,4 +1,6 @@
import {
+ ArrowDownTrayIcon,
+ ArrowUpTrayIcon,
BeakerIcon,
ChatBubbleLeftEllipsisIcon,
ChatBubbleLeftRightIcon,
@@ -7,6 +9,7 @@ import {
Cog6ToothIcon,
CogIcon,
CpuChipIcon,
+ EyeIcon,
FunnelIcon,
HandRaisedIcon,
RocketLaunchIcon,
@@ -104,7 +107,10 @@ const toInput = (
// --- Setting Tabs Configuration ---
-const getSettingTabsConfiguration = (config: Configuration): SettingTab[] => [
+const getSettingTabsConfiguration = (
+ config: Configuration,
+ onClose: () => void
+): SettingTab[] => [
/* General */
{
title: (
@@ -184,6 +190,32 @@ const getSettingTabsConfiguration = (config: Configuration): SettingTab[] => [
>
),
fields: [
+ {
+ type: SettingInputType.SECTION,
+ label: (
+ <>
+
+ Chats
+ >
+ ),
+ },
+ {
+ type: SettingInputType.CUSTOM,
+ key: 'custom', // dummy key, won't be used
+ component: () => ,
+ },
+ {
+ type: SettingInputType.DELIMETER,
+ },
+ {
+ type: SettingInputType.SECTION,
+ label: (
+ <>
+
+ Technical Demo
+ >
+ ),
+ },
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
@@ -193,17 +225,15 @@ const getSettingTabsConfiguration = (config: Configuration): SettingTab[] => [
const res = await fetch('/demo-conversation.json');
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const demoConv = await res.json();
- StorageUtils.remove(demoConv.id);
- for (const msg of demoConv.messages) {
- StorageUtils.appendMsg(demoConv.id, msg);
- }
+ StorageUtils.importDB(demoConv);
+ onClose();
} catch (error) {
console.error('Failed to import demo conversation:', error);
}
};
return (
);
},
@@ -396,7 +426,7 @@ export default function SettingDialog({
JSON.parse(JSON.stringify(config))
);
const settingTabs = useMemo(
- () => getSettingTabsConfiguration(localConfig),
+ () => getSettingTabsConfiguration(localConfig, onClose),
[localConfig]
);
@@ -684,3 +714,61 @@ const SettingsModalCheckbox: React.FC = ({
>
);
};
+const ImportExportComponent: React.FC<{ onClose: () => void }> = ({
+ onClose,
+}) => {
+ const onExport = async () => {
+ const data = await StorageUtils.exportDB();
+ const conversationJson = JSON.stringify(data, null, 2);
+ const blob = new Blob([conversationJson], {
+ type: 'application/json',
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `database.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const onImport = async (e: React.ChangeEvent) => {
+ try {
+ const files = e.target.files;
+ if (!files || files.length != 1) return false;
+ const data = await files[0].text();
+ await StorageUtils.importDB(JSON.parse(data));
+ onClose();
+ } catch (error) {
+ console.error('Failed to import file:', error);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
index 6a7a53b..9c04f56 100644
--- a/src/utils/storage.ts
+++ b/src/utils/storage.ts
@@ -1,39 +1,75 @@
-// coversations is stored in localStorage
-// format: { [convId]: { id: string, lastModified: number, messages: [...] } }
+// Conversations are stored in IndexedDB via Dexie.
+// Format (conceptual): { [convId]: { id: string, lastModified: number, messages: [...] } }
import Dexie, { Table } from 'dexie';
-import { CONFIG_DEFAULT } from '../config';
-import { Configuration, Conversation, Message, TimingReport } from './types';
+import toast from 'react-hot-toast';
+import { CONFIG_DEFAULT, isDev } from '../config';
+import {
+ Configuration,
+ Conversation,
+ ExportJsonStructure,
+ Message,
+ TimingReport,
+} from './types';
+// --- Event Handling ---
+
+/**
+ * Event target for internal communication about conversation changes.
+ */
const event = new EventTarget();
+/**
+ * Type for callback functions triggered on conversation change.
+ */
type CallbackConversationChanged = (convId: string) => void;
-let onConversationChangedHandlers: [
+
+/**
+ * Stores registered event listeners to allow for removal.
+ */
+const onConversationChangedHandlers: [
CallbackConversationChanged,
EventListener,
][] = [];
+
+/**
+ * Dispatches a custom event indicating a conversation has changed.
+ * @param convId The ID of the conversation that changed.
+ */
const dispatchConversationChange = (convId: string) => {
event.dispatchEvent(
- new CustomEvent('conversationChange', { detail: { convId } })
+ new CustomEvent('conversationChange', { detail: convId })
);
};
+// --- Dexie Database Setup ---
+
+/**
+ * Dexie database instance for the application.
+ */
const db = new Dexie('LlamacppWebui') as Dexie & {
- conversations: Table;
- messages: Table;
+ conversations: Table;
+ messages: Table;
};
+// Define database schema
// 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.
+ // Index conversations by 'id' (unique) and 'lastModified'
conversations: '&id, lastModified',
+ // Index messages by 'id' (unique), 'convId', composite key '[convId+id]', and 'timestamp'
messages: '&id, convId, [convId+id], timestamp',
});
-// convId is a string prefixed with 'conv-'
+// --- Main Storage Utility Functions ---
+
+/**
+ * Utility functions for interacting with application data (conversations, messages, config).
+ */
const StorageUtils = {
/**
- * manage conversations
+ * Retrieves all conversations, sorted by last modified date (descending).
+ * @returns A promise resolving to an array of Conversation objects.
*/
async getAllConversations(): Promise {
await migrationLStoIDB().catch(console.error); // noop if already migrated
@@ -41,22 +77,32 @@ const StorageUtils = {
(a, b) => b.lastModified - a.lastModified
);
},
+
/**
- * can return null if convId does not exist
+ * Retrieves a single conversation by its ID.
+ * @param convId The ID of the conversation to retrieve.
+ * @returns A promise resolving to the Conversation object or null if not found.
*/
async getOneConversation(convId: string): Promise {
- return (await db.conversations.where('id').equals(convId).first()) ?? null;
+ return (await db.conversations.get(convId)) ?? null;
},
+
/**
- * get all message nodes in a conversation
+ * Retrieves all messages belonging to a specific conversation.
+ * @param convId The ID of the conversation.
+ * @returns A promise resolving to an array of Message objects.
*/
async getMessages(convId: string): Promise {
- return await db.messages.where({ convId }).toArray();
+ return await db.messages.where('convId').equals(convId).toArray();
},
+
/**
- * 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
+ * Filters messages to represent the path from a given leaf node to the root.
+ * @param msgs The array of messages to filter (typically from getMessages).
+ * @param leafNodeId The ID of the leaf message node.
+ * @param includeRoot Whether to include the root node in the result.
+ * @returns A new array of messages representing the path from leaf to root (sorted by timestamp).
+ * If leafNodeId is not found, returns the path ending at the message with the latest timestamp.
*/
filterByLeafNodeId(
msgs: Readonly,
@@ -68,9 +114,10 @@ const StorageUtils = {
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
+ // If leaf node not found, find the message with the latest timestamp
let latestTime = -1;
for (const msg of msgs) {
if (msg.timestamp > latestTime) {
@@ -79,45 +126,66 @@ const StorageUtils = {
}
}
}
- // traverse the path from leafNodeId to root
- // startNode can never be undefined here
+
+ // Traverse the path from the start node (found leaf or latest) up to the root
let currNode: Message | undefined = startNode;
while (currNode) {
- if (currNode.type !== 'root' || (currNode.type === 'root' && includeRoot))
+ // Add node to result if it's not the root, or if it is the root and we want to include it
+ if (
+ currNode.type !== 'root' ||
+ (currNode.type === 'root' && includeRoot)
+ ) {
res.push(currNode);
+ }
+ // Move to the parent node
currNode = nodeMap.get(currNode.parent ?? -1);
}
+
+ // Sort the result by timestamp to ensure chronological order
res.sort((a, b) => a.timestamp - b.timestamp);
return res;
},
+
/**
- * create a new conversation with a default root node
+ * Creates a new conversation with an initial root message.
+ * @param name The name/title for the new conversation.
+ * @returns A promise resolving to the newly created Conversation object.
*/
async createConversation(name: string): Promise {
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: [],
+
+ await db.transaction('rw', db.conversations, db.messages, async () => {
+ await db.conversations.add(conv);
+ // Create the initial root node
+ await db.messages.add({
+ id: msgId,
+ convId: conv.id,
+ type: 'root',
+ timestamp: now,
+ role: 'system',
+ content: '',
+ parent: -1,
+ children: [],
+ });
});
+
+ dispatchConversationChange(conv.id);
return conv;
},
+
/**
- * update the name of a conversation
+ * Updates the name and lastModified timestamp of an existing conversation.
+ * @param convId The ID of the conversation to update.
+ * @param name The new name for the conversation.
+ * @returns A promise that resolves when the update is complete.
*/
async updateConversationName(convId: string, name: string): Promise {
await db.conversations.update(convId, {
@@ -126,21 +194,28 @@ const StorageUtils = {
});
dispatchConversationChange(convId);
},
+
/**
- * if convId does not exist, throw an error
+ * Appends a new message to a conversation as a child of a specified parent node.
+ * @param msg The message content to append (must have content).
+ * @param parentNodeId The ID of the parent message node.
+ * @returns A promise that resolves when the message is appended.
+ * @throws Error if the conversation or parent message does not exist.
*/
async appendMsg(
msg: Exclude,
parentNodeId: Message['id']
): Promise {
+ // Early return if message content is null
if (msg.content === null) return;
+
const { convId } = msg;
+
await db.transaction('rw', db.conversations, db.messages, async () => {
+ // Fetch conversation and parent message within the transaction
const conv = await StorageUtils.getOneConversation(convId);
- const parentMsg = await db.messages
- .where({ convId, id: parentNodeId })
- .first();
- // update the currNode of conversation
+ const parentMsg = await db.messages.get({ convId, id: parentNodeId });
+
if (!conv) {
throw new Error(`Conversation ${convId} does not exist`);
}
@@ -149,63 +224,197 @@ const StorageUtils = {
`Parent message ID ${parentNodeId} does not exist in conversation ${convId}`
);
}
+
+ // Update conversation's lastModified and currNode
await db.conversations.update(convId, {
lastModified: Date.now(),
currNode: msg.id,
});
- // update parent
+
+ // Update parent's children array
await db.messages.update(parentNodeId, {
children: [...parentMsg.children, msg.id],
});
- // create message
+
+ // Add the new message
await db.messages.add({
...msg,
parent: parentNodeId,
children: [],
});
});
+
+ // Dispatch event after successful transaction
dispatchConversationChange(convId);
},
+
/**
- * remove conversation by id
+ * Removes a conversation and all its associated messages.
+ * @param convId The ID of the conversation to remove.
+ * @returns A promise that resolves when the conversation is removed.
*/
async remove(convId: string): Promise {
await db.transaction('rw', db.conversations, db.messages, async () => {
await db.conversations.delete(convId);
- await db.messages.where({ convId }).delete();
+ await db.messages.where('convId').equals(convId).delete();
});
dispatchConversationChange(convId);
},
- // event listeners
+ // --- Export / Import Functions ---
+
+ /**
+ * Exports all from the database.
+ * @returns A promise resolving to a database records.
+ */
+ async exportDB(): Promise {
+ try {
+ const exportData = await db.transaction('r', db.tables, async () => {
+ const data: ExportJsonStructure = [];
+ for (const table of db.tables) {
+ const rows = await table.toArray();
+ if (isDev)
+ console.debug(
+ `Export - Fetched ${rows.length} rows from table '${table.name}'.`
+ );
+ data.push({ table: table.name, rows: rows });
+ }
+ return data;
+ });
+ console.info('Database export completed successfully.');
+ if (isDev)
+ exportData.forEach((tableData) => {
+ console.debug(
+ `Exported table '${tableData.table}' with ${tableData.rows.length} rows.`
+ );
+ });
+ return exportData;
+ } catch (error) {
+ console.error('Error during database export:', error);
+ toast.error('An error occurred during database export.');
+ throw error; // Re-throw to allow caller to handle
+ }
+ },
+
+ /**
+ * Import data into database.
+ * @returns A promise that resolves when import is complete.
+ */
+ async importDB(data: ExportJsonStructure) {
+ try {
+ await db.transaction('rw', db.tables, async () => {
+ for (const record of data) {
+ console.debug(`Import - Processing table '${record.table}'...`);
+ if (db.tables.some((t) => t.name === record.table)) {
+ // Override existing rows if key exists.
+ await db.table(record.table).bulkPut(record.rows);
+ console.debug(
+ `Import - Imported ${record.rows.length} rows into table '${record.table}'.`
+ );
+ } else {
+ console.warn(`Import - Skipping unknown table '${record.table}'.`);
+ }
+ }
+
+ // Dispatch change events for conversations that were imported/updated.
+ const convRecords = data.filter(
+ (r) => r.table === db.conversations.name
+ );
+ for (const record of convRecords) {
+ for (const row of record.rows) {
+ const convRow = row as Partial;
+ if (convRow.id !== undefined) {
+ dispatchConversationChange(convRow.id);
+ } else {
+ console.warn("Imported conversation row missing 'id':", row);
+ }
+ }
+ }
+ });
+ console.info('Database import completed successfully.');
+ } catch (error) {
+ console.error('Error during database import:', error);
+ toast.error('An error occurred during data import.');
+ throw error; // Re-throw to allow caller to handle
+ }
+ },
+
+ // --- Event Listeners ---
+
+ /**
+ * Registers a callback to be invoked when a conversation changes.
+ * @param callback The function to call when a conversation changes.
+ */
onConversationChanged(callback: CallbackConversationChanged) {
- const fn = (e: Event) => callback((e as CustomEvent).detail.convId);
- onConversationChangedHandlers.push([callback, fn]);
- event.addEventListener('conversationChange', fn);
+ const wrappedListener: EventListener = (event: Event) => {
+ const customEvent = event as CustomEvent;
+ callback(customEvent.detail); // Pass the convId from the event detail
+ };
+ onConversationChangedHandlers.push([callback, wrappedListener]);
+ event.addEventListener('conversationChange', wrappedListener);
},
+
+ /**
+ * Unregisters a previously registered conversation change callback.
+ * @param callback The function to unregister.
+ */
offConversationChanged(callback: CallbackConversationChanged) {
- const fn = onConversationChangedHandlers.find(([cb, _]) => cb === callback);
- if (fn) {
- event.removeEventListener('conversationChange', fn[1]);
+ const index = onConversationChangedHandlers.findIndex(
+ ([cb, _]) => cb === callback
+ );
+ if (index !== -1) {
+ const [_, wrappedListener] = onConversationChangedHandlers[index];
+ event.removeEventListener('conversationChange', wrappedListener);
+ onConversationChangedHandlers.splice(index, 1); // Remove the specific listener entry
}
- onConversationChangedHandlers = [];
},
- // manage config
+ // --- Configuration Management (localStorage) ---
+
+ /**
+ * Retrieves the current application configuration.
+ * Merges saved values with defaults to handle missing keys.
+ * @returns The current Configuration object.
+ */
getConfig(): Configuration {
- const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
- // to prevent breaking changes in the future, we always provide default value for missing keys
+ const savedConfigString = localStorage.getItem('config');
+ let savedVal: Partial = {};
+ if (savedConfigString) {
+ try {
+ savedVal = JSON.parse(savedConfigString);
+ } catch (e) {
+ console.error('Failed to parse saved config from localStorage:', e);
+ toast.error('Failed to parse saved config.');
+ }
+ }
+ // Provide default values for any missing keys
return {
...CONFIG_DEFAULT,
...savedVal,
};
},
+
+ /**
+ * Saves the application configuration to localStorage.
+ * @param config The Configuration object to save.
+ */
setConfig(config: Configuration) {
localStorage.setItem('config', JSON.stringify(config));
},
+
+ /**
+ * Retrieves the currently selected UI theme.
+ * @returns The theme string ('auto', 'light', 'dark', etc.) or 'auto' if not set.
+ */
getTheme(): string {
return localStorage.getItem('theme') || 'auto';
},
+
+ /**
+ * Saves the selected UI theme to localStorage.
+ * If 'auto' is selected, the theme item is removed.
+ * @param theme The theme string to save.
+ */
setTheme(theme: string) {
if (theme === 'auto') {
localStorage.removeItem('theme');
@@ -217,7 +426,7 @@ const StorageUtils = {
export default StorageUtils;
-// Migration from localStorage to IndexedDB
+// --- Migration Logic (from localStorage to IndexedDB) ---
// these are old types, LS prefix stands for LocalStorage
interface LSConversation {
@@ -231,64 +440,115 @@ interface LSMessage {
content: string;
timings?: TimingReport;
}
+
+/**
+ * Migrates conversation data from localStorage to IndexedDB.
+ * Runs only once, indicated by the 'migratedToIDB' flag in localStorage.
+ * @returns A promise that resolves when migration is complete or skipped.
+ */
async function migrationLStoIDB() {
- if (localStorage.getItem('migratedToIDB')) return;
+ const MIGRATION_FLAG_KEY = 'migratedToIDB';
+ if (localStorage.getItem(MIGRATION_FLAG_KEY)) {
+ return; // Already migrated
+ }
+
+ console.log('Starting migration from localStorage to IndexedDB...');
const res: LSConversation[] = [];
+
+ // Iterate through localStorage keys to find conversation data
for (const key in localStorage) {
if (key.startsWith('conv-')) {
- res.push(JSON.parse(localStorage.getItem(key) ?? '{}'));
+ try {
+ const item = localStorage.getItem(key);
+ if (item) {
+ const parsedItem: unknown = JSON.parse(item);
+ res.push(parsedItem as LSConversation);
+ }
+ } catch (e) {
+ console.warn(`Failed to parse localStorage item with key ${key}:`, e);
+ }
}
}
- 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];
+
+ if (res.length === 0) {
+ console.log('No legacy conversations found for migration.');
+ return;
+ }
+
+ // Perform migration within a single transaction
+ await db
+ .transaction('rw', db.conversations, db.messages, async () => {
+ let migratedCount = 0;
+ for (const conv of res) {
+ const { id: convId, lastModified, messages } = conv;
+
+ // Validate legacy conversation structure
+ if (messages.length < 2) {
+ console.log(
+ `Skipping conversation ${convId} with fewer than 2 messages.`
+ );
+ continue;
+ }
+ const firstMsg = messages[0];
+ const lastMsg = messages[messages.length - 1];
+ if (!firstMsg || !lastMsg) {
+ console.log(
+ `Skipping conversation ${convId} with ${messages.length} messages.`
+ );
+ continue;
+ }
+
+ const name = firstMsg.content || '(no messages)';
+
+ // 1. Add the conversation record
+ await db.conversations.add({
+ id: convId,
+ lastModified,
+ currNode: lastMsg.id,
+ name,
+ });
+
+ // 2. Create and add the root node
+ const rootId = firstMsg.id - 2;
await db.messages.add({
- ...msg,
- type: 'text',
+ id: rootId,
convId: convId,
- timestamp: msg.id,
- parent: i === 0 ? rootId : messages[i - 1].id,
- children: i === messages.length - 1 ? [] : [messages[i + 1].id],
+ type: 'root',
+ timestamp: rootId,
+ role: 'system',
+ content: '',
+ parent: -1,
+ children: [firstMsg.id],
});
+
+ // 3. Add the legacy messages, linking them appropriately
+ 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.`
+ );
}
- migratedCount++;
console.log(
- `Migrated conversation ${convId} with ${messages.length} messages`
+ `Migration complete. Migrated ${migratedCount} conversations from localStorage to IndexedDB.`
);
- }
- console.log(
- `Migrated ${migratedCount} conversations from localStorage to IndexedDB`
- );
- localStorage.setItem('migratedToIDB', '1');
- });
+ })
+ .then(() => {
+ // Mark migration as complete only after the transaction finishes successfully
+ localStorage.setItem(MIGRATION_FLAG_KEY, '1');
+ })
+ .catch((error) => {
+ console.error('Error during migration transaction:', error);
+ toast.error('An error occurred during data migration.');
+ });
}
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 87a9f64..a30d8de 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -173,6 +173,14 @@ export interface MessageDisplay {
isPending?: boolean;
}
+/**
+ * Data format on export messages
+ */
+export type ExportJsonStructure = Array<{
+ table: string;
+ rows: unknown[];
+}>;
+
export enum CanvasType {
PY_INTERPRETER,
}