From 7ac4413e9fb5967576da2dce7b6a3b0bd5ac5292 Mon Sep 17 00:00:00 2001 From: Oleg Shulyakov Date: Sat, 16 Aug 2025 22:22:34 +0300 Subject: [PATCH 1/4] refactor: storage review --- src/utils/storage.ts | 300 +++++++++++++++++++++++++++++++++---------- 1 file changed, 235 insertions(+), 65 deletions(-) diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 6a7a53b..abe4ebc 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,39 +1,69 @@ -// 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'; +// --- 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 +71,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 +108,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 +120,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 +188,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 +218,119 @@ 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 + // --- 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 +342,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,35 +356,75 @@ 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; + + 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.at(-1); - if (messages.length < 2 || !firstMsg || !lastMsg) { + const lastMsg = messages[messages.length - 1]; + if (!firstMsg || !lastMsg) { console.log( - `Skipping conversation ${convId} with ${messages.length} messages` + `Skipping conversation ${convId} with ${messages.length} messages.` ); continue; } - const name = firstMsg.content ?? '(no messages)'; + + const name = firstMsg.content || '(no messages)'; + + // 1. Add the conversation record await db.conversations.add({ id: convId, lastModified, currNode: lastMsg.id, name, }); - const rootId = messages[0].id - 2; + + // 2. Create and add the root node + const rootId = firstMsg.id - 2; await db.messages.add({ id: rootId, convId: convId, @@ -270,6 +435,8 @@ async function migrationLStoIDB() { 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({ @@ -281,14 +448,17 @@ async function migrationLStoIDB() { children: i === messages.length - 1 ? [] : [messages[i + 1].id], }); } + migratedCount++; console.log( - `Migrated conversation ${convId} with ${messages.length} messages` + `Migrated conversation ${convId} with ${messages.length} messages.` ); } console.log( - `Migrated ${migratedCount} conversations from localStorage to IndexedDB` + `Migration complete. Migrated ${migratedCount} conversations from localStorage to IndexedDB.` ); - localStorage.setItem('migratedToIDB', '1'); + + // Mark migration as complete + localStorage.setItem(MIGRATION_FLAG_KEY, '1'); }); } From 27cb4f37fd1cfed883af7aa65cca83021d736312 Mon Sep 17 00:00:00 2001 From: Oleg Shulyakov Date: Sun, 17 Aug 2025 00:19:55 +0300 Subject: [PATCH 2/4] style: code review --- src/utils/storage.ts | 130 ++++++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 62 deletions(-) diff --git a/src/utils/storage.ts b/src/utils/storage.ts index abe4ebc..0c6b9e7 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -2,9 +2,9 @@ // Format (conceptual): { [convId]: { id: string, lastModified: number, messages: [...] } } import Dexie, { Table } from 'dexie'; +import toast from 'react-hot-toast'; import { CONFIG_DEFAULT } from '../config'; import { Configuration, Conversation, Message, TimingReport } from './types'; -import toast from 'react-hot-toast'; // --- Event Handling --- @@ -300,7 +300,7 @@ const StorageUtils = { savedVal = JSON.parse(savedConfigString); } catch (e) { console.error('Failed to parse saved config from localStorage:', e); - toast.error('Failed to parse saved config.`'); + toast.error('Failed to parse saved config.'); } } // Provide default values for any missing keys @@ -392,73 +392,79 @@ async function migrationLStoIDB() { } // 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; - } + 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)'; + 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({ - id: rootId, - convId: convId, - type: 'root', - timestamp: rootId, - role: 'system', - content: '', - parent: -1, - children: [firstMsg.id], - }); + // 1. Add the conversation record + await db.conversations.add({ + id: convId, + lastModified, + currNode: lastMsg.id, + name, + }); - // 3. Add the legacy messages, linking them appropriately - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; + // 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], }); - } - migratedCount++; + // 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.` + ); + } console.log( - `Migrated conversation ${convId} with ${messages.length} messages.` + `Migration complete. Migrated ${migratedCount} conversations from localStorage to IndexedDB.` ); - } - console.log( - `Migration complete. Migrated ${migratedCount} conversations from localStorage to IndexedDB.` - ); - - // Mark migration as complete - localStorage.setItem(MIGRATION_FLAG_KEY, '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.'); + }); } From 8f8114446a474cd33954f3a2e6f693f5d42e8968 Mon Sep 17 00:00:00 2001 From: Oleg Shulyakov Date: Sun, 17 Aug 2025 01:05:52 +0300 Subject: [PATCH 3/4] feat: import export functionality --- public/demo-conversation.json | 114 ++++++++++++++++++++++--------- src/components/SettingDialog.tsx | 92 +++++++++++++++++++++++-- src/utils/storage.ts | 88 +++++++++++++++++++++++- src/utils/types.ts | 8 +++ 4 files changed, 261 insertions(+), 41 deletions(-) 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..33a3e37 100644 --- a/src/components/SettingDialog.tsx +++ b/src/components/SettingDialog.tsx @@ -104,7 +104,10 @@ const toInput = ( // --- Setting Tabs Configuration --- -const getSettingTabsConfiguration = (config: Configuration): SettingTab[] => [ +const getSettingTabsConfiguration = ( + config: Configuration, + onClose: () => void +): SettingTab[] => [ /* General */ { title: ( @@ -184,6 +187,83 @@ const getSettingTabsConfiguration = (config: Configuration): SettingTab[] => [ ), fields: [ + { + type: SettingInputType.CUSTOM, + key: 'custom', // dummy key, won't be used + component: () => { + 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); + }; + + return ( + + ); + }, + }, + { + type: SettingInputType.CUSTOM, + key: 'custom', // dummy key, won't be used + component: () => { + 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 ( + <> + + + + ); + }, + }, + { + type: SettingInputType.DELIMETER, + }, + { + type: SettingInputType.SECTION, + label: ( + <> + + Technical Demo + + ), + }, { type: SettingInputType.CUSTOM, key: 'custom', // dummy key, won't be used @@ -193,17 +273,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 +474,7 @@ export default function SettingDialog({ JSON.parse(JSON.stringify(config)) ); const settingTabs = useMemo( - () => getSettingTabsConfiguration(localConfig), + () => getSettingTabsConfiguration(localConfig, onClose), [localConfig] ); diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 0c6b9e7..9c04f56 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -3,8 +3,14 @@ import Dexie, { Table } from 'dexie'; import toast from 'react-hot-toast'; -import { CONFIG_DEFAULT } from '../config'; -import { Configuration, Conversation, Message, TimingReport } from './types'; +import { CONFIG_DEFAULT, isDev } from '../config'; +import { + Configuration, + Conversation, + ExportJsonStructure, + Message, + TimingReport, +} from './types'; // --- Event Handling --- @@ -255,6 +261,84 @@ const StorageUtils = { dispatchConversationChange(convId); }, + // --- 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 --- /** 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, } From 4d1284eb66404d699f95964cf5d76abfbc2071e5 Mon Sep 17 00:00:00 2001 From: Oleg Shulyakov Date: Sun, 17 Aug 2025 01:26:50 +0300 Subject: [PATCH 4/4] style: code review --- src/components/SettingDialog.tsx | 130 +++++++++++++++++-------------- 1 file changed, 70 insertions(+), 60 deletions(-) diff --git a/src/components/SettingDialog.tsx b/src/components/SettingDialog.tsx index 33a3e37..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, @@ -188,69 +191,18 @@ const getSettingTabsConfiguration = ( ), fields: [ { - type: SettingInputType.CUSTOM, - key: 'custom', // dummy key, won't be used - component: () => { - 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); - }; - - return ( - - ); - }, + type: SettingInputType.SECTION, + label: ( + <> + + Chats + + ), }, { type: SettingInputType.CUSTOM, key: 'custom', // dummy key, won't be used - component: () => { - 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 ( - <> - - - - ); - }, + component: () => , }, { type: SettingInputType.DELIMETER, @@ -259,7 +211,7 @@ const getSettingTabsConfiguration = ( type: SettingInputType.SECTION, label: ( <> - + Technical Demo ), @@ -762,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