Improved file naming & structure for UI components (#17405)
* refactor: Component iles naming & structure * chore: update webui build output * refactor: Dialog titles + components namig * chore: update webui build output * refactor: Imports * chore: update webui build output
This commit is contained in:
parent
92c0b387a9
commit
4c91f2633f
Binary file not shown.
|
|
@ -0,0 +1,273 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
<script lang="ts">
|
<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 { Button } from '$lib/components/ui/button';
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import { FileTypeCategory } from '$lib/enums/files';
|
import { FileTypeCategory } from '$lib/enums/files';
|
||||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||||
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
|
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
|
||||||
import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
|
|
||||||
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -200,7 +199,7 @@
|
||||||
>
|
>
|
||||||
{#each displayItems as item (item.id)}
|
{#each displayItems as item (item.id)}
|
||||||
{#if item.isImage && item.preview}
|
{#if item.isImage && item.preview}
|
||||||
<ChatAttachmentImagePreview
|
<ChatAttachmentThumbnailImage
|
||||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||||
id={item.id}
|
id={item.id}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
|
|
@ -213,7 +212,7 @@
|
||||||
onClick={(event) => openPreview(item, event)}
|
onClick={(event) => openPreview(item, event)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ChatAttachmentFilePreview
|
<ChatAttachmentThumbnailFile
|
||||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||||
id={item.id}
|
id={item.id}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
|
|
@ -256,7 +255,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if previewItem}
|
{#if previewItem}
|
||||||
<ChatAttachmentPreviewDialog
|
<DialogChatAttachmentPreview
|
||||||
bind:open={previewDialogOpen}
|
bind:open={previewDialogOpen}
|
||||||
uploadedFile={previewItem.uploadedFile}
|
uploadedFile={previewItem.uploadedFile}
|
||||||
attachment={previewItem.attachment}
|
attachment={previewItem.attachment}
|
||||||
|
|
@ -268,7 +267,7 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ChatAttachmentsViewAllDialog
|
<DialogChatAttachmentsViewAll
|
||||||
bind:open={viewAllDialogOpen}
|
bind:open={viewAllDialogOpen}
|
||||||
{uploadedFiles}
|
{uploadedFiles}
|
||||||
{attachments}
|
{attachments}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import {
|
||||||
import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
|
ChatAttachmentThumbnailImage,
|
||||||
|
ChatAttachmentThumbnailFile,
|
||||||
|
DialogChatAttachmentPreview
|
||||||
|
} from '$lib/components/app';
|
||||||
import { FileTypeCategory } from '$lib/enums/files';
|
import { FileTypeCategory } from '$lib/enums/files';
|
||||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||||
import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
|
|
||||||
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
|
||||||
uploadedFiles?: ChatUploadedFile[];
|
uploadedFiles?: ChatUploadedFile[];
|
||||||
attachments?: DatabaseMessageExtra[];
|
attachments?: DatabaseMessageExtra[];
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
|
@ -18,7 +19,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
|
||||||
uploadedFiles = [],
|
uploadedFiles = [],
|
||||||
attachments = [],
|
attachments = [],
|
||||||
readonly = false,
|
readonly = false,
|
||||||
|
|
@ -127,70 +127,57 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<div class="space-y-4">
|
||||||
<Dialog.Portal>
|
<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
|
||||||
<Dialog.Overlay />
|
{#if fileItems.length > 0}
|
||||||
|
<div>
|
||||||
<Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
|
<h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
|
||||||
<Dialog.Header>
|
<div class="flex flex-wrap items-start gap-3">
|
||||||
<Dialog.Title>All Attachments ({displayItems.length})</Dialog.Title>
|
{#each fileItems as item (item.id)}
|
||||||
<Dialog.Description class="text-sm text-muted-foreground">
|
<ChatAttachmentThumbnailFile
|
||||||
View and manage all attached files
|
class="cursor-pointer"
|
||||||
</Dialog.Description>
|
id={item.id}
|
||||||
</Dialog.Header>
|
name={item.name}
|
||||||
|
type={item.type}
|
||||||
<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
|
size={item.size}
|
||||||
{#if fileItems.length > 0}
|
{readonly}
|
||||||
<div>
|
onRemove={onFileRemove}
|
||||||
<h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
|
textContent={item.textContent}
|
||||||
<div class="flex flex-wrap items-start gap-3">
|
onClick={(event) => openPreview(item, event)}
|
||||||
{#each fileItems as item (item.id)}
|
/>
|
||||||
<ChatAttachmentFilePreview
|
{/each}
|
||||||
class="cursor-pointer"
|
</div>
|
||||||
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>
|
</div>
|
||||||
</Dialog.Content>
|
{/if}
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
{#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}
|
{#if previewItem}
|
||||||
<ChatAttachmentPreviewDialog
|
<DialogChatAttachmentPreview
|
||||||
bind:open={previewDialogOpen}
|
bind:open={previewDialogOpen}
|
||||||
uploadedFile={previewItem.uploadedFile}
|
uploadedFile={previewItem.uploadedFile}
|
||||||
attachment={previewItem.attachment}
|
attachment={previewItem.attachment}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Square, ArrowUp } from '@lucide/svelte';
|
import { Square, ArrowUp } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import ChatFormActionFileAttachments from './ChatFormActionFileAttachments.svelte';
|
import {
|
||||||
import ChatFormActionRecord from './ChatFormActionRecord.svelte';
|
ChatFormActionFileAttachments,
|
||||||
import ChatFormModelSelector from './ChatFormModelSelector.svelte';
|
ChatFormActionRecord,
|
||||||
|
ChatFormModelSelector
|
||||||
|
} from '$lib/components/app';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import type { FileTypeCategory } from '$lib/enums/files';
|
import type { FileTypeCategory } from '$lib/enums/files';
|
||||||
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
|
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
|
||||||
import { ActionButton, ConfirmationDialog } from '$lib/components/app';
|
import {
|
||||||
import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
|
ActionButton,
|
||||||
|
ChatMessageBranchingControls,
|
||||||
|
DialogConfirmation
|
||||||
|
} from '$lib/components/app';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
|
|
@ -80,7 +83,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmationDialog
|
<DialogConfirmation
|
||||||
bind:open={showDeleteDialog}
|
bind:open={showDeleteDialog}
|
||||||
title="Delete Message"
|
title="Delete Message"
|
||||||
description={deletionInfo && deletionInfo.totalCount > 1
|
description={deletionInfo && deletionInfo.totalCount > 1
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
ChatScreenHeader,
|
ChatScreenHeader,
|
||||||
ChatScreenWarning,
|
ChatScreenWarning,
|
||||||
ChatMessages,
|
ChatMessages,
|
||||||
ChatProcessingInfo,
|
ChatScreenProcessingInfo,
|
||||||
EmptyFileAlertDialog,
|
DialogEmptyFileAlert,
|
||||||
ChatErrorDialog,
|
DialogChatError,
|
||||||
ServerErrorSplash,
|
ServerErrorSplash,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
ServerLoadingSplash,
|
ServerLoadingSplash,
|
||||||
ConfirmationDialog
|
DialogConfirmation
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
|
|
@ -299,7 +299,7 @@
|
||||||
class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
|
class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
|
||||||
in:slide={{ duration: 150, axis: 'y' }}
|
in:slide={{ duration: 150, axis: 'y' }}
|
||||||
>
|
>
|
||||||
<ChatProcessingInfo />
|
<ChatScreenProcessingInfo />
|
||||||
|
|
||||||
{#if serverWarning()}
|
{#if serverWarning()}
|
||||||
<ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
|
<ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
|
||||||
|
|
@ -432,7 +432,7 @@
|
||||||
</AlertDialog.Portal>
|
</AlertDialog.Portal>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
|
||||||
<ConfirmationDialog
|
<DialogConfirmation
|
||||||
bind:open={showDeleteDialog}
|
bind:open={showDeleteDialog}
|
||||||
title="Delete Conversation"
|
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."
|
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
|
||||||
|
|
@ -444,7 +444,7 @@
|
||||||
onCancel={() => (showDeleteDialog = false)}
|
onCancel={() => (showDeleteDialog = false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EmptyFileAlertDialog
|
<DialogEmptyFileAlert
|
||||||
bind:open={showEmptyFileDialog}
|
bind:open={showEmptyFileDialog}
|
||||||
emptyFiles={emptyFileNames}
|
emptyFiles={emptyFileNames}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -454,7 +454,7 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatErrorDialog
|
<DialogChatError
|
||||||
message={activeErrorDialog?.message ?? ''}
|
message={activeErrorDialog?.message ?? ''}
|
||||||
onOpenChange={handleErrorDialogOpenChange}
|
onOpenChange={handleErrorDialogOpenChange}
|
||||||
open={Boolean(activeErrorDialog)}
|
open={Boolean(activeErrorDialog)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings } from '@lucide/svelte';
|
import { Settings } from '@lucide/svelte';
|
||||||
import { ChatSettingsDialog } from '$lib/components/app';
|
import { DialogChatSettings } from '$lib/components/app';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
let settingsOpen = $state(false);
|
let settingsOpen = $state(false);
|
||||||
|
|
@ -20,4 +20,4 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ChatSettingsDialog open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
|
<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,21 @@
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Database
|
Database
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
|
import {
|
||||||
import ImportExportTab from './ImportExportTab.svelte';
|
ChatSettingsFooter,
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
ChatSettingsImportExportTab,
|
||||||
|
ChatSettingsFields
|
||||||
|
} from '$lib/components/app';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||||
import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
|
import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
|
||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenChange?: (open: boolean) => void;
|
onSave?: () => void;
|
||||||
open?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onOpenChange, open = false }: Props = $props();
|
let { onSave }: Props = $props();
|
||||||
|
|
||||||
const settingSections: Array<{
|
const settingSections: Array<{
|
||||||
fields: SettingsFieldConfig[];
|
fields: SettingsFieldConfig[];
|
||||||
|
|
@ -269,7 +270,6 @@
|
||||||
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
||||||
);
|
);
|
||||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||||
let originalTheme: string = $state('');
|
|
||||||
|
|
||||||
let canScrollLeft = $state(false);
|
let canScrollLeft = $state(false);
|
||||||
let canScrollRight = $state(false);
|
let canScrollRight = $state(false);
|
||||||
|
|
@ -285,18 +285,10 @@
|
||||||
localConfig[key] = value;
|
localConfig[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
if (localConfig.theme !== originalTheme) {
|
|
||||||
setMode(originalTheme as 'light' | 'dark' | 'system');
|
|
||||||
}
|
|
||||||
onOpenChange?.(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
localConfig = { ...config() };
|
localConfig = { ...config() };
|
||||||
|
|
||||||
setMode(localConfig.theme as 'light' | 'dark' | 'system');
|
setMode(localConfig.theme as 'light' | 'dark' | 'system');
|
||||||
originalTheme = localConfig.theme as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
|
|
@ -347,7 +339,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMultipleConfig(processedConfig);
|
updateMultipleConfig(processedConfig);
|
||||||
onOpenChange?.(false);
|
onSave?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToCenter(element: HTMLElement) {
|
function scrollToCenter(element: HTMLElement) {
|
||||||
|
|
@ -383,14 +375,11 @@
|
||||||
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
|
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
export function reset() {
|
||||||
if (open) {
|
localConfig = { ...config() };
|
||||||
localConfig = { ...config() };
|
|
||||||
originalTheme = config().theme as string;
|
|
||||||
|
|
||||||
setTimeout(updateScrollButtons, 100);
|
setTimeout(updateScrollButtons, 100);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
|
|
@ -399,120 +388,106 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open} onOpenChange={handleClose}>
|
<div class="flex h-full flex-col overflow-hidden md:flex-row">
|
||||||
<Dialog.Content
|
<!-- Desktop Sidebar -->
|
||||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
|
<div class="hidden w-64 border-r border-border/30 p-6 md:block">
|
||||||
md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
|
<nav class="space-y-1 py-2">
|
||||||
style="max-width: 48rem;"
|
{#each settingSections as section (section.title)}
|
||||||
>
|
<button
|
||||||
<div class="flex flex-1 flex-col overflow-hidden md:flex-row">
|
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 ===
|
||||||
<!-- Desktop Sidebar -->
|
section.title
|
||||||
<div class="hidden w-64 border-r border-border/30 p-6 md:block">
|
? 'bg-accent text-accent-foreground'
|
||||||
<nav class="space-y-1 py-2">
|
: 'text-muted-foreground'}"
|
||||||
<Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
|
onclick={() => (activeSection = section.title)}
|
||||||
|
>
|
||||||
|
<section.icon class="h-4 w-4" />
|
||||||
|
|
||||||
{#each settingSections as section (section.title)}
|
<span class="ml-2">{section.title}</span>
|
||||||
<button
|
</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 ===
|
{/each}
|
||||||
section.title
|
</nav>
|
||||||
? 'bg-accent text-accent-foreground'
|
</div>
|
||||||
: 'text-muted-foreground'}"
|
|
||||||
onclick={() => (activeSection = section.title)}
|
|
||||||
>
|
|
||||||
<section.icon class="h-4 w-4" />
|
|
||||||
|
|
||||||
<span class="ml-2">{section.title}</span>
|
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
||||||
</button>
|
<div class="flex flex-col md:hidden">
|
||||||
{/each}
|
<div class="border-b border-border/30 py-4">
|
||||||
</nav>
|
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- Mobile Header with Horizontal Scrollable Menu -->
|
<div
|
||||||
<div class="flex flex-col md:hidden">
|
class="scrollbar-hide overflow-x-auto py-2"
|
||||||
<div class="border-b border-border/30 py-4">
|
bind:this={scrollContainer}
|
||||||
<Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
|
onscroll={updateScrollButtons}
|
||||||
|
>
|
||||||
<!-- Horizontal Scrollable Category Menu with Navigation -->
|
<div class="flex min-w-max gap-2">
|
||||||
<div class="relative flex items-center" style="scroll-padding: 1rem;">
|
{#each settingSections as section (section.title)}
|
||||||
<button
|
<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
|
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 ===
|
||||||
? 'opacity-100'
|
section.title
|
||||||
: 'pointer-events-none opacity-0'}"
|
? 'bg-accent text-accent-foreground'
|
||||||
onclick={scrollLeft}
|
: 'text-muted-foreground'}"
|
||||||
aria-label="Scroll left"
|
onclick={(e: MouseEvent) => {
|
||||||
>
|
activeSection = section.title;
|
||||||
<ChevronLeft class="h-4 w-4" />
|
scrollToCenter(e.currentTarget as HTMLElement);
|
||||||
</button>
|
}}
|
||||||
|
>
|
||||||
<div
|
<section.icon class="h-4 w-4 flex-shrink-0" />
|
||||||
class="scrollbar-hide overflow-x-auto py-2"
|
<span>{section.title}</span>
|
||||||
bind:this={scrollContainer}
|
</button>
|
||||||
onscroll={updateScrollButtons}
|
{/each}
|
||||||
>
|
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
|
<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
|
||||||
</Dialog.Content>
|
<div class="space-y-6 p-4 md:p-6">
|
||||||
</Dialog.Root>
|
<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} />
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import { supportsVision } from '$lib/stores/server.svelte';
|
import { supportsVision } from '$lib/stores/server.svelte';
|
||||||
import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
|
import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
|
||||||
import { ParameterSyncService } from '$lib/services/parameter-sync';
|
import { ParameterSyncService } from '$lib/services/parameter-sync';
|
||||||
import ParameterSourceIndicator from './ParameterSourceIndicator.svelte';
|
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</Label>
|
||||||
{#if isCustomRealTime}
|
{#if isCustomRealTime}
|
||||||
<ParameterSourceIndicator />
|
<ChatSettingsParameterSourceIndicator />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</Label>
|
||||||
{#if isCustomRealTime}
|
{#if isCustomRealTime}
|
||||||
<ParameterSourceIndicator />
|
<ChatSettingsParameterSourceIndicator />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Download, Upload } from '@lucide/svelte';
|
import { Download, Upload } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import ConversationSelectionDialog from './ConversationSelectionDialog.svelte';
|
import { DialogConversationSelection } from '$lib/components/app';
|
||||||
import { DatabaseStore } from '$lib/stores/database';
|
import { DatabaseStore } from '$lib/stores/database';
|
||||||
import type { ExportedConversations } from '$lib/types/database';
|
import type { ExportedConversations } from '$lib/types/database';
|
||||||
import { createMessageCountMap } from '$lib/utils/conversation-utils';
|
import { createMessageCountMap } from '$lib/utils/conversation-utils';
|
||||||
|
|
@ -236,7 +236,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConversationSelectionDialog
|
<DialogConversationSelection
|
||||||
conversations={availableConversations}
|
conversations={availableConversations}
|
||||||
{messageCountMap}
|
{messageCountMap}
|
||||||
mode="export"
|
mode="export"
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
onConfirm={handleExportConfirm}
|
onConfirm={handleExportConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConversationSelectionDialog
|
<DialogConversationSelection
|
||||||
conversations={availableConversations}
|
conversations={availableConversations}
|
||||||
{messageCountMap}
|
{messageCountMap}
|
||||||
mode="import"
|
mode="import"
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Trash2 } from '@lucide/svelte';
|
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 ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
|
|
@ -158,7 +158,7 @@
|
||||||
<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
|
<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<ConfirmationDialog
|
<DialogConfirmation
|
||||||
bind:open={showDeleteDialog}
|
bind:open={showDeleteDialog}
|
||||||
title="Delete Conversation"
|
title="Delete Conversation"
|
||||||
description={selectedConversation
|
description={selectedConversation
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,56 +1,63 @@
|
||||||
|
// 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 ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
|
||||||
export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
|
export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.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 ChatForm } from './chat/ChatForm/ChatForm.svelte';
|
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
|
||||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
|
||||||
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions.svelte';
|
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
|
||||||
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActionFileAttachments.svelte';
|
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.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 ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.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 ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
||||||
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.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 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 ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
|
|
||||||
export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte';
|
|
||||||
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.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 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 ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
||||||
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
|
||||||
export { default as ImportExportTab } from './chat/ChatSettings/ImportExportTab.svelte';
|
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
|
||||||
export { default as ConversationSelectionDialog } from './chat/ChatSettings/ConversationSelectionDialog.svelte';
|
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
|
||||||
export { default as ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.svelte';
|
|
||||||
|
|
||||||
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
||||||
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
|
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
|
||||||
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.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 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';
|
||||||
|
|
||||||
|
// 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 KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
||||||
|
|
||||||
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
||||||
|
|
||||||
export { default as RemoveButton } from './misc/RemoveButton.svelte';
|
export { default as RemoveButton } from './misc/RemoveButton.svelte';
|
||||||
|
|
||||||
|
// Server
|
||||||
|
|
||||||
export { default as ServerStatus } from './server/ServerStatus.svelte';
|
export { default as ServerStatus } from './server/ServerStatus.svelte';
|
||||||
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
|
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
|
||||||
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
|
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
|
||||||
export { default as ServerInfo } from './server/ServerInfo.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';
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ChatSidebar, ConversationTitleUpdateDialog } from '$lib/components/app';
|
import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
|
||||||
import {
|
import {
|
||||||
activeMessages,
|
activeMessages,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
|
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
|
|
||||||
<ConversationTitleUpdateDialog
|
<DialogConversationTitleUpdate
|
||||||
bind:open={titleUpdateDialogOpen}
|
bind:open={titleUpdateDialogOpen}
|
||||||
currentTitle={titleUpdateCurrentTitle}
|
currentTitle={titleUpdateCurrentTitle}
|
||||||
newTitle={titleUpdateNewTitle}
|
newTitle={titleUpdateNewTitle}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<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" />
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<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 }} />
|
|
||||||
Loading…
Reference in New Issue