diff --git a/tools/ui/src/lib/components/app/DropImportOverlay.svelte b/tools/ui/src/lib/components/app/DropImportOverlay.svelte new file mode 100644 index 0000000000..b3895503a0 --- /dev/null +++ b/tools/ui/src/lib/components/app/DropImportOverlay.svelte @@ -0,0 +1,17 @@ + + +
+
+ + +

Import conversations or settings

+ +

Drop your files here to import

+
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogImportConversationPreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogImportConversationPreview.svelte new file mode 100644 index 0000000000..ac547d5014 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogImportConversationPreview.svelte @@ -0,0 +1,55 @@ + + + + + + + + + Import conversation? + + + + Preview of + "{conversationName || 'Untitled conversation'}". Confirm to + import it into your library. + + + +
+ +
+ + + Cancel + + Import + +
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogImportConversationsResult.svelte b/tools/ui/src/lib/components/app/dialogs/DialogImportConversationsResult.svelte new file mode 100644 index 0000000000..e0b7384843 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogImportConversationsResult.svelte @@ -0,0 +1,106 @@ + + + + + + + + + + + + Imported Conversations + + + + {conversations.length} conversation{conversations.length === 1 ? '' : 's'} imported. + Select one to open it. + + + +
+ + +
+ + + + + + + + + + + + {#if filteredConversations.length === 0} + + + + {:else} + {#each filteredConversations as conv (conv.id)} + onOpen(conv)} + > + + + + + {/each} + {/if} + +
Conversation NameMessages
+ No conversations found matching "{searchQuery}" +
+
+ {conv.name || 'Untitled conversation'} +
+
+ {messageCountMap.get(conv.id) ?? 0} +
+
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogSettingsImportPreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogSettingsImportPreview.svelte new file mode 100644 index 0000000000..e76bfb3928 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogSettingsImportPreview.svelte @@ -0,0 +1,84 @@ + + + + + + + + + Import settings? + + + + Review the settings that would change before importing. + + + +
+ {#if diff.length === 0} +

No settings would change.

+ {:else} +
+ {#each diff as entry (entry.key)} +
+
+

{entry.label}

+ +

{entry.key}

+
+ +
+ {formatValue(entry.from)} + + + + {formatValue(entry.to)} +
+
+ {/each} +
+ {/if} +
+ + + Cancel + + Import + +
+
diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts index 38aa099354..4a2c523f24 100644 --- a/tools/ui/src/lib/components/app/dialogs/index.ts +++ b/tools/ui/src/lib/components/app/dialogs/index.ts @@ -375,6 +375,65 @@ export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.sv */ export { default as DialogConversationSelection } from './DialogConversationSelection.svelte'; +/** + * **DialogSettingsImportPreview** - Review the settings diff before importing + * + * Alert dialog shown when a settings file is imported via drag-and-drop. + * Lists each setting that would change (old value -> new value) so the user + * can review before confirming the import. + * + * @example + * ```svelte + * (showSettingsPreview = false)} + * /> + * ``` + */ +export { default as DialogSettingsImportPreview } from './DialogSettingsImportPreview.svelte'; + +/** + * **DialogImportConversationPreview** - Preview a single conversation before importing + * + * Alert dialog shown when a single conversation is imported via drag-and-drop. + * Renders the conversation's messages (via ChatMessages) so the user can review + * it before confirming the import. + * + * @example + * ```svelte + * (showPreview = false)} + * /> + * ``` + */ +export { default as DialogImportConversationPreview } from './DialogImportConversationPreview.svelte'; + +/** + * **DialogImportConversationsResult** - Pick one imported conversation to open + * + * Dialog shown after multiple conversations are imported via drag-and-drop. + * Lists the imported conversations in a table (with search) and lets the user + * click one to open it. + * + * @example + * ```svelte + * (showOpenBulk = false)} + * /> + * ``` + */ +export { default as DialogImportConversationsResult } from './DialogImportConversationsResult.svelte'; + /** * * MODEL INFORMATION DIALOGS diff --git a/tools/ui/src/lib/components/app/index.ts b/tools/ui/src/lib/components/app/index.ts index 4914c743a5..5d049807e2 100644 --- a/tools/ui/src/lib/components/app/index.ts +++ b/tools/ui/src/lib/components/app/index.ts @@ -1,5 +1,6 @@ export * from './actions'; export * from './badges'; +export { default as DropImportOverlay } from './DropImportOverlay.svelte'; export * from './chat'; export * from './content'; export * from './dialogs'; diff --git a/tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts index 47356a63fc..ca44e21c88 100644 --- a/tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts +++ b/tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts @@ -8,6 +8,7 @@ */ import { chatStore } from '$lib/stores'; +import { isImportFileByExtension } from '$lib/utils/import-file.utils'; interface UseChatScreenDragAndDropOptions { /** Called when the user drops files and no message is being edited. */ @@ -49,6 +50,17 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption const files = Array.from(event.dataTransfer.files); + // Defer import files (conversation/settings exports) to the global + // drag-and-drop import handler instead of attaching them to a message. + if (files.some(isImportFileByExtension)) { + console.log('[chat-drop] deferring import files:', files.map((f) => f.name)); + + return; + } + + // Stop bubbling so the global import handler ignores this attachment. + event.stopPropagation(); + if (chatStore.isEditing()) { const handler = chatStore.getAddFilesHandler(); diff --git a/tools/ui/src/lib/hooks/use-drop-import.svelte.ts b/tools/ui/src/lib/hooks/use-drop-import.svelte.ts new file mode 100644 index 0000000000..058b98b017 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-drop-import.svelte.ts @@ -0,0 +1,280 @@ +/** + * Global drag-and-drop import state machine. + * + * Tracks pointer enter/leave nesting so the overlay stays visible while the + * cursor traverses child elements, then routes dropped files to the right + * importer: settings files (JSON with a `config` key) are restored directly, + * while conversation files (JSONL/ZIP/JSON) go through the existing + * selection dialog before asking whether to open the result. + */ + +import { goto } from '$app/navigation'; +import { ZIP_MAGIC } from '$lib/constants'; +import { SETTINGS_REGISTRY } from '$lib/constants/settings.constants'; +import { ConversationTransferService, RouterService } from '$lib/services'; +import { conversationsStore, settingsStore } from '$lib/stores'; +import { createMessageCountMap } from '$lib/utils'; +import { strFromU8 } from 'fflate'; +import { toast } from 'svelte-sonner'; + +import type { SettingsDiffEntry } from '$lib/components/app/dialogs/DialogSettingsImportPreview.svelte'; +import type { SettingsConfigType, SettingsExportType } from '$lib/types'; + +type FileKind = 'settings' | 'conversations'; + +/** + * Detects whether a dropped file holds settings or conversations. + * Settings files are JSON objects carrying a `config` key; everything else + * (ZIP archives, JSONL sessions, legacy JSON) is treated as conversations. + */ +async function classifyFile(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + + if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) { + return 'conversations'; + } + + const text = strFromU8(bytes); + + try { + const parsed = JSON.parse(text); + + if (parsed && typeof parsed === 'object' && 'config' in parsed) { + return 'settings'; + } + } catch { + // Not a JSON object, so not a settings file. + } + + return 'conversations'; +} + +export function computeSettingsDiff( + current: SettingsConfigType, + imported: SettingsConfigType +): SettingsDiffEntry[] { + const labels = new Map(); + + for (const section of SETTINGS_REGISTRY) { + for (const setting of section.settings) { + labels.set(setting.key, setting.label); + } + } + + const keys = new Set([...Object.keys(current), ...Object.keys(imported)]); + const diff: SettingsDiffEntry[] = []; + + for (const key of keys) { + const from = current[key]; + const to = imported[key]; + + if (from !== to) { + diff.push({ key, label: labels.get(key) ?? key, from, to }); + } + } + + return diff; +} + +export function useDropImport() { + let dragCounter = $state(0); + let isDragOver = $state(false); + + // All dialog state lives in one reactive object so it stays reactive when + // exposed through the hook and bound from the layout. + const ui = $state({ + // Settings import preview dialog (review diff before applying). + showSettingsPreview: false, + settingsData: null as SettingsExportType | null, + settingsDiff: [] as SettingsDiffEntry[], + // Single conversation preview dialog (confirm before importing). + showPreview: false, + previewData: null as ExportedConversation | null, + // Bulk result dialog: pick one of the imported conversations to open. + showOpenBulk: false, + importedConversations: [] as DatabaseConversation[], + bulkMessageCountMap: new Map() as Map, + // Selection dialog (the existing import flow) for multiple conversations. + showSelection: false, + availableConversations: [] as DatabaseConversation[], + selectionMessageCountMap: new Map() as Map, + fullImportData: [] as ExportedConversation[] + }); + + function handleDragEnter(event: DragEvent) { + event.preventDefault(); + dragCounter++; + + if (event.dataTransfer?.types.includes('Files')) { + isDragOver = true; + } + } + + function handleDragLeave(event: DragEvent) { + event.preventDefault(); + dragCounter--; + + if (dragCounter === 0) { + isDragOver = false; + } + } + + function handleDragOver(event: DragEvent) { + event.preventDefault(); + } + + async function handleDrop(event: DragEvent) { + event.preventDefault(); + isDragOver = false; + dragCounter = 0; + + if (!event.dataTransfer?.files) return; + + const files = Array.from(event.dataTransfer.files); + + await processFiles(files); + } + + async function processFiles(files: File[]) { + const allConversations: ExportedConversation[] = []; + + for (const file of files) { + const kind = await classifyFile(file); + + if (kind === 'settings') { + try { + const data = JSON.parse(await file.text()); + + if (data?.config) { + ui.settingsData = data as SettingsExportType; + ui.settingsDiff = computeSettingsDiff( + $state.snapshot(settingsStore.config) as SettingsConfigType, + data.config + ); + ui.showSettingsPreview = true; + } else { + toast.error(`Invalid settings file: ${file.name}`); + } + } catch (err) { + console.error('Failed to import settings:', err); + toast.error(`Failed to import settings from ${file.name}`); + } + } else { + try { + const parsed = await ConversationTransferService.parseImportFile(file); + + allConversations.push(...parsed); + } catch (err) { + console.error('Failed to parse file:', err); + toast.error(`Failed to parse ${file.name}`); + } + } + } + + if (allConversations.length === 0) { + return; + } + + if (allConversations.length === 1) { + ui.previewData = allConversations[0]; + ui.showPreview = true; + } else { + ui.fullImportData = allConversations; + ui.availableConversations = allConversations.map((item) => item.conv); + ui.selectionMessageCountMap = createMessageCountMap(allConversations); + ui.showSelection = true; + } + } + + async function confirmSettingsImport() { + const data = ui.settingsData; + + if (!data) return; + + try { + settingsStore.importSettings(data); + ui.showSettingsPreview = false; + toast.success('Settings imported successfully'); + } catch (err) { + console.error('Failed to import settings:', err); + toast.error('Failed to import settings'); + } + } + + function cancelSettingsImport() { + ui.showSettingsPreview = false; + } + + async function confirmImportSingle() { + const data = ui.previewData; + + if (!data) return; + + try { + await conversationsStore.importConversationsData([data]); + + ui.showPreview = false; + goto(RouterService.chat(data.conv.id)); + } catch (err) { + console.error('Failed to import conversation:', err); + toast.error('Failed to import conversation'); + } + } + + async function handleSelectionConfirm(selectedConversations: DatabaseConversation[]) { + try { + const selectedIds = new Set(selectedConversations.map((c) => c.id)); + const selectedData = ($state.snapshot(ui.fullImportData) as ExportedConversation[]).filter( + (item) => selectedIds.has(item.conv.id) + ); + + await conversationsStore.importConversationsData(selectedData); + + ui.importedConversations = selectedConversations; + ui.bulkMessageCountMap = createMessageCountMap(selectedData); + ui.showSelection = false; + ui.showOpenBulk = true; + } catch (err) { + console.error('Import failed:', err); + toast.error('Failed to import conversations'); + } + } + + function openConversation(conversation: DatabaseConversation) { + ui.showOpenBulk = false; + goto(RouterService.chat(conversation.id)); + } + + function cancelPreview() { + ui.showPreview = false; + } + + function cancelBulk() { + ui.showOpenBulk = false; + } + + function cancelSelection() { + ui.showSelection = false; + } + + return { + dragHandlers: { + dragenter: handleDragEnter, + dragleave: handleDragLeave, + dragover: handleDragOver, + drop: handleDrop + }, + get isDragOver() { + return isDragOver; + }, + ui, + confirmSettingsImport, + cancelSettingsImport, + confirmImportSingle, + cancelPreview, + openConversation, + cancelBulk, + cancelSelection, + handleSelectionConfirm + }; +} diff --git a/tools/ui/src/lib/utils/import-file.utils.ts b/tools/ui/src/lib/utils/import-file.utils.ts new file mode 100644 index 0000000000..fca74d0d6c --- /dev/null +++ b/tools/ui/src/lib/utils/import-file.utils.ts @@ -0,0 +1,17 @@ +/** + * Helpers for deciding whether a dropped file is an import file + * (conversation/settings export) rather than a plain attachment. + * + * The chat screen attaches arbitrary files to messages, while the global + * drag-and-drop handler imports conversations and settings. Exported + * conversations are `.jsonl` (single) or `.zip` (archive), and settings are + * `.json`, so the extension is a reliable way to route a drop to the importer. + */ + +const IMPORT_FILE_EXTENSIONS = ['.zip', '.jsonl', '.json']; + +export function isImportFileByExtension(file: File): boolean { + const name = file.name.toLowerCase(); + + return IMPORT_FILE_EXTENSIONS.some((ext) => name.endsWith(ext)); +} diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index f87bbe26a2..87ae9f80b0 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -4,7 +4,14 @@ import { goto } from '$app/navigation'; import { base } from '$app/paths'; import { page } from '$app/state'; - import { SidebarNavigation } from '$lib/components/app'; + import { + DialogConversationSelection, + DialogImportConversationPreview, + DialogImportConversationsResult, + DialogSettingsImportPreview, + DropImportOverlay, + SidebarNavigation + } from '$lib/components/app'; import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa'; import * as Tooltip from '$lib/components/ui/tooltip'; import { @@ -15,6 +22,7 @@ SETTINGS_KEYS, TOOLTIP_DELAY_DURATION } from '$lib/constants'; + import { useDropImport } from '$lib/hooks/use-drop-import.svelte'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; import { usePwa } from '$lib/hooks/use-pwa.svelte'; import { RouterService } from '$lib/services/router.service'; @@ -100,6 +108,9 @@ } } + // Global drag-and-drop import (works on any route) + const dropImport = useDropImport(); + // Global keyboard shortcuts const { handleKeydown } = useKeyboardShortcuts({ editActiveConversation: () => chatSidebar?.editActiveConversation?.(), @@ -272,7 +283,13 @@ - +
@@ -294,6 +311,42 @@ + + {#if dropImport.isDragOver} + + {/if} + + + + + + + +