Skip to content

Commit fef2fe1

Browse files
committed
feat: File Upload UI
1 parent 48f64f9 commit fef2fe1

File tree

3 files changed

+210
-6
lines changed

3 files changed

+210
-6
lines changed

tools/server/webui/src/lib/components/chat/ChatForm.svelte

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script lang="ts">
22
import { Button } from '$lib/components/ui/button';
33
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
4-
import { Square, Paperclip, Mic, ArrowUp } from '@lucide/svelte';
4+
import { Square, Paperclip, Mic, ArrowUp, Upload, X } from '@lucide/svelte';
5+
import type { ChatUploadedFile } from '$lib/types/chat.d.ts';
56
67
interface Props {
78
class?: string;
@@ -10,6 +11,9 @@
1011
onSend?: (message: string) => void;
1112
onStop?: () => void;
1213
showHelperText?: boolean;
14+
uploadedFiles?: ChatUploadedFile[];
15+
onFileUpload?: (files: File[]) => void;
16+
onFileRemove?: (fileId: string) => void;
1317
}
1418
1519
let {
@@ -18,11 +22,15 @@
1822
isLoading = false,
1923
onSend,
2024
onStop,
21-
showHelperText = true
25+
showHelperText = true,
26+
uploadedFiles = [],
27+
onFileUpload,
28+
onFileRemove
2229
}: Props = $props();
2330
2431
let message = $state('');
2532
let textareaElement: HTMLTextAreaElement | undefined;
33+
let fileInputElement: HTMLInputElement | undefined;
2634
2735
function handleSubmit(event: SubmitEvent) {
2836
event.preventDefault();
@@ -54,14 +62,95 @@
5462
function handleStop() {
5563
onStop?.();
5664
}
65+
66+
function handleFileUpload() {
67+
fileInputElement?.click();
68+
}
69+
70+
function handleFileSelect(event: Event) {
71+
const input = event.target as HTMLInputElement;
72+
if (input.files) {
73+
onFileUpload?.(Array.from(input.files));
74+
}
75+
}
76+
77+
function formatFileSize(bytes: number): string {
78+
if (bytes === 0) return '0 Bytes';
79+
const k = 1024;
80+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
81+
const i = Math.floor(Math.log(bytes) / Math.log(k));
82+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
83+
}
5784
</script>
5885

86+
<!-- Hidden file input -->
87+
<input
88+
bind:this={fileInputElement}
89+
type="file"
90+
multiple
91+
accept="image/*,audio/*,video/*,.pdf,.txt,.doc,.docx"
92+
onchange={handleFileSelect}
93+
class="hidden"
94+
/>
95+
5996
<form
6097
onsubmit={handleSubmit}
61-
class="bg-background dark:bg-muted border-radius-bottom-none mx-auto max-w-4xl overflow-hidden rounded-3xl {className}"
98+
class="border bg-muted/30 border-border/40 focus-within:border-primary/40 bg-background dark:bg-muted border-radius-bottom-none mx-auto max-w-4xl overflow-hidden rounded-3xl {className}"
6299
>
100+
<!-- File previews -->
101+
{#if uploadedFiles.length > 0}
102+
<div class="mb-3 flex flex-wrap items-start gap-3 px-5 pt-3">
103+
{#each uploadedFiles as file (file.id)}
104+
{#if file.preview}
105+
<!-- Image file with thumbnail -->
106+
<div class="bg-muted border-border relative rounded-lg border overflow-hidden">
107+
<img
108+
src={file.preview}
109+
alt={file.name}
110+
class="h-24 w-24 object-cover"
111+
/>
112+
<div class="absolute top-1 right-1 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
113+
<Button
114+
type="button"
115+
variant="ghost"
116+
size="sm"
117+
class="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 text-white"
118+
onclick={() => onFileRemove?.(file.id)}
119+
>
120+
<X class="h-3 w-3" />
121+
</Button>
122+
</div>
123+
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white p-1">
124+
<p class="text-xs opacity-80">{formatFileSize(file.size)}</p>
125+
</div>
126+
</div>
127+
{:else}
128+
<!-- Non-image file with badge -->
129+
<div class="bg-muted border-border flex items-center gap-2 rounded-lg border p-2">
130+
<div class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded text-xs font-medium">
131+
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
132+
</div>
133+
<div class="flex flex-col">
134+
<span class="text-foreground text-sm font-medium truncate max-w-48">{file.name}</span>
135+
<span class="text-muted-foreground text-xs">{formatFileSize(file.size)}</span>
136+
</div>
137+
<Button
138+
type="button"
139+
variant="ghost"
140+
size="sm"
141+
class="h-6 w-6 p-0"
142+
onclick={() => onFileRemove?.(file.id)}
143+
>
144+
<X class="h-3 w-3" />
145+
</Button>
146+
</div>
147+
{/if}
148+
{/each}
149+
</div>
150+
{/if}
151+
63152
<div
64-
class="bg-muted/30 border-border/40 focus-within:border-primary/40 flex-column relative min-h-[48px] items-center rounded-3xl border px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
153+
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
65154
>
66155
<div class="flex-1">
67156
<textarea
@@ -81,6 +170,7 @@
81170
variant="ghost"
82171
class="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full p-0"
83172
disabled={disabled || isLoading}
173+
onclick={handleFileUpload}
84174
>
85175
<Paperclip class="h-4 w-4" />
86176
</Button>

tools/server/webui/src/lib/components/chat/ChatScreen.svelte

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@
1010
} from '$lib/stores/chat.svelte';
1111
import { onMount } from 'svelte';
1212
import { fly, slide } from 'svelte/transition';
13+
import { Upload } from '@lucide/svelte';
14+
import type { ChatUploadedFile } from '$lib/types/chat.d.ts';
1315
1416
let { showCenteredEmpty = false } = $props();
1517
let chatScrollContainer: HTMLDivElement | undefined = $state();
1618
let scrollInterval: ReturnType<typeof setInterval> | undefined;
1719
let autoScrollEnabled = $state(true);
20+
let uploadedFiles = $state<ChatUploadedFile[]>([]);
21+
let isDragOver = $state(false);
22+
let dragCounter = $state(0);
1823
1924
const isEmpty = $derived(
2025
showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
@@ -58,10 +63,96 @@
5863
scrollInterval = undefined;
5964
}
6065
})
66+
67+
function processFiles(files: File[]) {
68+
files.forEach((file) => {
69+
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
70+
const uploadedFile: ChatUploadedFile = {
71+
id,
72+
name: file.name,
73+
size: file.size,
74+
type: file.type,
75+
file
76+
};
77+
78+
// Create preview for images
79+
if (file.type.startsWith('image/')) {
80+
const reader = new FileReader();
81+
reader.onload = (e) => {
82+
uploadedFile.preview = e.target?.result as string;
83+
// Add file to array after preview is ready
84+
uploadedFiles = [...uploadedFiles, uploadedFile];
85+
};
86+
reader.readAsDataURL(file);
87+
} else {
88+
// For non-image files, add immediately
89+
uploadedFiles = [...uploadedFiles, uploadedFile];
90+
}
91+
});
92+
}
93+
94+
function handleFileUpload(files: File[]) {
95+
processFiles(files);
96+
}
97+
98+
function handleFileRemove(fileId: string) {
99+
uploadedFiles = uploadedFiles.filter(f => f.id !== fileId);
100+
}
101+
102+
function handleDragEnter(event: DragEvent) {
103+
event.preventDefault();
104+
dragCounter++;
105+
if (event.dataTransfer?.types.includes('Files')) {
106+
isDragOver = true;
107+
}
108+
}
109+
110+
function handleDragLeave(event: DragEvent) {
111+
event.preventDefault();
112+
dragCounter--;
113+
if (dragCounter === 0) {
114+
isDragOver = false;
115+
}
116+
}
117+
118+
function handleDragOver(event: DragEvent) {
119+
event.preventDefault();
120+
}
121+
122+
function handleDrop(event: DragEvent) {
123+
event.preventDefault();
124+
isDragOver = false;
125+
dragCounter = 0;
126+
127+
if (event.dataTransfer?.files) {
128+
processFiles(Array.from(event.dataTransfer.files));
129+
}
130+
}
61131
</script>
62132

133+
<!-- Drag and drop overlay -->
134+
{#if isDragOver}
135+
<div class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
136+
<div class="bg-background border-border flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 shadow-lg">
137+
<Upload class="text-muted-foreground mb-4 h-12 w-12" />
138+
<p class="text-foreground text-lg font-medium">Attach a file</p>
139+
<p class="text-muted-foreground text-sm">Drop your files here to upload</p>
140+
</div>
141+
</div>
142+
{/if}
143+
63144
{#if !isEmpty}
64-
<div class="flex h-full flex-col overflow-y-auto" bind:this={chatScrollContainer} onscroll={handleScroll}>
145+
<div
146+
class="flex h-full flex-col overflow-y-auto"
147+
bind:this={chatScrollContainer}
148+
onscroll={handleScroll}
149+
ondragenter={handleDragEnter}
150+
ondragleave={handleDragLeave}
151+
ondragover={handleDragOver}
152+
ondrop={handleDrop}
153+
role="main"
154+
aria-label="Chat interface with file drop zone"
155+
>
65156
<ChatMessages class="mb-36" messages={activeMessages()} />
66157

67158
<div
@@ -74,12 +165,23 @@
74165
showHelperText={false}
75166
onSend={handleSendMessage}
76167
onStop={() => stopGeneration()}
168+
uploadedFiles={uploadedFiles}
169+
onFileUpload={handleFileUpload}
170+
onFileRemove={handleFileRemove}
77171
/>
78172
</div>
79173
</div>
80174
</div>
81175
{:else}
82-
<div class="flex h-full items-center justify-center">
176+
<div
177+
class="flex h-full items-center justify-center"
178+
ondragenter={handleDragEnter}
179+
ondragleave={handleDragLeave}
180+
ondragover={handleDragOver}
181+
ondrop={handleDrop}
182+
role="main"
183+
aria-label="Welcome screen with file drop zone"
184+
>
83185
<div class="w-full max-w-2xl px-4">
84186
<div class="mb-8 text-center" in:fly={{ y: -30, duration: 600 }}>
85187
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
@@ -100,6 +202,9 @@
100202
showHelperText={true}
101203
onSend={handleSendMessage}
102204
onStop={() => stopGeneration()}
205+
uploadedFiles={uploadedFiles}
206+
onFileUpload={handleFileUpload}
207+
onFileRemove={handleFileRemove}
103208
/>
104209
</div>
105210
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
export type ChatMessageType = 'root' | 'text' | 'think';
22
export type ChatRole = 'user' | 'assistant' | 'system';
3+
4+
export interface ChatUploadedFile {
5+
id: string;
6+
name: string;
7+
size: number;
8+
type: string;
9+
file: File;
10+
preview?: string;
11+
}

0 commit comments

Comments
 (0)