Skip to content

Commit f6cede3

Browse files
committed
feat: Enable editing attachments in user messages
1 parent 669696e commit f6cede3

File tree

5 files changed

+375
-69
lines changed

5 files changed

+375
-69
lines changed

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,21 @@
1212
onCopy?: (message: DatabaseMessage) => void;
1313
onContinueAssistantMessage?: (message: DatabaseMessage) => void;
1414
onDelete?: (message: DatabaseMessage) => void;
15-
onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void;
15+
onEditWithBranching?: (
16+
message: DatabaseMessage,
17+
newContent: string,
18+
newExtras?: DatabaseMessageExtra[]
19+
) => void;
1620
onEditWithReplacement?: (
1721
message: DatabaseMessage,
1822
newContent: string,
1923
shouldBranch: boolean
2024
) => void;
21-
onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
25+
onEditUserMessagePreserveResponses?: (
26+
message: DatabaseMessage,
27+
newContent: string,
28+
newExtras?: DatabaseMessageExtra[]
29+
) => void;
2230
onNavigateToSibling?: (siblingId: string) => void;
2331
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
2432
siblingInfo?: ChatMessageSiblingInfo | null;
@@ -45,6 +53,8 @@
4553
messageTypes: string[];
4654
} | null>(null);
4755
let editedContent = $state(message.content);
56+
let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
57+
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
4858
let isEditing = $state(false);
4959
let showDeleteDialog = $state(false);
5060
let shouldBranchAfterEdit = $state(false);
@@ -85,6 +95,16 @@
8595
function handleCancelEdit() {
8696
isEditing = false;
8797
editedContent = message.content;
98+
editedExtras = message.extra ? [...message.extra] : [];
99+
editedUploadedFiles = [];
100+
}
101+
102+
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
103+
editedExtras = extras;
104+
}
105+
106+
function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
107+
editedUploadedFiles = files;
88108
}
89109
90110
async function handleCopy() {
@@ -107,6 +127,8 @@
107127
function handleEdit() {
108128
isEditing = true;
109129
editedContent = message.content;
130+
editedExtras = message.extra ? [...message.extra] : [];
131+
editedUploadedFiles = [];
110132
111133
setTimeout(() => {
112134
if (textareaElement) {
@@ -143,9 +165,10 @@
143165
onContinueAssistantMessage?.(message);
144166
}
145167
146-
function handleSaveEdit() {
168+
async function handleSaveEdit() {
147169
if (message.role === 'user' || message.role === 'system') {
148-
onEditWithBranching?.(message, editedContent.trim());
170+
const finalExtras = await getMergedExtras();
171+
onEditWithBranching?.(message, editedContent.trim(), finalExtras);
149172
} else {
150173
// For assistant messages, preserve exact content including trailing whitespace
151174
// This is important for the Continue feature to work properly
@@ -154,15 +177,30 @@
154177
155178
isEditing = false;
156179
shouldBranchAfterEdit = false;
180+
editedUploadedFiles = [];
157181
}
158182
159-
function handleSaveEditOnly() {
183+
async function handleSaveEditOnly() {
160184
if (message.role === 'user') {
161185
// For user messages, trim to avoid accidental whitespace
162-
onEditUserMessagePreserveResponses?.(message, editedContent.trim());
186+
const finalExtras = await getMergedExtras();
187+
onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
163188
}
164189
165190
isEditing = false;
191+
editedUploadedFiles = [];
192+
}
193+
194+
async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
195+
if (editedUploadedFiles.length === 0) {
196+
return editedExtras;
197+
}
198+
199+
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
200+
const result = await parseFilesToMessageExtras(editedUploadedFiles);
201+
const newExtras = result?.extras || [];
202+
203+
return [...editedExtras, ...newExtras];
166204
}
167205
168206
function handleShowDeleteDialogChange(show: boolean) {
@@ -197,6 +235,8 @@
197235
class={className}
198236
{deletionInfo}
199237
{editedContent}
238+
{editedExtras}
239+
{editedUploadedFiles}
200240
{isEditing}
201241
{message}
202242
onCancelEdit={handleCancelEdit}
@@ -206,6 +246,8 @@
206246
onEdit={handleEdit}
207247
onEditKeydown={handleEditKeydown}
208248
onEditedContentChange={handleEditedContentChange}
249+
onEditedExtrasChange={handleEditedExtrasChange}
250+
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
209251
{onNavigateToSibling}
210252
onSaveEdit={handleSaveEdit}
211253
onSaveEditOnly={handleSaveEditOnly}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
<script lang="ts">
2+
import { X, ArrowUp, Paperclip } from '@lucide/svelte';
3+
import { Button } from '$lib/components/ui/button';
4+
import { Checkbox } from '$lib/components/ui/checkbox';
5+
import { Label } from '$lib/components/ui/label';
6+
import { ChatAttachmentsList, ModelsSelector } from '$lib/components/app';
7+
import { INPUT_CLASSES } from '$lib/constants/input-classes';
8+
import { AttachmentType, FileTypeCategory } from '$lib/enums';
9+
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
10+
import { conversationsStore } from '$lib/stores/conversations.svelte';
11+
import { modelsStore } from '$lib/stores/models.svelte';
12+
import { isRouterMode } from '$lib/stores/server.svelte';
13+
import {
14+
autoResizeTextarea,
15+
getFileTypeCategory,
16+
getFileTypeCategoryByExtension
17+
} from '$lib/utils';
18+
19+
interface Props {
20+
messageId: string;
21+
editedContent: string;
22+
editedExtras?: DatabaseMessageExtra[];
23+
editedUploadedFiles?: ChatUploadedFile[];
24+
showSaveOnlyOption?: boolean;
25+
onCancelEdit: () => void;
26+
onSaveEdit: () => void;
27+
onSaveEditOnly?: () => void;
28+
onEditKeydown: (event: KeyboardEvent) => void;
29+
onEditedContentChange: (content: string) => void;
30+
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
31+
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
32+
textareaElement?: HTMLTextAreaElement;
33+
}
34+
35+
let {
36+
messageId,
37+
editedContent,
38+
editedExtras = [],
39+
editedUploadedFiles = [],
40+
showSaveOnlyOption = false,
41+
onCancelEdit,
42+
onSaveEdit,
43+
onSaveEditOnly,
44+
onEditKeydown,
45+
onEditedContentChange,
46+
onEditedExtrasChange,
47+
onEditedUploadedFilesChange,
48+
textareaElement = $bindable()
49+
}: Props = $props();
50+
51+
let fileInputElement: HTMLInputElement | undefined = $state();
52+
let saveWithoutRegenerate = $state(false);
53+
let isRouter = $derived(isRouterMode());
54+
55+
let hasAttachments = $derived(
56+
(editedExtras && editedExtras.length > 0) ||
57+
(editedUploadedFiles && editedUploadedFiles.length > 0)
58+
);
59+
60+
let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
61+
62+
function getEditedAttachmentsModalities(): ModelModalities {
63+
const modalities: ModelModalities = { vision: false, audio: false };
64+
65+
for (const extra of editedExtras) {
66+
if (extra.type === AttachmentType.IMAGE) {
67+
modalities.vision = true;
68+
}
69+
if (
70+
extra.type === AttachmentType.PDF &&
71+
'processedAsImages' in extra &&
72+
extra.processedAsImages
73+
) {
74+
modalities.vision = true;
75+
}
76+
if (extra.type === AttachmentType.AUDIO) {
77+
modalities.audio = true;
78+
}
79+
}
80+
81+
for (const file of editedUploadedFiles) {
82+
const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
83+
if (category === FileTypeCategory.IMAGE) {
84+
modalities.vision = true;
85+
}
86+
if (category === FileTypeCategory.AUDIO) {
87+
modalities.audio = true;
88+
}
89+
}
90+
91+
return modalities;
92+
}
93+
94+
function getRequiredModalities(): ModelModalities {
95+
const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
96+
const editedModalities = getEditedAttachmentsModalities();
97+
98+
return {
99+
vision: beforeModalities.vision || editedModalities.vision,
100+
audio: beforeModalities.audio || editedModalities.audio
101+
};
102+
}
103+
104+
const { handleModelChange } = useModelChangeValidation({
105+
getRequiredModalities,
106+
onValidationFailure: async (previousModelId) => {
107+
if (previousModelId) {
108+
await modelsStore.selectModelById(previousModelId);
109+
}
110+
}
111+
});
112+
113+
function handleSubmit() {
114+
if (!canSubmit) return;
115+
116+
if (saveWithoutRegenerate && onSaveEditOnly) {
117+
onSaveEditOnly();
118+
} else {
119+
onSaveEdit();
120+
}
121+
saveWithoutRegenerate = false;
122+
}
123+
124+
function handleRemoveExistingAttachment(index: number) {
125+
if (!onEditedExtrasChange) return;
126+
const newExtras = [...editedExtras];
127+
newExtras.splice(index, 1);
128+
onEditedExtrasChange(newExtras);
129+
}
130+
131+
function handleRemoveUploadedFile(fileId: string) {
132+
if (!onEditedUploadedFilesChange) return;
133+
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
134+
onEditedUploadedFilesChange(newFiles);
135+
}
136+
137+
function handleFileInputChange(event: Event) {
138+
const input = event.target as HTMLInputElement;
139+
if (!input.files || input.files.length === 0) return;
140+
141+
const files = Array.from(input.files);
142+
processNewFiles(files);
143+
input.value = '';
144+
}
145+
146+
async function processNewFiles(files: File[]) {
147+
if (!onEditedUploadedFilesChange) return;
148+
149+
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
150+
const processed = await processFilesToChatUploaded(files);
151+
onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
152+
}
153+
154+
$effect(() => {
155+
if (textareaElement) {
156+
autoResizeTextarea(textareaElement);
157+
}
158+
});
159+
</script>
160+
161+
<input
162+
bind:this={fileInputElement}
163+
type="file"
164+
multiple
165+
class="hidden"
166+
onchange={handleFileInputChange}
167+
/>
168+
169+
<div
170+
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
171+
data-slot="edit-form"
172+
>
173+
<ChatAttachmentsList
174+
attachments={editedExtras}
175+
uploadedFiles={editedUploadedFiles}
176+
readonly={false}
177+
onFileRemove={(fileId) => {
178+
if (fileId.startsWith('attachment-')) {
179+
const index = parseInt(fileId.replace('attachment-', ''), 10);
180+
if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
181+
handleRemoveExistingAttachment(index);
182+
}
183+
} else {
184+
handleRemoveUploadedFile(fileId);
185+
}
186+
}}
187+
limitToSingleRow
188+
class="py-5"
189+
style="scroll-padding: 1rem;"
190+
/>
191+
192+
<div class="relative min-h-[48px] px-5 py-3">
193+
<textarea
194+
bind:this={textareaElement}
195+
bind:value={editedContent}
196+
class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
197+
onkeydown={onEditKeydown}
198+
oninput={(e) => {
199+
autoResizeTextarea(e.currentTarget);
200+
onEditedContentChange(e.currentTarget.value);
201+
}}
202+
placeholder="Edit your message..."
203+
></textarea>
204+
205+
<div class="flex w-full items-center gap-3" style="container-type: inline-size">
206+
<Button
207+
class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
208+
onclick={() => fileInputElement?.click()}
209+
type="button"
210+
title="Add attachment"
211+
>
212+
<span class="sr-only">Attach files</span>
213+
214+
<Paperclip class="h-4 w-4" />
215+
</Button>
216+
217+
<div class="flex-1"></div>
218+
219+
{#if isRouter}
220+
<ModelsSelector
221+
forceForegroundText={true}
222+
useGlobalSelection={true}
223+
onModelChange={handleModelChange}
224+
/>
225+
{/if}
226+
227+
<Button
228+
class="h-8 w-8 shrink-0 rounded-full p-0"
229+
onclick={handleSubmit}
230+
disabled={!canSubmit}
231+
type="button"
232+
title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
233+
>
234+
<span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
235+
236+
<ArrowUp class="h-5 w-5" />
237+
</Button>
238+
</div>
239+
</div>
240+
</div>
241+
242+
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
243+
{#if showSaveOnlyOption && onSaveEditOnly}
244+
<div class="flex items-center gap-2">
245+
<Checkbox id="save-without-regenerate" bind:checked={saveWithoutRegenerate} class="h-4 w-4" />
246+
1
247+
<Label for="save-without-regenerate" class="cursor-pointer text-xs text-muted-foreground">
248+
Save only
249+
</Label>
250+
</div>
251+
{:else}
252+
<div></div>
253+
{/if}
254+
255+
<Button class="h-7 px-3 text-xs" onclick={onCancelEdit} size="sm" variant="ghost">
256+
<X class="mr-1 h-3 w-3" />
257+
258+
Cancel
259+
</Button>
260+
</div>

0 commit comments

Comments
 (0)