Skip to content

Commit 839140b

Browse files
committed
feat: Adds file attachment previews to chat
Implements file attachment previews in the chat interface, including support for text file previews with truncated display and remove functionality. Improves the display of image attachments. Text file detection is enhanced using filename extensions.
1 parent e01ae49 commit 839140b

File tree

7 files changed

+157
-52
lines changed

7 files changed

+157
-52
lines changed

tools/server/webui/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VITE_BASE_URL=http://localhost:8080
1+
VITE_BASE_URL=http://localhost:8080

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

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
readonly?: boolean;
1111
onRemove?: (id: string) => void;
1212
class?: string;
13+
textContent?: string;
14+
onClick?: () => void;
1315
}
1416
1517
let {
@@ -19,7 +21,9 @@
1921
size,
2022
readonly = false,
2123
onRemove,
22-
class: className = ''
24+
class: className = '',
25+
textContent,
26+
onClick
2327
}: Props = $props();
2428
2529
function formatFileSize(bytes: number): string {
@@ -33,27 +37,105 @@
3337
function getFileTypeLabel(fileType: string): string {
3438
return fileType.split('/').pop()?.toUpperCase() || 'FILE';
3539
}
40+
41+
function getPreviewText(content: string): string {
42+
// Get first 150 characters for preview
43+
return content.length > 150 ? content.substring(0, 150) : content;
44+
}
3645
</script>
3746

38-
<div class="bg-muted border-border flex items-center gap-2 rounded-lg border p-2 {className}">
39-
<div class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded text-xs font-medium">
40-
{getFileTypeLabel(type)}
41-
</div>
42-
<div class="flex flex-col">
43-
<span class="text-foreground text-sm font-medium truncate max-w-48">{name}</span>
44-
{#if size}
45-
<span class="text-muted-foreground text-xs">{formatFileSize(size)}</span>
46-
{/if}
47-
</div>
48-
{#if !readonly}
49-
<Button
47+
{#if type === 'text/plain' || type === 'text'}
48+
{#if readonly}
49+
<button
50+
class="bg-muted border-border cursor-pointer rounded-lg border p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
51+
onclick={onClick}
52+
aria-label={`Preview ${name}`}
5053
type="button"
51-
variant="ghost"
52-
size="sm"
53-
class="h-6 w-6 p-0"
54-
onclick={() => onRemove?.(id)}
5554
>
56-
<X class="h-3 w-3" />
57-
</Button>
55+
<div class="flex items-start gap-3">
56+
<div class="flex min-w-0 flex-1 flex-col items-start text-left">
57+
<span class="text-foreground w-full truncate text-sm font-medium">{name}</span>
58+
{#if size}
59+
<span class="text-muted-foreground text-xs">{formatFileSize(size)}</span>
60+
{/if}
61+
{#if textContent && type === 'text'}
62+
<div class="relative mt-2 w-full">
63+
<div
64+
class="text-muted-foreground overflow-hidden whitespace-pre-wrap break-words font-mono text-xs leading-relaxed"
65+
>
66+
{getPreviewText(textContent)}
67+
</div>
68+
{#if textContent.length > 150}
69+
<div
70+
class="from-muted pointer-events-none absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t to-transparent"
71+
></div>
72+
{/if}
73+
</div>
74+
{/if}
75+
</div>
76+
</div>
77+
</button>
78+
{:else}
79+
<!-- Non-readonly mode (ChatForm) -->
80+
<div class="bg-muted border-border relative rounded-lg border p-3 {className} w-64">
81+
<!-- Remove button in top-right corner -->
82+
<Button
83+
type="button"
84+
variant="ghost"
85+
size="sm"
86+
class="absolute right-2 top-2 h-6 w-6 bg-white/20 p-0 hover:bg-white/30"
87+
onclick={() => onRemove?.(id)}
88+
aria-label="Remove file"
89+
>
90+
<X class="h-3 w-3" />
91+
</Button>
92+
93+
<!-- Content -->
94+
<div class="pr-8">
95+
<!-- Add right padding to avoid overlap with X button -->
96+
<span class="text-foreground mb-3 block truncate text-sm font-medium">{name}</span>
97+
98+
{#if textContent}
99+
<div class="relative">
100+
<div
101+
class="text-muted-foreground overflow-hidden whitespace-pre-wrap break-words font-mono text-xs leading-relaxed"
102+
style="max-height: 3.6em; line-height: 1.2em;"
103+
>
104+
{getPreviewText(textContent)}
105+
</div>
106+
{#if textContent.length > 150}
107+
<div
108+
class="from-muted pointer-events-none absolute bottom-0 left-0 right-0 h-4 bg-gradient-to-t to-transparent"
109+
></div>
110+
{/if}
111+
</div>
112+
{/if}
113+
</div>
114+
</div>
58115
{/if}
59-
</div>
116+
{:else}
117+
<div class="bg-muted border-border flex items-center gap-2 rounded-lg border p-2 {className}">
118+
<div
119+
class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded text-xs font-medium"
120+
>
121+
{getFileTypeLabel(type)}
122+
</div>
123+
<div class="flex flex-col">
124+
<span class="text-foreground max-w-48 truncate text-sm font-medium">{name}</span>
125+
{#if size}
126+
<span class="text-muted-foreground text-xs">{formatFileSize(size)}</span>
127+
{/if}
128+
</div>
129+
{#if !readonly}
130+
<Button
131+
type="button"
132+
variant="ghost"
133+
size="sm"
134+
class="h-6 w-6 p-0"
135+
onclick={() => onRemove?.(id)}
136+
>
137+
<X class="h-3 w-3" />
138+
</Button>
139+
{/if}
140+
</div>
141+
{/if}

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

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,21 @@
3939
}
4040
</script>
4141

42-
<div class="bg-muted border-border relative rounded-lg border overflow-hidden {className}">
43-
<img
44-
src={preview}
45-
alt={name}
46-
class="{height} {width} object-cover {imageClass}"
47-
/>
42+
<div class="bg-muted border-border relative overflow-hidden rounded-lg border {className}">
43+
<img src={preview} alt={name} class="{height} {width} object-cover {imageClass}" />
4844
{#if !readonly}
49-
<div class="absolute top-1 right-1 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
45+
<div
46+
class="absolute right-1 top-1 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100"
47+
>
5048
<Button
5149
type="button"
5250
variant="ghost"
5351
size="sm"
54-
class="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 text-white"
52+
class="h-6 w-6 bg-white/20 p-0 text-white hover:bg-white/30"
5553
onclick={() => onRemove?.(id)}
5654
>
5755
<X class="h-3 w-3" />
5856
</Button>
5957
</div>
6058
{/if}
61-
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white p-1">
62-
{#if size}
63-
<p class="text-xs opacity-80">{formatFileSize(size)}</p>
64-
{/if}
65-
</div>
6659
</div>

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
preview?: string;
4242
type: string;
4343
isImage: boolean;
44+
uploadedFile?: ChatUploadedFile;
45+
attachment?: DatabaseMessageExtra;
46+
attachmentIndex?: number;
47+
textContent?: string;
4448
}> = [];
4549
4650
// Add uploaded files (ChatForm)
@@ -51,7 +55,9 @@
5155
size: file.size,
5256
preview: file.preview,
5357
type: file.type,
54-
isImage: file.type.startsWith('image/')
58+
isImage: file.type.startsWith('image/'),
59+
uploadedFile: file,
60+
textContent: file.textContent
5561
});
5662
}
5763
@@ -70,7 +76,10 @@
7076
id: `attachment-${index}`,
7177
name: attachment.name,
7278
type: 'text',
73-
isImage: false
79+
isImage: false,
80+
attachment,
81+
attachmentIndex: index,
82+
textContent: attachment.content
7483
});
7584
} else if (attachment.type === 'audioFile') {
7685
items.push({
@@ -109,6 +118,7 @@
109118
size={item.size}
110119
readonly={readonly}
111120
onRemove={onFileRemove}
121+
textContent={item.textContent}
112122
/>
113123
{/if}
114124
{/each}

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

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,14 @@
9797
9898
// Handle long text conversion to file
9999
const text = event.clipboardData.getData('text/plain');
100-
if (
101-
text.length > 0 &&
102-
pasteLongTextToFileLen > 0 &&
103-
text.length > pasteLongTextToFileLen
104-
) {
100+
if (text.length > 0 && pasteLongTextToFileLen > 0 && text.length > pasteLongTextToFileLen) {
105101
event.preventDefault();
106-
102+
107103
// Create a text file from the pasted content
108-
const textFile = new File([text], 'Pasted Content.txt', {
104+
const textFile = new File([text], 'Pasted', {
109105
type: 'text/plain'
110106
});
111-
107+
112108
onFileUpload?.([textFile]);
113109
}
114110
}
@@ -126,13 +122,9 @@
126122

127123
<form
128124
onsubmit={handleSubmit}
129-
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}"
125+
class="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 border {className}"
130126
>
131-
<ChatAttachmentsList
132-
bind:uploadedFiles={uploadedFiles}
133-
onFileRemove={onFileRemove}
134-
class="mb-3 px-5 pt-3"
135-
/>
127+
<ChatAttachmentsList bind:uploadedFiles {onFileRemove} class="mb-3 px-5 pt-5" />
136128

137129
<div
138130
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
@@ -197,8 +189,7 @@
197189
<div class="mt-4 flex items-center justify-center">
198190
<p class="text-muted-foreground text-xs">
199191
Press <kbd class="bg-muted rounded px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
200-
<kbd class="bg-muted rounded px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new
201-
line
192+
<kbd class="bg-muted rounded px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
202193
</p>
203194
</div>
204195
{/if}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@
120120
uploadedFiles = [...uploadedFiles, uploadedFile];
121121
};
122122
reader.readAsDataURL(file);
123+
} else if (file.type.startsWith('text/') || isTextFileByName(file.name)) {
124+
// Read text content for text files to enable preview
125+
const reader = new FileReader();
126+
reader.onload = (e) => {
127+
const content = e.target?.result as string;
128+
if (content) {
129+
uploadedFile.textContent = content;
130+
}
131+
uploadedFiles = [...uploadedFiles, uploadedFile];
132+
};
133+
reader.onerror = () => {
134+
// If reading fails, still add the file without text content
135+
uploadedFiles = [...uploadedFiles, uploadedFile];
136+
};
137+
reader.readAsText(file);
123138
} else {
124139
uploadedFiles = [...uploadedFiles, uploadedFile];
125140
}
@@ -164,6 +179,19 @@
164179
}
165180
}
166181
182+
/**
183+
* Check if a file is likely a text file based on its filename extension
184+
*/
185+
function isTextFileByName(filename: string): boolean {
186+
const textExtensions = [
187+
'.txt', '.md', '.js', '.ts', '.jsx', '.tsx', '.css', '.html', '.htm',
188+
'.json', '.xml', '.yaml', '.yml', '.csv', '.log', '.py', '.java',
189+
'.cpp', '.c', '.h', '.php', '.rb', '.go', '.rs', '.sh', '.bat',
190+
'.sql', '.r', '.scala', '.kt', '.swift', '.dart', '.vue', '.svelte'
191+
];
192+
return textExtensions.some(ext => filename.toLowerCase().endsWith(ext));
193+
}
194+
167195
/**
168196
* Read a file as text content
169197
*/

tools/server/webui/src/lib/types/chat.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export interface ChatUploadedFile {
88
type: string;
99
file: File;
1010
preview?: string;
11+
textContent?: string;
1112
}

0 commit comments

Comments
 (0)