mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-19 17:24:57 +02:00
ui : add global drag-and-drop import for conversations and settings
Dropping an exported conversation (.zip, .jsonl) or settings (.json) anywhere in the app now routes to a global importer instead of attaching the file to a message. A state machine in use-drop-import classifies the file, shows a review dialog (settings diff, single-conversation preview, or bulk picker), and applies it on confirmation. The chat screen defers import files so they are not treated as attachments.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { FileDown } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-1000000 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
|
||||
>
|
||||
<FileDown class="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
|
||||
<p class="text-lg font-medium text-foreground">Import conversations or settings</p>
|
||||
|
||||
<p class="text-sm text-muted-foreground">Drop your files here to import</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { MessageSquarePlus } from '@lucide/svelte';
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
conversationName?: string;
|
||||
messages?: DatabaseMessage[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
conversationName = '',
|
||||
messages = [],
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content class="sm:max-w-3xl">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<MessageSquarePlus class="h-5 w-5" />
|
||||
|
||||
Import conversation?
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
Preview of
|
||||
<span class="font-medium">"{conversationName || 'Untitled conversation'}"</span>. Confirm to
|
||||
import it into your library.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<ChatMessages {messages} />
|
||||
</div>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action onclick={onConfirm}>Import</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { MessageSquarePlus } from '@lucide/svelte';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { UI_DATA_ATTRS } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
conversations: DatabaseConversation[];
|
||||
messageCountMap?: Map<string, number>;
|
||||
onOpen: (conversation: DatabaseConversation) => void;
|
||||
onClose: () => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
conversations,
|
||||
messageCountMap = new Map(),
|
||||
onClose,
|
||||
onOpen,
|
||||
open = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let filteredConversations = $derived(
|
||||
conversations.filter((conv) => {
|
||||
const name = conv.name || 'Untitled conversation';
|
||||
|
||||
return name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="z-1000000" />
|
||||
|
||||
<Dialog.Content class="z-1000001 max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<MessageSquarePlus class="h-5 w-5" />
|
||||
|
||||
Imported Conversations
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description>
|
||||
{conversations.length} conversation{conversations.length === 1 ? '' : 's'} imported.
|
||||
Select one to open it.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<SearchInput bind:value={searchQuery} placeholder="Search conversations..." />
|
||||
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<ScrollArea class="h-100">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 z-10 bg-muted">
|
||||
<tr class="border-b">
|
||||
<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
|
||||
|
||||
<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#if filteredConversations.length === 0}
|
||||
<tr>
|
||||
<td colspan="2" class="p-8 text-center text-sm text-muted-foreground">
|
||||
No conversations found matching "{searchQuery}"
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each filteredConversations as conv (conv.id)}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||
{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conv.id }}
|
||||
onclick={() => onOpen(conv)}
|
||||
>
|
||||
<td class="p-3 text-sm">
|
||||
<div class="max-w-68 truncate" title={conv.name || 'Untitled conversation'}>
|
||||
{conv.name || 'Untitled conversation'}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-3 text-sm text-muted-foreground">
|
||||
{messageCountMap.get(conv.id) ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, Settings as SettingsIcon } from '@lucide/svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
export interface SettingsDiffEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
from: SettingsConfigValue;
|
||||
to: SettingsConfigValue;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
diff?: SettingsDiffEntry[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { diff = [], onCancel, onConfirm, open = $bindable() }: Props = $props();
|
||||
|
||||
function formatValue(value: SettingsConfigValue): string {
|
||||
if (value === undefined) return '(unset)';
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value || '(empty)';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content class="sm:max-w-2xl">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
|
||||
Import settings?
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
Review the settings that would change before importing.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
{#if diff.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No settings would change.</p>
|
||||
{:else}
|
||||
<div class="divide-y">
|
||||
{#each diff as entry (entry.key)}
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{entry.label}</p>
|
||||
|
||||
<p class="truncate text-xs text-muted-foreground">{entry.key}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2 text-sm">
|
||||
<span class="line-through text-muted-foreground">{formatValue(entry.from)}</span>
|
||||
|
||||
<ArrowRight class="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
<span class="font-medium">{formatValue(entry.to)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action onclick={onConfirm}>Import</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -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
|
||||
* <DialogSettingsImportPreview
|
||||
* bind:open={showSettingsPreview}
|
||||
* diff={settingsDiff}
|
||||
* onConfirm={handleConfirm}
|
||||
* onCancel={() => (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
|
||||
* <DialogImportConversationPreview
|
||||
* bind:open={showPreview}
|
||||
* conversationName={conv?.name}
|
||||
* messages={conv?.messages}
|
||||
* onConfirm={handleConfirm}
|
||||
* onCancel={() => (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
|
||||
* <DialogImportConversationsResult
|
||||
* bind:open={showOpenBulk}
|
||||
* conversations={imported}
|
||||
* messageCountMap={countMap}
|
||||
* onOpen={handleOpen}
|
||||
* onClose={() => (showOpenBulk = false)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogImportConversationsResult } from './DialogImportConversationsResult.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* MODEL INFORMATION DIALOGS
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<FileKind> {
|
||||
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<string, string>();
|
||||
|
||||
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<string, number>,
|
||||
// Selection dialog (the existing import flow) for multiple conversations.
|
||||
showSelection: false,
|
||||
availableConversations: [] as DatabaseConversation[],
|
||||
selectionMessageCountMap: new Map() as Map<string, number>,
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 @@
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} bind:innerHeight bind:innerWidth />
|
||||
<svelte:document onvisibilitychange={handleVisibilityChange} />
|
||||
<svelte:document
|
||||
onvisibilitychange={handleVisibilityChange}
|
||||
ondragenter={dropImport.dragHandlers.dragenter}
|
||||
ondragleave={dropImport.dragHandlers.dragleave}
|
||||
ondragover={dropImport.dragHandlers.dragover}
|
||||
ondrop={dropImport.dragHandlers.drop}
|
||||
/>
|
||||
|
||||
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@@ -294,6 +311,42 @@
|
||||
<ModeWatcher />
|
||||
|
||||
<Toaster richColors />
|
||||
|
||||
{#if dropImport.isDragOver}
|
||||
<DropImportOverlay />
|
||||
{/if}
|
||||
|
||||
<DialogConversationSelection
|
||||
bind:open={dropImport.ui.showSelection}
|
||||
conversations={dropImport.ui.availableConversations}
|
||||
messageCountMap={dropImport.ui.selectionMessageCountMap}
|
||||
mode="import"
|
||||
onConfirm={dropImport.handleSelectionConfirm}
|
||||
onCancel={dropImport.cancelSelection}
|
||||
/>
|
||||
|
||||
<DialogSettingsImportPreview
|
||||
bind:open={dropImport.ui.showSettingsPreview}
|
||||
diff={dropImport.ui.settingsDiff}
|
||||
onConfirm={dropImport.confirmSettingsImport}
|
||||
onCancel={dropImport.cancelSettingsImport}
|
||||
/>
|
||||
|
||||
<DialogImportConversationPreview
|
||||
bind:open={dropImport.ui.showPreview}
|
||||
conversationName={dropImport.ui.previewData?.conv.name}
|
||||
messages={dropImport.ui.previewData?.messages}
|
||||
onConfirm={dropImport.confirmImportSingle}
|
||||
onCancel={dropImport.cancelPreview}
|
||||
/>
|
||||
|
||||
<DialogImportConversationsResult
|
||||
bind:open={dropImport.ui.showOpenBulk}
|
||||
conversations={dropImport.ui.importedConversations}
|
||||
messageCountMap={dropImport.ui.bulkMessageCountMap}
|
||||
onOpen={dropImport.openConversation}
|
||||
onClose={dropImport.cancelBulk}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- PWA update prompt + version -->
|
||||
|
||||
Reference in New Issue
Block a user