feat: carry uploaded attachments on the pending chat user message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-17 08:39:01 +02:00
co-authored by Claude Opus 5
parent 3b1b3cb0ca
commit bb89e45a7f
7 changed files with 53 additions and 9 deletions
+4 -2
View File
@@ -244,8 +244,10 @@ await chat.sendMessage('What does this contract say?', {
Each file is uploaded to the workspace's object storage under
`windmill_uploads/chat/<turn>/<index>/<name>` and handed to the input as `{ s3, filename }`
objects (the object for a single-file input). The name's extension is corrected to the
file's media type for PNG, JPEG and PDF, because the worker reads the type off the key.
objects (the object for a single-file input). Once the uploads return, the pending user
message lists them in `attachments`, as `{ input, s3, filename }` references. The name's
extension is corrected to the file's media type for PNG, JPEG and PDF, because the worker
reads the type off the key.
A failed upload rejects `sendMessage` before any run starts, and `stop()` during the
upload aborts it; both leave the transcript as it was. The workspace needs object
storage set up. With Enterprise advanced storage permissions, the user needs read, write
+3 -3
View File
@@ -1,5 +1,5 @@
import type { WindmillChatApi } from './api'
import type { ChatAttachment } from './types'
import type { AttachmentUpload } from './types'
import { abortError, isAbortError } from './utils'
/**
@@ -45,7 +45,7 @@ export function storedAttachmentName(filename: string, mediaType: string): strin
}
/** The bytes of an attachment as a Blob carrying its media type. */
export function attachmentBlob(attachment: ChatAttachment): Blob {
export function attachmentBlob(attachment: AttachmentUpload): Blob {
const data = attachment.data
if (typeof data !== 'string') {
return attachment.mediaType && attachment.mediaType !== data.type
@@ -83,7 +83,7 @@ function dataUrlToBlob(dataUrl: string, fallbackType: string): Blob {
*/
export async function uploadAttachments(
api: WindmillChatApi,
attachments: ChatAttachment[],
attachments: AttachmentUpload[],
turnId: string,
signal?: AbortSignal
): Promise<UploadedAttachment[]> {
+7
View File
@@ -155,6 +155,13 @@ class ChatImpl implements Chat {
// and `stop()` can abort the upload, while a second send is refused as usual.
const uploaded = await uploadAttachments(this.#api, attachments, randomId(), turn.controller.signal)
args[attachmentsInput.name] = attachmentsInput.multiple ? uploaded : uploaded[0]
// Shown on the pending message until its server row replaces it, carrying its own.
const carried = uploaded.map((u) => ({ input: attachmentsInput.name, s3: u.s3, filename: u.filename }))
if (this.#turnActive(turn)) {
this.#set({
messages: this.#state.messages.map((m) => (m.id === turn.userMessageId ? { ...m, attachments: carried } : m))
})
}
}
turn.started = true
// Listed only once the run is asked for: a send that never runs (an upload that failed
+1
View File
@@ -16,6 +16,7 @@ export { extractChatAnswer, conversationIdFor } from './utils'
export { storedAttachmentName, uploadAttachments, CHAT_UPLOADS_PREFIX, type UploadedAttachment } from './attachments'
export type {
AttachmentsInput,
AttachmentUpload,
Chat,
ChatAttachment,
ChatMessage,
+6 -2
View File
@@ -26,6 +26,8 @@ export interface ToolInvocation {
status: 'running' | 'success' | 'error'
}
export interface ChatAttachment { input: string; s3: string; storage?: string; filename?: string }
export interface ChatMessage {
id: string
role: ChatRole
@@ -39,6 +41,8 @@ export interface ChatMessage {
jobId?: string
/** The flow step that produced the message. */
stepName?: string
/** The files a user message carried, as object-storage references. */
attachments?: ChatAttachment[]
/** True while the message is optimistic or still streaming. */
pending: boolean
/** Id of the persisted row once the server has it; `id` itself never changes, so list keys stay stable. */
@@ -119,7 +123,7 @@ export interface ChatOptions {
}
/** A file sent with a message. It is uploaded to the workspace's object storage before the run starts. */
export interface ChatAttachment {
export interface AttachmentUpload {
/** Kept as the last segment of the stored key, its extension corrected to the media type for PNG, JPEG and PDF. */
name: string
/** The bytes: a Blob, or a `data:` URL of them. */
@@ -142,7 +146,7 @@ export interface SendMessageOptions {
* the way an AI agent step reads `user_attachments`. A failed upload rejects `sendMessage`
* and the run never starts; `stop()` during the upload does the same with an `AbortError`.
*/
attachments?: ChatAttachment[]
attachments?: AttachmentUpload[]
/** Required with `attachments`. With `multiple: false`, more than one attachment is refused before anything uploads. */
attachmentsInput?: AttachmentsInput
}
+30
View File
@@ -353,4 +353,34 @@ describe('sendMessage with attachments', () => {
).rejects.toMatchObject({ name: 'AbortError' })
expect(calls).toHaveLength(0)
})
test('the pending user message carries its uploaded files before the run returns', async () => {
let releaseRun: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
upload,
(c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? new Promise<Response>((resolve) => (releaseRun = resolve))
: undefined,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read these', {
attachments: [
{ name: 'photo.webp', data: png },
{ name: 'contract', data: pdf }
],
attachmentsInput: { name: 'files', multiple: true }
})
while (runs(calls).length === 0) await new Promise((r) => setTimeout(r, 1))
const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
const pending = chat.getState().messages.find((m) => m.role === 'user')!
expect(pending.pending).toBe(true)
expect(pending.attachments).toEqual([
{ input: 'files', s3: keys[0], filename: 'photo.png' },
{ input: 'files', s3: keys[1], filename: 'contract.pdf' }
])
releaseRun(text('job-1'))
await sending
})
})
@@ -1,4 +1,4 @@
import type { Chat, ChatAttachment, ChatMessage, ChatState } from 'windmill-chat'
import type { AttachmentUpload, Chat, ChatMessage, ChatState } from 'windmill-chat'
import type {
ChatSendRequestOptions,
ChatViewHost
@@ -269,7 +269,7 @@ export class FlowChatViewHost implements ChatViewHost {
true
)
}
const attachments: ChatAttachment[] = target
const attachments: AttachmentUpload[] = target
? [...images, ...blobs].map((attachment, index) => ({
name: attachment.name ?? `attachment-${index + 1}`,
data: attachment.dataUrl,