feat: attach files to a flow chat message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 20:41:47 +02:00
co-authored by Claude Opus 5
parent a9ec0aec3a
commit 43591fe0e9
28 changed files with 1573 additions and 118 deletions
+22 -2
View File
@@ -181,7 +181,7 @@ await chat.sendMessage('Hello')
| `workspace` | Detected inside a raw app. |
| `token` | A token, or a function returning one (called before every request, so it can fetch a short-lived token from your backend). Omit it inside a raw app. |
| `history` | `'server'`, `'local'` or `'none'`, see [History](#history). Defaults to `'server'` with a viewer session and `'local'` with an explicit `token`. |
| `inputs` | Extra flow inputs sent with every message. `sendMessage(text, { inputs })` adds per-message ones. |
| `inputs` | Extra flow inputs sent with every message. `sendMessage(text, { inputs })` adds per-message ones, and `{ attachments, attachmentsInput }` files, see [Attachments](#attachments). |
| `storageKey` | Namespace for `local` history, e.g. the signed-in user's id. Local history is per browser and per flow; without it, users sharing a browser share it. |
| `fetch`, `storage` | Replacements for the globals, for tests and unusual runtimes. |
| `pageSize` | Messages and conversations per page of server history. Default 50. |
@@ -224,12 +224,32 @@ A turn goes `submitted` (the flow is queued) → `streaming` (the answer is arri
answer, an `assistant` message with `success: false`. `status: 'error'` (with `error`
set) means the turn could not run or be followed at all, such as a refused request.
Methods: `sendMessage(text, { inputs? })`, `stop()`, `newConversation()`,
Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`, `newConversation()`,
`selectConversation(id)`, `loadConversations({ page?, perPage? })`,
`deleteConversation(id)`, `loadOlderMessages()`, `destroy()`. Switching conversations
stops following the current answer; the flow keeps running and, with server history,
its answer is there when you come back.
## Attachments
A flow whose AI agent step reads `user_attachments` from an `s3object[]` (or a single
`s3object`) flow input takes files with a message:
```ts
await chat.sendMessage('What does this contract say?', {
attachments: [{ name: file.name, data: file }], // a Blob/File, or a `data:` URL
attachmentsInput: { name: 'files', multiple: true }
})
```
Each file is uploaded to the workspace's object storage under
`windmill_chat_uploads/<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.
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, and the token needs to be allowed to upload.
## History
Windmill stores every conversation of a chat-mode flow, and each Windmill user sees
+27 -1
View File
@@ -193,6 +193,27 @@ export class WindmillChatApi {
return (await res.json()) as FlowConversationMessage[]
}
/**
* Puts bytes in the workspace's object storage under `fileKey` and returns the key they were
* stored under (the server may rewrite it). Needs the workspace to have object storage set.
*/
async uploadFile(
fileKey: string,
body: Blob,
options: { contentType?: string; signal?: AbortSignal } = {}
): Promise<{ file_key: string }> {
const query: Record<string, string> = { file_key: fileKey }
if (options.contentType) query.content_type = options.contentType
const res = await this.#request('job_helpers/upload_s3_file', {
method: 'POST',
query,
raw: body,
contentType: options.contentType || 'application/octet-stream',
signal: options.signal
})
return (await res.json()) as { file_key: string }
}
async deleteConversation(conversationId: string): Promise<void> {
await this.#request(`flow_conversations/delete/${encodeURIComponent(conversationId)}`, {
method: 'DELETE'
@@ -204,7 +225,11 @@ export class WindmillChatApi {
init: {
method?: string
query?: Record<string, string>
/** JSON-encoded. */
body?: unknown
/** Sent as is, under `contentType`. */
raw?: Blob
contentType?: string
accept?: string
signal?: AbortSignal
} = {}
@@ -215,13 +240,14 @@ export class WindmillChatApi {
const headers: Record<string, string> = {}
if (init.accept) headers['Accept'] = init.accept
if (init.body !== undefined) headers['Content-Type'] = 'application/json'
else if (init.raw !== undefined) headers['Content-Type'] = init.contentType ?? 'application/octet-stream'
const token = typeof this.#token === 'function' ? await this.#token() : this.#token
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await this.#fetch(url.toString(), {
method: init.method ?? 'GET',
headers,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
body: init.body === undefined ? init.raw : JSON.stringify(init.body),
// A token must not be paired with ambient cookies; without one, the cookie is
// the credential and only rides same-origin requests.
credentials: token ? 'omit' : 'same-origin',
+96
View File
@@ -0,0 +1,96 @@
import type { WindmillChatApi } from './api'
import type { ChatAttachment } from './types'
/** Where a chat's uploads live in the workspace's object storage. */
export const CHAT_UPLOADS_PREFIX = 'windmill_chat_uploads'
/** What an AI agent step reads out of `user_attachments`. */
export interface UploadedAttachment {
s3: string
filename: string
}
/**
* The extension each type a chat composer sends must be stored under.
*
* The worker reads an attachment's media type from the object key and nothing else —
* `mime_guess::from_path` in `windmill-ai/src/image_handler.rs`, falling back to
* `image/png` when it can read no extension — and never from the content type stored
* beside it. So the key's extension is a claim about the bytes, and it has to be true.
*/
const EXTENSION_BY_MEDIA_TYPE: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'application/pdf': 'pdf'
}
/**
* The name an attachment is stored under. A composer commonly re-encodes every image to PNG
* or JPEG, so keeping the picked `photo.webp` would hand the provider PNG bytes labelled webp,
* which Anthropic rejects outright; and a PDF picked without an extension would be read back
* as the `image/png` fallback. A type not listed is left as picked — other files upload byte
* for byte, so their name is already true.
*/
export function storedAttachmentName(filename: string, mediaType: string): string {
const extension = EXTENSION_BY_MEDIA_TYPE[mediaType]
if (!extension) return filename
const stem = filename.replace(/\.[^./]+$/, '')
return `${stem || filename}.${extension}`
}
/** The bytes of an attachment as a Blob carrying its media type. */
export function attachmentBlob(attachment: ChatAttachment): Blob {
const data = attachment.data
if (typeof data !== 'string') {
return attachment.mediaType && attachment.mediaType !== data.type
? new Blob([data], { type: attachment.mediaType })
: data
}
return dataUrlToBlob(data, attachment.mediaType ?? 'application/octet-stream')
}
function dataUrlToBlob(dataUrl: string, fallbackType: string): Blob {
const comma = dataUrl.indexOf(',')
if (!dataUrl.startsWith('data:') || comma === -1) {
throw new Error('windmill-chat: an attachment given as a string must be a data: URL')
}
const header = dataUrl.slice(5, comma)
const isBase64 = header.endsWith(';base64')
const mediaType = (isBase64 ? header.slice(0, -';base64'.length) : header) || fallbackType
const payload = dataUrl.slice(comma + 1)
if (!isBase64) return new Blob([decodeURIComponent(payload)], { type: mediaType })
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new Blob([bytes], { type: mediaType })
}
/**
* Put each attachment in the workspace's object storage and hand back what the agent reads.
* The flow runs on a worker, so the bytes have to exist somewhere the worker can fetch.
*
* A prefix per turn, and a segment per attachment inside it. The turn's prefix keeps a
* re-attached filename off the copy an earlier message still points at; the segment does the
* same within one turn, where two files can arrive under one name and would otherwise race to
* a single key and leave the agent reading one of them twice. The name itself stays the last
* segment, so anything that reads a name off the key still sees what the user attached.
*/
export async function uploadAttachments(
api: WindmillChatApi,
attachments: ChatAttachment[],
turnId: string,
signal?: AbortSignal
): Promise<UploadedAttachment[]> {
const prefix = `${CHAT_UPLOADS_PREFIX}/${turnId}`
return Promise.all(
attachments.map(async (attachment, index) => {
const blob = attachmentBlob(attachment)
const filename = storedAttachmentName(attachment.name || `attachment-${index + 1}`, blob.type)
const { file_key } = await api.uploadFile(`${prefix}/${index}/${filename}`, blob, {
contentType: blob.type,
signal
})
return { s3: file_key, filename }
})
)
}
+58 -5
View File
@@ -7,6 +7,7 @@ import {
import { resolveConfig, type ResolvedConfig } from './config'
import { followJob } from './follow'
import { createLocalHistory, type LocalHistory } from './history'
import { uploadAttachments } from './attachments'
import type { AgentStreamEvent } from './stream'
import type {
Chat,
@@ -14,6 +15,7 @@ import type {
ChatOptions,
ChatState,
Conversation,
SendMessageOptions,
ToolInvocation
} from './types'
import {
@@ -39,6 +41,10 @@ interface Turn {
conversationId: string
/** Id of the turn's user message; the answer is whatever follows it. */
userMessageId: string
/** The turn opened the conversation; withdrawing it closes the conversation again. */
isNew: boolean
/** The run was asked for. Before that, a failure or a stop withdraws the turn instead of failing it. */
started: boolean
jobId?: string
/** The flow job and its step jobs; a persisted answer carries one of them as `job_id`. */
jobIds?: Set<string>
@@ -97,21 +103,25 @@ class ChatImpl implements Chat {
}
}
sendMessage = async (
text: string,
options: { inputs?: Record<string, unknown> } = {}
): Promise<void> => {
sendMessage = async (text: string, options: SendMessageOptions = {}): Promise<void> => {
const content = text.trim()
if (!content) return
if (this.#turn) {
throw new Error('windmill-chat: a message is already being answered; call stop() first')
}
const attachments = options.attachments ?? []
const attachmentsInput = options.attachmentsInput
if (attachments.length > 0 && !attachmentsInput) {
throw new Error('windmill-chat: attachments need `attachmentsInput`, the flow input that takes them')
}
const isNew = this.#state.conversationId === undefined
const conversationId = this.#state.conversationId ?? randomId()
const turn: Turn = {
controller: new AbortController(),
conversationId,
userMessageId: `pending-${randomId()}`,
isNew,
started: false,
streamedText: false
}
this.#turn = turn
@@ -134,7 +144,14 @@ class ChatImpl implements Chat {
this.#rememberConversation()
try {
const args = { ...this.#config.inputs, ...options.inputs, user_message: content }
const args: Record<string, unknown> = { ...this.#config.inputs, ...options.inputs, user_message: content }
if (attachmentsInput && attachments.length > 0) {
// Uploaded with the turn already shown as submitted: the message is in the transcript
// 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]
}
turn.started = true
const context = { memoryId: conversationId, conversationId, signal: turn.controller.signal }
turn.jobId = this.#config.run
? await this.#config.run(args, context)
@@ -148,6 +165,13 @@ class ChatImpl implements Chat {
}
await this.#finishTurn(turn, result, isNew)
} catch (e) {
if (!turn.started) {
// Nothing ran: the message is withdrawn rather than shown as a failed turn, and the
// caller gets the reason (an upload that failed, or the AbortError of a stop()).
if (this.#turn === turn) this.#turn = undefined
this.#withdrawTurn(turn)
throw e
}
// stop() and a conversation switch abort the turn and settle the state themselves.
if (turn.controller.signal.aborted || isAbortError(e)) return
this.#failTurn(turn, e)
@@ -160,6 +184,12 @@ class ChatImpl implements Chat {
const turn = this.#turn
if (!turn) return
this.#detachTurn()
if (!turn.started) {
// Still uploading its attachments: there is no run to cancel, and the message the
// reader took back must not stay in the transcript as sent.
this.#withdrawTurn(turn)
return
}
if (this.#state.conversationId === turn.conversationId) {
this.#set({ messages: finalized(this.#state.messages), status: 'idle' })
this.#persistLocal()
@@ -545,6 +575,29 @@ class ChatImpl implements Chat {
}
}
/**
* Undo what `sendMessage` showed for a turn that never ran: its user message, and the
* conversation it opened when there was none. Only while that conversation is still the
* one on screen; a switch meanwhile has already left it behind.
*/
#withdrawTurn(turn: Turn): void {
if (this.#state.conversationId !== turn.conversationId) return
const messages = this.#state.messages.filter((m) => m.id !== turn.userMessageId)
if (turn.isNew) {
if (this.#state.history === 'local') this.#local.deleteConversation(turn.conversationId)
this.#set({
conversationId: undefined,
conversations: this.#state.conversations.filter((c) => c.id !== turn.conversationId),
messages,
status: 'idle',
error: undefined
})
return
}
this.#set({ messages, status: 'idle', error: undefined })
this.#persistLocal()
}
#failTurn(turn: Turn, e: unknown): void {
if (!this.#turnActive(turn)) return
const error = toError(e)
+4
View File
@@ -13,8 +13,11 @@ export {
export { parseStreamEvents, createStreamEventParser, type AgentStreamEvent } from './stream'
export { followJob, type FollowEvent } from './follow'
export { extractChatAnswer, conversationIdFor } from './utils'
export { storedAttachmentName, uploadAttachments, CHAT_UPLOADS_PREFIX, type UploadedAttachment } from './attachments'
export type {
AttachmentsInput,
Chat,
ChatAttachment,
ChatMessage,
ChatOptions,
ChatRole,
@@ -23,6 +26,7 @@ export type {
Conversation,
FetchLike,
HistoryMode,
SendMessageOptions,
StorageLike,
TokenSource,
ToolInvocation
+35 -2
View File
@@ -118,12 +118,45 @@ export interface ChatOptions {
onError?: (error: Error, turn: { conversationId: string; jobId?: string }) => void
}
/** A file sent with a message. It is uploaded to the workspace's object storage before the run starts. */
export interface ChatAttachment {
/** 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. */
data: Blob | string
/** The file's media type. Defaults to the Blob's own type, or the data URL's. */
mediaType?: string
}
/** The flow input the uploaded attachments are handed to: an `s3object` (`multiple: false`) or an `s3object[]`. */
export interface AttachmentsInput {
name: string
multiple: boolean
}
export interface SendMessageOptions {
/** Extra flow inputs for this message, on top of `ChatOptions.inputs`. */
inputs?: Record<string, unknown>
/**
* Files to upload and hand to the flow as `{ s3, filename }` objects in `attachmentsInput`,
* 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[]
/** Required with `attachments`. */
attachmentsInput?: AttachmentsInput
}
export interface Chat {
getState(): ChatState
/** Calls `listener` now and on every change; returns the unsubscribe function (Svelte store contract). */
subscribe(listener: (state: ChatState) => void): () => void
/** Sends a message in the current conversation, starting one when there is none. Resolves when the answer is complete. */
sendMessage(text: string, options?: { inputs?: Record<string, unknown> }): Promise<void>
/**
* Sends a message in the current conversation, starting one when there is none. Resolves
* when the answer is complete. Rejects when the message could not be sent at all — a turn
* already running, an attachment that failed to upload — without touching the transcript.
*/
sendMessage(text: string, options?: SendMessageOptions): Promise<void>
/** Stops following the answer and asks Windmill to cancel the run. */
stop(): Promise<void>
newConversation(): void
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, test } from 'bun:test'
import { storedAttachmentName } from '../src/attachments'
import { createChat } from '../src/chat'
import type { ChatOptions } from '../src/types'
import { abortError } from '../src/utils'
import { fetchMock, json, memoryStorage, sse, text, type RecordedCall, type Route } from './support'
const BASE = 'http://wm.test'
const FLOW = 'f/chat/agent'
const UPLOAD_PATH = '/api/w/ws/job_helpers/upload_s3_file'
const run: Route = (c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? text('job-1')
: undefined
/** Stores under the key it was asked to, like the server with a `file_key`. */
const upload: Route = (c) =>
c.url.pathname === UPLOAD_PATH
? json({ file_key: c.url.searchParams.get('file_key') })
: undefined
const answer: Route = (c) =>
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1'
? sse([
{
type: 'update',
completed: true,
only_result: { output: 'ok', messages: [] }
}
])
: undefined
function options(fetch: ChatOptions['fetch']): ChatOptions {
return {
flowPath: FLOW,
baseUrl: BASE,
workspace: 'ws',
token: 'tok',
fetch,
storage: memoryStorage()
}
}
const uploads = (calls: RecordedCall[]) => calls.filter((c) => c.url.pathname === UPLOAD_PATH)
const runs = (calls: RecordedCall[]) =>
calls.filter((c) => c.url.pathname.startsWith('/api/w/ws/jobs/run/'))
const png = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], {
type: 'image/png'
})
const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
describe('storedAttachmentName', () => {
// The worker reads the media type from the key's extension, so it has to match the bytes.
test('renames a re-encoded image and gives a bare name its extension', () => {
expect(storedAttachmentName('photo.webp', 'image/png')).toBe('photo.png')
expect(storedAttachmentName('holiday.png', 'image/jpeg')).toBe('holiday.jpg')
expect(storedAttachmentName('contract', 'application/pdf')).toBe('contract.pdf')
expect(storedAttachmentName('report.2026.final.webp', 'image/png')).toBe(
'report.2026.final.png'
)
})
test('leaves a type it does not know alone', () => {
expect(storedAttachmentName('notes.csv', 'text/csv')).toBe('notes.csv')
})
})
describe('sendMessage with attachments', () => {
test('uploads each file under the turn prefix and hands the list to the input', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read these', {
inputs: { locale: 'fr' },
attachments: [
{ name: 'photo.webp', data: png },
{ name: 'contract', data: pdf },
// A data URL is decoded to its bytes; the mediaType names what they are.
{
name: 'photo.webp',
data: `data:image/png;base64,${btoa('\x89PNG')}`
}
],
attachmentsInput: { name: 'files', multiple: true }
})
const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
expect(keys).toHaveLength(3)
const prefix = keys[0].split('/').slice(0, 2).join('/')
expect(prefix).toMatch(/^windmill_chat_uploads\/[0-9a-f-]{36}$/)
expect(keys).toEqual([
`${prefix}/0/photo.png`,
`${prefix}/1/contract.pdf`,
`${prefix}/2/photo.png`
])
expect(uploads(calls).map((c) => c.url.searchParams.get('content_type'))).toEqual([
'image/png',
'application/pdf',
'image/png'
])
expect(uploads(calls).map((c) => c.headers['content-type'])).toEqual([
'image/png',
'application/pdf',
'image/png'
])
expect(new Uint8Array(await uploads(calls)[2].raw!.arrayBuffer())).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47])
)
expect(runs(calls)[0].body).toEqual({
locale: 'fr',
user_message: 'read these',
files: [
{ s3: `${prefix}/0/photo.png`, filename: 'photo.png' },
{ s3: `${prefix}/1/contract.pdf`, filename: 'contract.pdf' },
{ s3: `${prefix}/2/photo.png`, filename: 'photo.png' }
]
})
expect(chat.getState().status).toBe('idle')
})
test('hands a single object to an input that holds one file', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'file', multiple: false }
})
const body = runs(calls)[0].body as Record<string, unknown>
expect(body.file).toEqual({
s3: expect.stringMatching(/\/0\/contract\.pdf$/),
filename: 'contract.pdf'
})
})
test('refuses attachments without an input to put them in', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage('hi', { attachments: [{ name: 'a.pdf', data: pdf }] })
).rejects.toThrow('attachmentsInput')
expect(calls).toHaveLength(0)
})
test('a failed upload rejects without a run, and withdraws the message', async () => {
const { fetch, calls } = fetchMock(
(c) => (c.url.pathname === UPLOAD_PATH ? text('no object storage', 500) : undefined),
run,
answer
)
const chat = createChat(options(fetch))
const statuses: string[] = []
chat.subscribe((s) => statuses.push(s.status))
await expect(
chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toThrow('no object storage')
expect(runs(calls)).toHaveLength(0)
// Shown as submitted while uploading, then withdrawn whole: no message, no conversation.
expect(statuses).toContain('submitted')
const state = chat.getState()
expect(state.status).toBe('idle')
expect(state.messages).toEqual([])
expect(state.conversationId).toBeUndefined()
expect(state.conversations).toEqual([])
// The chat is free for the next message.
await chat.sendMessage('plain')
expect(runs(calls)).toHaveLength(1)
})
test('stop() during the upload aborts it and withdraws the message', async () => {
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise((_, reject) =>
c.signal!.addEventListener('abort', () => reject(abortError()))
)
: undefined,
run,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
expect(chat.getState().status).toBe('submitted')
await chat.stop()
await expect(sending).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(chat.getState()).toMatchObject({
status: 'idle',
messages: [],
conversationId: undefined
})
})
})
+6 -1
View File
@@ -5,6 +5,9 @@ export interface RecordedCall {
url: URL
headers: Record<string, string>
body: unknown
/** A body sent as is rather than as JSON (an upload). */
raw?: Blob
signal?: AbortSignal
}
export type Route = (call: RecordedCall) => Response | Promise<Response> | undefined
@@ -20,7 +23,9 @@ export function fetchMock(...routes: Route[]): { fetch: FetchLike; calls: Record
headers: Object.fromEntries(
Object.entries((init?.headers as Record<string, string>) ?? {}).map(([k, v]) => [k.toLowerCase(), v])
),
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
raw: init?.body instanceof Blob ? init.body : undefined,
signal: init?.signal ?? undefined
}
calls.push(call)
for (const route of routes) {
@@ -473,6 +473,7 @@
hideSidebar={true}
path={$pathStore}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
@@ -8,7 +8,7 @@
import type { PickableProperties } from './flows/previousResults'
import InputTransformForm from './InputTransformForm.svelte'
import InputTransformPickers from './InputTransformPickers.svelte'
import { useS3StorageConfigured } from './inputTransformEnv.svelte'
import { useWorkspaceStorageConfigured } from './inputTransformEnv.svelte'
import type ItemPicker from './ItemPicker.svelte'
import type VariableEditor from './VariableEditor.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
@@ -86,7 +86,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
const s3Storage = useWorkspaceStorageConfigured(() => ws)
let keys: string[] = $state([])
$effect(() => {
@@ -361,7 +361,15 @@
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
)
const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled)
// Why attaching is off, when this chat takes attachments but cannot right now. The `+` is
// kept and disabled rather than dropped: the input is the composer's either way, so the
// reader has to be able to see here why nothing can be attached.
const attachmentsOffReason = $derived(
chatHost.supportsMessageAttachments ? chatHost.attachmentsUnavailableReason : undefined
)
const canAttachFiles = $derived(
chatHost.supportsMessageAttachments && !disabled && !attachmentsOffReason
)
// Folders are linked as session-wide assets, which only a host that reads files in
// the browser can do — a host running the turn server-side takes attachments only.
const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled)
@@ -430,24 +438,32 @@
return Array.from(e.dataTransfer?.types ?? []).includes('Files')
}
// A drop is claimed while attaching is off for a stated reason, too: the browser would
// otherwise navigate to the dropped file, and the reader is owed the reason instead.
const panelTakesDrops = $derived(canAttachFiles || attachmentsOffReason !== undefined)
function onPanelDragEnter(e: DragEvent) {
if (!canAttachFiles || !dragHasFiles(e)) return
if (!panelTakesDrops || !dragHasFiles(e)) return
e.preventDefault()
dragDepth++
}
function onPanelDragOver(e: DragEvent) {
if (!canAttachFiles || !dragHasFiles(e)) return
if (!panelTakesDrops || !dragHasFiles(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
function onPanelDragLeave(_e: DragEvent) {
if (!canAttachFiles) return
if (!panelTakesDrops) return
dragDepth = Math.max(0, dragDepth - 1)
}
async function onPanelDrop(e: DragEvent) {
dragDepth = 0
if (!canAttachFiles || !dragHasFiles(e)) return
if (!panelTakesDrops || !dragHasFiles(e)) return
e.preventDefault()
if (attachmentsOffReason) {
sendUserToast(attachmentsOffReason, true)
return
}
const dt = e.dataTransfer
if (!dt) return
// Images and loose text files attach to the message; folders link as session
@@ -484,9 +500,8 @@
handles.length === 0
? flatFiles
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
// Loose text files attach to the message, like images.
const textFiles = looseFiles.filter((f) => !isImageFile(f))
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
// Loose files attach to the message, like images.
await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f)))
// Folders link as a live handle.
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
@@ -523,24 +538,31 @@
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
await attachNonImageFiles(topLevelText)
}
}
async function onFileInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
if (input.files && input.files.length > 0) {
const picked = Array.from(input.files)
const imageFiles = picked.filter(isImageFile)
const textFiles = picked.filter((f) => !isImageFile(f))
// Reserved before the text work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
await imageWork
await attachPickedFiles(Array.from(input.files))
}
input.value = '' // allow re-selecting the same file
}
async function attachNonImageFiles(files: File[]) {
await aiChatInput?.addNonImageFiles(files)
}
async function attachPickedFiles(picked: File[]) {
const imageFiles = picked.filter(isImageFile)
const others = picked.filter((f) => !isImageFile(f))
// Reserved before the other work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
await attachNonImageFiles(others)
await imageWork
}
function onFolderInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
// webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups
@@ -622,6 +644,7 @@
const showFooterLeftControls = $derived(
!footerMessageShown &&
(canAttachFiles ||
attachmentsOffReason !== undefined ||
showContextPicker ||
showAutonomyModeSelector ||
(chatHost.mode === AIMode.SCRIPT && hasDiff))
@@ -991,7 +1014,21 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
{/if}
{#if canAttachFiles}
{#if attachmentsOffReason}
<Tooltip small placement="top">
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="default"
iconOnly
disabled
startIcon={{ icon: Plus }}
/>
{#snippet text()}
<div class="max-w-64 text-xs">{attachmentsOffReason}</div>
{/snippet}
</Tooltip>
{:else if canAttachFiles}
<DropdownV2
items={async () => {
// Both submenus fetch on the menu's first open, so they start
@@ -42,6 +42,13 @@
textByteLength,
type AttachedTextFile
} from './textFileUtils'
import {
fileToAttachedBlob,
matchesAccept,
MAX_ATTACHED_BLOBS,
MAX_BLOB_BYTES,
type AttachedBlob
} from './blobUtils'
import { MessageDraft } from './messageDraft.svelte'
import ExpandableImage, {
isImageViewerOpen
@@ -213,6 +220,45 @@
: undefined
)
/**
* Free slots in one attachment lane, against both that lane's own cap and any limit the
* host's consumer imposes on the turn as a whole — a flow input holding a single file
* caps images and blobs together, not one each. In-flight decodes count: two drops that
* both read the staged count before either resolves would claim the same slots twice.
*/
function attachmentSlots(laneCap: number, laneStaged: number): number {
const laneRemaining = laneCap - laneStaged
const turnCap = chatHost.maxMessageAttachments
if (turnCap === undefined) return laneRemaining
const staged =
draft.images.length +
pendingImages +
draft.files.length +
pendingFiles +
draft.blobs.length +
pendingBlobs
// A queue counts too: what is held mid-run merges into one turn on flush, so a
// second file accepted now would be dropped there instead of refused here.
const queued =
chatHost.queuedImages.length + chatHost.queuedFiles.length + chatHost.queuedBlobs.length
return Math.min(laneRemaining, Math.max(0, turnCap - staged - queued))
}
/** What to say when the host's own limit is the one that bit. */
function turnCapMessage(): string {
const turnCap = chatHost.maxMessageAttachments
return turnCap === 1
? 'This chat sends one attachment per message.'
: `This chat sends up to ${turnCap} attachments per message.`
}
/** Why some of what was picked did not fit, naming whichever limit actually bit. */
function skippedMessage(laneCap: number, lane: 'images' | 'files', skipped: number): string {
return chatHost.maxMessageAttachments !== undefined
? `${turnCapMessage()} ${skipped} file(s) were not attached.`
: `You can attach up to ${laneCap} ${lane}; ${skipped} were skipped.`
}
// Images being decoded right now. Holds off sending so a message can never go
// out without an attachment the user already dropped, and reserves cap slots
// against a concurrent drop.
@@ -221,6 +267,14 @@
/** Attach dropped/pasted image files (downscaled + bounded). */
export async function addImages(files: (File | Blob)[]) {
if (!chatHost.supportsMessageAttachments) return
// Attaching can be off despite the chat taking attachments — no object storage to
// upload to, say. The `+` renders disabled with the reason; a drop and a paste reach
// here instead, and would otherwise become a chip that only fails once sent.
const unavailable = chatHost.attachmentsUnavailableReason
if (unavailable) {
sendUserToast(unavailable, true)
return
}
const imageFiles = files.filter(isImageFile)
if (imageFiles.length === 0) return
// The vision check is about the model this composer's own turn will hit, so it
@@ -240,9 +294,14 @@
// Count decodes already in flight: two drops that both read the image count
// before either resolves would each claim the same free slots and overshoot
// the cap.
const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages
const remaining = attachmentSlots(MAX_ATTACHED_IMAGES, draft.images.length + pendingImages)
if (remaining <= 0) {
sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true)
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_IMAGES} images.`,
true
)
return
}
const oversized = imageFiles.filter((f) => f.size > MAX_IMAGE_BYTES)
@@ -255,7 +314,7 @@
const batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_IMAGES} images; ${usable.length - batch.length} were skipped.`,
skippedMessage(MAX_ATTACHED_IMAGES, 'images', usable.length - batch.length),
true
)
}
@@ -326,9 +385,14 @@
export async function addTextFiles(candidates: File[]) {
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
const remaining = attachmentSlots(MAX_ATTACHED_FILES, draft.files.length + pendingFiles)
if (remaining <= 0) {
sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true)
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_FILES} files.`,
true
)
return
}
const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES)
@@ -343,10 +407,7 @@
if (usable.length === 0) return
let batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`,
true
)
sendUserToast(skippedMessage(MAX_ATTACHED_FILES, 'files', usable.length - batch.length), true)
}
// Conversation-level byte budget: transcript + queue + every live
// composer's stage (this one and, mid-edit, the other) + this composer's
@@ -420,6 +481,86 @@
draft.files = draft.files.filter((_, i) => i !== index)
}
// Blobs being read right now — same send-hold/slot-reservation role as pendingImages.
let pendingBlobs = $state(0)
/**
* Attach non-image files through the lane the host actually reads: a host that decodes
* them takes text, one that forwards them verbatim (to object storage) takes blobs, and
* its narrower `accept` is re-applied because a drop and a paste both bypass the picker's
* own filtering. Every way of attaching goes through here, so no route can take the lane
* the host ignores and drop the file at send.
*/
export async function addNonImageFiles(files: File[]) {
if (files.length === 0) return
// Attaching can be off despite the chat taking attachments — no object storage to
// upload to, say. The `+` renders disabled with the reason; a drop and a paste reach
// here instead, and would otherwise become a chip that only fails once sent.
const unavailable = chatHost.attachmentsUnavailableReason
if (unavailable) {
sendUserToast(unavailable, true)
return
}
if (!chatHost.attachmentsAsBlobs) {
await addTextFiles(files)
return
}
const allowed = files.filter((f) => matchesAccept(f, chatHost.attachmentAccept))
if (allowed.length < files.length) {
sendUserToast(
`${files.length - allowed.length} file(s) skipped — this chat accepts ${chatHost.attachmentAccept}.`,
true
)
}
await addBlobs(allowed)
}
/** Attach files the host takes verbatim (a PDF, say). Kept out of addTextFiles:
* that one decodes to a string and drops anything the binary sniff rejects. */
export async function addBlobs(candidates: File[]) {
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const oversized = candidates.filter((f) => f.size > MAX_BLOB_BYTES)
if (oversized.length > 0) {
const mb = Math.round(MAX_BLOB_BYTES / 1_000_000)
sendUserToast(`${oversized.length} file(s) over ${mb}MB were skipped.`, true)
}
const usable = candidates.filter((f) => f.size <= MAX_BLOB_BYTES)
if (usable.length === 0) return
const remaining = attachmentSlots(MAX_ATTACHED_BLOBS, draft.blobs.length + pendingBlobs)
if (remaining <= 0) {
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_BLOBS} files.`,
true
)
return
}
const batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(skippedMessage(MAX_ATTACHED_BLOBS, 'files', usable.length - batch.length), true)
}
pendingBlobs += batch.length
try {
const added: AttachedBlob[] = []
for (const file of batch) {
try {
added.push(await fileToAttachedBlob(file))
} catch (e) {
sendUserToast(`Could not read ${file.name}`, true)
}
}
if (added.length > 0) draft.addBlobs(added)
} finally {
pendingBlobs -= batch.length
}
}
function removeBlob(index: number) {
draft.blobs = draft.blobs.filter((_, i) => i !== index)
}
// App mode @ mention state
let showAppContextTooltip = $state(false)
let appContextTooltipWord = $state('')
@@ -501,7 +642,8 @@
// Attachments still decoding/reading (or mid-drop-routing) count as
// occupancy too — they belong to a draft the user started even though
// their lane is still empty.
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false
if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0)
return false
if (
!draft.replaceIfEmpty({
text: value,
@@ -524,15 +666,17 @@
export function prependText(
text: string,
restoredImages: AttachedImage[] = [],
restoredFiles: AttachedTextFile[] = []
restoredFiles: AttachedTextFile[] = [],
restoredBlobs: AttachedBlob[] = []
): boolean {
// mergedIntoDraft: the restored text landed on top of a draft the user was
// already writing — both instructions now share one composer, so the caller
// must keep both their contexts rather than replacing one with the other.
const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({
const { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs } = draft.prepend({
text,
images: restoredImages,
files: restoredFiles
files: restoredFiles,
blobs: restoredBlobs
})
if (droppedImages > 0) {
sendUserToast(
@@ -546,6 +690,12 @@
true
)
}
if (droppedBlobs > 0) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_BLOBS} files; ${droppedBlobs} restored file(s) were dropped.`,
true
)
}
focusInput()
return mergedIntoDraft
}
@@ -717,7 +867,7 @@
function sendRequest() {
// The send button is disabled while decoding, but Enter reaches here directly.
// Sending now would drop the in-flight attachments onto the following message.
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0) {
return
}
// A host whose consumer needs a message of its own refuses an attachment-only
@@ -761,7 +911,8 @@
expanded(chatDraft(sent.text, sent.pastes)),
sent.images,
[...selectedContext],
sent.files
sent.files,
sent.blobs
)
// Consumed at enqueue, not at flush: the entry above pinned them.
consumeMentionsIfGlobal()
@@ -789,11 +940,15 @@
// when given no override, and the consume below empties it.
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
consumeMentionsIfGlobal()
// A host that refuses the turn puts the draft back itself (see AIChatManager's
// restoreToInput and FlowChatViewHost's upload failure): restoring here too
// would double the text and every attachment.
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
files: sent.files,
blobs: sent.blobs,
contextOverride: carried,
contextOverrideOrigin: carried ? 'pinned' : undefined
})
@@ -1015,6 +1170,26 @@
updateAppTooltipPosition(appTooltipCurrentViewNumber)
}
})
/**
* Clipboard files on the plain composer. ContextTextarea does this for the rich one; a
* host that attaches but renders the plain field would otherwise take files from the `+`
* and from a drop and silently ignore the same file pasted.
*
* Only when the clipboard carries no text, as there: a spreadsheet or browser copy puts a
* bitmap alongside the text, and pasting a cell range must paste the cells.
*/
function handlePlainPaste(e: ClipboardEvent) {
if (!chatHost.supportsMessageAttachments) return
if ((e.clipboardData?.getData('text/plain') ?? '').trim()) return
const pasted = Array.from(e.clipboardData?.files ?? [])
const images = pasted.filter((f) => f.type.startsWith('image/'))
const others = pasted.filter((f) => !f.type.startsWith('image/'))
if (images.length === 0 && others.length === 0) return
e.preventDefault()
if (images.length > 0) void addImages(images)
if (others.length > 0) void addNonImageFiles(others)
}
</script>
{#snippet sendStopButton()}
@@ -1035,6 +1210,7 @@
disabled ||
pendingImages > 0 ||
pendingFiles > 0 ||
pendingBlobs > 0 ||
ingestionHolds > 0 ||
needsText ||
(emptyDraft &&
@@ -1068,7 +1244,7 @@
thumbnails get their own row (different height). -->
{#snippet badgeRow()}
{@const contextChips = showContext ? selectedContext : domSelectorChips}
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0}
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0 || draft.blobs.length > 0 || pendingBlobs > 0}
<div class="flex flex-row flex-wrap items-center gap-1 px-2.5 pt-2">
{#each contextChips as element (contextKey(element))}
<ContextElementBadge
@@ -1087,7 +1263,19 @@
onDelete={() => removeFile(i)}
/>
{/each}
{#each { length: pendingFiles } as _, i (i)}
<!-- Blobs are shown by the same badge as text files. Their preview line stands
in for content the badge cannot render (a PDF has no text to show). -->
{#each draft.blobs as blob, i (i)}
<ContextElementBadge
contextElement={createAttachedFileContextElement(
blob.name,
`${blob.mediaType} · ${Math.max(1, Math.round(blob.size / 1024))} KB`
)}
deletable
onDelete={() => removeBlob(i)}
/>
{/each}
{#each { length: pendingFiles + pendingBlobs } as _, i (i)}
<div
class="h-6 w-24 rounded-md border bg-surface flex items-center justify-center"
title="Reading file..."
@@ -1152,6 +1340,7 @@
draft.isEmpty &&
pendingImages === 0 &&
pendingFiles === 0 &&
pendingBlobs === 0 &&
ingestionHolds === 0
) {
// Shell-style recall: ArrowUp in the empty main composer pulls the
@@ -1167,6 +1356,7 @@
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
chatHost.queuedBlobs.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
@@ -1299,6 +1489,7 @@
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onpaste={handlePlainPaste}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
@@ -1,3 +1,4 @@
import type { AttachedBlob } from './blobUtils'
import type { ChatViewHost } from './chatViewHost'
import type { ScriptLang } from '$lib/gen/types.gen'
import { JobService, type CompletedJob } from '$lib/gen'
@@ -463,6 +464,10 @@ export class AIChatManager implements ChatViewHost {
get supportsLinkedFolders() {
return this.mode === AIMode.GLOBAL
}
// The copilot reads attachments in the browser, so non-image files decode to text.
attachmentsAsBlobs = false
// The copilot decodes its attachments, so nothing ever lands in the blob lane.
queuedBlobs: AttachedBlob[] = []
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
// the message — text files after a content sniff).
attachmentAccept =
@@ -20,13 +20,13 @@
here only for context-ONLY queues: text queues pin the same chips, but
those stay visible in the composer, and repeating them would read as two
selections. -->
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || chatHost.queuedBlobs.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
<!-- The body and the X are sibling buttons for the same action (an X inside a
clickable chip would be a nested interactive control, invalid ARIA). -->
<div
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60 hover:opacity-100"
>
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || chatHost.queuedBlobs.length > 0}
<button
type="button"
class="min-w-0 grow text-left cursor-pointer"
@@ -58,6 +58,21 @@
{/each}
</div>
{/if}
{#if chatHost.queuedBlobs.length > 0}
<!-- Blobs are the same chip as files: a host that forwards bytes verbatim
queues them here instead, and a queue of them alone must still show. -->
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedBlobs as blob, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={blob.name}
>
<FileText size={10} class="shrink-0" />
<span class="truncate min-w-0">{blob.name}</span>
</span>
{/each}
</div>
{/if}
{#if chatHost.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{chatHost.queuedMessage}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { dataUrlToBlob, matchesAccept } from './blobUtils'
function file(name: string, type: string): File {
return new File(['x'], name, { type })
}
describe('dataUrlToBlob', () => {
// The bytes are re-uploaded verbatim, so a decode that drops or shifts one is a
// corrupted file the reader only discovers downstream.
it('decodes base64 back to the exact bytes', async () => {
const bytes = new Uint8Array([0x00, 0xff, 0x10, 0x89, 0x50])
const b64 = btoa(String.fromCharCode(...bytes))
const blob = dataUrlToBlob(`data:application/pdf;base64,${b64}`)
expect(blob.type).toBe('application/pdf')
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes)
})
it('percent-decodes a url that is not base64', async () => {
const blob = dataUrlToBlob('data:text/plain,hello%20world')
expect(blob.type).toBe('text/plain')
expect(await blob.text()).toBe('hello world')
})
it('falls back to a media type when the url names none', async () => {
expect(dataUrlToBlob('data:;base64,QQ==').type).toBe('application/octet-stream')
expect(dataUrlToBlob('data:;base64,QQ==', 'image/png').type).toBe('image/png')
})
})
describe('matchesAccept', () => {
it('matches an extension, a type wildcard and an exact media type', () => {
expect(matchesAccept(file('report.PDF', ''), '.pdf')).toBe(true)
expect(matchesAccept(file('shot.png', 'image/png'), 'image/*')).toBe(true)
expect(matchesAccept(file('shot.png', 'image/png'), 'image/png')).toBe(true)
})
it('refuses a file no pattern covers, and allows everything when the list is empty', () => {
expect(matchesAccept(file('notes.txt', 'text/plain'), '.pdf, image/*')).toBe(false)
expect(matchesAccept(file('notes.txt', 'text/plain'), '')).toBe(true)
})
})
@@ -0,0 +1,79 @@
/**
* Message-scoped attachments that are neither an image nor readable text a PDF
* being the case that matters. They ride the composer next to images and files,
* as chips cleared on send, and reach the host through `ChatSendRequestOptions`.
*
* The bytes are kept verbatim, unlike an image (which normalises to a bounded
* PNG/JPEG for the model) and unlike a text file (which is decoded to a string):
* a host that forwards these to object storage has to upload what the user
* picked, not a re-encoding of it.
*/
/** Blobs one message may carry — the same slot cap images and text files use. */
export const MAX_ATTACHED_BLOBS = 8
/**
* Per-blob byte cap. The data URL sits in composer state until send, so this
* bounds what one message can hold in memory; a host uploading elsewhere pays
* the same bytes again on the wire.
*/
export const MAX_BLOB_BYTES = 20_000_000
export type AttachedBlob = {
name: string
/** The file's own media type, verbatim — the upload's Content-Type depends on it. */
mediaType: string
/** `data:<mediaType>;base64,<...>` of the original bytes. */
dataUrl: string
size: number
}
/**
* Whether a file satisfies an `accept` list the same list the OS picker gets, applied
* again on drop, where the browser enforces nothing.
*/
export function matchesAccept(file: File, accept: string): boolean {
const patterns = accept
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
if (patterns.length === 0) return true
const type = file.type.toLowerCase()
const name = file.name.toLowerCase()
return patterns.some((pattern) => {
if (pattern.startsWith('.')) return name.endsWith(pattern)
if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1))
return type === pattern
})
}
export async function fileToAttachedBlob(file: File): Promise<AttachedBlob> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`))
reader.readAsDataURL(file)
})
return {
name: file.name,
mediaType: file.type || 'application/octet-stream',
dataUrl,
size: file.size
}
}
/** The bytes behind a `data:` URL, for a host that has to re-upload them. */
export function dataUrlToBlob(dataUrl: string, fallbackType = 'application/octet-stream'): Blob {
const comma = dataUrl.indexOf(',')
const header = dataUrl.slice(5, comma)
const isBase64 = header.endsWith(';base64')
const mediaType = (isBase64 ? header.slice(0, -';base64'.length) : header) || fallbackType
const payload = dataUrl.slice(comma + 1)
if (!isBase64) {
return new Blob([decodeURIComponent(payload)], { type: mediaType })
}
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new Blob([bytes], { type: mediaType })
}
@@ -3,6 +3,7 @@ import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { DisplayMessage, Tool } from './shared'
import type { ContextElement } from './context'
import type { AttachedBlob } from './blobUtils'
import type { AttachedImage } from './imageUtils'
import type { AttachedTextFile } from './textFileUtils'
import type { PasteAttachment } from './pasteTokens'
@@ -18,6 +19,7 @@ export type ChatSendRequestOptions = {
pastes?: PasteAttachment[]
images?: AttachedImage[]
files?: AttachedTextFile[]
blobs?: AttachedBlob[]
/** Selected-context snapshot for this turn, in place of the live selection. Set
* whenever a send settles its context ahead of the turn. A host with no context
* of its own ignores it. */
@@ -73,11 +75,13 @@ export interface ChatViewHost {
queuedContext: ContextElement[] | undefined
readonly queuedImages: AttachedImage[]
readonly queuedFiles: AttachedTextFile[]
readonly queuedBlobs: AttachedBlob[]
queueMessage: (
text: string,
images?: AttachedImage[],
context?: ContextElement[],
files?: AttachedTextFile[]
files?: AttachedTextFile[],
blobs?: AttachedBlob[]
) => void
dequeueMessage: () => void
setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void
@@ -111,8 +115,16 @@ export interface ChatViewHost {
/** Click a user message to edit and resend it. Needs a host that can rewind
* its own transcript, which a host replaying a server-side run cannot. */
supportsMessageEditing: boolean
/** The `+` menu's file entry and drag-and-drop onto the panel. */
/** The `+` menu's file entry and drag-and-drop onto the panel. Attachments ride
* one message; where they go afterwards is the host's business (see sendRequest). */
supportsMessageAttachments: boolean
/**
* Why attaching is off right now, when the host would otherwise take attachments. Distinct
* from `supportsMessageAttachments` being false, which means this chat never takes them:
* here the composer keeps the control and says what is missing, because moving the input
* elsewhere would only offer an editor that cannot work either.
*/
attachmentsUnavailableReason?: string
/** The turn needs text: attachments alone cannot be sent. True where the consumer
* requires a message of its own an AI agent step refuses a run with neither a
* `user_message` nor manual memory. */
@@ -120,8 +132,17 @@ export interface ChatViewHost {
/** The `+` menu's folder entries, backed by `attachedFiles`. A linked folder is a
* live handle on the user's disk, so only a host reading files in the browser has one. */
supportsLinkedFolders: boolean
/** `accept` for the file picker. */
/** `accept` for the file picker, and the drop filter. A host whose consumer only
* understands some formats narrows it so the rest are refused rather than ignored. */
attachmentAccept: string
/** How many attachments one turn can carry, when the consumer holds a fixed number
* a flow input that is a single file, say. Undefined means no limit. Enforced at the
* picker and on drop, so what the composer shows is what the turn actually sends. */
maxMessageAttachments?: number
/** Take non-image attachments verbatim (`blobs`) instead of decoding them to text.
* True where the bytes are forwarded somewhere object storage rather than read
* in the browser. */
attachmentsAsBlobs: boolean
tools: Tool<any>[]
autonomyMode: AIAutonomyMode
setAutonomyMode: (mode: AIAutonomyMode) => void
@@ -1,6 +1,6 @@
/**
* A message draft: the four lanes that ship together with one send text,
* pastes, images, text files. Every place a draft accumulates or moves
* A message draft: the five lanes that ship together with one send text,
* pastes, images, text files, blobs. Every place a draft accumulates or moves
* (composer attach, queue append, dequeue restore, failure restore) goes
* through this type, so the draft rules file dedupe by source identity,
* courtesy rename, attachment slot caps, all-lanes-move-together live here
@@ -10,6 +10,7 @@
* manager-wide state enforced at the composer until it moves into the
* store) and @context/DOM picks (ContextManager owns their lifecycle).
*/
import { MAX_ATTACHED_BLOBS, type AttachedBlob } from './blobUtils'
import { MAX_ATTACHED_IMAGES, type AttachedImage } from './imageUtils'
import type { PasteAttachment } from './pasteTokens'
import {
@@ -19,12 +20,13 @@ import {
type AttachedTextFile
} from './textFileUtils'
/** A draft's four lanes as plain data — what moves between owners. */
/** A draft's five lanes as plain data — what moves between owners. */
export interface DraftSnapshot {
text: string
pastes: PasteAttachment[]
images: AttachedImage[]
files: AttachedTextFile[]
blobs: AttachedBlob[]
}
export class MessageDraft {
@@ -32,12 +34,14 @@ export class MessageDraft {
pastes = $state<PasteAttachment[]>([])
images = $state<AttachedImage[]>([])
files = $state<AttachedTextFile[]>([])
blobs = $state<AttachedBlob[]>([])
constructor(seed?: Partial<DraftSnapshot>) {
if (seed?.text) this.text = seed.text
if (seed?.pastes) this.pastes = [...seed.pastes]
if (seed?.images) this.images = [...seed.images]
if (seed?.files) this.files = [...seed.files]
if (seed?.blobs) this.blobs = [...seed.blobs]
}
get isEmpty(): boolean {
@@ -45,12 +49,13 @@ export class MessageDraft {
this.text.trim() === '' &&
this.pastes.length === 0 &&
this.images.length === 0 &&
this.files.length === 0
this.files.length === 0 &&
this.blobs.length === 0
)
}
get hasAttachments(): boolean {
return this.images.length > 0 || this.files.length > 0
return this.images.length > 0 || this.files.length > 0 || this.blobs.length > 0
}
/** Files joining a draft always fold (dedupe by source identity, courtesy
@@ -83,6 +88,14 @@ export class MessageDraft {
return dropped
}
/** Blobs join up to the slot cap. Returns the dropped count (caller toasts). */
addBlobs(blobs: AttachedBlob[]): number {
const merged = [...this.blobs, ...blobs]
const dropped = Math.max(0, merged.length - MAX_ATTACHED_BLOBS)
this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS)
return dropped
}
/**
* Merge a restored draft on top of this one (queued-message delete, restore
* after a cancelled/errored turn): the restored draft was written FIRST, so
@@ -91,10 +104,16 @@ export class MessageDraft {
* Returns whether text merged onto a non-empty draft (the caller must then
* keep both drafts' context), plus dropped counts for toasts.
*/
prepend(restored: { text: string; images?: AttachedImage[]; files?: AttachedTextFile[] }): {
prepend(restored: {
text: string
images?: AttachedImage[]
files?: AttachedTextFile[]
blobs?: AttachedBlob[]
}): {
mergedIntoDraft: boolean
droppedImages: number
droppedFiles: number
droppedBlobs: number
} {
const mergedIntoDraft = !!restored.text && !!this.text.trim()
// An attachment-only restore has empty text; prepending would only add blank lines.
@@ -115,7 +134,13 @@ export class MessageDraft {
droppedFiles = Math.max(0, merged.length - MAX_ATTACHED_FILES)
this.files = merged.slice(0, MAX_ATTACHED_FILES)
}
return { mergedIntoDraft, droppedImages, droppedFiles }
let droppedBlobs = 0
if (restored.blobs?.length) {
const merged = [...restored.blobs, ...this.blobs]
droppedBlobs = Math.max(0, merged.length - MAX_ATTACHED_BLOBS)
this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS)
}
return { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs }
}
/** Replace the draft with a snapshot, but only when it is empty an occupied
@@ -132,16 +157,18 @@ export class MessageDraft {
this.pastes = [...(snapshot.pastes ?? [])]
this.images = [...(snapshot.images ?? [])]
this.files = [...(snapshot.files ?? [])]
this.blobs = [...(snapshot.blobs ?? [])]
}
/** Snapshot and clear atomically the four lanes always move together, so no
/** Snapshot and clear atomically the five lanes always move together, so no
* call site can take one and forget another. */
take(): DraftSnapshot {
const snapshot: DraftSnapshot = {
text: this.text,
pastes: this.pastes,
images: this.images,
files: this.files
files: this.files,
blobs: this.blobs
}
this.clear()
return snapshot
@@ -152,5 +179,6 @@ export class MessageDraft {
this.pastes = []
this.images = []
this.files = []
this.blobs = []
}
}
@@ -38,7 +38,7 @@
import StepInputsGen from '$lib/components/copilot/StepInputsGen.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import InputTransformPickers from '$lib/components/InputTransformPickers.svelte'
import { useS3StorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import type ItemPicker from '$lib/components/ItemPicker.svelte'
import type VariableEditor from '$lib/components/VariableEditor.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -153,7 +153,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
const s3Storage = useWorkspaceStorageConfigured(() => ws)
// The per-field copilot only ever writes a JavaScript transform, so it belongs only where one can
// be stored. On a static-only field the write lands in a key the config drops on deploy, which
@@ -750,6 +750,7 @@
path={$pathStore}
hideSidebar={true}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
@@ -6,6 +6,7 @@
import FlowChatInterface from './FlowChatInterface.svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import type { FlowModule } from '$lib/gen'
interface Props {
/**
@@ -22,6 +23,8 @@
path: string
hideSidebar?: boolean
inputSchema?: Record<string, any>
/** The flow's steps, read for the AI agent inputs the composer drives itself. */
flowModules?: FlowModule[]
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
@@ -33,6 +36,7 @@
path,
hideSidebar = false,
inputSchema = undefined,
flowModules = undefined,
description = undefined,
wideLayout = false
}: Props = $props()
@@ -102,6 +106,7 @@
{chat}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{workspace}
{description}
@@ -10,11 +10,21 @@
import { emptyString, type DynamicInput } from '$lib/utils'
import { onDestroy, tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import type { FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import {
attachmentsTargetFor,
isEmptyAgentChatInputValue,
PER_TURN_AGENT_CHAT_INPUT_KEY,
resolveAgentChatInputs
} from './agentAttachmentInput'
interface Props {
chat: Chat
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
/** The flow's steps, read for the AI agent inputs the composer drives itself. */
flowModules?: FlowModule[]
path: string
workspace?: string
/** The flow's description, shown under the empty transcript's prompt. */
@@ -26,6 +36,7 @@
chat,
deploymentInProgress = false,
additionalInputsSchema,
flowModules = undefined,
path,
workspace = undefined,
description = undefined,
@@ -42,6 +53,39 @@
return undefined
})
// The composer's attachments feed this input, and the paperclip is its whole editor.
const attachmentsTarget = $derived(
attachmentsTargetFor(
resolveAgentChatInputs(flowModules, additionalInputsSchema).find(
(input) => input.key === PER_TURN_AGENT_CHAT_INPUT_KEY
)
)
)
// Uploading needs the workspace's object storage; without one the `+` is drawn disabled
// saying so, since the modal could not upload either.
const workspaceStorage = useWorkspaceStorageConfigured(() => workspace)
// What the Configure-inputs modal asks for: every flow input the composer does not edit
// itself. A stored value for the promoted input is left where it is; the host drops it
// from what a turn sends.
const modalSchema = $derived.by(() => {
if (!additionalInputsSchema) return undefined
const promoted = attachmentsTarget?.name
if (!promoted) return additionalInputsSchema
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => key !== promoted)
)
if (Object.keys(properties).length === 0) return undefined
const required: string[] = Array.isArray(additionalInputsSchema.required)
? additionalInputsSchema.required
: []
return {
...additionalInputsSchema,
properties,
required: required.filter((key) => key !== promoted)
}
})
// LocalStorage helpers
const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
@@ -85,12 +129,9 @@
}
const hasMissingRequired = $derived.by(() => {
if (!additionalInputsSchema?.required?.length) return false
if (!modalSchema?.required?.length) return false
const values = additionalInputsValues ?? {}
return additionalInputsSchema.required.some(
(field: string) =>
values[field] === undefined || values[field] === '' || values[field] === null
)
return modalSchema.required.some((field: string) => isEmptyAgentChatInputValue(values[field]))
})
// The host follows the chat it was built on for the life of this component: FlowChat
@@ -100,6 +141,11 @@
{
additionalInputs: () =>
additionalInputsSchema ? (loadInputsFromStorage() ?? additionalInputsValues) : undefined,
attachmentsTarget: () => attachmentsTarget,
attachmentsUnavailable: () =>
workspaceStorage.current
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
workspace: () => workspace,
sendDisabled: () => deploymentInProgress
}
@@ -127,10 +173,10 @@
</script>
<!-- Additional Inputs Modal -->
{#if additionalInputsSchema}
{#if modalSchema}
<Modal title="Configure inputs" bind:open={showInputsModal}>
<SchemaForm
schema={additionalInputsSchema}
schema={modalSchema}
bind:args={additionalInputsValues}
helperScript={dynamicInputHelperScript}
{workspace}
@@ -159,7 +205,7 @@
{/snippet}
{#snippet footerSettings()}
{#if additionalInputsSchema}
{#if modalSchema}
<div class="relative">
<Button
unifiedSize="2xs"
@@ -200,7 +246,7 @@
hideModeSelector
{wideLayout}
{emptyHint}
footerSettings={additionalInputsSchema ? footerSettings : undefined}
footerSettings={modalSchema ? footerSettings : undefined}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress}
disabledMessage={deploymentInProgress ? 'Deployment in progress' : ''}
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
agentSteps,
attachmentsTargetFor,
flowInputRef,
resolveAgentChatInputs
} from './agentAttachmentInput'
import type { FlowModule } from '$lib/gen'
/** An agent step with the given input transforms, as the editor stores them. */
function agentWith(input_transforms: Record<string, any>, id = 'a'): FlowModule {
return {
id,
value: { type: 'aiagent', tools: [], input_transforms }
} as unknown as FlowModule
}
describe('agentSteps', () => {
it('finds agents inside loops and branches', () => {
const modules = [
{ id: 'loop', value: { type: 'forloopflow', modules: [agentWith({}, 'in-loop')] } },
{
id: 'branch',
value: {
type: 'branchone',
default: [agentWith({}, 'in-default')],
branches: [{ modules: [agentWith({}, 'in-branch')] }]
}
},
{
id: 'all',
value: { type: 'branchall', branches: [{ modules: [agentWith({}, 'in-all')] }] }
}
] as unknown as FlowModule[]
expect(agentSteps(modules).map((m) => m.id)).toEqual([
'in-loop',
'in-default',
'in-branch',
'in-all'
])
})
// The graph walks an agent's tools as child steps; a tool agent's inputs belong to the
// agent that calls it, not to the chat.
it("ignores an agent carried as another agent's tool", () => {
const parent = agentWith({})
;(parent.value as any).tools = [
{ id: 'summarize', value: { tool_type: 'flowmodule', type: 'aiagent', tools: [] } }
]
expect(agentSteps([parent]).map((m) => m.id)).toEqual(['a'])
})
})
describe('flowInputRef', () => {
it('names the one input an expression reads, however it reshapes it', () => {
expect(
flowInputRef({
type: 'javascript',
expr: '(flow_input.files || []).map(f => ({ bucket: f.storage, key: f.s3 }))'
})
).toBe('files')
expect(flowInputRef({ type: 'javascript', expr: 'flow_input?.docs' })).toBe('docs')
})
it('names nothing for two inputs or a static value', () => {
expect(
flowInputRef({ type: 'javascript', expr: '[...flow_input.a, ...flow_input.b]' })
).toBeUndefined()
expect(flowInputRef({ type: 'static', value: [] })).toBeUndefined()
})
})
describe('resolveAgentChatInputs', () => {
const schema = { properties: { files: { type: 'array' } }, required: [] }
const reader = (name: string) =>
agentWith({ user_attachments: { type: 'javascript', expr: `flow_input.${name}` } })
// Every agent step carries a placeholder transform for each key of AI_AGENT_SCHEMA
// (loadSchemaFromModule writes them back onto the module), so an agent that reads
// nothing must not be mistaken for one reading a different input.
const seeded = () => agentWith({ user_attachments: { type: 'static', value: undefined } })
it('promotes the input one agent reads', () => {
expect(resolveAgentChatInputs([reader('files')], schema).map((i) => i.name)).toEqual(['files'])
})
it('still promotes it when another agent leaves the field unwired', () => {
expect(resolveAgentChatInputs([reader('files'), seeded()], schema).map((i) => i.name)).toEqual([
'files'
])
})
it('promotes nothing when two agents read different inputs', () => {
const twoInputs = {
properties: { files: { type: 'array' }, docs: { type: 'array' } },
required: []
}
expect(resolveAgentChatInputs([reader('files'), reader('docs')], twoInputs)).toEqual([])
})
it('promotes nothing for an input the schema does not declare', () => {
expect(resolveAgentChatInputs([reader('missing')], schema)).toEqual([])
})
})
describe('attachmentsTargetFor', () => {
const input = (property: Record<string, any>) =>
({ name: 'files', key: 'user_attachments', property }) as any
it('takes a list of s3 files, and says it holds several', () => {
expect(
attachmentsTargetFor(input({ type: 'array', items: { resourceType: 's3object' } }))
).toEqual({ name: 'files', multiple: true })
})
it('takes a single s3 file', () => {
expect(attachmentsTargetFor(input({ format: 'resource-s3_object' }))).toEqual({
name: 'files',
multiple: false
})
})
// The transform can build the s3 object itself, promoting an input that holds a key
// rather than a file. Uploading into it would write an object where a string is declared.
it('offers no paperclip where the input cannot hold a file', () => {
expect(attachmentsTargetFor(input({ type: 'string' }))).toBeUndefined()
expect(
attachmentsTargetFor(input({ type: 'array', items: { type: 'string' } }))
).toBeUndefined()
expect(attachmentsTargetFor(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,169 @@
import type { FlowModule, InputTransform } from '$lib/gen'
/**
* The flow's own AI agent steps, including those inside loops and branches but never one
* carried as another agent's tool.
*
* The graph walks an agent's tools as if they were child steps (flowTree.ts), which is
* right for the graph and wrong here: a tool agent's inputs belong to the agent that
* calls it, not to the chat. Counting it would let a nested agent's wiring speak for the
* step the reader is actually talking to.
*/
export function agentSteps(modules: FlowModule[] | undefined): FlowModule[] {
const found: FlowModule[] = []
const walk = (mods: FlowModule[]) => {
for (const module of mods) {
const value = module.value as any
if (value?.type === 'aiagent') {
found.push(module)
continue
}
if (value?.type === 'forloopflow' || value?.type === 'whileloopflow') {
walk(value.modules ?? [])
} else if (value?.type === 'branchone') {
walk(value.default ?? [])
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
} else if (value?.type === 'branchall') {
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
}
}
}
walk(modules ?? [])
return found
}
/**
* AI agent inputs the chat composer can drive.
*
* The composer never edits the flow: an agent field is reachable only when the author
* wired a flow input to it, so what the composer writes is a run input like any other. That
* also keeps it working on the deployed chat, where the reader has no write access, and
* on a step linked to an `ai_agent` resource, where every field but `user_message` /
* `user_attachments` comes from the resource and is not overridable at all.
*
* Only what the person chatting legitimately owns turn to turn belongs here, which today
* is the files they attach and nothing else. `system_prompt`, `temperature` and
* `max_completion_tokens` shape how the agent behaves for everyone who runs the flow
* surfacing them per conversation invites tuning the flow from the chat instead of
* fixing it in the editor. They stay flow settings, reachable through Configure inputs
* when the author deliberately exposes them. `max_iterations` is absent for the same
* reason, and because it caps the tool-use loop rather than a single generation.
*/
export const AGENT_CHAT_INPUT_KEYS = ['user_attachments'] as const
export type AgentChatInputKey = (typeof AGENT_CHAT_INPUT_KEYS)[number]
/** The key that rides one message rather than the conversation, which the composer
* clears on send. A later key of the other sort would not be this one. */
export const PER_TURN_AGENT_CHAT_INPUT_KEY: AgentChatInputKey = 'user_attachments'
export type AgentChatInput = {
/** Flow input property feeding the agent field. */
name: string
key: AgentChatInputKey
/** The flow input's own schema entry — the chip renders it with the same editor the modal would. */
property: Record<string, any>
}
const FLOW_INPUT_REF = /flow_input\??\.([A-Za-z_$][\w$]*)/g
/**
* The flow input a transform is fed by, when exactly one feeds it.
*
* The expression need not be a bare pass-through a step commonly reshapes what it
* reads, e.g. `(flow_input.files || []).map(f => ({ bucket: f.storage, key: f.s3 }))`.
* Writing that input is still right, because the expression consumes it. Two or more
* inputs are ambiguous: the composer would have no way to say which one it is editing.
*/
export function flowInputRef(transform: InputTransform | undefined): string | undefined {
if (transform?.type !== 'javascript') return undefined
const names = new Set([...transform.expr.matchAll(FLOW_INPUT_REF)].map((match) => match[1]))
return names.size === 1 ? [...names][0] : undefined
}
/** Whether a schema entry holds an s3 file, as the flow input editor recognises one. */
function holdsS3File(property: Record<string, any> | undefined): boolean {
return (
property?.format === 'resource-s3_object' ||
property?.resourceType === 's3object' ||
property?.resourceType === 's3_object'
)
}
/** The flow input the composer's attachments feed, and whether it holds a list. */
export type AttachmentsTarget = { name: string; multiple: boolean }
/**
* Where the composer's attachments go, or nothing when there is nowhere they fit.
*
* The agent reads `user_attachments` through a transform that may reshape what it takes, so
* the flow input feeding it is not necessarily an s3 field: an expression building the s3
* object itself promotes a plain string. Writing `{ s3, filename }` into that input fails at
* run time, so the paperclip appears only where the schema says the value belongs.
*/
export function attachmentsTargetFor(
input: AgentChatInput | undefined
): AttachmentsTarget | undefined {
if (!input) return undefined
if (holdsS3File(input.property)) return { name: input.name, multiple: false }
return input.property?.type === 'array' && holdsS3File(input.property.items)
? { name: input.name, multiple: true }
: undefined
}
export function isEmptyAgentChatInputValue(value: any): boolean {
if (value === undefined || value === null || value === '') return true
return Array.isArray(value) && value.length === 0
}
/**
* The flow inputs that an AI agent step reads directly into one of its chat-relevant
* fields. Several agents may resolve to the same flow input; it is one chip either way,
* and one that stays unambiguous however many agents read it.
*/
export function resolveAgentChatInputs(
modules: FlowModule[] | undefined,
additionalInputsSchema: Record<string, any> | undefined
): AgentChatInput[] {
const properties = additionalInputsSchema?.properties
if (!modules || !properties) return []
// One input per key, and only when every agent reading that key reads the same one:
// the composer writes a single flow input, so promoting one of two would feed one
// agent and leave the other with nothing — while hiding both from the modal, where
// the reader could at least have filled them in.
const namesPerKey = new Map<AgentChatInputKey, Set<string | undefined>>()
for (const module of agentSteps(modules)) {
const transforms = (module.value as any).input_transforms ?? {}
for (const key of AGENT_CHAT_INPUT_KEYS) {
const transform = transforms[key]
// An agent that feeds the key from anything but a flow input — a literal, another
// step's result, or the empty placeholder every agent step carries for the keys of
// AI_AGENT_SCHEMA — is not reading an input, so it has no say in which one the
// composer drives.
if (transform?.type !== 'javascript' || !transform.expr.includes('flow_input')) continue
const name = flowInputRef(transform)
// A name the schema doesn't declare has no field to promote, and one expression
// reading two inputs names none: either way this agent reads something the
// composer cannot drive, which is what disagreement means here.
const usable = name && name in properties ? name : undefined
const names = namesPerKey.get(key) ?? new Set<string | undefined>()
names.add(usable)
namesPerKey.set(key, names)
}
}
const keyOf = new Map<string, AgentChatInputKey>()
for (const [key, names] of namesPerKey) {
if (names.size !== 1) continue
const name = [...names][0]
if (name === undefined || keyOf.has(name)) continue
keyOf.set(name, key)
}
return [...keyOf.entries()].map(([name, key]) => ({
name,
key,
property: properties[name]
}))
}
@@ -1,4 +1,4 @@
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
import type { Chat, ChatAttachment, ChatMessage, ChatState } from 'windmill-chat'
import type {
ChatSendRequestOptions,
ChatViewHost
@@ -8,12 +8,21 @@ import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
import { dataUrlToBlob, type AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils'
import { sendUserToast } from '$lib/toast'
import type { AttachmentsTarget } from './agentAttachmentInput'
export type FlowChatViewHostOptions = {
/** The flow inputs sent next to `user_message` with every turn. */
additionalInputs?: () => Record<string, any> | undefined
/** The flow input the composer's attachments feed. Undefined where the flow has none:
* the chat then takes no attachments at all. */
attachmentsTarget?: () => AttachmentsTarget | undefined
/** Why attaching is off despite the flow taking attachments no object storage, say.
* Undefined while the workspace has not answered: an explanation must not be a guess. */
attachmentsUnavailable?: () => string | undefined
/** The workspace the transcript's paths resolve against. */
workspace?: () => string | undefined
/** Whether sending is refused right now (a deployment in progress, say). The composer
@@ -26,6 +35,17 @@ function isBusy(status: ChatState['status']): boolean {
return status === 'submitted' || status === 'streaming'
}
/** The chat's own signal for a send stopped before it ran; nothing to tell the reader. */
function isAbort(e: unknown): boolean {
return e instanceof Error && e.name === 'AbortError'
}
type Queue = { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }
function emptyQueue(): Queue {
return { text: '', images: [], blobs: [] }
}
/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */
function parseToolPayload(raw: string | undefined): unknown {
if (raw === undefined || raw === '') return undefined
@@ -131,11 +151,12 @@ export class FlowChatViewHost implements ChatViewHost {
#disposed = false
/** Stops following the chat, and drops what was queued: a flush still waiting on the
* turn's release would otherwise start a run from a panel that is gone. The chat itself
* is the caller's to destroy. */
* turn's release would otherwise start a run from a panel that is gone. A send still
* uploading its attachments stops with the chat, which is the caller's to destroy; its
* draft is not handed back, since the composer it came from is gone too. */
dispose() {
this.#disposed = true
this.#queued = ''
this.#queue = emptyQueue()
this.#unsubscribe()
}
@@ -205,27 +226,95 @@ export class FlowChatViewHost implements ChatViewHost {
sendInFlight = false
sendRequest = async (options: ChatSendRequestOptions = {}): Promise<boolean> => {
const text = options.instructions?.trim() ?? ''
let images = options.images ?? []
let blobs = options.blobs ?? []
// The composer refuses an attachment-only send (requiresMessageText), so this is
// the same rule at the other end: nothing runs without a message.
if (!text) return false
if (this.loading) {
this.queueMessage(text)
this.queueMessage(text, images, undefined, undefined, blobs)
return true
}
if (this.#options.sendDisabled?.()) {
// Refused, not dropped: the text waits in the composer for sending to reopen.
this.#aiChatInput?.prependText(text)
// Refused, not dropped: the draft waits in the composer for sending to reopen.
this.#aiChatInput?.prependText(text, images, [], blobs)
return false
}
const target = this.#options.attachmentsTarget?.()
const inputs = { ...(this.#options.additionalInputs?.() ?? {}) }
// Where the paperclip is the input's editor, the stored settings have no say over it:
// a value saved while the modal owned it — before this workspace had object storage —
// would otherwise ride along on every later message. The chat sets it from the
// attachments, or not at all.
if (target) delete inputs[target.name]
// The per-turn cap again, at the place the truncation would happen: the composer
// enforces it as files are attached, but a queue built over several turns arrives
// here as one send, and a scalar input keeps the first upload — uploading the rest
// would strand them in storage while the transcript claimed they went.
const cap = this.maxMessageAttachments
if (cap !== undefined && images.length + blobs.length > cap) {
const dropped = images.length + blobs.length - cap
images = images.slice(0, cap)
blobs = blobs.slice(0, Math.max(0, cap - images.length))
sendUserToast(
cap === 1
? `This chat sends one attachment per message; ${dropped} file(s) were not sent.`
: `This chat sends up to ${cap} attachments per message; ${dropped} file(s) were not sent.`,
true
)
}
// The bytes go up as picked (or as the composer re-encoded them, for an image): the
// chat uploads them to the workspace's object storage and names them by their type.
const attachments: ChatAttachment[] = target
? [...images, ...blobs].map((attachment, index) => ({
name: attachment.name ?? `attachment-${index + 1}`,
data: dataUrlToBlob(attachment.dataUrl, attachment.mediaType)
}))
: []
this.#automaticScroll = true
// A run that fails is reported through the chat's `onError` and as a failed message;
// the promise itself only rejects when the chat refuses the turn outright, and the
// text is then handed back rather than dropped.
// the promise itself only rejects when the chat refuses the turn outright — a turn
// already running, an upload that failed, Stop pressed while it ran — and the draft
// is then handed back rather than dropped. The composer took it before calling, so
// nothing else would.
const turn = this.#chat
.sendMessage(text, { inputs: this.#options.additionalInputs?.() })
.catch(() => this.#aiChatInput?.prependText(text))
.sendMessage(text, {
inputs: this.#options.additionalInputs?.() ? inputs : undefined,
attachments,
attachmentsInput: target
})
.catch((e) => {
if (this.#disposed) return
if (attachments.length > 0 && !isAbort(e)) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
}
this.#restoreToComposer(text, images, blobs)
})
this.#turnDone = turn
await turn
return true
}
/**
* Give a spent draft back after a send that did not run. Back to the front of the queue
* whenever one is waiting: anything queued was typed later, and the next settled turn
* sends the queue whole so putting the older draft in the composer would run the two
* out of order.
*/
#restoreToComposer(text: string, images: AttachedImage[], blobs: AttachedBlob[]) {
const queue = this.#queue
if (!queue.text && queue.images.length === 0 && queue.blobs.length === 0) {
this.#aiChatInput?.prependText(text, images, [], blobs)
return
}
this.#queue = {
text: queue.text ? `${text}\n${queue.text}` : text,
images: [...images, ...queue.images],
blobs: [...blobs, ...queue.blobs]
}
}
/** Settles when the chat has released the last turn this host started. */
#turnDone: Promise<unknown> = Promise.resolve()
cancel = () => {
@@ -241,32 +330,53 @@ export class FlowChatViewHost implements ChatViewHost {
this.#aiChatInput = aiChatInput
}
// One message typed while the turn runs, sent whole once it settles. Enter again
// appends a line rather than replacing what waits.
#queued = $state('')
// One message typed while the turn runs, sent whole with its attachments once the turn
// settles. Enter again appends a line rather than replacing what waits.
#queue = $state<Queue>(emptyQueue())
get queuedMessage(): string {
return this.#queued
return this.#queue.text
}
queuedContext = undefined
queuedImages: AttachedImage[] = []
queuedFiles: AttachedTextFile[] = []
queueMessage = (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
this.#queued = this.#queued ? `${this.#queued}\n${trimmed}` : trimmed
get queuedImages(): AttachedImage[] {
return this.#queue.images
}
/** Put the queued draft back in the composer. */
queuedFiles: AttachedTextFile[] = []
get queuedBlobs(): AttachedBlob[] {
return this.#queue.blobs
}
queueMessage = (
text: string,
images: AttachedImage[] = [],
_context?: unknown,
_files?: unknown,
blobs: AttachedBlob[] = []
) => {
const trimmed = text.trim()
if (!trimmed && images.length === 0 && blobs.length === 0) return
const queue = this.#queue
this.#queue = {
text: !trimmed ? queue.text : queue.text ? `${queue.text}\n${trimmed}` : trimmed,
images: [...queue.images, ...images],
blobs: [...queue.blobs, ...blobs]
}
}
/** Put the queued draft back in the composer, attachments included. */
dequeueMessage = () => {
const text = this.#queued
if (!text) return
this.#queued = ''
this.#aiChatInput?.prependText(text)
const { text, images, blobs } = this.#takeQueue()
if (!text && images.length === 0 && blobs.length === 0) return
this.#aiChatInput?.prependText(text, images, [], blobs)
}
flushQueuedMessage = () => {
const text = this.#queued
if (!text || this.#disposed) return
this.#queued = ''
void this.sendRequest({ instructions: text })
// Same rule as sendRequest, read before the queue is drained: a turn with no message
// cannot run, and taking the queue for it would drop the attachments on the floor.
if (!this.#queue.text || this.#disposed) return
const { text, images, blobs } = this.#takeQueue()
void this.sendRequest({ instructions: text, images, blobs })
}
#takeQueue(): Queue {
const taken = this.#queue
this.#queue = emptyQueue()
return taken
}
setComposerStaged = () => {}
clearComposerStaged = () => {}
@@ -291,11 +401,32 @@ export class FlowChatViewHost implements ChatViewHost {
isSessionChat = false
supportsModelSettings = false
supportsMessageEditing = false
supportsMessageAttachments = false
// The input's shape alone: whether this chat takes attachments at all is a fact about the
// flow, not about the workspace. Object storage decides whether it can right now, which is
// `attachmentsUnavailableReason` — a state on the control rather than a reason to move the
// input to the modal, where a file picker would be just as unable to upload.
get supportsMessageAttachments(): boolean {
return !!this.#options.attachmentsTarget?.()
}
get attachmentsUnavailableReason(): string | undefined {
return this.#options.attachmentsUnavailable?.()
}
// An AI agent step refuses a run with no `user_message`.
requiresMessageText = true
// Attachments go to object storage for the worker to read, so a linked folder — a live
// handle on the user's own disk — has no meaning here.
supportsLinkedFolders = false
attachmentAccept = ''
attachmentsAsBlobs = true
// A scalar flow input holds one file; sending more would upload every one and run with
// the first, leaving the rest orphaned in storage and the transcript claiming otherwise.
get maxMessageAttachments(): number | undefined {
return this.#options.attachmentsTarget?.()?.multiple === false ? 1 : undefined
}
// What a provider actually takes. Anthropic's document block accepts base64
// `application/pdf` and nothing else, so the wider set `is_document_mime`
// (windmill-ai/src/ai_types.rs) claims — csv, html, plain, docx, xlsx — is rejected with a
// 400 rather than read. Widen this only alongside a worker that inlines text as text.
attachmentAccept = 'image/*,application/pdf,.pdf'
tools = []
// The enum's value, written out so this module never imports the copilot manager at
// runtime: its unit test would otherwise load the manager and the editor it pulls in.
@@ -140,7 +140,11 @@ describe('FlowChatViewHost', () => {
const host = new FlowChatViewHost(chat, { additionalInputs: () => ({ tone: 'brief' }) })
expect(host.loading).toBe(false)
expect(await host.sendRequest({ instructions: ' hello ' })).toBe(true)
expect(chat.sendMessage).toHaveBeenCalledWith('hello', { inputs: { tone: 'brief' } })
expect(chat.sendMessage).toHaveBeenCalledWith('hello', {
inputs: { tone: 'brief' },
attachments: [],
attachmentsInput: undefined
})
expect(await host.sendRequest({ instructions: ' ' })).toBe(false)
set({ status: 'streaming' })
expect(host.loading).toBe(true)
@@ -169,7 +173,11 @@ describe('FlowChatViewHost', () => {
releaseTurn()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(host.queuedMessage).toBe('')
expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', { inputs: undefined })
expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', {
inputs: undefined,
attachments: [],
attachmentsInput: undefined
})
host.dispose()
})
@@ -189,7 +197,7 @@ describe('FlowChatViewHost', () => {
message({ role: 'assistant', content: 'boom', success: false })
]
})
expect(prependText).toHaveBeenCalledWith('later')
expect(prependText).toHaveBeenCalledWith('later', [], [], [])
expect(chat.sendMessage).not.toHaveBeenCalled()
host.dispose()
})
@@ -206,7 +214,7 @@ describe('FlowChatViewHost', () => {
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('after deploy')
expect(prependText).toHaveBeenCalledWith('after deploy', [], [], [])
expect(host.queuedMessage).toBe('')
host.dispose()
})
@@ -235,7 +243,7 @@ describe('FlowChatViewHost', () => {
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
await host.sendRequest({ instructions: 'kept' })
expect(prependText).toHaveBeenCalledWith('kept')
expect(prependText).toHaveBeenCalledWith('kept', [], [], [])
host.dispose()
})
@@ -247,16 +255,101 @@ describe('FlowChatViewHost', () => {
host.queueMessage('later')
host.cancel()
expect(chat.stop).toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('later')
expect(prependText).toHaveBeenCalledWith('later', [], [], [])
expect(host.queuedMessage).toBe('')
host.queueMessage('after error')
set({ status: 'error' })
expect(prependText).toHaveBeenLastCalledWith('after error')
expect(prependText).toHaveBeenLastCalledWith('after error', [], [], [])
expect(chat.sendMessage).not.toHaveBeenCalled()
host.dispose()
})
const PNG = `data:image/png;base64,${btoa('\x89PNG')}`
const image = { name: 'shot.webp', dataUrl: PNG, mediaType: 'image/png' } as any
const pdf = {
name: 'contract.pdf',
dataUrl: `data:application/pdf;base64,${btoa('%PDF')}`,
mediaType: 'application/pdf',
size: 4
}
const listInput = { name: 'files', multiple: true }
it('takes attachments only where the flow has an input for them', () => {
const { chat } = fakeChat()
const none = new FlowChatViewHost(chat)
expect(none.supportsMessageAttachments).toBe(false)
const list = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
expect(list.supportsMessageAttachments).toBe(true)
expect(list.maxMessageAttachments).toBeUndefined()
const single = new FlowChatViewHost(chat, {
attachmentsTarget: () => ({ name: 'file', multiple: false }),
attachmentsUnavailable: () => 'no storage'
})
expect(single.maxMessageAttachments).toBe(1)
expect(single.attachmentsUnavailableReason).toBe('no storage')
})
it('hands the attachments to the chat as bytes, and drops a stored value for their input', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
additionalInputs: () => ({ tone: 'brief', files: [{ s3: 'stale' }] }),
attachmentsTarget: () => listInput
})
await host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
const [, options] = chat.sendMessage.mock.calls[0] as any
expect(options.inputs).toEqual({ tone: 'brief' })
expect(options.attachmentsInput).toBe(listInput)
expect(options.attachments.map((a: any) => [a.name, a.data.type])).toEqual([
['shot.webp', 'image/png'],
['contract.pdf', 'application/pdf']
])
expect(new Uint8Array(await options.attachments[0].data.arrayBuffer())).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47])
)
})
// The composer caps as files are attached, but a queue built over several turns
// arrives as one send; a scalar input would keep the first upload and strand the rest.
it('re-applies a single-file cap to a merged queue', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
attachmentsTarget: () => ({ name: 'file', multiple: false })
})
await host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
const [, options] = chat.sendMessage.mock.calls[0] as any
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp'])
})
it('hands the draft back with its attachments when the upload is refused or stopped', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
chat.sendMessage.mockRejectedValueOnce(new Error('POST upload failed (500)'))
await host.sendRequest({ instructions: 'read', blobs: [pdf] })
expect(prependText).toHaveBeenCalledWith('read', [], [], [pdf])
chat.sendMessage.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'))
await host.sendRequest({ instructions: 'again', images: [image] })
expect(prependText).toHaveBeenLastCalledWith('again', [image], [], [])
host.dispose()
})
it('queues attachments with the text and sends them together', async () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
host.queueMessage('look', [image], undefined, undefined, [pdf])
expect(host.queuedImages).toEqual([image])
expect(host.queuedBlobs).toEqual([pdf])
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
const [text, options] = chat.sendMessage.mock.calls[0] as any
expect(text).toBe('look')
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp', 'contract.pdf'])
expect(host.queuedBlobs).toEqual([])
host.dispose()
})
it('stops following the chat once disposed', () => {
const { chat, set } = fakeChat()
const host = new FlowChatViewHost(chat)
@@ -1,11 +1,31 @@
import { CancelError, WorkspaceService } from '$lib/gen'
import { CancelError, WorkspaceService, type LargeFileStorage } from '$lib/gen'
import { resource } from 'runed'
/**
* Whether the workspace has S3 storage configured, for the fields that warn without it. Call during
* component initialisation and read `.current` where the answer is used.
* Whether the workspace has large-file storage the upload endpoints can resolve. Every
* kind counts, not only S3: Azure Blob, Azure Workload Identity, S3 via AWS OIDC and GCS
* all go through the same object-store abstraction, so reading `s3_resource_path` alone
* calls a perfectly good workspace unconfigured.
*/
export function useS3StorageConfigured(ws: () => string | undefined): {
function storageConfigured(storage: LargeFileStorage | undefined): boolean {
if (!storage) return false
return (
storage.type !== undefined ||
storage.s3_resource_path !== undefined ||
storage.azure_blob_resource_path !== undefined ||
storage.gcs_resource_path !== undefined
)
}
/**
* Whether the workspace can store uploaded files. Call during component initialisation and
* read `.current` where the answer is used.
*
* Assumed configured until this workspace's own answer lands, so a surface that says "no
* storage configured" never flashes that on navigation, nor claims it merely because the
* fetch failed.
*/
export function useWorkspaceStorageConfigured(ws: () => string | undefined): {
readonly current: boolean
} {
const settings = resource(ws, async (ws, _previousWs, { onCleanup }) => {
@@ -24,12 +44,10 @@ export function useS3StorageConfigured(ws: () => string | undefined): {
}
})
// Assume configured until this workspace's own answer lands: the warning must not
// linger from the previous workspace, nor appear merely because the fetch failed.
const configured = $derived.by(() => {
const loaded = settings.current
return loaded && loaded.ws === ws()
? loaded.settings.large_file_storage?.s3_resource_path !== undefined
? storageConfigured(loaded.settings.large_file_storage)
: true
})
@@ -705,6 +705,7 @@
path={flow?.path ?? ''}
description={flow?.description}
inputSchema={flow?.schema}
flowModules={flow?.value?.modules}
wideLayout
/>
{:else}