+++ /dev/null
-<script lang="ts">
- import { RemoveButton } from '$lib/components/app';
- import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
- import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
-
- interface Props {
- class?: string;
- id: string;
- onClick?: (event?: MouseEvent) => void;
- onRemove?: (id: string) => void;
- name: string;
- readonly?: boolean;
- size?: number;
- textContent?: string;
- type: string;
- }
-
- let {
- class: className = '',
- id,
- onClick,
- onRemove,
- name,
- readonly = false,
- size,
- textContent,
- type
- }: Props = $props();
-</script>
-
-{#if type === MimeTypeText.PLAIN || type === FileTypeCategory.TEXT}
- {#if readonly}
- <!-- Readonly mode (ChatMessage) -->
- <button
- class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
- onclick={onClick}
- aria-label={`Preview ${name}`}
- type="button"
- >
- <div class="flex items-start gap-3">
- <div class="flex min-w-0 flex-1 flex-col items-start text-left">
- <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
-
- {#if size}
- <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
- {/if}
-
- {#if textContent && type === 'text'}
- <div class="relative mt-2 w-full">
- <div
- class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
- >
- {getPreviewText(textContent)}
- </div>
-
- {#if textContent.length > 150}
- <div
- class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
- ></div>
- {/if}
- </div>
- {/if}
- </div>
- </div>
- </button>
- {:else}
- <!-- Non-readonly mode (ChatForm) -->
- <button
- class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
- ? 'max-h-24 max-w-72'
- : 'max-w-36'} cursor-pointer text-left"
- onclick={onClick}
- >
- <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
- <RemoveButton {id} {onRemove} />
- </div>
-
- <div class="pr-8">
- <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
-
- {#if textContent}
- <div class="relative">
- <div
- class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
- style="max-height: 3rem; line-height: 1.2em;"
- >
- {getPreviewText(textContent)}
- </div>
-
- {#if textContent.length > 150}
- <div
- class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
- ></div>
- {/if}
- </div>
- {/if}
- </div>
- </button>
- {/if}
-{:else}
- <button
- class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
- onclick={onClick}
- >
- <div
- class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
- >
- {getFileTypeLabel(type)}
- </div>
-
- <div class="flex flex-col gap-1">
- <span
- class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
- >
- {name}
- </span>
-
- {#if size}
- <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
- {/if}
- </div>
-
- {#if !readonly}
- <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
- <RemoveButton {id} {onRemove} />
- </div>
- {/if}
- </button>
-{/if}
+++ /dev/null
-<script lang="ts">
- import { RemoveButton } from '$lib/components/app';
-
- interface Props {
- id: string;
- name: string;
- preview: string;
- readonly?: boolean;
- onRemove?: (id: string) => void;
- onClick?: (event?: MouseEvent) => void;
- class?: string;
- // Customizable size props
- width?: string;
- height?: string;
- imageClass?: string;
- }
-
- let {
- id,
- name,
- preview,
- readonly = false,
- onRemove,
- onClick,
- class: className = '',
- // Default to small size for form previews
- width = 'w-auto',
- height = 'h-16',
- imageClass = ''
- }: Props = $props();
-</script>
-
-<div class="group relative overflow-hidden rounded-lg border border-border bg-muted {className}">
- {#if onClick}
- <button
- type="button"
- class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
- onclick={onClick}
- aria-label="Preview {name}"
- >
- <img
- src={preview}
- alt={name}
- class="{height} {width} cursor-pointer object-cover {imageClass}"
- />
- </button>
- {:else}
- <img
- src={preview}
- alt={name}
- class="{height} {width} cursor-pointer object-cover {imageClass}"
- />
- {/if}
-
- {#if !readonly}
- <div
- class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
- >
- <RemoveButton {id} {onRemove} class="text-white" />
- </div>
- {/if}
-</div>
--- /dev/null
+<script lang="ts">
+ import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
+ import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
+ import { convertPDFToImage } from '$lib/utils/pdf-processing';
+ import { Button } from '$lib/components/ui/button';
+ import { getFileTypeCategory } from '$lib/utils/file-type';
+
+ interface Props {
+ // Either an uploaded file or a stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ // For uploaded files
+ preview?: string;
+ name?: string;
+ type?: string;
+ textContent?: string;
+ }
+
+ let { uploadedFile, attachment, preview, name, type, textContent }: Props = $props();
+
+ let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+ let displayPreview = $derived(
+ uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
+ );
+
+ let displayType = $derived(
+ uploadedFile?.type ||
+ (attachment?.type === 'imageFile'
+ ? 'image'
+ : attachment?.type === 'textFile'
+ ? 'text'
+ : attachment?.type === 'audioFile'
+ ? attachment.mimeType || 'audio'
+ : attachment?.type === 'pdfFile'
+ ? MimeTypeApplication.PDF
+ : type || 'unknown')
+ );
+
+ let displayTextContent = $derived(
+ uploadedFile?.textContent ||
+ (attachment?.type === 'textFile'
+ ? attachment.content
+ : attachment?.type === 'pdfFile'
+ ? attachment.content
+ : textContent)
+ );
+
+ let isAudio = $derived(
+ getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
+ );
+
+ let isImage = $derived(
+ getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
+ );
+
+ let isPdf = $derived(displayType === MimeTypeApplication.PDF);
+
+ let isText = $derived(
+ getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
+ );
+
+ let IconComponent = $derived(() => {
+ if (isImage) return Image;
+ if (isText || isPdf) return FileText;
+ if (isAudio) return Music;
+
+ return FileIcon;
+ });
+
+ let pdfViewMode = $state<'text' | 'pages'>('pages');
+
+ let pdfImages = $state<string[]>([]);
+
+ let pdfImagesLoading = $state(false);
+
+ let pdfImagesError = $state<string | null>(null);
+
+ async function loadPdfImages() {
+ if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
+
+ pdfImagesLoading = true;
+ pdfImagesError = null;
+
+ try {
+ let file: File | null = null;
+
+ if (uploadedFile?.file) {
+ file = uploadedFile.file;
+ } else if (attachment?.type === 'pdfFile') {
+ // Check if we have pre-processed images
+ if (attachment.images && Array.isArray(attachment.images)) {
+ pdfImages = attachment.images;
+ return;
+ }
+
+ // Convert base64 back to File for processing
+ if (attachment.base64Data) {
+ const base64Data = attachment.base64Data;
+ const byteCharacters = atob(base64Data);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
+ }
+ }
+
+ if (file) {
+ pdfImages = await convertPDFToImage(file);
+ } else {
+ throw new Error('No PDF file available for conversion');
+ }
+ } catch (error) {
+ pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
+ } finally {
+ pdfImagesLoading = false;
+ }
+ }
+
+ export function reset() {
+ pdfImages = [];
+ pdfImagesLoading = false;
+ pdfImagesError = null;
+ pdfViewMode = 'pages';
+ }
+
+ $effect(() => {
+ if (isPdf && pdfViewMode === 'pages') {
+ loadPdfImages();
+ }
+ });
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-end gap-6">
+ {#if isPdf}
+ <div class="flex items-center gap-2">
+ <Button
+ variant={pdfViewMode === 'text' ? 'default' : 'outline'}
+ size="sm"
+ onclick={() => (pdfViewMode = 'text')}
+ disabled={pdfImagesLoading}
+ >
+ <FileText class="mr-1 h-4 w-4" />
+
+ Text
+ </Button>
+
+ <Button
+ variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
+ size="sm"
+ onclick={() => {
+ pdfViewMode = 'pages';
+ loadPdfImages();
+ }}
+ disabled={pdfImagesLoading}
+ >
+ {#if pdfImagesLoading}
+ <div
+ class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
+ ></div>
+ {:else}
+ <Eye class="mr-1 h-4 w-4" />
+ {/if}
+
+ Pages
+ </Button>
+ </div>
+ {/if}
+ </div>
+
+ <div class="flex-1 overflow-auto">
+ {#if isImage && displayPreview}
+ <div class="flex items-center justify-center">
+ <img
+ src={displayPreview}
+ alt={displayName}
+ class="max-h-full rounded-lg object-contain shadow-lg"
+ />
+ </div>
+ {:else if isPdf && pdfViewMode === 'pages'}
+ {#if pdfImagesLoading}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <div
+ class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
+ ></div>
+
+ <p class="text-muted-foreground">Converting PDF to images...</p>
+ </div>
+ </div>
+ {:else if pdfImagesError}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
+
+ <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
+
+ <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
+ </div>
+ </div>
+ {:else if pdfImages.length > 0}
+ <div class="max-h-[70vh] space-y-4 overflow-auto">
+ {#each pdfImages as image, index (image)}
+ <div class="text-center">
+ <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
+
+ <img
+ src={image}
+ alt="PDF Page {index + 1}"
+ class="mx-auto max-w-full rounded-lg shadow-lg"
+ />
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ <p class="mb-4 text-muted-foreground">No PDF pages available</p>
+ </div>
+ </div>
+ {/if}
+ {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
+ <div
+ class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
+ >
+ {displayTextContent}
+ </div>
+ {:else if isAudio}
+ <div class="flex items-center justify-center p-8">
+ <div class="w-full max-w-md text-center">
+ <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+ {#if attachment?.type === 'audioFile'}
+ <audio
+ controls
+ class="mb-4 w-full"
+ src="data:{attachment.mimeType};base64,{attachment.base64Data}"
+ >
+ Your browser does not support the audio element.
+ </audio>
+ {:else if uploadedFile?.preview}
+ <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
+ Your browser does not support the audio element.
+ </audio>
+ {:else}
+ <p class="mb-4 text-muted-foreground">Audio preview not available</p>
+ {/if}
+
+ <p class="text-sm text-muted-foreground">
+ {displayName}
+ </p>
+ </div>
+ </div>
+ {:else}
+ <div class="flex items-center justify-center p-8">
+ <div class="text-center">
+ {#if IconComponent}
+ <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+ {/if}
+
+ <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
+ </div>
+ </div>
+ {/if}
+ </div>
+</div>
+++ /dev/null
-<script lang="ts">
- import * as Dialog from '$lib/components/ui/dialog';
- import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
- import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
- import { convertPDFToImage } from '$lib/utils/pdf-processing';
- import { Button } from '$lib/components/ui/button';
- import { getFileTypeCategory } from '$lib/utils/file-type';
- import { formatFileSize } from '$lib/utils/file-preview';
-
- interface Props {
- open: boolean;
- // Either an uploaded file or a stored attachment
- uploadedFile?: ChatUploadedFile;
- attachment?: DatabaseMessageExtra;
- // For uploaded files
- preview?: string;
- name?: string;
- type?: string;
- size?: number;
- textContent?: string;
- }
-
- let {
- open = $bindable(),
- uploadedFile,
- attachment,
- preview,
- name,
- type,
- size,
- textContent
- }: Props = $props();
-
- let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
-
- let displayPreview = $derived(
- uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
- );
-
- let displayType = $derived(
- uploadedFile?.type ||
- (attachment?.type === 'imageFile'
- ? 'image'
- : attachment?.type === 'textFile'
- ? 'text'
- : attachment?.type === 'audioFile'
- ? attachment.mimeType || 'audio'
- : attachment?.type === 'pdfFile'
- ? MimeTypeApplication.PDF
- : type || 'unknown')
- );
-
- let displaySize = $derived(uploadedFile?.size || size);
-
- let displayTextContent = $derived(
- uploadedFile?.textContent ||
- (attachment?.type === 'textFile'
- ? attachment.content
- : attachment?.type === 'pdfFile'
- ? attachment.content
- : textContent)
- );
-
- let isAudio = $derived(
- getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
- );
-
- let isImage = $derived(
- getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
- );
-
- let isPdf = $derived(displayType === MimeTypeApplication.PDF);
-
- let isText = $derived(
- getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
- );
-
- let IconComponent = $derived(() => {
- if (isImage) return Image;
- if (isText || isPdf) return FileText;
- if (isAudio) return Music;
-
- return FileIcon;
- });
-
- let pdfViewMode = $state<'text' | 'pages'>('pages');
-
- let pdfImages = $state<string[]>([]);
-
- let pdfImagesLoading = $state(false);
-
- let pdfImagesError = $state<string | null>(null);
-
- async function loadPdfImages() {
- if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
-
- pdfImagesLoading = true;
- pdfImagesError = null;
-
- try {
- let file: File | null = null;
-
- if (uploadedFile?.file) {
- file = uploadedFile.file;
- } else if (attachment?.type === 'pdfFile') {
- // Check if we have pre-processed images
- if (attachment.images && Array.isArray(attachment.images)) {
- pdfImages = attachment.images;
- return;
- }
-
- // Convert base64 back to File for processing
- if (attachment.base64Data) {
- const base64Data = attachment.base64Data;
- const byteCharacters = atob(base64Data);
- const byteNumbers = new Array(byteCharacters.length);
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
- }
- const byteArray = new Uint8Array(byteNumbers);
- file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
- }
- }
-
- if (file) {
- pdfImages = await convertPDFToImage(file);
- } else {
- throw new Error('No PDF file available for conversion');
- }
- } catch (error) {
- pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
- } finally {
- pdfImagesLoading = false;
- }
- }
-
- $effect(() => {
- if (open) {
- pdfImages = [];
- pdfImagesLoading = false;
- pdfImagesError = null;
- pdfViewMode = 'pages';
- }
- });
-
- $effect(() => {
- if (open && isPdf && pdfViewMode === 'pages') {
- loadPdfImages();
- }
- });
-</script>
-
-<Dialog.Root bind:open>
- <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden !p-10 sm:w-auto sm:max-w-6xl">
- <Dialog.Header class="flex-shrink-0">
- <div class="flex items-center justify-between gap-6">
- <div class="flex items-center gap-3">
- {#if IconComponent}
- <IconComponent class="h-5 w-5 text-muted-foreground" />
- {/if}
-
- <div>
- <Dialog.Title class="text-left">{displayName}</Dialog.Title>
-
- <div class="flex items-center gap-2 text-sm text-muted-foreground">
- <span>{displayType}</span>
-
- {#if displaySize}
- <span>•</span>
-
- <span>{formatFileSize(displaySize)}</span>
- {/if}
- </div>
- </div>
- </div>
-
- {#if isPdf}
- <div class="flex items-center gap-2">
- <Button
- variant={pdfViewMode === 'text' ? 'default' : 'outline'}
- size="sm"
- onclick={() => (pdfViewMode = 'text')}
- disabled={pdfImagesLoading}
- >
- <FileText class="mr-1 h-4 w-4" />
-
- Text
- </Button>
-
- <Button
- variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
- size="sm"
- onclick={() => {
- pdfViewMode = 'pages';
- loadPdfImages();
- }}
- disabled={pdfImagesLoading}
- >
- {#if pdfImagesLoading}
- <div
- class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
- ></div>
- {:else}
- <Eye class="mr-1 h-4 w-4" />
- {/if}
-
- Pages
- </Button>
- </div>
- {/if}
- </div>
- </Dialog.Header>
-
- <div class="flex-1 overflow-auto">
- {#if isImage && displayPreview}
- <div class="flex items-center justify-center">
- <img
- src={displayPreview}
- alt={displayName}
- class="max-h-full rounded-lg object-contain shadow-lg"
- />
- </div>
- {:else if isPdf && pdfViewMode === 'pages'}
- {#if pdfImagesLoading}
- <div class="flex items-center justify-center p-8">
- <div class="text-center">
- <div
- class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
- ></div>
-
- <p class="text-muted-foreground">Converting PDF to images...</p>
- </div>
- </div>
- {:else if pdfImagesError}
- <div class="flex items-center justify-center p-8">
- <div class="text-center">
- <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
- <p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
-
- <p class="text-sm text-muted-foreground">{pdfImagesError}</p>
-
- <Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
- </div>
- </div>
- {:else if pdfImages.length > 0}
- <div class="max-h-[70vh] space-y-4 overflow-auto">
- {#each pdfImages as image, index (image)}
- <div class="text-center">
- <p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
-
- <img
- src={image}
- alt="PDF Page {index + 1}"
- class="mx-auto max-w-full rounded-lg shadow-lg"
- />
- </div>
- {/each}
- </div>
- {:else}
- <div class="flex items-center justify-center p-8">
- <div class="text-center">
- <FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
- <p class="mb-4 text-muted-foreground">No PDF pages available</p>
- </div>
- </div>
- {/if}
- {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
- <div
- class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
- >
- {displayTextContent}
- </div>
- {:else if isAudio}
- <div class="flex items-center justify-center p-8">
- <div class="w-full max-w-md text-center">
- <Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
- {#if attachment?.type === 'audioFile'}
- <audio
- controls
- class="mb-4 w-full"
- src="data:{attachment.mimeType};base64,{attachment.base64Data}"
- >
- Your browser does not support the audio element.
- </audio>
- {:else if uploadedFile?.preview}
- <audio controls class="mb-4 w-full" src={uploadedFile.preview}>
- Your browser does not support the audio element.
- </audio>
- {:else}
- <p class="mb-4 text-muted-foreground">Audio preview not available</p>
- {/if}
-
- <p class="text-sm text-muted-foreground">
- {displayName}
- </p>
- </div>
- </div>
- {:else}
- <div class="flex items-center justify-center p-8">
- <div class="text-center">
- {#if IconComponent}
- <IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
- {/if}
-
- <p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
- </div>
- </div>
- {/if}
- </div>
- </Dialog.Content>
-</Dialog.Root>
--- /dev/null
+<script lang="ts">
+ import { RemoveButton } from '$lib/components/app';
+ import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
+ import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
+
+ interface Props {
+ class?: string;
+ id: string;
+ onClick?: (event?: MouseEvent) => void;
+ onRemove?: (id: string) => void;
+ name: string;
+ readonly?: boolean;
+ size?: number;
+ textContent?: string;
+ type: string;
+ }
+
+ let {
+ class: className = '',
+ id,
+ onClick,
+ onRemove,
+ name,
+ readonly = false,
+ size,
+ textContent,
+ type
+ }: Props = $props();
+</script>
+
+{#if type === MimeTypeText.PLAIN || type === FileTypeCategory.TEXT}
+ {#if readonly}
+ <!-- Readonly mode (ChatMessage) -->
+ <button
+ class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
+ onclick={onClick}
+ aria-label={`Preview ${name}`}
+ type="button"
+ >
+ <div class="flex items-start gap-3">
+ <div class="flex min-w-0 flex-1 flex-col items-start text-left">
+ <span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
+
+ {#if size}
+ <span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
+ {/if}
+
+ {#if textContent && type === 'text'}
+ <div class="relative mt-2 w-full">
+ <div
+ class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+ >
+ {getPreviewText(textContent)}
+ </div>
+
+ {#if textContent.length > 150}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </button>
+ {:else}
+ <!-- Non-readonly mode (ChatForm) -->
+ <button
+ class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
+ ? 'max-h-24 max-w-72'
+ : 'max-w-36'} cursor-pointer text-left"
+ onclick={onClick}
+ >
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
+
+ <div class="pr-8">
+ <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
+
+ {#if textContent}
+ <div class="relative">
+ <div
+ class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
+ style="max-height: 3rem; line-height: 1.2em;"
+ >
+ {getPreviewText(textContent)}
+ </div>
+
+ {#if textContent.length > 150}
+ <div
+ class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
+ ></div>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ </button>
+ {/if}
+{:else}
+ <button
+ class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
+ onclick={onClick}
+ >
+ <div
+ class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
+ >
+ {getFileTypeLabel(type)}
+ </div>
+
+ <div class="flex flex-col gap-1">
+ <span
+ class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
+ >
+ {name}
+ </span>
+
+ {#if size}
+ <span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
+ {/if}
+ </div>
+
+ {#if !readonly}
+ <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+ <RemoveButton {id} {onRemove} />
+ </div>
+ {/if}
+ </button>
+{/if}
--- /dev/null
+<script lang="ts">
+ import { RemoveButton } from '$lib/components/app';
+
+ interface Props {
+ id: string;
+ name: string;
+ preview: string;
+ readonly?: boolean;
+ onRemove?: (id: string) => void;
+ onClick?: (event?: MouseEvent) => void;
+ class?: string;
+ // Customizable size props
+ width?: string;
+ height?: string;
+ imageClass?: string;
+ }
+
+ let {
+ id,
+ name,
+ preview,
+ readonly = false,
+ onRemove,
+ onClick,
+ class: className = '',
+ // Default to small size for form previews
+ width = 'w-auto',
+ height = 'h-16',
+ imageClass = ''
+ }: Props = $props();
+</script>
+
+<div class="group relative overflow-hidden rounded-lg border border-border bg-muted {className}">
+ {#if onClick}
+ <button
+ type="button"
+ class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
+ onclick={onClick}
+ aria-label="Preview {name}"
+ >
+ <img
+ src={preview}
+ alt={name}
+ class="{height} {width} cursor-pointer object-cover {imageClass}"
+ />
+ </button>
+ {:else}
+ <img
+ src={preview}
+ alt={name}
+ class="{height} {width} cursor-pointer object-cover {imageClass}"
+ />
+ {/if}
+
+ {#if !readonly}
+ <div
+ class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
+ >
+ <RemoveButton {id} {onRemove} class="text-white" />
+ </div>
+ {/if}
+</div>
<script lang="ts">
- import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
+ import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { FileTypeCategory } from '$lib/enums/files';
import { getFileTypeCategory } from '$lib/utils/file-type';
- import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
- import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
+ import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
interface Props {
>
{#each displayItems as item (item.id)}
{#if item.isImage && item.preview}
- <ChatAttachmentImagePreview
+ <ChatAttachmentThumbnailImage
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
id={item.id}
name={item.name}
onClick={(event) => openPreview(item, event)}
/>
{:else}
- <ChatAttachmentFilePreview
+ <ChatAttachmentThumbnailFile
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
id={item.id}
name={item.name}
{/if}
{#if previewItem}
- <ChatAttachmentPreviewDialog
+ <DialogChatAttachmentPreview
bind:open={previewDialogOpen}
uploadedFile={previewItem.uploadedFile}
attachment={previewItem.attachment}
/>
{/if}
-<ChatAttachmentsViewAllDialog
+<DialogChatAttachmentsViewAll
bind:open={viewAllDialogOpen}
{uploadedFiles}
{attachments}
--- /dev/null
+<script lang="ts">
+ import {
+ ChatAttachmentThumbnailImage,
+ ChatAttachmentThumbnailFile,
+ DialogChatAttachmentPreview
+ } from '$lib/components/app';
+ import { FileTypeCategory } from '$lib/enums/files';
+ import { getFileTypeCategory } from '$lib/utils/file-type';
+ import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
+
+ interface Props {
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ imageHeight?: string;
+ imageWidth?: string;
+ imageClass?: string;
+ }
+
+ let {
+ uploadedFiles = [],
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ imageClass = ''
+ }: Props = $props();
+
+ let previewDialogOpen = $state(false);
+ let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+
+ let displayItems = $derived(getDisplayItems());
+ let imageItems = $derived(displayItems.filter((item) => item.isImage));
+ let fileItems = $derived(displayItems.filter((item) => !item.isImage));
+
+ function getDisplayItems(): ChatAttachmentDisplayItem[] {
+ const items: ChatAttachmentDisplayItem[] = [];
+
+ for (const file of uploadedFiles) {
+ items.push({
+ id: file.id,
+ name: file.name,
+ size: file.size,
+ preview: file.preview,
+ type: file.type,
+ isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
+ uploadedFile: file,
+ textContent: file.textContent
+ });
+ }
+
+ for (const [index, attachment] of attachments.entries()) {
+ if (attachment.type === 'imageFile') {
+ items.push({
+ id: `attachment-${index}`,
+ name: attachment.name,
+ preview: attachment.base64Url,
+ type: 'image',
+ isImage: true,
+ attachment,
+ attachmentIndex: index
+ });
+ } else if (attachment.type === 'textFile') {
+ items.push({
+ id: `attachment-${index}`,
+ name: attachment.name,
+ type: 'text',
+ isImage: false,
+ attachment,
+ attachmentIndex: index,
+ textContent: attachment.content
+ });
+ } else if (attachment.type === 'context') {
+ // Legacy format from old webui - treat as text file
+ items.push({
+ id: `attachment-${index}`,
+ name: attachment.name,
+ type: 'text',
+ isImage: false,
+ attachment,
+ attachmentIndex: index,
+ textContent: attachment.content
+ });
+ } else if (attachment.type === 'audioFile') {
+ items.push({
+ id: `attachment-${index}`,
+ name: attachment.name,
+ type: attachment.mimeType || 'audio',
+ isImage: false,
+ attachment,
+ attachmentIndex: index
+ });
+ } else if (attachment.type === 'pdfFile') {
+ items.push({
+ id: `attachment-${index}`,
+ name: attachment.name,
+ type: 'application/pdf',
+ isImage: false,
+ attachment,
+ attachmentIndex: index,
+ textContent: attachment.content
+ });
+ }
+ }
+
+ return items.reverse();
+ }
+
+ function openPreview(item: (typeof displayItems)[0], event?: Event) {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ previewItem = {
+ uploadedFile: item.uploadedFile,
+ attachment: item.attachment,
+ preview: item.preview,
+ name: item.name,
+ type: item.type,
+ size: item.size,
+ textContent: item.textContent
+ };
+ previewDialogOpen = true;
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
+ {#if fileItems.length > 0}
+ <div>
+ <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
+ <div class="flex flex-wrap items-start gap-3">
+ {#each fileItems as item (item.id)}
+ <ChatAttachmentThumbnailFile
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ type={item.type}
+ size={item.size}
+ {readonly}
+ onRemove={onFileRemove}
+ textContent={item.textContent}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if imageItems.length > 0}
+ <div>
+ <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
+ <div class="flex flex-wrap items-start gap-3">
+ {#each imageItems as item (item.id)}
+ {#if item.preview}
+ <ChatAttachmentThumbnailImage
+ class="cursor-pointer"
+ id={item.id}
+ name={item.name}
+ preview={item.preview}
+ {readonly}
+ onRemove={onFileRemove}
+ height={imageHeight}
+ width={imageWidth}
+ {imageClass}
+ onClick={(event) => openPreview(item, event)}
+ />
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+</div>
+
+{#if previewItem}
+ <DialogChatAttachmentPreview
+ bind:open={previewDialogOpen}
+ uploadedFile={previewItem.uploadedFile}
+ attachment={previewItem.attachment}
+ preview={previewItem.preview}
+ name={previewItem.name}
+ type={previewItem.type}
+ size={previewItem.size}
+ textContent={previewItem.textContent}
+ />
+{/if}
+++ /dev/null
-<script lang="ts">
- import * as Dialog from '$lib/components/ui/dialog';
- import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
- import { FileTypeCategory } from '$lib/enums/files';
- import { getFileTypeCategory } from '$lib/utils/file-type';
- import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
- import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
-
- interface Props {
- open?: boolean;
- uploadedFiles?: ChatUploadedFile[];
- attachments?: DatabaseMessageExtra[];
- readonly?: boolean;
- onFileRemove?: (fileId: string) => void;
- imageHeight?: string;
- imageWidth?: string;
- imageClass?: string;
- }
-
- let {
- open = $bindable(false),
- uploadedFiles = [],
- attachments = [],
- readonly = false,
- onFileRemove,
- imageHeight = 'h-24',
- imageWidth = 'w-auto',
- imageClass = ''
- }: Props = $props();
-
- let previewDialogOpen = $state(false);
- let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
-
- let displayItems = $derived(getDisplayItems());
- let imageItems = $derived(displayItems.filter((item) => item.isImage));
- let fileItems = $derived(displayItems.filter((item) => !item.isImage));
-
- function getDisplayItems(): ChatAttachmentDisplayItem[] {
- const items: ChatAttachmentDisplayItem[] = [];
-
- for (const file of uploadedFiles) {
- items.push({
- id: file.id,
- name: file.name,
- size: file.size,
- preview: file.preview,
- type: file.type,
- isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
- uploadedFile: file,
- textContent: file.textContent
- });
- }
-
- for (const [index, attachment] of attachments.entries()) {
- if (attachment.type === 'imageFile') {
- items.push({
- id: `attachment-${index}`,
- name: attachment.name,
- preview: attachment.base64Url,
- type: 'image',
- isImage: true,
- attachment,
- attachmentIndex: index
- });
- } else if (attachment.type === 'textFile') {
- items.push({
- id: `attachment-${index}`,
- name: attachment.name,
- type: 'text',
- isImage: false,
- attachment,
- attachmentIndex: index,
- textContent: attachment.content
- });
- } else if (attachment.type === 'context') {
- // Legacy format from old webui - treat as text file
- items.push({
- id: `attachment-${index}`,
- name: attachment.name,
- type: 'text',
- isImage: false,
- attachment,
- attachmentIndex: index,
- textContent: attachment.content
- });
- } else if (attachment.type === 'audioFile') {
- items.push({
- id: `attachment-${index}`,
- name: attachment.name,
- type: attachment.mimeType || 'audio',
- isImage: false,
- attachment,
- attachmentIndex: index
- });
- } else if (attachment.type === 'pdfFile') {
- items.push({
- id: `attachment-${index}`,
- name: attachment.name,
- type: 'application/pdf',
- isImage: false,
- attachment,
- attachmentIndex: index,
- textContent: attachment.content
- });
- }
- }
-
- return items.reverse();
- }
-
- function openPreview(item: (typeof displayItems)[0], event?: Event) {
- if (event) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- previewItem = {
- uploadedFile: item.uploadedFile,
- attachment: item.attachment,
- preview: item.preview,
- name: item.name,
- type: item.type,
- size: item.size,
- textContent: item.textContent
- };
- previewDialogOpen = true;
- }
-</script>
-
-<Dialog.Root bind:open>
- <Dialog.Portal>
- <Dialog.Overlay />
-
- <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
- <Dialog.Header>
- <Dialog.Title>All Attachments ({displayItems.length})</Dialog.Title>
- <Dialog.Description class="text-sm text-muted-foreground">
- View and manage all attached files
- </Dialog.Description>
- </Dialog.Header>
-
- <div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
- {#if fileItems.length > 0}
- <div>
- <h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
- <div class="flex flex-wrap items-start gap-3">
- {#each fileItems as item (item.id)}
- <ChatAttachmentFilePreview
- class="cursor-pointer"
- id={item.id}
- name={item.name}
- type={item.type}
- size={item.size}
- {readonly}
- onRemove={onFileRemove}
- textContent={item.textContent}
- onClick={(event) => openPreview(item, event)}
- />
- {/each}
- </div>
- </div>
- {/if}
-
- {#if imageItems.length > 0}
- <div>
- <h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
- <div class="flex flex-wrap items-start gap-3">
- {#each imageItems as item (item.id)}
- {#if item.preview}
- <ChatAttachmentImagePreview
- class="cursor-pointer"
- id={item.id}
- name={item.name}
- preview={item.preview}
- {readonly}
- onRemove={onFileRemove}
- height={imageHeight}
- width={imageWidth}
- {imageClass}
- onClick={(event) => openPreview(item, event)}
- />
- {/if}
- {/each}
- </div>
- </div>
- {/if}
- </div>
- </Dialog.Content>
- </Dialog.Portal>
-</Dialog.Root>
-
-{#if previewItem}
- <ChatAttachmentPreviewDialog
- bind:open={previewDialogOpen}
- uploadedFile={previewItem.uploadedFile}
- attachment={previewItem.attachment}
- preview={previewItem.preview}
- name={previewItem.name}
- type={previewItem.type}
- size={previewItem.size}
- textContent={previewItem.textContent}
- />
-{/if}
+++ /dev/null
-<script lang="ts">
- import { Paperclip, Image, FileText, File, Volume2 } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
- import { FileTypeCategory } from '$lib/enums/files';
- import { supportsAudio, supportsVision } from '$lib/stores/server.svelte';
-
- interface Props {
- class?: string;
- disabled?: boolean;
- onFileUpload?: (fileType?: FileTypeCategory) => void;
- }
-
- let { class: className = '', disabled = false, onFileUpload }: Props = $props();
-
- const fileUploadTooltipText = $derived.by(() => {
- return !supportsVision()
- ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
- : 'Attach files';
- });
-
- function handleFileUpload(fileType?: FileTypeCategory) {
- onFileUpload?.(fileType);
- }
-</script>
-
-<div class="flex items-center gap-1 {className}">
- <DropdownMenu.Root>
- <DropdownMenu.Trigger name="Attach files">
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger>
- <Button
- class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
- {disabled}
- type="button"
- >
- <span class="sr-only">Attach files</span>
-
- <Paperclip class="h-4 w-4" />
- </Button>
- </Tooltip.Trigger>
-
- <Tooltip.Content>
- <p>{fileUploadTooltipText}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- </DropdownMenu.Trigger>
-
- <DropdownMenu.Content align="start" class="w-48">
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="images-button flex cursor-pointer items-center gap-2"
- disabled={!supportsVision()}
- onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
- >
- <Image class="h-4 w-4" />
-
- <span>Images</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- {#if !supportsVision()}
- <Tooltip.Content>
- <p>Images require vision models to be processed</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
-
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="audio-button flex cursor-pointer items-center gap-2"
- disabled={!supportsAudio()}
- onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
- >
- <Volume2 class="h-4 w-4" />
-
- <span>Audio Files</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- {#if !supportsAudio()}
- <Tooltip.Content>
- <p>Audio files require audio models to be processed</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
-
- <DropdownMenu.Item
- class="flex cursor-pointer items-center gap-2"
- onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
- >
- <FileText class="h-4 w-4" />
-
- <span>Text Files</span>
- </DropdownMenu.Item>
-
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger class="w-full">
- <DropdownMenu.Item
- class="flex cursor-pointer items-center gap-2"
- onclick={() => handleFileUpload(FileTypeCategory.PDF)}
- >
- <File class="h-4 w-4" />
-
- <span>PDF Files</span>
- </DropdownMenu.Item>
- </Tooltip.Trigger>
-
- {#if !supportsVision()}
- <Tooltip.Content>
- <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
- </DropdownMenu.Content>
- </DropdownMenu.Root>
-</div>
+++ /dev/null
-<script lang="ts">
- import { Mic } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import * as Tooltip from '$lib/components/ui/tooltip';
- import { supportsAudio } from '$lib/stores/server.svelte';
-
- interface Props {
- class?: string;
- disabled?: boolean;
- isLoading?: boolean;
- isRecording?: boolean;
- onMicClick?: () => void;
- }
-
- let {
- class: className = '',
- disabled = false,
- isLoading = false,
- isRecording = false,
- onMicClick
- }: Props = $props();
-</script>
-
-<div class="flex items-center gap-1 {className}">
- <Tooltip.Root delayDuration={100}>
- <Tooltip.Trigger>
- <Button
- class="h-8 w-8 rounded-full p-0 {isRecording
- ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
- : 'bg-transparent text-muted-foreground hover:bg-foreground/10 hover:text-foreground'} {!supportsAudio()
- ? 'cursor-not-allowed opacity-50'
- : ''}"
- disabled={disabled || isLoading || !supportsAudio()}
- onclick={onMicClick}
- type="button"
- >
- <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
-
- <Mic class="h-4 w-4" />
- </Button>
- </Tooltip.Trigger>
-
- {#if !supportsAudio()}
- <Tooltip.Content>
- <p>Current model does not support audio</p>
- </Tooltip.Content>
- {/if}
- </Tooltip.Root>
-</div>
+++ /dev/null
-<script lang="ts">
- import { Square, ArrowUp } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import ChatFormActionFileAttachments from './ChatFormActionFileAttachments.svelte';
- import ChatFormActionRecord from './ChatFormActionRecord.svelte';
- import ChatFormModelSelector from './ChatFormModelSelector.svelte';
- import { config } from '$lib/stores/settings.svelte';
- import type { FileTypeCategory } from '$lib/enums/files';
-
- interface Props {
- canSend?: boolean;
- class?: string;
- disabled?: boolean;
- isLoading?: boolean;
- isRecording?: boolean;
- onFileUpload?: (fileType?: FileTypeCategory) => void;
- onMicClick?: () => void;
- onStop?: () => void;
- }
-
- let {
- canSend = false,
- class: className = '',
- disabled = false,
- isLoading = false,
- isRecording = false,
- onFileUpload,
- onMicClick,
- onStop
- }: Props = $props();
-
- let currentConfig = $derived(config());
-</script>
-
-<div class="flex w-full items-center gap-2 {className}">
- <ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
-
- {#if currentConfig.modelSelectorEnabled}
- <ChatFormModelSelector class="shrink-0" />
- {/if}
-
- {#if isLoading}
- <Button
- type="button"
- onclick={onStop}
- class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
- >
- <span class="sr-only">Stop</span>
- <Square class="h-8 w-8 fill-destructive stroke-destructive" />
- </Button>
- {:else}
- <ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
-
- <Button
- type="submit"
- disabled={!canSend || disabled || isLoading}
- class="h-8 w-8 rounded-full p-0"
- >
- <span class="sr-only">Send</span>
- <ArrowUp class="h-12 w-12" />
- </Button>
- {/if}
-</div>
--- /dev/null
+<script lang="ts">
+ import { Paperclip, Image, FileText, File, Volume2 } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+ import { FileTypeCategory } from '$lib/enums/files';
+ import { supportsAudio, supportsVision } from '$lib/stores/server.svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ onFileUpload?: (fileType?: FileTypeCategory) => void;
+ }
+
+ let { class: className = '', disabled = false, onFileUpload }: Props = $props();
+
+ const fileUploadTooltipText = $derived.by(() => {
+ return !supportsVision()
+ ? 'Text files and PDFs supported. Images, audio, and video require vision models.'
+ : 'Attach files';
+ });
+
+ function handleFileUpload(fileType?: FileTypeCategory) {
+ onFileUpload?.(fileType);
+ }
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <DropdownMenu.Root>
+ <DropdownMenu.Trigger name="Attach files">
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger>
+ <Button
+ class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+ {disabled}
+ type="button"
+ >
+ <span class="sr-only">Attach files</span>
+
+ <Paperclip class="h-4 w-4" />
+ </Button>
+ </Tooltip.Trigger>
+
+ <Tooltip.Content>
+ <p>{fileUploadTooltipText}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </DropdownMenu.Trigger>
+
+ <DropdownMenu.Content align="start" class="w-48">
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="images-button flex cursor-pointer items-center gap-2"
+ disabled={!supportsVision()}
+ onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
+ >
+ <Image class="h-4 w-4" />
+
+ <span>Images</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !supportsVision()}
+ <Tooltip.Content>
+ <p>Images require vision models to be processed</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="audio-button flex cursor-pointer items-center gap-2"
+ disabled={!supportsAudio()}
+ onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
+ >
+ <Volume2 class="h-4 w-4" />
+
+ <span>Audio Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !supportsAudio()}
+ <Tooltip.Content>
+ <p>Audio files require audio models to be processed</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
+ >
+ <FileText class="h-4 w-4" />
+
+ <span>Text Files</span>
+ </DropdownMenu.Item>
+
+ <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
+ <Tooltip.Trigger class="w-full">
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={() => handleFileUpload(FileTypeCategory.PDF)}
+ >
+ <File class="h-4 w-4" />
+
+ <span>PDF Files</span>
+ </DropdownMenu.Item>
+ </Tooltip.Trigger>
+
+ {#if !supportsVision()}
+ <Tooltip.Content>
+ <p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+ </DropdownMenu.Content>
+ </DropdownMenu.Root>
+</div>
--- /dev/null
+<script lang="ts">
+ import { Mic } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { supportsAudio } from '$lib/stores/server.svelte';
+
+ interface Props {
+ class?: string;
+ disabled?: boolean;
+ isLoading?: boolean;
+ isRecording?: boolean;
+ onMicClick?: () => void;
+ }
+
+ let {
+ class: className = '',
+ disabled = false,
+ isLoading = false,
+ isRecording = false,
+ onMicClick
+ }: Props = $props();
+</script>
+
+<div class="flex items-center gap-1 {className}">
+ <Tooltip.Root delayDuration={100}>
+ <Tooltip.Trigger>
+ <Button
+ class="h-8 w-8 rounded-full p-0 {isRecording
+ ? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
+ : 'bg-transparent text-muted-foreground hover:bg-foreground/10 hover:text-foreground'} {!supportsAudio()
+ ? 'cursor-not-allowed opacity-50'
+ : ''}"
+ disabled={disabled || isLoading || !supportsAudio()}
+ onclick={onMicClick}
+ type="button"
+ >
+ <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
+
+ <Mic class="h-4 w-4" />
+ </Button>
+ </Tooltip.Trigger>
+
+ {#if !supportsAudio()}
+ <Tooltip.Content>
+ <p>Current model does not support audio</p>
+ </Tooltip.Content>
+ {/if}
+ </Tooltip.Root>
+</div>
--- /dev/null
+<script lang="ts">
+ import { Square, ArrowUp } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import {
+ ChatFormActionFileAttachments,
+ ChatFormActionRecord,
+ ChatFormModelSelector
+ } from '$lib/components/app';
+ import { config } from '$lib/stores/settings.svelte';
+ import type { FileTypeCategory } from '$lib/enums/files';
+
+ interface Props {
+ canSend?: boolean;
+ class?: string;
+ disabled?: boolean;
+ isLoading?: boolean;
+ isRecording?: boolean;
+ onFileUpload?: (fileType?: FileTypeCategory) => void;
+ onMicClick?: () => void;
+ onStop?: () => void;
+ }
+
+ let {
+ canSend = false,
+ class: className = '',
+ disabled = false,
+ isLoading = false,
+ isRecording = false,
+ onFileUpload,
+ onMicClick,
+ onStop
+ }: Props = $props();
+
+ let currentConfig = $derived(config());
+</script>
+
+<div class="flex w-full items-center gap-2 {className}">
+ <ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
+
+ {#if currentConfig.modelSelectorEnabled}
+ <ChatFormModelSelector class="shrink-0" />
+ {/if}
+
+ {#if isLoading}
+ <Button
+ type="button"
+ onclick={onStop}
+ class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
+ >
+ <span class="sr-only">Stop</span>
+ <Square class="h-8 w-8 fill-destructive stroke-destructive" />
+ </Button>
+ {:else}
+ <ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
+
+ <Button
+ type="submit"
+ disabled={!canSend || disabled || isLoading}
+ class="h-8 w-8 rounded-full p-0"
+ >
+ <span class="sr-only">Send</span>
+ <ArrowUp class="h-12 w-12" />
+ </Button>
+ {/if}
+</div>
<script lang="ts">
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
- import { ActionButton, ConfirmationDialog } from '$lib/components/app';
- import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
+ import {
+ ActionButton,
+ ChatMessageBranchingControls,
+ DialogConfirmation
+ } from '$lib/components/app';
interface Props {
role: 'user' | 'assistant';
</div>
</div>
-<ConfirmationDialog
+<DialogConfirmation
bind:open={showDeleteDialog}
title="Delete Message"
description={deletionInfo && deletionInfo.totalCount > 1
+++ /dev/null
-<script lang="ts">
- import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
- import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
- import { slotsService } from '$lib/services/slots';
- import { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
- import { config } from '$lib/stores/settings.svelte';
-
- const processingState = useProcessingState();
-
- let isCurrentConversationLoading = $derived(isLoading());
- let processingDetails = $derived(processingState.getProcessingDetails());
- let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
-
- // Track loading state reactively by checking if conversation ID is in loading conversations array
- $effect(() => {
- const keepStatsVisible = config().keepStatsVisible;
-
- if (keepStatsVisible || isCurrentConversationLoading) {
- processingState.startMonitoring();
- }
-
- if (!isCurrentConversationLoading && !keepStatsVisible) {
- setTimeout(() => {
- if (!config().keepStatsVisible) {
- processingState.stopMonitoring();
- }
- }, PROCESSING_INFO_TIMEOUT);
- }
- });
-
- // Update processing state from stored timings
- $effect(() => {
- const conversation = activeConversation();
- const messages = activeMessages() as DatabaseMessage[];
- const keepStatsVisible = config().keepStatsVisible;
-
- if (keepStatsVisible && conversation) {
- if (messages.length === 0) {
- slotsService.clearConversationState(conversation.id);
- return;
- }
-
- // Search backwards through messages to find most recent assistant message with timing data
- // Using reverse iteration for performance - avoids array copy and stops at first match
- let foundTimingData = false;
-
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i];
- if (message.role === 'assistant' && message.timings) {
- foundTimingData = true;
-
- slotsService
- .updateFromTimingData(
- {
- prompt_n: message.timings.prompt_n || 0,
- predicted_n: message.timings.predicted_n || 0,
- predicted_per_second:
- message.timings.predicted_n && message.timings.predicted_ms
- ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
- : 0,
- cache_n: message.timings.cache_n || 0
- },
- conversation.id
- )
- .catch((error) => {
- console.warn('Failed to update processing state from stored timings:', error);
- });
- break;
- }
- }
-
- if (!foundTimingData) {
- slotsService.clearConversationState(conversation.id);
- }
- }
- });
-</script>
-
-<div class="chat-processing-info-container pointer-events-none" class:visible={showSlotsInfo}>
- <div class="chat-processing-info-content">
- {#each processingDetails as detail (detail)}
- <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
- {/each}
- </div>
-</div>
-
-<style>
- .chat-processing-info-container {
- position: sticky;
- top: 0;
- z-index: 10;
- padding: 1.5rem 1rem;
- opacity: 0;
- transform: translateY(50%);
- transition:
- opacity 300ms ease-out,
- transform 300ms ease-out;
- }
-
- .chat-processing-info-container.visible {
- opacity: 1;
- transform: translateY(0);
- }
-
- .chat-processing-info-content {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 1rem;
- justify-content: center;
- max-width: 48rem;
- margin: 0 auto;
- }
-
- .chat-processing-info-detail {
- color: var(--muted-foreground);
- font-size: 0.75rem;
- padding: 0.25rem 0.75rem;
- background: var(--muted);
- border-radius: 0.375rem;
- font-family:
- ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
- white-space: nowrap;
- }
-
- @media (max-width: 768px) {
- .chat-processing-info-content {
- gap: 0.5rem;
- }
-
- .chat-processing-info-detail {
- font-size: 0.7rem;
- padding: 0.2rem 0.5rem;
- }
- }
-</style>
ChatScreenHeader,
ChatScreenWarning,
ChatMessages,
- ChatProcessingInfo,
- EmptyFileAlertDialog,
- ChatErrorDialog,
+ ChatScreenProcessingInfo,
+ DialogEmptyFileAlert,
+ DialogChatError,
ServerErrorSplash,
ServerInfo,
ServerLoadingSplash,
- ConfirmationDialog
+ DialogConfirmation
} from '$lib/components/app';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import {
class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
in:slide={{ duration: 150, axis: 'y' }}
>
- <ChatProcessingInfo />
+ <ChatScreenProcessingInfo />
{#if serverWarning()}
<ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
</AlertDialog.Portal>
</AlertDialog.Root>
-<ConfirmationDialog
+<DialogConfirmation
bind:open={showDeleteDialog}
title="Delete Conversation"
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
onCancel={() => (showDeleteDialog = false)}
/>
-<EmptyFileAlertDialog
+<DialogEmptyFileAlert
bind:open={showEmptyFileDialog}
emptyFiles={emptyFileNames}
onOpenChange={(open) => {
}}
/>
-<ChatErrorDialog
+<DialogChatError
message={activeErrorDialog?.message ?? ''}
onOpenChange={handleErrorDialogOpenChange}
open={Boolean(activeErrorDialog)}
<script lang="ts">
import { Settings } from '@lucide/svelte';
- import { ChatSettingsDialog } from '$lib/components/app';
+ import { DialogChatSettings } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
let settingsOpen = $state(false);
</div>
</header>
-<ChatSettingsDialog open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
+<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
--- /dev/null
+<script lang="ts">
+ import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { slotsService } from '$lib/services/slots';
+ import { isLoading, activeMessages, activeConversation } from '$lib/stores/chat.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+
+ const processingState = useProcessingState();
+
+ let isCurrentConversationLoading = $derived(isLoading());
+ let processingDetails = $derived(processingState.getProcessingDetails());
+ let showSlotsInfo = $derived(isCurrentConversationLoading || config().keepStatsVisible);
+
+ // Track loading state reactively by checking if conversation ID is in loading conversations array
+ $effect(() => {
+ const keepStatsVisible = config().keepStatsVisible;
+
+ if (keepStatsVisible || isCurrentConversationLoading) {
+ processingState.startMonitoring();
+ }
+
+ if (!isCurrentConversationLoading && !keepStatsVisible) {
+ setTimeout(() => {
+ if (!config().keepStatsVisible) {
+ processingState.stopMonitoring();
+ }
+ }, PROCESSING_INFO_TIMEOUT);
+ }
+ });
+
+ // Update processing state from stored timings
+ $effect(() => {
+ const conversation = activeConversation();
+ const messages = activeMessages() as DatabaseMessage[];
+ const keepStatsVisible = config().keepStatsVisible;
+
+ if (keepStatsVisible && conversation) {
+ if (messages.length === 0) {
+ slotsService.clearConversationState(conversation.id);
+ return;
+ }
+
+ // Search backwards through messages to find most recent assistant message with timing data
+ // Using reverse iteration for performance - avoids array copy and stops at first match
+ let foundTimingData = false;
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === 'assistant' && message.timings) {
+ foundTimingData = true;
+
+ slotsService
+ .updateFromTimingData(
+ {
+ prompt_n: message.timings.prompt_n || 0,
+ predicted_n: message.timings.predicted_n || 0,
+ predicted_per_second:
+ message.timings.predicted_n && message.timings.predicted_ms
+ ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
+ : 0,
+ cache_n: message.timings.cache_n || 0
+ },
+ conversation.id
+ )
+ .catch((error) => {
+ console.warn('Failed to update processing state from stored timings:', error);
+ });
+ break;
+ }
+ }
+
+ if (!foundTimingData) {
+ slotsService.clearConversationState(conversation.id);
+ }
+ }
+ });
+</script>
+
+<div class="chat-processing-info-container pointer-events-none" class:visible={showSlotsInfo}>
+ <div class="chat-processing-info-content">
+ {#each processingDetails as detail (detail)}
+ <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
+ {/each}
+ </div>
+</div>
+
+<style>
+ .chat-processing-info-container {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ padding: 1.5rem 1rem;
+ opacity: 0;
+ transform: translateY(50%);
+ transition:
+ opacity 300ms ease-out,
+ transform 300ms ease-out;
+ }
+
+ .chat-processing-info-container.visible {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .chat-processing-info-content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ justify-content: center;
+ max-width: 48rem;
+ margin: 0 auto;
+ }
+
+ .chat-processing-info-detail {
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ background: var(--muted);
+ border-radius: 0.375rem;
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+ white-space: nowrap;
+ }
+
+ @media (max-width: 768px) {
+ .chat-processing-info-content {
+ gap: 0.5rem;
+ }
+
+ .chat-processing-info-detail {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.5rem;
+ }
+ }
+</style>
--- /dev/null
+<script lang="ts">
+ import {
+ Settings,
+ Funnel,
+ AlertTriangle,
+ Brain,
+ Code,
+ Monitor,
+ Sun,
+ Moon,
+ ChevronLeft,
+ ChevronRight,
+ Database
+ } from '@lucide/svelte';
+ import {
+ ChatSettingsFooter,
+ ChatSettingsImportExportTab,
+ ChatSettingsFields
+ } from '$lib/components/app';
+ import { ScrollArea } from '$lib/components/ui/scroll-area';
+ import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
+ import { setMode } from 'mode-watcher';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ onSave?: () => void;
+ }
+
+ let { onSave }: Props = $props();
+
+ const settingSections: Array<{
+ fields: SettingsFieldConfig[];
+ icon: Component;
+ title: string;
+ }> = [
+ {
+ title: 'General',
+ icon: Settings,
+ fields: [
+ { key: 'apiKey', label: 'API Key', type: 'input' },
+ {
+ key: 'systemMessage',
+ label: 'System Message (will be disabled if left empty)',
+ type: 'textarea'
+ },
+ {
+ key: 'theme',
+ label: 'Theme',
+ type: 'select',
+ options: [
+ { value: 'system', label: 'System', icon: Monitor },
+ { value: 'light', label: 'Light', icon: Sun },
+ { value: 'dark', label: 'Dark', icon: Moon }
+ ]
+ },
+ {
+ key: 'pasteLongTextToFileLen',
+ label: 'Paste long text to file length',
+ type: 'input'
+ },
+ {
+ key: 'showMessageStats',
+ label: 'Show message generation statistics',
+ type: 'checkbox'
+ },
+ {
+ key: 'showTokensPerSecond',
+ label: 'Show tokens per second',
+ type: 'checkbox'
+ },
+ {
+ key: 'keepStatsVisible',
+ label: 'Keep stats visible after generation',
+ type: 'checkbox'
+ },
+ {
+ key: 'showModelInfo',
+ label: 'Show model information',
+ type: 'checkbox'
+ },
+ {
+ key: 'enableContinueGeneration',
+ label: 'Enable "Continue" button',
+ type: 'checkbox',
+ isExperimental: true
+ },
+ {
+ key: 'pdfAsImage',
+ label: 'Parse PDF as image',
+ type: 'checkbox'
+ },
+ {
+ key: 'renderUserContentAsMarkdown',
+ label: 'Render user content as Markdown',
+ type: 'checkbox'
+ },
+ {
+ key: 'askForTitleConfirmation',
+ label: 'Ask for confirmation before changing conversation title',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Sampling',
+ icon: Funnel,
+ fields: [
+ {
+ key: 'temperature',
+ label: 'Temperature',
+ type: 'input'
+ },
+ {
+ key: 'dynatemp_range',
+ label: 'Dynamic temperature range',
+ type: 'input'
+ },
+ {
+ key: 'dynatemp_exponent',
+ label: 'Dynamic temperature exponent',
+ type: 'input'
+ },
+ {
+ key: 'top_k',
+ label: 'Top K',
+ type: 'input'
+ },
+ {
+ key: 'top_p',
+ label: 'Top P',
+ type: 'input'
+ },
+ {
+ key: 'min_p',
+ label: 'Min P',
+ type: 'input'
+ },
+ {
+ key: 'xtc_probability',
+ label: 'XTC probability',
+ type: 'input'
+ },
+ {
+ key: 'xtc_threshold',
+ label: 'XTC threshold',
+ type: 'input'
+ },
+ {
+ key: 'typ_p',
+ label: 'Typical P',
+ type: 'input'
+ },
+ {
+ key: 'max_tokens',
+ label: 'Max tokens',
+ type: 'input'
+ },
+ {
+ key: 'samplers',
+ label: 'Samplers',
+ type: 'input'
+ }
+ ]
+ },
+ {
+ title: 'Penalties',
+ icon: AlertTriangle,
+ fields: [
+ {
+ key: 'repeat_last_n',
+ label: 'Repeat last N',
+ type: 'input'
+ },
+ {
+ key: 'repeat_penalty',
+ label: 'Repeat penalty',
+ type: 'input'
+ },
+ {
+ key: 'presence_penalty',
+ label: 'Presence penalty',
+ type: 'input'
+ },
+ {
+ key: 'frequency_penalty',
+ label: 'Frequency penalty',
+ type: 'input'
+ },
+ {
+ key: 'dry_multiplier',
+ label: 'DRY multiplier',
+ type: 'input'
+ },
+ {
+ key: 'dry_base',
+ label: 'DRY base',
+ type: 'input'
+ },
+ {
+ key: 'dry_allowed_length',
+ label: 'DRY allowed length',
+ type: 'input'
+ },
+ {
+ key: 'dry_penalty_last_n',
+ label: 'DRY penalty last N',
+ type: 'input'
+ }
+ ]
+ },
+ {
+ title: 'Reasoning',
+ icon: Brain,
+ fields: [
+ {
+ key: 'showThoughtInProgress',
+ label: 'Show thought in progress',
+ type: 'checkbox'
+ }
+ ]
+ },
+ {
+ title: 'Import/Export',
+ icon: Database,
+ fields: []
+ },
+ {
+ title: 'Developer',
+ icon: Code,
+ fields: [
+ {
+ key: 'modelSelectorEnabled',
+ label: 'Enable model selector',
+ type: 'checkbox'
+ },
+ {
+ key: 'showToolCalls',
+ label: 'Show tool call labels',
+ type: 'checkbox'
+ },
+ {
+ key: 'disableReasoningFormat',
+ label: 'Show raw LLM output',
+ type: 'checkbox'
+ },
+ {
+ key: 'custom',
+ label: 'Custom JSON',
+ type: 'textarea'
+ }
+ ]
+ }
+ // TODO: Experimental features section will be implemented after initial release
+ // This includes Python interpreter (Pyodide integration) and other experimental features
+ // {
+ // title: 'Experimental',
+ // icon: Beaker,
+ // fields: [
+ // {
+ // key: 'pyInterpreterEnabled',
+ // label: 'Enable Python interpreter',
+ // type: 'checkbox'
+ // }
+ // ]
+ // }
+ ];
+
+ let activeSection = $state('General');
+ let currentSection = $derived(
+ settingSections.find((section) => section.title === activeSection) || settingSections[0]
+ );
+ let localConfig: SettingsConfigType = $state({ ...config() });
+
+ let canScrollLeft = $state(false);
+ let canScrollRight = $state(false);
+ let scrollContainer: HTMLDivElement | undefined = $state();
+
+ function handleThemeChange(newTheme: string) {
+ localConfig.theme = newTheme;
+
+ setMode(newTheme as 'light' | 'dark' | 'system');
+ }
+
+ function handleConfigChange(key: string, value: string | boolean) {
+ localConfig[key] = value;
+ }
+
+ function handleReset() {
+ localConfig = { ...config() };
+
+ setMode(localConfig.theme as 'light' | 'dark' | 'system');
+ }
+
+ function handleSave() {
+ if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
+ try {
+ JSON.parse(localConfig.custom);
+ } catch (error) {
+ alert('Invalid JSON in custom parameters. Please check the format and try again.');
+ console.error(error);
+ return;
+ }
+ }
+
+ // Convert numeric strings to numbers for numeric fields
+ const processedConfig = { ...localConfig };
+ const numericFields = [
+ 'temperature',
+ 'top_k',
+ 'top_p',
+ 'min_p',
+ 'max_tokens',
+ 'pasteLongTextToFileLen',
+ 'dynatemp_range',
+ 'dynatemp_exponent',
+ 'typ_p',
+ 'xtc_probability',
+ 'xtc_threshold',
+ 'repeat_last_n',
+ 'repeat_penalty',
+ 'presence_penalty',
+ 'frequency_penalty',
+ 'dry_multiplier',
+ 'dry_base',
+ 'dry_allowed_length',
+ 'dry_penalty_last_n'
+ ];
+
+ for (const field of numericFields) {
+ if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
+ const numValue = Number(processedConfig[field]);
+ if (!isNaN(numValue)) {
+ processedConfig[field] = numValue;
+ } else {
+ alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
+ return;
+ }
+ }
+ }
+
+ updateMultipleConfig(processedConfig);
+ onSave?.();
+ }
+
+ function scrollToCenter(element: HTMLElement) {
+ if (!scrollContainer) return;
+
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const elementRect = element.getBoundingClientRect();
+
+ const elementCenter = elementRect.left + elementRect.width / 2;
+ const containerCenter = containerRect.left + containerRect.width / 2;
+ const scrollOffset = elementCenter - containerCenter;
+
+ scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
+ }
+
+ function scrollLeft() {
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
+ }
+
+ function scrollRight() {
+ if (!scrollContainer) return;
+
+ scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
+ }
+
+ function updateScrollButtons() {
+ if (!scrollContainer) return;
+
+ const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+ canScrollLeft = scrollLeft > 0;
+ canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
+ }
+
+ export function reset() {
+ localConfig = { ...config() };
+
+ setTimeout(updateScrollButtons, 100);
+ }
+
+ $effect(() => {
+ if (scrollContainer) {
+ updateScrollButtons();
+ }
+ });
+</script>
+
+<div class="flex h-full flex-col overflow-hidden md:flex-row">
+ <!-- Desktop Sidebar -->
+ <div class="hidden w-64 border-r border-border/30 p-6 md:block">
+ <nav class="space-y-1 py-2">
+ {#each settingSections as section (section.title)}
+ <button
+ class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
+ section.title
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground'}"
+ onclick={() => (activeSection = section.title)}
+ >
+ <section.icon class="h-4 w-4" />
+
+ <span class="ml-2">{section.title}</span>
+ </button>
+ {/each}
+ </nav>
+ </div>
+
+ <!-- Mobile Header with Horizontal Scrollable Menu -->
+ <div class="flex flex-col md:hidden">
+ <div class="border-b border-border/30 py-4">
+ <!-- Horizontal Scrollable Category Menu with Navigation -->
+ <div class="relative flex items-center" style="scroll-padding: 1rem;">
+ <button
+ class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollLeft}
+ aria-label="Scroll left"
+ >
+ <ChevronLeft class="h-4 w-4" />
+ </button>
+
+ <div
+ class="scrollbar-hide overflow-x-auto py-2"
+ bind:this={scrollContainer}
+ onscroll={updateScrollButtons}
+ >
+ <div class="flex min-w-max gap-2">
+ {#each settingSections as section (section.title)}
+ <button
+ class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
+ section.title
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground'}"
+ onclick={(e: MouseEvent) => {
+ activeSection = section.title;
+ scrollToCenter(e.currentTarget as HTMLElement);
+ }}
+ >
+ <section.icon class="h-4 w-4 flex-shrink-0" />
+ <span>{section.title}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ <button
+ class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
+ ? 'opacity-100'
+ : 'pointer-events-none opacity-0'}"
+ onclick={scrollRight}
+ aria-label="Scroll right"
+ >
+ <ChevronRight class="h-4 w-4" />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
+ <div class="space-y-6 p-4 md:p-6">
+ <div class="grid">
+ <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
+ <currentSection.icon class="h-5 w-5" />
+
+ <h3 class="text-lg font-semibold">{currentSection.title}</h3>
+ </div>
+
+ {#if currentSection.title === 'Import/Export'}
+ <ChatSettingsImportExportTab />
+ {:else}
+ <div class="space-y-6">
+ <ChatSettingsFields
+ fields={currentSection.fields}
+ {localConfig}
+ onConfigChange={handleConfigChange}
+ onThemeChange={handleThemeChange}
+ />
+ </div>
+ {/if}
+ </div>
+
+ <div class="mt-8 border-t pt-6">
+ <p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
+ </div>
+ </div>
+ </ScrollArea>
+</div>
+
+<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
+++ /dev/null
-<script lang="ts">
- import {
- Settings,
- Funnel,
- AlertTriangle,
- Brain,
- Code,
- Monitor,
- Sun,
- Moon,
- ChevronLeft,
- ChevronRight,
- Database
- } from '@lucide/svelte';
- import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
- import ImportExportTab from './ImportExportTab.svelte';
- import * as Dialog from '$lib/components/ui/dialog';
- import { ScrollArea } from '$lib/components/ui/scroll-area';
- import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
- import { setMode } from 'mode-watcher';
- import type { Component } from 'svelte';
-
- interface Props {
- onOpenChange?: (open: boolean) => void;
- open?: boolean;
- }
-
- let { onOpenChange, open = false }: Props = $props();
-
- const settingSections: Array<{
- fields: SettingsFieldConfig[];
- icon: Component;
- title: string;
- }> = [
- {
- title: 'General',
- icon: Settings,
- fields: [
- { key: 'apiKey', label: 'API Key', type: 'input' },
- {
- key: 'systemMessage',
- label: 'System Message (will be disabled if left empty)',
- type: 'textarea'
- },
- {
- key: 'theme',
- label: 'Theme',
- type: 'select',
- options: [
- { value: 'system', label: 'System', icon: Monitor },
- { value: 'light', label: 'Light', icon: Sun },
- { value: 'dark', label: 'Dark', icon: Moon }
- ]
- },
- {
- key: 'pasteLongTextToFileLen',
- label: 'Paste long text to file length',
- type: 'input'
- },
- {
- key: 'showMessageStats',
- label: 'Show message generation statistics',
- type: 'checkbox'
- },
- {
- key: 'showTokensPerSecond',
- label: 'Show tokens per second',
- type: 'checkbox'
- },
- {
- key: 'keepStatsVisible',
- label: 'Keep stats visible after generation',
- type: 'checkbox'
- },
- {
- key: 'showModelInfo',
- label: 'Show model information',
- type: 'checkbox'
- },
- {
- key: 'enableContinueGeneration',
- label: 'Enable "Continue" button',
- type: 'checkbox',
- isExperimental: true
- },
- {
- key: 'pdfAsImage',
- label: 'Parse PDF as image',
- type: 'checkbox'
- },
- {
- key: 'renderUserContentAsMarkdown',
- label: 'Render user content as Markdown',
- type: 'checkbox'
- },
- {
- key: 'askForTitleConfirmation',
- label: 'Ask for confirmation before changing conversation title',
- type: 'checkbox'
- }
- ]
- },
- {
- title: 'Sampling',
- icon: Funnel,
- fields: [
- {
- key: 'temperature',
- label: 'Temperature',
- type: 'input'
- },
- {
- key: 'dynatemp_range',
- label: 'Dynamic temperature range',
- type: 'input'
- },
- {
- key: 'dynatemp_exponent',
- label: 'Dynamic temperature exponent',
- type: 'input'
- },
- {
- key: 'top_k',
- label: 'Top K',
- type: 'input'
- },
- {
- key: 'top_p',
- label: 'Top P',
- type: 'input'
- },
- {
- key: 'min_p',
- label: 'Min P',
- type: 'input'
- },
- {
- key: 'xtc_probability',
- label: 'XTC probability',
- type: 'input'
- },
- {
- key: 'xtc_threshold',
- label: 'XTC threshold',
- type: 'input'
- },
- {
- key: 'typ_p',
- label: 'Typical P',
- type: 'input'
- },
- {
- key: 'max_tokens',
- label: 'Max tokens',
- type: 'input'
- },
- {
- key: 'samplers',
- label: 'Samplers',
- type: 'input'
- }
- ]
- },
- {
- title: 'Penalties',
- icon: AlertTriangle,
- fields: [
- {
- key: 'repeat_last_n',
- label: 'Repeat last N',
- type: 'input'
- },
- {
- key: 'repeat_penalty',
- label: 'Repeat penalty',
- type: 'input'
- },
- {
- key: 'presence_penalty',
- label: 'Presence penalty',
- type: 'input'
- },
- {
- key: 'frequency_penalty',
- label: 'Frequency penalty',
- type: 'input'
- },
- {
- key: 'dry_multiplier',
- label: 'DRY multiplier',
- type: 'input'
- },
- {
- key: 'dry_base',
- label: 'DRY base',
- type: 'input'
- },
- {
- key: 'dry_allowed_length',
- label: 'DRY allowed length',
- type: 'input'
- },
- {
- key: 'dry_penalty_last_n',
- label: 'DRY penalty last N',
- type: 'input'
- }
- ]
- },
- {
- title: 'Reasoning',
- icon: Brain,
- fields: [
- {
- key: 'showThoughtInProgress',
- label: 'Show thought in progress',
- type: 'checkbox'
- }
- ]
- },
- {
- title: 'Import/Export',
- icon: Database,
- fields: []
- },
- {
- title: 'Developer',
- icon: Code,
- fields: [
- {
- key: 'modelSelectorEnabled',
- label: 'Enable model selector',
- type: 'checkbox'
- },
- {
- key: 'showToolCalls',
- label: 'Show tool call labels',
- type: 'checkbox'
- },
- {
- key: 'disableReasoningFormat',
- label: 'Show raw LLM output',
- type: 'checkbox'
- },
- {
- key: 'custom',
- label: 'Custom JSON',
- type: 'textarea'
- }
- ]
- }
- // TODO: Experimental features section will be implemented after initial release
- // This includes Python interpreter (Pyodide integration) and other experimental features
- // {
- // title: 'Experimental',
- // icon: Beaker,
- // fields: [
- // {
- // key: 'pyInterpreterEnabled',
- // label: 'Enable Python interpreter',
- // type: 'checkbox'
- // }
- // ]
- // }
- ];
-
- let activeSection = $state('General');
- let currentSection = $derived(
- settingSections.find((section) => section.title === activeSection) || settingSections[0]
- );
- let localConfig: SettingsConfigType = $state({ ...config() });
- let originalTheme: string = $state('');
-
- let canScrollLeft = $state(false);
- let canScrollRight = $state(false);
- let scrollContainer: HTMLDivElement | undefined = $state();
-
- function handleThemeChange(newTheme: string) {
- localConfig.theme = newTheme;
-
- setMode(newTheme as 'light' | 'dark' | 'system');
- }
-
- function handleConfigChange(key: string, value: string | boolean) {
- localConfig[key] = value;
- }
-
- function handleClose() {
- if (localConfig.theme !== originalTheme) {
- setMode(originalTheme as 'light' | 'dark' | 'system');
- }
- onOpenChange?.(false);
- }
-
- function handleReset() {
- localConfig = { ...config() };
-
- setMode(localConfig.theme as 'light' | 'dark' | 'system');
- originalTheme = localConfig.theme as string;
- }
-
- function handleSave() {
- if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
- try {
- JSON.parse(localConfig.custom);
- } catch (error) {
- alert('Invalid JSON in custom parameters. Please check the format and try again.');
- console.error(error);
- return;
- }
- }
-
- // Convert numeric strings to numbers for numeric fields
- const processedConfig = { ...localConfig };
- const numericFields = [
- 'temperature',
- 'top_k',
- 'top_p',
- 'min_p',
- 'max_tokens',
- 'pasteLongTextToFileLen',
- 'dynatemp_range',
- 'dynatemp_exponent',
- 'typ_p',
- 'xtc_probability',
- 'xtc_threshold',
- 'repeat_last_n',
- 'repeat_penalty',
- 'presence_penalty',
- 'frequency_penalty',
- 'dry_multiplier',
- 'dry_base',
- 'dry_allowed_length',
- 'dry_penalty_last_n'
- ];
-
- for (const field of numericFields) {
- if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
- const numValue = Number(processedConfig[field]);
- if (!isNaN(numValue)) {
- processedConfig[field] = numValue;
- } else {
- alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
- return;
- }
- }
- }
-
- updateMultipleConfig(processedConfig);
- onOpenChange?.(false);
- }
-
- function scrollToCenter(element: HTMLElement) {
- if (!scrollContainer) return;
-
- const containerRect = scrollContainer.getBoundingClientRect();
- const elementRect = element.getBoundingClientRect();
-
- const elementCenter = elementRect.left + elementRect.width / 2;
- const containerCenter = containerRect.left + containerRect.width / 2;
- const scrollOffset = elementCenter - containerCenter;
-
- scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
- }
-
- function scrollLeft() {
- if (!scrollContainer) return;
-
- scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
- }
-
- function scrollRight() {
- if (!scrollContainer) return;
-
- scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
- }
-
- function updateScrollButtons() {
- if (!scrollContainer) return;
-
- const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
- canScrollLeft = scrollLeft > 0;
- canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
- }
-
- $effect(() => {
- if (open) {
- localConfig = { ...config() };
- originalTheme = config().theme as string;
-
- setTimeout(updateScrollButtons, 100);
- }
- });
-
- $effect(() => {
- if (scrollContainer) {
- updateScrollButtons();
- }
- });
-</script>
-
-<Dialog.Root {open} onOpenChange={handleClose}>
- <Dialog.Content
- class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
- md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
- style="max-width: 48rem;"
- >
- <div class="flex flex-1 flex-col overflow-hidden md:flex-row">
- <!-- Desktop Sidebar -->
- <div class="hidden w-64 border-r border-border/30 p-6 md:block">
- <nav class="space-y-1 py-2">
- <Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
-
- {#each settingSections as section (section.title)}
- <button
- class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
- section.title
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground'}"
- onclick={() => (activeSection = section.title)}
- >
- <section.icon class="h-4 w-4" />
-
- <span class="ml-2">{section.title}</span>
- </button>
- {/each}
- </nav>
- </div>
-
- <!-- Mobile Header with Horizontal Scrollable Menu -->
- <div class="flex flex-col md:hidden">
- <div class="border-b border-border/30 py-4">
- <Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
-
- <!-- Horizontal Scrollable Category Menu with Navigation -->
- <div class="relative flex items-center" style="scroll-padding: 1rem;">
- <button
- class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
- ? 'opacity-100'
- : 'pointer-events-none opacity-0'}"
- onclick={scrollLeft}
- aria-label="Scroll left"
- >
- <ChevronLeft class="h-4 w-4" />
- </button>
-
- <div
- class="scrollbar-hide overflow-x-auto py-2"
- bind:this={scrollContainer}
- onscroll={updateScrollButtons}
- >
- <div class="flex min-w-max gap-2">
- {#each settingSections as section (section.title)}
- <button
- class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
- section.title
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground'}"
- onclick={(e: MouseEvent) => {
- activeSection = section.title;
- scrollToCenter(e.currentTarget as HTMLElement);
- }}
- >
- <section.icon class="h-4 w-4 flex-shrink-0" />
- <span>{section.title}</span>
- </button>
- {/each}
- </div>
- </div>
-
- <button
- class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
- ? 'opacity-100'
- : 'pointer-events-none opacity-0'}"
- onclick={scrollRight}
- aria-label="Scroll right"
- >
- <ChevronRight class="h-4 w-4" />
- </button>
- </div>
- </div>
- </div>
-
- <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
- <div class="space-y-6 p-4 md:p-6">
- <div class="grid">
- <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
- <currentSection.icon class="h-5 w-5" />
-
- <h3 class="text-lg font-semibold">{currentSection.title}</h3>
- </div>
-
- {#if currentSection.title === 'Import/Export'}
- <ImportExportTab />
- {:else}
- <div class="space-y-6">
- <ChatSettingsFields
- fields={currentSection.fields}
- {localConfig}
- onConfigChange={handleConfigChange}
- onThemeChange={handleThemeChange}
- />
- </div>
- {/if}
- </div>
-
- <div class="mt-8 border-t pt-6">
- <p class="text-xs text-muted-foreground">
- Settings are saved in browser's localStorage
- </p>
- </div>
- </div>
- </ScrollArea>
- </div>
-
- <ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
- </Dialog.Content>
-</Dialog.Root>
import { supportsVision } from '$lib/stores/server.svelte';
import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
import { ParameterSyncService } from '$lib/services/parameter-sync';
- import ParameterSourceIndicator from './ParameterSourceIndicator.svelte';
+ import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
import type { Component } from 'svelte';
interface Props {
{/if}
</Label>
{#if isCustomRealTime}
- <ParameterSourceIndicator />
+ <ChatSettingsParameterSourceIndicator />
{/if}
</div>
{/if}
</Label>
{#if isCustomRealTime}
- <ParameterSourceIndicator />
+ <ChatSettingsParameterSourceIndicator />
{/if}
</div>
--- /dev/null
+<script lang="ts">
+ import { Download, Upload } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { DialogConversationSelection } from '$lib/components/app';
+ import { DatabaseStore } from '$lib/stores/database';
+ import type { ExportedConversations } from '$lib/types/database';
+ import { createMessageCountMap } from '$lib/utils/conversation-utils';
+ import { chatStore } from '$lib/stores/chat.svelte';
+
+ let exportedConversations = $state<DatabaseConversation[]>([]);
+ let importedConversations = $state<DatabaseConversation[]>([]);
+ let showExportSummary = $state(false);
+ let showImportSummary = $state(false);
+
+ let showExportDialog = $state(false);
+ let showImportDialog = $state(false);
+ let availableConversations = $state<DatabaseConversation[]>([]);
+ let messageCountMap = $state<Map<string, number>>(new Map());
+ let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
+ []
+ );
+
+ async function handleExportClick() {
+ try {
+ const allConversations = await DatabaseStore.getAllConversations();
+ if (allConversations.length === 0) {
+ alert('No conversations to export');
+ return;
+ }
+
+ const conversationsWithMessages = await Promise.all(
+ allConversations.map(async (conv) => {
+ const messages = await DatabaseStore.getConversationMessages(conv.id);
+ return { conv, messages };
+ })
+ );
+
+ messageCountMap = createMessageCountMap(conversationsWithMessages);
+ availableConversations = allConversations;
+ showExportDialog = true;
+ } catch (err) {
+ console.error('Failed to load conversations:', err);
+ alert('Failed to load conversations');
+ }
+ }
+
+ async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
+ try {
+ const allData: ExportedConversations = await Promise.all(
+ selectedConversations.map(async (conv) => {
+ const messages = await DatabaseStore.getConversationMessages(conv.id);
+ return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
+ })
+ );
+
+ const blob = new Blob([JSON.stringify(allData, null, 2)], {
+ type: 'application/json'
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+
+ a.href = url;
+ a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ exportedConversations = selectedConversations;
+ showExportSummary = true;
+ showImportSummary = false;
+ showExportDialog = false;
+ } catch (err) {
+ console.error('Export failed:', err);
+ alert('Failed to export conversations');
+ }
+ }
+
+ async function handleImportClick() {
+ try {
+ const input = document.createElement('input');
+
+ input.type = 'file';
+ input.accept = '.json';
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement)?.files?.[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ const parsedData = JSON.parse(text);
+ let importedData: ExportedConversations;
+
+ if (Array.isArray(parsedData)) {
+ importedData = parsedData;
+ } else if (
+ parsedData &&
+ typeof parsedData === 'object' &&
+ 'conv' in parsedData &&
+ 'messages' in parsedData
+ ) {
+ // Single conversation object
+ importedData = [parsedData];
+ } else {
+ throw new Error(
+ 'Invalid file format: expected array of conversations or single conversation object'
+ );
+ }
+
+ fullImportData = importedData;
+ availableConversations = importedData.map(
+ (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
+ );
+ messageCountMap = createMessageCountMap(importedData);
+ showImportDialog = true;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Unknown error';
+
+ console.error('Failed to parse file:', err);
+ alert(`Failed to parse file: ${message}`);
+ }
+ };
+
+ input.click();
+ } catch (err) {
+ console.error('Import failed:', err);
+ alert('Failed to import conversations');
+ }
+ }
+
+ async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
+ try {
+ const selectedIds = new Set(selectedConversations.map((c) => c.id));
+ const selectedData = $state
+ .snapshot(fullImportData)
+ .filter((item) => selectedIds.has(item.conv.id));
+
+ await DatabaseStore.importConversations(selectedData);
+
+ await chatStore.loadConversations();
+
+ importedConversations = selectedConversations;
+ showImportSummary = true;
+ showExportSummary = false;
+ showImportDialog = false;
+ } catch (err) {
+ console.error('Import failed:', err);
+ alert('Failed to import conversations. Please check the file format.');
+ }
+ }
+</script>
+
+<div class="space-y-6">
+ <div class="space-y-4">
+ <div class="grid">
+ <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Download all your conversations as a JSON file. This includes all messages, attachments, and
+ conversation history.
+ </p>
+
+ <Button
+ class="w-full justify-start justify-self-start md:w-auto"
+ onclick={handleExportClick}
+ variant="outline"
+ >
+ <Download class="mr-2 h-4 w-4" />
+
+ Export conversations
+ </Button>
+
+ {#if showExportSummary && exportedConversations.length > 0}
+ <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+ <h5 class="mb-2 text-sm font-medium">
+ Exported {exportedConversations.length} conversation{exportedConversations.length === 1
+ ? ''
+ : 's'}
+ </h5>
+
+ <ul class="space-y-1 text-sm text-muted-foreground">
+ {#each exportedConversations.slice(0, 10) as conv (conv.id)}
+ <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+ {/each}
+
+ {#if exportedConversations.length > 10}
+ <li class="italic">
+ ... and {exportedConversations.length - 10} more
+ </li>
+ {/if}
+ </ul>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid border-t border-border/30 pt-4">
+ <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
+
+ <p class="mb-4 text-sm text-muted-foreground">
+ Import one or more conversations from a previously exported JSON file. This will merge with
+ your existing conversations.
+ </p>
+
+ <Button
+ class="w-full justify-start justify-self-start md:w-auto"
+ onclick={handleImportClick}
+ variant="outline"
+ >
+ <Upload class="mr-2 h-4 w-4" />
+ Import conversations
+ </Button>
+
+ {#if showImportSummary && importedConversations.length > 0}
+ <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+ <h5 class="mb-2 text-sm font-medium">
+ Imported {importedConversations.length} conversation{importedConversations.length === 1
+ ? ''
+ : 's'}
+ </h5>
+
+ <ul class="space-y-1 text-sm text-muted-foreground">
+ {#each importedConversations.slice(0, 10) as conv (conv.id)}
+ <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+ {/each}
+
+ {#if importedConversations.length > 10}
+ <li class="italic">
+ ... and {importedConversations.length - 10} more
+ </li>
+ {/if}
+ </ul>
+ </div>
+ {/if}
+ </div>
+ </div>
+</div>
+
+<DialogConversationSelection
+ conversations={availableConversations}
+ {messageCountMap}
+ mode="export"
+ bind:open={showExportDialog}
+ onCancel={() => (showExportDialog = false)}
+ onConfirm={handleExportConfirm}
+/>
+
+<DialogConversationSelection
+ conversations={availableConversations}
+ {messageCountMap}
+ mode="import"
+ bind:open={showImportDialog}
+ onCancel={() => (showImportDialog = false)}
+ onConfirm={handleImportConfirm}
+/>
--- /dev/null
+<script lang="ts">
+ import { Wrench } from '@lucide/svelte';
+ import { Badge } from '$lib/components/ui/badge';
+
+ interface Props {
+ class?: string;
+ }
+
+ let { class: className = '' }: Props = $props();
+</script>
+
+<Badge
+ variant="secondary"
+ class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
+>
+ <Wrench class="mr-1 h-3 w-3" />
+ Custom
+</Badge>
+++ /dev/null
-<script lang="ts">
- import { Search, X } from '@lucide/svelte';
- import * as Dialog from '$lib/components/ui/dialog';
- import { Button } from '$lib/components/ui/button';
- import { Input } from '$lib/components/ui/input';
- import { Checkbox } from '$lib/components/ui/checkbox';
- import { ScrollArea } from '$lib/components/ui/scroll-area';
- import { SvelteSet } from 'svelte/reactivity';
-
- interface Props {
- conversations: DatabaseConversation[];
- messageCountMap?: Map<string, number>;
- mode: 'export' | 'import';
- onCancel: () => void;
- onConfirm: (selectedConversations: DatabaseConversation[]) => void;
- open?: boolean;
- }
-
- let {
- conversations,
- messageCountMap = new Map(),
- mode,
- onCancel,
- onConfirm,
- open = $bindable(false)
- }: Props = $props();
-
- let searchQuery = $state('');
- let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
- let lastClickedId = $state<string | null>(null);
-
- let filteredConversations = $derived(
- conversations.filter((conv) => {
- const name = conv.name || 'Untitled conversation';
- return name.toLowerCase().includes(searchQuery.toLowerCase());
- })
- );
-
- let allSelected = $derived(
- filteredConversations.length > 0 &&
- filteredConversations.every((conv) => selectedIds.has(conv.id))
- );
-
- let someSelected = $derived(
- filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
- );
-
- function toggleConversation(id: string, shiftKey: boolean = false) {
- const newSet = new SvelteSet(selectedIds);
-
- if (shiftKey && lastClickedId !== null) {
- const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
- const currentIndex = filteredConversations.findIndex((c) => c.id === id);
-
- if (lastIndex !== -1 && currentIndex !== -1) {
- const start = Math.min(lastIndex, currentIndex);
- const end = Math.max(lastIndex, currentIndex);
-
- const shouldSelect = !newSet.has(id);
-
- for (let i = start; i <= end; i++) {
- if (shouldSelect) {
- newSet.add(filteredConversations[i].id);
- } else {
- newSet.delete(filteredConversations[i].id);
- }
- }
-
- selectedIds = newSet;
- return;
- }
- }
-
- if (newSet.has(id)) {
- newSet.delete(id);
- } else {
- newSet.add(id);
- }
-
- selectedIds = newSet;
- lastClickedId = id;
- }
-
- function toggleAll() {
- if (allSelected) {
- const newSet = new SvelteSet(selectedIds);
-
- filteredConversations.forEach((conv) => newSet.delete(conv.id));
- selectedIds = newSet;
- } else {
- const newSet = new SvelteSet(selectedIds);
-
- filteredConversations.forEach((conv) => newSet.add(conv.id));
- selectedIds = newSet;
- }
- }
-
- function handleConfirm() {
- const selected = conversations.filter((conv) => selectedIds.has(conv.id));
- onConfirm(selected);
- }
-
- function handleCancel() {
- selectedIds = new SvelteSet(conversations.map((c) => c.id));
- searchQuery = '';
- lastClickedId = null;
-
- onCancel();
- }
-
- let previousOpen = $state(false);
-
- $effect(() => {
- if (open && !previousOpen) {
- selectedIds = new SvelteSet(conversations.map((c) => c.id));
- searchQuery = '';
- lastClickedId = null;
- } else if (!open && previousOpen) {
- onCancel();
- }
-
- previousOpen = open;
- });
-</script>
-
-<Dialog.Root bind:open>
- <Dialog.Portal>
- <Dialog.Overlay class="z-[1000000]" />
-
- <Dialog.Content class="z-[1000001] max-w-2xl">
- <Dialog.Header>
- <Dialog.Title>
- Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
- </Dialog.Title>
-
- <Dialog.Description>
- {#if mode === 'export'}
- Choose which conversations you want to export. Selected conversations will be downloaded
- as a JSON file.
- {:else}
- Choose which conversations you want to import. Selected conversations will be merged
- with your existing conversations.
- {/if}
- </Dialog.Description>
- </Dialog.Header>
-
- <div class="space-y-4">
- <div class="relative">
- <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
-
- <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
-
- {#if searchQuery}
- <button
- class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
- onclick={() => (searchQuery = '')}
- type="button"
- >
- <X class="h-4 w-4" />
- </button>
- {/if}
- </div>
-
- <div class="flex items-center justify-between text-sm text-muted-foreground">
- <span>
- {selectedIds.size} of {conversations.length} selected
- {#if searchQuery}
- ({filteredConversations.length} shown)
- {/if}
- </span>
- </div>
-
- <div class="overflow-hidden rounded-md border">
- <ScrollArea class="h-[400px]">
- <table class="w-full">
- <thead class="sticky top-0 z-10 bg-muted">
- <tr class="border-b">
- <th class="w-12 p-3 text-left">
- <Checkbox
- checked={allSelected}
- indeterminate={someSelected}
- onCheckedChange={toggleAll}
- />
- </th>
-
- <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="3" class="p-8 text-center text-sm text-muted-foreground">
- {#if searchQuery}
- No conversations found matching "{searchQuery}"
- {:else}
- No conversations available
- {/if}
- </td>
- </tr>
- {:else}
- {#each filteredConversations as conv (conv.id)}
- <tr
- class="cursor-pointer border-b transition-colors hover:bg-muted/50"
- onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
- >
- <td class="p-3">
- <Checkbox
- checked={selectedIds.has(conv.id)}
- onclick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- toggleConversation(conv.id, e.shiftKey);
- }}
- />
- </td>
-
- <td class="p-3 text-sm">
- <div
- class="max-w-[17rem] 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.Footer>
- <Button variant="outline" onclick={handleCancel}>Cancel</Button>
-
- <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
- {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
- </Button>
- </Dialog.Footer>
- </Dialog.Content>
- </Dialog.Portal>
-</Dialog.Root>
+++ /dev/null
-<script lang="ts">
- import { Download, Upload } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import ConversationSelectionDialog from './ConversationSelectionDialog.svelte';
- import { DatabaseStore } from '$lib/stores/database';
- import type { ExportedConversations } from '$lib/types/database';
- import { createMessageCountMap } from '$lib/utils/conversation-utils';
- import { chatStore } from '$lib/stores/chat.svelte';
-
- let exportedConversations = $state<DatabaseConversation[]>([]);
- let importedConversations = $state<DatabaseConversation[]>([]);
- let showExportSummary = $state(false);
- let showImportSummary = $state(false);
-
- let showExportDialog = $state(false);
- let showImportDialog = $state(false);
- let availableConversations = $state<DatabaseConversation[]>([]);
- let messageCountMap = $state<Map<string, number>>(new Map());
- let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
- []
- );
-
- async function handleExportClick() {
- try {
- const allConversations = await DatabaseStore.getAllConversations();
- if (allConversations.length === 0) {
- alert('No conversations to export');
- return;
- }
-
- const conversationsWithMessages = await Promise.all(
- allConversations.map(async (conv) => {
- const messages = await DatabaseStore.getConversationMessages(conv.id);
- return { conv, messages };
- })
- );
-
- messageCountMap = createMessageCountMap(conversationsWithMessages);
- availableConversations = allConversations;
- showExportDialog = true;
- } catch (err) {
- console.error('Failed to load conversations:', err);
- alert('Failed to load conversations');
- }
- }
-
- async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
- try {
- const allData: ExportedConversations = await Promise.all(
- selectedConversations.map(async (conv) => {
- const messages = await DatabaseStore.getConversationMessages(conv.id);
- return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
- })
- );
-
- const blob = new Blob([JSON.stringify(allData, null, 2)], {
- type: 'application/json'
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
-
- a.href = url;
- a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
- exportedConversations = selectedConversations;
- showExportSummary = true;
- showImportSummary = false;
- showExportDialog = false;
- } catch (err) {
- console.error('Export failed:', err);
- alert('Failed to export conversations');
- }
- }
-
- async function handleImportClick() {
- try {
- const input = document.createElement('input');
-
- input.type = 'file';
- input.accept = '.json';
-
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement)?.files?.[0];
- if (!file) return;
-
- try {
- const text = await file.text();
- const parsedData = JSON.parse(text);
- let importedData: ExportedConversations;
-
- if (Array.isArray(parsedData)) {
- importedData = parsedData;
- } else if (
- parsedData &&
- typeof parsedData === 'object' &&
- 'conv' in parsedData &&
- 'messages' in parsedData
- ) {
- // Single conversation object
- importedData = [parsedData];
- } else {
- throw new Error(
- 'Invalid file format: expected array of conversations or single conversation object'
- );
- }
-
- fullImportData = importedData;
- availableConversations = importedData.map(
- (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
- );
- messageCountMap = createMessageCountMap(importedData);
- showImportDialog = true;
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : 'Unknown error';
-
- console.error('Failed to parse file:', err);
- alert(`Failed to parse file: ${message}`);
- }
- };
-
- input.click();
- } catch (err) {
- console.error('Import failed:', err);
- alert('Failed to import conversations');
- }
- }
-
- async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
- try {
- const selectedIds = new Set(selectedConversations.map((c) => c.id));
- const selectedData = $state
- .snapshot(fullImportData)
- .filter((item) => selectedIds.has(item.conv.id));
-
- await DatabaseStore.importConversations(selectedData);
-
- await chatStore.loadConversations();
-
- importedConversations = selectedConversations;
- showImportSummary = true;
- showExportSummary = false;
- showImportDialog = false;
- } catch (err) {
- console.error('Import failed:', err);
- alert('Failed to import conversations. Please check the file format.');
- }
- }
-</script>
-
-<div class="space-y-6">
- <div class="space-y-4">
- <div class="grid">
- <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
-
- <p class="mb-4 text-sm text-muted-foreground">
- Download all your conversations as a JSON file. This includes all messages, attachments, and
- conversation history.
- </p>
-
- <Button
- class="w-full justify-start justify-self-start md:w-auto"
- onclick={handleExportClick}
- variant="outline"
- >
- <Download class="mr-2 h-4 w-4" />
-
- Export conversations
- </Button>
-
- {#if showExportSummary && exportedConversations.length > 0}
- <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
- <h5 class="mb-2 text-sm font-medium">
- Exported {exportedConversations.length} conversation{exportedConversations.length === 1
- ? ''
- : 's'}
- </h5>
-
- <ul class="space-y-1 text-sm text-muted-foreground">
- {#each exportedConversations.slice(0, 10) as conv (conv.id)}
- <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
- {/each}
-
- {#if exportedConversations.length > 10}
- <li class="italic">
- ... and {exportedConversations.length - 10} more
- </li>
- {/if}
- </ul>
- </div>
- {/if}
- </div>
-
- <div class="grid border-t border-border/30 pt-4">
- <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
-
- <p class="mb-4 text-sm text-muted-foreground">
- Import one or more conversations from a previously exported JSON file. This will merge with
- your existing conversations.
- </p>
-
- <Button
- class="w-full justify-start justify-self-start md:w-auto"
- onclick={handleImportClick}
- variant="outline"
- >
- <Upload class="mr-2 h-4 w-4" />
- Import conversations
- </Button>
-
- {#if showImportSummary && importedConversations.length > 0}
- <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
- <h5 class="mb-2 text-sm font-medium">
- Imported {importedConversations.length} conversation{importedConversations.length === 1
- ? ''
- : 's'}
- </h5>
-
- <ul class="space-y-1 text-sm text-muted-foreground">
- {#each importedConversations.slice(0, 10) as conv (conv.id)}
- <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
- {/each}
-
- {#if importedConversations.length > 10}
- <li class="italic">
- ... and {importedConversations.length - 10} more
- </li>
- {/if}
- </ul>
- </div>
- {/if}
- </div>
- </div>
-</div>
-
-<ConversationSelectionDialog
- conversations={availableConversations}
- {messageCountMap}
- mode="export"
- bind:open={showExportDialog}
- onCancel={() => (showExportDialog = false)}
- onConfirm={handleExportConfirm}
-/>
-
-<ConversationSelectionDialog
- conversations={availableConversations}
- {messageCountMap}
- mode="import"
- bind:open={showImportDialog}
- onCancel={() => (showImportDialog = false)}
- onConfirm={handleImportConfirm}
-/>
+++ /dev/null
-<script lang="ts">
- import { Wrench } from '@lucide/svelte';
- import { Badge } from '$lib/components/ui/badge';
-
- interface Props {
- class?: string;
- }
-
- let { class: className = '' }: Props = $props();
-</script>
-
-<Badge
- variant="secondary"
- class="h-5 bg-orange-100 px-1.5 py-0.5 text-xs text-orange-800 dark:bg-orange-900 dark:text-orange-200 {className}"
->
- <Wrench class="mr-1 h-3 w-3" />
- Custom
-</Badge>
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Trash2 } from '@lucide/svelte';
- import { ChatSidebarConversationItem, ConfirmationDialog } from '$lib/components/app';
+ import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import * as Sidebar from '$lib/components/ui/sidebar';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
</ScrollArea>
-<ConfirmationDialog
+<DialogConfirmation
bind:open={showDeleteDialog}
title="Delete Conversation"
description={selectedConversation
+++ /dev/null
-<script lang="ts">
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
- import { AlertTriangle, TimerOff } from '@lucide/svelte';
-
- interface Props {
- open: boolean;
- type: 'timeout' | 'server';
- message: string;
- onOpenChange?: (open: boolean) => void;
- }
-
- let { open = $bindable(), type, message, onOpenChange }: Props = $props();
-
- const isTimeout = $derived(type === 'timeout');
- const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
- const description = $derived(
- isTimeout
- ? 'The request did not receive a response from the server before timing out.'
- : 'The server responded with an error message. Review the details below.'
- );
- const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500');
- const badgeClass = $derived(
- isTimeout
- ? 'border-destructive/40 bg-destructive/10 text-destructive'
- : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
- );
-
- function handleOpenChange(newOpen: boolean) {
- open = newOpen;
- onOpenChange?.(newOpen);
- }
-</script>
-
-<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
- <AlertDialog.Content>
- <AlertDialog.Header>
- <AlertDialog.Title class="flex items-center gap-2">
- {#if isTimeout}
- <TimerOff class={`h-5 w-5 ${iconClass}`} />
- {:else}
- <AlertTriangle class={`h-5 w-5 ${iconClass}`} />
- {/if}
-
- {title}
- </AlertDialog.Title>
-
- <AlertDialog.Description>
- {description}
- </AlertDialog.Description>
- </AlertDialog.Header>
-
- <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}>
- <p class="font-medium">{message}</p>
- </div>
-
- <AlertDialog.Footer>
- <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action>
- </AlertDialog.Footer>
- </AlertDialog.Content>
-</AlertDialog.Root>
+++ /dev/null
-<script lang="ts">
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
- import type { Component } from 'svelte';
-
- interface Props {
- open: boolean;
- title: string;
- description: string;
- confirmText?: string;
- cancelText?: string;
- variant?: 'default' | 'destructive';
- icon?: Component;
- onConfirm: () => void;
- onCancel: () => void;
- onKeydown?: (event: KeyboardEvent) => void;
- }
-
- let {
- open = $bindable(),
- title,
- description,
- confirmText = 'Confirm',
- cancelText = 'Cancel',
- variant = 'default',
- icon,
- onConfirm,
- onCancel,
- onKeydown
- }: Props = $props();
-
- function handleKeydown(event: KeyboardEvent) {
- if (event.key === 'Enter') {
- event.preventDefault();
- onConfirm();
- }
- onKeydown?.(event);
- }
-
- function handleOpenChange(newOpen: boolean) {
- if (!newOpen) {
- onCancel();
- }
- }
-</script>
-
-<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
- <AlertDialog.Content onkeydown={handleKeydown}>
- <AlertDialog.Header>
- <AlertDialog.Title class="flex items-center gap-2">
- {#if icon}
- {@const IconComponent = icon}
- <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" />
- {/if}
- {title}
- </AlertDialog.Title>
-
- <AlertDialog.Description>
- {description}
- </AlertDialog.Description>
- </AlertDialog.Header>
-
- <AlertDialog.Footer>
- <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
- <AlertDialog.Action
- onclick={onConfirm}
- class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''}
- >
- {confirmText}
- </AlertDialog.Action>
- </AlertDialog.Footer>
- </AlertDialog.Content>
-</AlertDialog.Root>
+++ /dev/null
-<script lang="ts">
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
- import { Button } from '$lib/components/ui/button';
-
- interface Props {
- open: boolean;
- currentTitle: string;
- newTitle: string;
- onConfirm: () => void;
- onCancel: () => void;
- }
-
- let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props();
-</script>
-
-<AlertDialog.Root bind:open>
- <AlertDialog.Content>
- <AlertDialog.Header>
- <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
-
- <AlertDialog.Description>
- Do you want to update the conversation title to match the first message content?
- </AlertDialog.Description>
- </AlertDialog.Header>
-
- <div class="space-y-4 pt-2 pb-6">
- <div class="space-y-2">
- <p class="text-sm font-medium text-muted-foreground">Current title:</p>
-
- <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
- </div>
-
- <div class="space-y-2">
- <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
-
- <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
- </div>
- </div>
-
- <AlertDialog.Footer>
- <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
-
- <Button onclick={onConfirm}>Update Title</Button>
- </AlertDialog.Footer>
- </AlertDialog.Content>
-</AlertDialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ChatAttachmentPreview } from '$lib/components/app';
+ import { formatFileSize } from '$lib/utils/file-preview';
+
+ interface Props {
+ open: boolean;
+ // Either an uploaded file or a stored attachment
+ uploadedFile?: ChatUploadedFile;
+ attachment?: DatabaseMessageExtra;
+ // For uploaded files
+ preview?: string;
+ name?: string;
+ type?: string;
+ size?: number;
+ textContent?: string;
+ }
+
+ let {
+ open = $bindable(),
+ uploadedFile,
+ attachment,
+ preview,
+ name,
+ type,
+ size,
+ textContent
+ }: Props = $props();
+
+ let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
+
+ let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+ let displayType = $derived(
+ uploadedFile?.type ||
+ (attachment?.type === 'imageFile'
+ ? 'image'
+ : attachment?.type === 'textFile'
+ ? 'text'
+ : attachment?.type === 'audioFile'
+ ? attachment.mimeType || 'audio'
+ : attachment?.type === 'pdfFile'
+ ? 'application/pdf'
+ : type || 'unknown')
+ );
+
+ let displaySize = $derived(uploadedFile?.size || size);
+
+ $effect(() => {
+ if (open && chatAttachmentPreviewRef) {
+ chatAttachmentPreviewRef.reset();
+ }
+ });
+</script>
+
+<Dialog.Root bind:open>
+ <Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
+ <Dialog.Header>
+ <Dialog.Title>{displayName}</Dialog.Title>
+ <Dialog.Description>
+ {displayType}
+ {#if displaySize}
+ • {formatFileSize(displaySize)}
+ {/if}
+ </Dialog.Description>
+ </Dialog.Header>
+
+ <ChatAttachmentPreview
+ bind:this={chatAttachmentPreviewRef}
+ {uploadedFile}
+ {attachment}
+ {preview}
+ {name}
+ {type}
+ {textContent}
+ />
+ </Dialog.Content>
+</Dialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ChatAttachmentsViewAll } from '$lib/components/app';
+
+ interface Props {
+ open?: boolean;
+ uploadedFiles?: ChatUploadedFile[];
+ attachments?: DatabaseMessageExtra[];
+ readonly?: boolean;
+ onFileRemove?: (fileId: string) => void;
+ imageHeight?: string;
+ imageWidth?: string;
+ imageClass?: string;
+ }
+
+ let {
+ open = $bindable(false),
+ uploadedFiles = [],
+ attachments = [],
+ readonly = false,
+ onFileRemove,
+ imageHeight = 'h-24',
+ imageWidth = 'w-auto',
+ imageClass = ''
+ }: Props = $props();
+
+ let totalCount = $derived(uploadedFiles.length + attachments.length);
+</script>
+
+<Dialog.Root bind:open>
+ <Dialog.Portal>
+ <Dialog.Overlay />
+
+ <Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
+ <Dialog.Header>
+ <Dialog.Title>All Attachments ({totalCount})</Dialog.Title>
+ <Dialog.Description>View and manage all attached files</Dialog.Description>
+ </Dialog.Header>
+
+ <ChatAttachmentsViewAll
+ {uploadedFiles}
+ {attachments}
+ {readonly}
+ {onFileRemove}
+ {imageHeight}
+ {imageWidth}
+ {imageClass}
+ />
+ </Dialog.Content>
+ </Dialog.Portal>
+</Dialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { AlertTriangle, TimerOff } from '@lucide/svelte';
+
+ interface Props {
+ open: boolean;
+ type: 'timeout' | 'server';
+ message: string;
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), type, message, onOpenChange }: Props = $props();
+
+ const isTimeout = $derived(type === 'timeout');
+ const title = $derived(isTimeout ? 'TCP Timeout' : 'Server Error');
+ const description = $derived(
+ isTimeout
+ ? 'The request did not receive a response from the server before timing out.'
+ : 'The server responded with an error message. Review the details below.'
+ );
+ const iconClass = $derived(isTimeout ? 'text-destructive' : 'text-amber-500');
+ const badgeClass = $derived(
+ isTimeout
+ ? 'border-destructive/40 bg-destructive/10 text-destructive'
+ : 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
+ );
+
+ function handleOpenChange(newOpen: boolean) {
+ open = newOpen;
+ onOpenChange?.(newOpen);
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ {#if isTimeout}
+ <TimerOff class={`h-5 w-5 ${iconClass}`} />
+ {:else}
+ <AlertTriangle class={`h-5 w-5 ${iconClass}`} />
+ {/if}
+
+ {title}
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ {description}
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class={`rounded-lg border px-4 py-3 text-sm ${badgeClass}`}>
+ <p class="font-medium">{message}</p>
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => handleOpenChange(false)}>Close</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</AlertDialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ChatSettings } from '$lib/components/app';
+
+ interface Props {
+ onOpenChange?: (open: boolean) => void;
+ open?: boolean;
+ }
+
+ let { onOpenChange, open = false }: Props = $props();
+
+ let chatSettingsRef: ChatSettings | undefined = $state();
+
+ function handleClose() {
+ onOpenChange?.(false);
+ }
+
+ function handleSave() {
+ onOpenChange?.(false);
+ }
+
+ $effect(() => {
+ if (open && chatSettingsRef) {
+ chatSettingsRef.reset();
+ }
+ });
+</script>
+
+<Dialog.Root {open} onOpenChange={handleClose}>
+ <Dialog.Content
+ class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
+ md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
+ style="max-width: 48rem;"
+ >
+ <ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
+ </Dialog.Content>
+</Dialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import type { Component } from 'svelte';
+
+ interface Props {
+ open: boolean;
+ title: string;
+ description: string;
+ confirmText?: string;
+ cancelText?: string;
+ variant?: 'default' | 'destructive';
+ icon?: Component;
+ onConfirm: () => void;
+ onCancel: () => void;
+ onKeydown?: (event: KeyboardEvent) => void;
+ }
+
+ let {
+ open = $bindable(),
+ title,
+ description,
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ variant = 'default',
+ icon,
+ onConfirm,
+ onCancel,
+ onKeydown
+ }: Props = $props();
+
+ function handleKeydown(event: KeyboardEvent) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ onConfirm();
+ }
+ onKeydown?.(event);
+ }
+
+ function handleOpenChange(newOpen: boolean) {
+ if (!newOpen) {
+ onCancel();
+ }
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content onkeydown={handleKeydown}>
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ {#if icon}
+ {@const IconComponent = icon}
+ <IconComponent class="h-5 w-5 {variant === 'destructive' ? 'text-destructive' : ''}" />
+ {/if}
+ {title}
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ {description}
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
+ <AlertDialog.Action
+ onclick={onConfirm}
+ class={variant === 'destructive' ? 'bg-destructive text-white hover:bg-destructive/80' : ''}
+ >
+ {confirmText}
+ </AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</AlertDialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as Dialog from '$lib/components/ui/dialog';
+ import { ConversationSelection } from '$lib/components/app';
+
+ interface Props {
+ conversations: DatabaseConversation[];
+ messageCountMap?: Map<string, number>;
+ mode: 'export' | 'import';
+ onCancel: () => void;
+ onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+ open?: boolean;
+ }
+
+ let {
+ conversations,
+ messageCountMap = new Map(),
+ mode,
+ onCancel,
+ onConfirm,
+ open = $bindable(false)
+ }: Props = $props();
+
+ let conversationSelectionRef: ConversationSelection | undefined = $state();
+
+ let previousOpen = $state(false);
+
+ $effect(() => {
+ if (open && !previousOpen && conversationSelectionRef) {
+ conversationSelectionRef.reset();
+ } else if (!open && previousOpen) {
+ onCancel();
+ }
+
+ previousOpen = open;
+ });
+</script>
+
+<Dialog.Root bind:open>
+ <Dialog.Portal>
+ <Dialog.Overlay class="z-[1000000]" />
+
+ <Dialog.Content class="z-[1000001] max-w-2xl">
+ <Dialog.Header>
+ <Dialog.Title>
+ Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
+ </Dialog.Title>
+ <Dialog.Description>
+ {#if mode === 'export'}
+ Choose which conversations you want to export. Selected conversations will be downloaded
+ as a JSON file.
+ {:else}
+ Choose which conversations you want to import. Selected conversations will be merged
+ with your existing conversations.
+ {/if}
+ </Dialog.Description>
+ </Dialog.Header>
+
+ <ConversationSelection
+ bind:this={conversationSelectionRef}
+ {conversations}
+ {messageCountMap}
+ {mode}
+ {onCancel}
+ {onConfirm}
+ />
+ </Dialog.Content>
+ </Dialog.Portal>
+</Dialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { Button } from '$lib/components/ui/button';
+
+ interface Props {
+ open: boolean;
+ currentTitle: string;
+ newTitle: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ }
+
+ let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props();
+</script>
+
+<AlertDialog.Root bind:open>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
+
+ <AlertDialog.Description>
+ Do you want to update the conversation title to match the first message content?
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="space-y-4 pt-2 pb-6">
+ <div class="space-y-2">
+ <p class="text-sm font-medium text-muted-foreground">Current title:</p>
+
+ <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
+ </div>
+
+ <div class="space-y-2">
+ <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
+
+ <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
+ </div>
+ </div>
+
+ <AlertDialog.Footer>
+ <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
+
+ <Button onclick={onConfirm}>Update Title</Button>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</AlertDialog.Root>
--- /dev/null
+<script lang="ts">
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import { FileX } from '@lucide/svelte';
+
+ interface Props {
+ open: boolean;
+ emptyFiles: string[];
+ onOpenChange?: (open: boolean) => void;
+ }
+
+ let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
+
+ function handleOpenChange(newOpen: boolean) {
+ open = newOpen;
+ onOpenChange?.(newOpen);
+ }
+</script>
+
+<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
+ <AlertDialog.Content>
+ <AlertDialog.Header>
+ <AlertDialog.Title class="flex items-center gap-2">
+ <FileX class="h-5 w-5 text-destructive" />
+
+ Empty Files Detected
+ </AlertDialog.Title>
+
+ <AlertDialog.Description>
+ The following files are empty and have been removed from your attachments:
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="space-y-3 text-sm">
+ <div class="rounded-lg bg-muted p-3">
+ <div class="mb-2 font-medium">Empty Files:</div>
+
+ <ul class="list-inside list-disc space-y-1 text-muted-foreground">
+ {#each emptyFiles as fileName (fileName)}
+ <li class="font-mono text-sm">{fileName}</li>
+ {/each}
+ </ul>
+ </div>
+
+ <div>
+ <div class="mb-2 font-medium">What happened:</div>
+
+ <ul class="list-inside list-disc space-y-1 text-muted-foreground">
+ <li>Empty files cannot be processed or sent to the AI model</li>
+
+ <li>These files have been automatically removed from your attachments</li>
+
+ <li>You can try uploading files with content instead</li>
+ </ul>
+ </div>
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+</AlertDialog.Root>
+++ /dev/null
-<script lang="ts">
- import * as AlertDialog from '$lib/components/ui/alert-dialog';
- import { FileX } from '@lucide/svelte';
-
- interface Props {
- open: boolean;
- emptyFiles: string[];
- onOpenChange?: (open: boolean) => void;
- }
-
- let { open = $bindable(), emptyFiles, onOpenChange }: Props = $props();
-
- function handleOpenChange(newOpen: boolean) {
- open = newOpen;
- onOpenChange?.(newOpen);
- }
-</script>
-
-<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
- <AlertDialog.Content>
- <AlertDialog.Header>
- <AlertDialog.Title class="flex items-center gap-2">
- <FileX class="h-5 w-5 text-destructive" />
-
- Empty Files Detected
- </AlertDialog.Title>
-
- <AlertDialog.Description>
- The following files are empty and have been removed from your attachments:
- </AlertDialog.Description>
- </AlertDialog.Header>
-
- <div class="space-y-3 text-sm">
- <div class="rounded-lg bg-muted p-3">
- <div class="mb-2 font-medium">Empty Files:</div>
-
- <ul class="list-inside list-disc space-y-1 text-muted-foreground">
- {#each emptyFiles as fileName (fileName)}
- <li class="font-mono text-sm">{fileName}</li>
- {/each}
- </ul>
- </div>
-
- <div>
- <div class="mb-2 font-medium">What happened:</div>
-
- <ul class="list-inside list-disc space-y-1 text-muted-foreground">
- <li>Empty files cannot be processed or sent to the AI model</li>
-
- <li>These files have been automatically removed from your attachments</li>
-
- <li>You can try uploading files with content instead</li>
- </ul>
- </div>
- </div>
-
- <AlertDialog.Footer>
- <AlertDialog.Action onclick={() => handleOpenChange(false)}>Got it</AlertDialog.Action>
- </AlertDialog.Footer>
- </AlertDialog.Content>
-</AlertDialog.Root>
+// Chat
+
+export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
+export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
+export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
-export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
-export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.svelte';
-export { default as ChatAttachmentPreviewDialog } from './chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte';
-export { default as ChatAttachmentsViewAllDialog } from './chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte';
+export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
-export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
-export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions.svelte';
-export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActionFileAttachments.svelte';
-export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActionRecord.svelte';
-export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
-export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
+export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
+export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
+export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
+export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
+export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
+export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
+export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
-export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-
-export { default as ChatProcessingInfo } from './chat/ChatProcessingInfo.svelte';
+export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
+export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte';
-export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
-export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
+export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
-export { default as ImportExportTab } from './chat/ChatSettings/ImportExportTab.svelte';
-export { default as ConversationSelectionDialog } from './chat/ChatSettings/ConversationSelectionDialog.svelte';
-export { default as ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.svelte';
+export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
+export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
-export { default as ChatErrorDialog } from './dialogs/ChatErrorDialog.svelte';
-export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
-export { default as ConversationTitleUpdateDialog } from './dialogs/ConversationTitleUpdateDialog.svelte';
+// Dialogs
-export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
+export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
+export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
+export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
+export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
+export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
+export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
+export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
+export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
-export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
+// Miscellanous
+export { default as ActionButton } from './misc/ActionButton.svelte';
+export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
+export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
+export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
+export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte';
+// Server
+
export { default as ServerStatus } from './server/ServerStatus.svelte';
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
export { default as ServerInfo } from './server/ServerInfo.svelte';
-
-// Shared components
-export { default as ActionButton } from './misc/ActionButton.svelte';
-export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
-export { default as ConfirmationDialog } from './dialogs/ConfirmationDialog.svelte';
--- /dev/null
+<script lang="ts">
+ import { Search, X } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Checkbox } from '$lib/components/ui/checkbox';
+ import { ScrollArea } from '$lib/components/ui/scroll-area';
+ import { SvelteSet } from 'svelte/reactivity';
+
+ interface Props {
+ conversations: DatabaseConversation[];
+ messageCountMap?: Map<string, number>;
+ mode: 'export' | 'import';
+ onCancel: () => void;
+ onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+ }
+
+ let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
+
+ let searchQuery = $state('');
+ let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
+ let lastClickedId = $state<string | null>(null);
+
+ let filteredConversations = $derived(
+ conversations.filter((conv) => {
+ const name = conv.name || 'Untitled conversation';
+ return name.toLowerCase().includes(searchQuery.toLowerCase());
+ })
+ );
+
+ let allSelected = $derived(
+ filteredConversations.length > 0 &&
+ filteredConversations.every((conv) => selectedIds.has(conv.id))
+ );
+
+ let someSelected = $derived(
+ filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
+ );
+
+ function toggleConversation(id: string, shiftKey: boolean = false) {
+ const newSet = new SvelteSet(selectedIds);
+
+ if (shiftKey && lastClickedId !== null) {
+ const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
+ const currentIndex = filteredConversations.findIndex((c) => c.id === id);
+
+ if (lastIndex !== -1 && currentIndex !== -1) {
+ const start = Math.min(lastIndex, currentIndex);
+ const end = Math.max(lastIndex, currentIndex);
+
+ const shouldSelect = !newSet.has(id);
+
+ for (let i = start; i <= end; i++) {
+ if (shouldSelect) {
+ newSet.add(filteredConversations[i].id);
+ } else {
+ newSet.delete(filteredConversations[i].id);
+ }
+ }
+
+ selectedIds = newSet;
+ return;
+ }
+ }
+
+ if (newSet.has(id)) {
+ newSet.delete(id);
+ } else {
+ newSet.add(id);
+ }
+
+ selectedIds = newSet;
+ lastClickedId = id;
+ }
+
+ function toggleAll() {
+ if (allSelected) {
+ const newSet = new SvelteSet(selectedIds);
+
+ filteredConversations.forEach((conv) => newSet.delete(conv.id));
+ selectedIds = newSet;
+ } else {
+ const newSet = new SvelteSet(selectedIds);
+
+ filteredConversations.forEach((conv) => newSet.add(conv.id));
+ selectedIds = newSet;
+ }
+ }
+
+ function handleConfirm() {
+ const selected = conversations.filter((conv) => selectedIds.has(conv.id));
+ onConfirm(selected);
+ }
+
+ function handleCancel() {
+ selectedIds = new SvelteSet(conversations.map((c) => c.id));
+ searchQuery = '';
+ lastClickedId = null;
+
+ onCancel();
+ }
+
+ export function reset() {
+ selectedIds = new SvelteSet(conversations.map((c) => c.id));
+ searchQuery = '';
+ lastClickedId = null;
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="relative">
+ <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+
+ <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
+
+ {#if searchQuery}
+ <button
+ class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ onclick={() => (searchQuery = '')}
+ type="button"
+ >
+ <X class="h-4 w-4" />
+ </button>
+ {/if}
+ </div>
+
+ <div class="flex items-center justify-between text-sm text-muted-foreground">
+ <span>
+ {selectedIds.size} of {conversations.length} selected
+ {#if searchQuery}
+ ({filteredConversations.length} shown)
+ {/if}
+ </span>
+ </div>
+
+ <div class="overflow-hidden rounded-md border">
+ <ScrollArea class="h-[400px]">
+ <table class="w-full">
+ <thead class="sticky top-0 z-10 bg-muted">
+ <tr class="border-b">
+ <th class="w-12 p-3 text-left">
+ <Checkbox
+ checked={allSelected}
+ indeterminate={someSelected}
+ onCheckedChange={toggleAll}
+ />
+ </th>
+
+ <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="3" class="p-8 text-center text-sm text-muted-foreground">
+ {#if searchQuery}
+ No conversations found matching "{searchQuery}"
+ {:else}
+ No conversations available
+ {/if}
+ </td>
+ </tr>
+ {:else}
+ {#each filteredConversations as conv (conv.id)}
+ <tr
+ class="cursor-pointer border-b transition-colors hover:bg-muted/50"
+ onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
+ >
+ <td class="p-3">
+ <Checkbox
+ checked={selectedIds.has(conv.id)}
+ onclick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleConversation(conv.id, e.shiftKey);
+ }}
+ />
+ </td>
+
+ <td class="p-3 text-sm">
+ <div class="max-w-[17rem] 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 class="flex justify-end gap-2">
+ <Button variant="outline" onclick={handleCancel}>Cancel</Button>
+
+ <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
+ {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
+ </Button>
+ </div>
+</div>
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
- import { ChatSidebar, ConversationTitleUpdateDialog } from '$lib/components/app';
+ import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
import {
activeMessages,
isLoading,
<Toaster richColors />
-<ConversationTitleUpdateDialog
+<DialogConversationTitleUpdate
bind:open={titleUpdateDialogOpen}
currentTitle={titleUpdateCurrentTitle}
newTitle={titleUpdateNewTitle}
--- /dev/null
+<script module>
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import { ChatSettings } from '$lib/components/app';
+ import { fn } from 'storybook/test';
+
+ const { Story } = defineMeta({
+ title: 'Components/ChatSettings',
+ component: ChatSettings,
+ parameters: {
+ layout: 'fullscreen'
+ },
+ args: {
+ onClose: fn(),
+ onSave: fn()
+ }
+ });
+</script>
+
+<Story name="Default" />
+++ /dev/null
-<script module>
- import { defineMeta } from '@storybook/addon-svelte-csf';
- import { ChatSettingsDialog } from '$lib/components/app';
- import { fn } from 'storybook/test';
-
- const { Story } = defineMeta({
- title: 'Components/ChatSettingsDialog',
- component: ChatSettingsDialog,
- parameters: {
- layout: 'fullscreen'
- },
- argTypes: {
- open: {
- control: 'boolean',
- description: 'Whether the dialog is open'
- }
- },
- args: {
- onOpenChange: fn()
- }
- });
-</script>
-
-<Story name="Open" args={{ open: true }} />
-
-<Story name="Closed" args={{ open: false }} />