Skip to content

Commit 028fb8c

Browse files
committed
feat: Adds chat attachments functionality
Introduces the ability to send and display attachments within the chat interface. This includes: - A new `ChatAttachmentsList` component to display attachments in both the `ChatForm` (pending uploads) and `ChatMessage` (stored attachments). - Updates to `ChatForm` to handle file uploads and display previews. - Updates to `ChatMessage` to display stored attachments. - Logic to convert uploaded files to a format suitable for storage and transmission.
1 parent d9fee52 commit 028fb8c

File tree

12 files changed

+251
-124
lines changed

12 files changed

+251
-124
lines changed

tools/server/webui/src/lib/components/MarkdownContent.svelte

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@
3232
3333
const preElements = tempDiv.querySelectorAll('pre');
3434
35-
preElements.forEach((pre, index) => {
35+
for (const [index, pre] of Array.from(preElements).entries()) {
3636
const codeElement = pre.querySelector('code');
37-
if (!codeElement) return;
37+
if (!codeElement) continue;
3838
3939
let language = 'text';
4040
const classList = Array.from(codeElement.classList);
@@ -81,7 +81,7 @@
8181
wrapper.appendChild(clonedPre);
8282
8383
pre.parentNode?.replaceChild(wrapper, pre);
84-
});
84+
}
8585
8686
return tempDiv.innerHTML;
8787
}
@@ -106,7 +106,7 @@
106106
const copyButtons = containerRef.querySelectorAll('.copy-code-btn');
107107
console.log('Found copy buttons:', copyButtons.length);
108108
109-
copyButtons.forEach((button, index) => {
109+
for (const [index, button] of Array.from(copyButtons).entries()) {
110110
console.log(`Setting up button ${index}:`, button);
111111
button.addEventListener('click', async (e) => {
112112
e.preventDefault();
@@ -147,7 +147,7 @@
147147
console.error('Failed to copy code:', error);
148148
}
149149
});
150-
});
150+
}
151151
}
152152
153153
$effect(() => {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<script lang="ts">
2+
import { Button } from '$lib/components/ui/button';
3+
import { X } from '@lucide/svelte';
4+
import type { ChatUploadedFile } from '$lib/types/chat.d.ts';
5+
import type { DatabaseMessageExtra } from '$lib/types/database.d.ts';
6+
7+
interface Props {
8+
// For ChatForm - pending uploads
9+
uploadedFiles?: ChatUploadedFile[];
10+
onFileRemove?: (fileId: string) => void;
11+
// For ChatMessage - stored attachments
12+
attachments?: DatabaseMessageExtra[];
13+
readonly?: boolean;
14+
class?: string;
15+
}
16+
17+
let {
18+
uploadedFiles = [],
19+
onFileRemove,
20+
attachments = [],
21+
readonly = false,
22+
class: className = ''
23+
}: Props = $props();
24+
25+
let displayItems = $derived(getDisplayItems());
26+
27+
function formatFileSize(bytes: number): string {
28+
if (bytes === 0) return '0 Bytes';
29+
const k = 1024;
30+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
31+
const i = Math.floor(Math.log(bytes) / Math.log(k));
32+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
33+
}
34+
35+
function getDisplayItems() {
36+
const items: Array<{
37+
id: string;
38+
name: string;
39+
size?: number;
40+
preview?: string;
41+
type: string;
42+
isImage: boolean;
43+
}> = [];
44+
45+
// Add uploaded files (ChatForm)
46+
for (const file of uploadedFiles) {
47+
items.push({
48+
id: file.id,
49+
name: file.name,
50+
size: file.size,
51+
preview: file.preview,
52+
type: file.type,
53+
isImage: file.type.startsWith('image/')
54+
});
55+
}
56+
57+
// Add stored attachments (ChatMessage)
58+
for (const [index, attachment] of attachments.entries()) {
59+
if (attachment.type === 'imageFile') {
60+
items.push({
61+
id: `attachment-${index}`,
62+
name: attachment.name,
63+
preview: attachment.base64Url,
64+
type: 'image',
65+
isImage: true
66+
});
67+
} else if (attachment.type === 'textFile') {
68+
items.push({
69+
id: `attachment-${index}`,
70+
name: attachment.name,
71+
type: 'text',
72+
isImage: false
73+
});
74+
} else if (attachment.type === 'audioFile') {
75+
items.push({
76+
id: `attachment-${index}`,
77+
name: attachment.name,
78+
type: attachment.mimeType || 'audio',
79+
isImage: false
80+
});
81+
}
82+
}
83+
84+
return items;
85+
}
86+
</script>
87+
88+
{#if displayItems.length > 0}
89+
<div class="flex flex-wrap items-start gap-3 {className}">
90+
{#each displayItems as item (item.id)}
91+
{#if item.isImage && item.preview}
92+
<div class="bg-muted border-border relative rounded-lg border overflow-hidden">
93+
<img
94+
src={item.preview}
95+
alt={item.name}
96+
class="h-24 w-24 object-cover"
97+
/>
98+
{#if !readonly}
99+
<div class="absolute top-1 right-1 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
100+
<Button
101+
type="button"
102+
variant="ghost"
103+
size="sm"
104+
class="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 text-white"
105+
onclick={() => onFileRemove?.(item.id)}
106+
>
107+
<X class="h-3 w-3" />
108+
</Button>
109+
</div>
110+
{/if}
111+
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white p-1">
112+
{#if item.size}
113+
<p class="text-xs opacity-80">{formatFileSize(item.size)}</p>
114+
{/if}
115+
</div>
116+
</div>
117+
{:else}
118+
<div class="bg-muted border-border flex items-center gap-2 rounded-lg border p-2">
119+
<div class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded text-xs font-medium">
120+
{item.type.split('/').pop()?.toUpperCase() || 'FILE'}
121+
</div>
122+
<div class="flex flex-col">
123+
<span class="text-foreground text-sm font-medium truncate max-w-48">{item.name}</span>
124+
{#if item.size}
125+
<span class="text-muted-foreground text-xs">{formatFileSize(item.size)}</span>
126+
{/if}
127+
</div>
128+
{#if !readonly}
129+
<Button
130+
type="button"
131+
variant="ghost"
132+
size="sm"
133+
class="h-6 w-6 p-0"
134+
onclick={() => onFileRemove?.(item.id)}
135+
>
136+
<X class="h-3 w-3" />
137+
</Button>
138+
{/if}
139+
</div>
140+
{/if}
141+
{/each}
142+
</div>
143+
{/if}

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

Lines changed: 9 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
44
import { Square, Paperclip, Mic, ArrowUp, Upload, X } from '@lucide/svelte';
55
import type { ChatUploadedFile } from '$lib/types/chat.d.ts';
6+
import { ChatAttachmentsList } from '$lib/components';
67
78
interface Props {
89
class?: string;
910
disabled?: boolean;
1011
isLoading?: boolean;
11-
onSend?: (message: string) => void;
12+
onSend?: (message: string, files?: ChatUploadedFile[]) => void;
1213
onStop?: () => void;
1314
showHelperText?: boolean;
1415
uploadedFiles?: ChatUploadedFile[];
@@ -36,7 +37,7 @@
3637
event.preventDefault();
3738
if (!message.trim() || disabled || isLoading) return;
3839
39-
onSend?.(message.trim());
40+
onSend?.(message.trim(), uploadedFiles);
4041
message = '';
4142
4243
if (textareaElement) {
@@ -50,7 +51,7 @@
5051
5152
if (!message.trim() || disabled || isLoading) return;
5253
53-
onSend?.(message.trim());
54+
onSend?.(message.trim(), uploadedFiles);
5455
message = '';
5556
5657
if (textareaElement) {
@@ -73,14 +74,6 @@
7374
onFileUpload?.(Array.from(input.files));
7475
}
7576
}
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-
}
8477
</script>
8578

8679
<!-- Hidden file input -->
@@ -98,56 +91,11 @@
9891
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}"
9992
>
10093
<!-- 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}
94+
<ChatAttachmentsList
95+
uploadedFiles={uploadedFiles}
96+
onFileRemove={onFileRemove}
97+
class="mb-3 px-5 pt-3"
98+
/>
15199

152100
<div
153101
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@
22
import { Edit, Copy, RefreshCw, Check, X } from '@lucide/svelte';
33
import { Card } from '$lib/components/ui/card';
44
import { Button } from '$lib/components/ui/button';
5-
import { ChatThinkingBlock, MarkdownContent } from '$lib/components';
5+
import { ChatAttachmentsList, ChatThinkingBlock, MarkdownContent } from '$lib/components';
66
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip';
77
import type { ChatRole } from '$lib/types/chat';
8-
import type { Message } from '$lib/types/database';
8+
import type { DatabaseMessage } from '$lib/types/database';
99
import { copyToClipboard } from '$lib/utils/copy';
1010
import { parseThinkingContent } from '$lib/utils/thinking';
1111
1212
interface Props {
1313
class?: string;
14-
message: Message;
15-
onEdit?: (message: Message) => void;
16-
onCopy?: (message: Message) => void;
17-
onRegenerate?: (message: Message) => void;
18-
onUpdateMessage?: (message: Message, newContent: string) => void;
14+
message: DatabaseMessage;
15+
onEdit?: (message: DatabaseMessage) => void;
16+
onCopy?: (message: DatabaseMessage) => void;
17+
onRegenerate?: (message: DatabaseMessage) => void;
18+
onUpdateMessage?: (message: DatabaseMessage, newContent: string) => void;
1919
}
2020
2121
let {
@@ -133,6 +133,15 @@
133133
</div>
134134
</div>
135135
{:else}
136+
{#if message.extra && message.extra.length > 0}
137+
<div class="max-w-[80%] mb-2">
138+
<ChatAttachmentsList
139+
attachments={message.extra}
140+
readonly={true}
141+
/>
142+
</div>
143+
{/if}
144+
136145
<Card class="bg-primary text-primary-foreground max-w-[80%] rounded-2xl px-2.5 py-1.5">
137146
<div class="text-md whitespace-pre-wrap">
138147
{message.content}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<script lang="ts">
2-
import type { Message } from '$lib/types/database';
2+
import type { DatabaseMessage } from '$lib/types/database';
33
import { updateMessage, regenerateMessage } from '$lib/stores/chat.svelte';
44
import { ChatMessage } from '$lib/components';
55
66
interface Props {
77
class?: string;
8-
messages?: Message[];
8+
messages?: DatabaseMessage[];
99
}
1010
1111
let { class: className, messages = [] }: Props = $props();

0 commit comments

Comments
 (0)