Compare commits

..
Author SHA1 Message Date
Guilhem LemouelandClaude Opus 5 03eed0d01b Merge origin/main into flow-chat-attachments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:43:47 +02:00
Guilhem LemouelandClaude Opus 5 7edf1546f9 test: pin that a failed upload aborts the rest of its batch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:37:57 +02:00
Guilhem LemouelandClaude Opus 5 6ef26c5863 refactor: never delete chat uploads from workspace storage
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:34:50 +02:00
Guilhem LemouelandClaude Opus 5 c5019115ca Merge origin/main into flow-chat-attachments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:17:40 +02:00
Guilhem LemouelandClaude Opus 5 e92daaf913 docs: keep uploadAttachments' doc comment on uploadAttachments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:56:12 +02:00
Guilhem LemouelandClaude Opus 5 7043087cbe fix: discard uploads when a send is stopped as its attachments are announced
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:52:37 +02:00
Guilhem LemouelandClaude Opus 5 43ca8dc40f fix: withdraw an attachment send stopped after its uploads answered
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:49:32 +02:00
Guilhem LemouelandClaude Opus 5 e279f918fd refactor: read flow input references through one parser for the model and attachments controls
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:27:19 +02:00
Guilhem LemouelandClaude Opus 5 22c4880253 Merge origin/main into flow-chat-attachments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:23:38 +02:00
Guilhem LemouelandClaude Opus 5 e017656def fix: resolve the attachments input from real flow input reads, ignoring loop iteration
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:56:44 +02:00
Guilhem LemouelandClaude Opus 5 8f40ff1af0 Merge origin/main into flow-chat-attachments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:38:58 +02:00
Guilhem LemouelandClaude Opus 5 a35bd0b190 fix: refuse chat attachments sent without text and shorten comments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:21:23 +02:00
Guilhem LemouelandClaude Opus 5 93aa7d5fdd fix: keep a stopped turn's files for retry and let an explicit media type win
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:14:10 +02:00
Guilhem LemouelandClaude Opus 5 4295649c82 fix: route rich-composer file paste through the attachment lanes and tighten comments
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:05:45 +02:00
Guilhem LemouelandClaude Opus 5 bb89e45a7f feat: carry uploaded attachments on the pending chat user message
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:39:01 +02:00
Guilhem LemouelandClaude Opus 5 3b1b3cb0ca fix: list a chat conversation only once its run starts and require files where the flow does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:50:00 +02:00
Guilhem LemouelandClaude Opus 5 f232b7dc28 fix: clean up partial upload batches, withdraw a stopped send once, free retry payloads
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:25:43 +02:00
Guilhem LemouelandClaude Opus 5 1739b75837 fix: refuse extra files for a single-file input and keep attachments on retry
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:08:12 +02:00
Guilhem LemouelandClaude Opus 5 5cd8488df7 fix: store chat uploads under windmill_uploads and withdraw refused sends cleanly
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:50:57 +02:00
Guilhem LemouelandClaude Opus 5 43591fe0e9 feat: attach files to a flow chat message
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:41:47 +02:00
30 changed files with 1999 additions and 198 deletions
+29 -3
View File
@@ -183,7 +183,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. |
@@ -226,8 +226,8 @@ 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()`,
`selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`,
`newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries
@@ -235,6 +235,32 @@ own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation`
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_uploads/chat/<turn>/<index>/<name>` and handed to the input as `{ s3, filename }`
objects (the object for a single-file input). Once the uploads return, the pending user
message lists them in `attachments`, as `{ input, s3, filename }` references. The name's
extension is corrected to the file's media type for PNG, JPEG and PDF, because the worker
reads the type off the key.
Files need message text to go with them. A failed upload rejects `sendMessage` before any
run starts, and `stop()` during the upload aborts it; both leave the transcript as it was.
The chat never deletes uploads, so files of a send that did not run stay in storage. The
workspace needs object storage set up. With Enterprise advanced storage permissions, the
user needs read and write on `windmill_uploads/*`, which the default rules grant. The upload goes through
`job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot
request that scope today, so attachments are not available there yet.
## History
Windmill stores every conversation of a chat-mode flow, and each Windmill user sees
+27 -1
View File
@@ -230,6 +230,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'
@@ -241,7 +262,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
} = {}
@@ -252,13 +277,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',
+116
View File
@@ -0,0 +1,116 @@
import type { WindmillChatApi } from './api'
import type { AttachmentUpload } from './types'
import { abortError, isAbortError } from './utils'
/**
* Where a chat's uploads live in the workspace's object storage. Under `windmill_uploads/`
* because the default Enterprise storage permissions grant every user write and read there
* and deny any other top-level prefix: a key outside it is refused for non-admins, both on
* upload and when the agent's job reads the file back.
*/
export const CHAT_UPLOADS_PREFIX = 'windmill_uploads/chat'
/** What an AI agent step reads out of `user_attachments`. */
export interface UploadedAttachment {
s3: string
filename: string
}
/**
* The extension each type must be stored under. The worker reads an attachment's media type
* from the key's extension only (`mime_guess` in `windmill-ai/src/image_handler.rs`, falling
* back to `image/png`), never from the stored content type, so the extension must 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: the picked name with the extension its media type
* needs, e.g. a `photo.webp` re-encoded to PNG becomes `photo.png`. Other types keep their name.
*/
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: AttachmentUpload): Blob {
const data =
typeof attachment.data === 'string'
? dataUrlToBlob(attachment.data, 'application/octet-stream')
: attachment.data
return attachment.mediaType && attachment.mediaType !== data.type
? new Blob([data], { type: attachment.mediaType })
: data
}
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 key's turn prefix and per-file index keep two files with the same name, in this turn or
* an earlier one, from overwriting each other; the name stays the last segment.
*/
export async function uploadAttachments(
api: WindmillChatApi,
attachments: AttachmentUpload[],
turnId: string,
signal?: AbortSignal
): Promise<UploadedAttachment[]> {
const prefix = `${CHAT_UPLOADS_PREFIX}/${turnId}`
// One failed upload aborts the rest. Nothing already stored is deleted: the chat never
// removes objects from the workspace's storage, so a send that does not run leaves them.
if (signal?.aborted) throw abortError()
const batch = new AbortController()
const abortBatch = () => batch.abort()
signal?.addEventListener('abort', abortBatch, { once: true })
try {
const results = await Promise.allSettled(
attachments.map(async (attachment, index) => {
try {
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: batch.signal
})
return { s3: file_key, filename }
} catch (e) {
batch.abort()
throw e
}
})
)
const reasons = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : []))
// A stop that lands once every upload has answered still withdraws the batch.
if (reasons.length === 0 && !signal?.aborted) {
return results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []))
}
// The failure that started it, not the aborts it caused in the other uploads.
throw reasons.find((reason) => !isAbortError(reason)) ?? reasons[0] ?? abortError()
} finally {
signal?.removeEventListener('abort', abortBatch)
}
}
+94 -8
View File
@@ -8,6 +8,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,
@@ -15,6 +16,7 @@ import type {
ChatOptions,
ChatState,
Conversation,
SendMessageOptions,
ToolInvocation
} from './types'
import {
@@ -22,6 +24,7 @@ import {
truncateTitle,
errorResultMessage,
extractChatAnswer,
abortError,
isAbortError,
isErrorResult,
now,
@@ -41,6 +44,12 @@ 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
/** Both `stop()` and the send's own rejection withdraw; only the first may. */
withdrawn: boolean
jobId?: string
/** The flow job and its step jobs; a persisted answer carries one of them as `job_id`. */
jobIds?: Set<string>
@@ -101,21 +110,34 @@ 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 (!content) {
// A run needs a message; files alone would otherwise be dropped without a word.
if (options.attachments?.length) throw new Error('windmill-chat: attachments need a message to go with them')
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')
}
if (attachmentsInput && !attachmentsInput.multiple && attachments.length > 1) {
// Uploading all of them would run with the first and leave the rest stranded in storage.
throw new Error(`windmill-chat: \`${attachmentsInput.name}\` holds one file; got ${attachments.length}`)
}
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,
withdrawn: false,
streamedText: false
}
this.#turn = turn
@@ -127,7 +149,6 @@ class ChatImpl implements Chat {
const touched = { ...conversation, updatedAt: timestamp }
this.#set({
conversationId,
conversations: [touched, ...this.#state.conversations.filter((c) => c.id !== conversationId)],
messages: [
...this.#state.messages,
{ id: turn.userMessageId, role: 'user', content, success: true, createdAt: timestamp, pending: true }
@@ -135,10 +156,32 @@ class ChatImpl implements Chat {
status: 'submitted',
error: undefined
})
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]
// Shown on the pending message until its server row replaces it, carrying its own.
const carried = uploaded.map((u) => ({ input: attachmentsInput.name, s3: u.s3, filename: u.filename }))
if (this.#turnActive(turn)) {
this.#set({
messages: this.#state.messages.map((m) => (m.id === turn.userMessageId ? { ...m, attachments: carried } : m))
})
}
}
// Nothing may start once stop() or a conversation switch has withdrawn the turn, including
// a stop from a subscriber told of the attachments just above.
if (turn.controller.signal.aborted) throw abortError()
turn.started = true
// Listed only once the run is asked for: a send that never runs (an upload that failed
// or was stopped) then has no conversation entry to take back.
this.#set({
conversations: [touched, ...this.#state.conversations.filter((c) => c.id !== conversationId)]
})
this.#rememberConversation()
const context = { memoryId: conversationId, conversationId, signal: turn.controller.signal }
turn.jobId = this.#config.run
? await this.#config.run(args, context)
@@ -152,6 +195,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)
@@ -164,6 +214,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()
@@ -578,6 +634,36 @@ class ChatImpl implements Chat {
}
}
/**
* Take back the user message of a turn that never ran. A conversation it would have opened
* was never listed (see `sendMessage`), so only the message goes, and, while it is the turn
* on screen, the busy status. A switch away mid-upload has already written the message to
* local history, so it is removed there too.
*/
#withdrawTurn(turn: Turn): void {
if (turn.withdrawn) return
turn.withdrawn = true
const id = turn.conversationId
const withoutTurn = (messages: ChatMessage[]) => messages.filter((m) => m.id !== turn.userMessageId)
if (this.#state.conversationId === id) {
const messages = withoutTurn(this.#state.messages)
// A turn started since, such as a resend right after Stop, owns the status.
const newerTurn = this.#turn !== undefined && this.#turn !== turn
if (newerTurn) {
this.#set({ messages })
} else {
const unopened = turn.isNew && messages.length === 0
this.#set({ messages, status: 'idle', error: undefined, ...(unopened ? { conversationId: undefined } : {}) })
}
this.#persistLocal()
}
if (this.#state.history === 'local' && this.#state.conversationId !== id) {
const stored = withoutTurn(this.#local.getMessages(id))
if (stored.length > 0) this.#local.saveMessages(id, stored)
else if (!this.#state.conversations.some((c) => c.id === id)) this.#local.deleteConversation(id)
}
}
#failTurn(turn: Turn, e: unknown): void {
if (!this.#turnActive(turn)) return
const error = toError(e)
+4
View File
@@ -14,7 +14,10 @@ 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,
AttachmentUpload,
Chat,
ChatAttachment,
ChatMessage,
@@ -25,6 +28,7 @@ export type {
Conversation,
FetchLike,
HistoryMode,
SendMessageOptions,
StorageLike,
TokenSource,
ToolInvocation
+35 -2
View File
@@ -127,12 +127,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 AttachmentUpload {
/** Kept as the last segment of the stored key, its extension corrected to the media type for PNG, JPEG and PDF. */
name: string
/** The bytes: a Blob, or a `data:` URL of them. */
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?: AttachmentUpload[]
/** Required with `attachments`, which also need message text. With `multiple: false`, more than one attachment is refused before anything uploads. */
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
+444
View File
@@ -0,0 +1,444 @@
import { describe, expect, test } from 'bun:test'
import { WindmillChatApi } from '../src/api'
import { storedAttachmentName, uploadAttachments } 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, 3).join('/')
expect(prefix).toMatch(/^windmill_uploads\/chat\/[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 several files for an input that holds one, before uploading any', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage('read these', {
attachments: [
{ name: 'a.pdf', data: pdf },
{ name: 'b.png', data: png }
],
attachmentsInput: { name: 'file', multiple: false }
})
).rejects.toThrow('holds one file')
expect(calls).toHaveLength(0)
expect(chat.getState().messages).toEqual([])
})
test('refuses attachments without message text, before uploading', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage(' ', {
attachments: [{ name: 'a.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toThrow('need a message')
expect(calls).toHaveLength(0)
})
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
})
})
test('a switch away mid-upload leaves no conversation behind', async () => {
const storage = memoryStorage()
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), storage })
const sending = chat.sendMessage('never runs', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
const opened = chat.getState().conversationId!
chat.newConversation()
await expect(sending).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(chat.getState().conversations.map((c) => c.id)).not.toContain(opened)
const reloaded = createChat({ ...options(fetch), storage })
expect((await reloaded.loadConversations()).map((c) => c.id)).not.toContain(opened)
})
test('a failed upload aborts the rest of its batch and deletes nothing', async () => {
let first: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
(c) => {
if (c.url.pathname !== UPLOAD_PATH) return undefined
const key = c.url.searchParams.get('file_key')!
// The first file lands after the second has already failed.
if (key.includes('/0/')) return new Promise<Response>((resolve) => (first = resolve))
setTimeout(() => first(json({ file_key: keys()[0] })), 5)
return text('quota exceeded', 507)
},
run,
answer
)
const keys = () => uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage('read these', {
attachments: [
{ name: 'a.pdf', data: pdf },
{ name: 'b.png', data: png }
],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toThrow('quota exceeded')
// The upload still in flight when the other failed was told to stop.
expect(uploads(calls)[0].signal?.aborted).toBe(true)
expect(calls.filter((c) => c.method === 'DELETE')).toEqual([])
expect(runs(calls)).toHaveLength(0)
})
test('a send made right after stop() is not reset by the stopped upload', async () => {
let releaseRun: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise((_, reject) =>
c.signal!.addEventListener('abort', () => reject(abortError()))
)
: undefined,
(c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? new Promise<Response>((resolve) => (releaseRun = resolve))
: undefined,
answer
)
const chat = createChat(options(fetch))
// An existing conversation, so the stopped turn and the next one share it.
const first = chat.sendMessage('first')
await new Promise((r) => setTimeout(r, 0))
releaseRun(text('job-1'))
await first
const stopped = chat.sendMessage('with a file', {
attachments: [{ name: 'a.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
void chat.stop()
const next = chat.sendMessage('right after')
await expect(stopped).rejects.toMatchObject({ name: 'AbortError' })
await new Promise((r) => setTimeout(r, 0))
expect(chat.getState().status).toBe('submitted')
expect(chat.getState().messages.map((m) => m.content)).toContain('right after')
expect(chat.getState().messages.map((m) => m.content)).not.toContain('with a file')
releaseRun(text('job-1'))
await next
expect(runs(calls)).toHaveLength(2)
})
test('a conversation is listed only once its run starts', async () => {
let failUpload: (r: Response) => void = () => {}
const { fetch } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise<Response>((resolve) => (failUpload = resolve))
: undefined,
run,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read this', {
attachments: [{ name: 'a.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
expect(chat.getState()).toMatchObject({ status: 'submitted', conversations: [] })
failUpload(text('boom', 500))
await expect(sending).rejects.toThrow('boom')
expect(chat.getState().conversations).toEqual([])
})
test('an already aborted signal uploads nothing', async () => {
const { fetch, calls } = fetchMock(upload)
const api = new WindmillChatApi({ baseUrl: BASE, workspace: 'ws', token: 'tok', fetch })
const controller = new AbortController()
controller.abort()
await expect(
uploadAttachments(api, [{ name: 'a.pdf', data: pdf }], 'turn', controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
expect(calls).toHaveLength(0)
})
test('the pending user message carries its uploaded files before the run returns', async () => {
let releaseRun: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
upload,
(c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? new Promise<Response>((resolve) => (releaseRun = resolve))
: undefined,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read these', {
attachments: [
{ name: 'photo.webp', data: png },
{ name: 'contract', data: pdf }
],
attachmentsInput: { name: 'files', multiple: true }
})
while (runs(calls).length === 0) await new Promise((r) => setTimeout(r, 1))
const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
const pending = chat.getState().messages.find((m) => m.role === 'user')!
expect(pending.pending).toBe(true)
expect(pending.attachments).toEqual([
{ input: 'files', s3: keys[0], filename: 'photo.png' },
{ input: 'files', s3: keys[1], filename: 'contract.pdf' }
])
releaseRun(text('job-1'))
await sending
})
test('an explicit mediaType wins over the type a data URL declares', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read this', {
attachments: [
{
name: 'contract',
data: `data:application/octet-stream;base64,${btoa('%PDF')}`,
mediaType: 'application/pdf'
}
],
attachmentsInput: { name: 'files', multiple: true }
})
const call = uploads(calls)[0]
expect(call.url.searchParams.get('file_key')).toMatch(/\/0\/contract\.pdf$/)
expect(call.url.searchParams.get('content_type')).toBe('application/pdf')
})
test('stop() after the uploads land but before the run starts runs nothing', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
// The upload responds at once; Stop lands before the send resumes after it.
while (uploads(calls).length === 0) await Promise.resolve()
await chat.stop()
await expect(sending).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(calls.filter((c) => c.method === 'DELETE')).toEqual([])
expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] })
})
test('a subscriber stopping when the attachments appear runs nothing', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
chat.subscribe((s) => {
if (s.messages.some((m) => m.attachments)) void chat.stop()
})
await expect(
chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(calls.filter((c) => c.method === 'DELETE')).toEqual([])
expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] })
})
})
+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) {
@@ -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,82 @@
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 reads: text for a host that decodes
* them, blobs for one that forwards them verbatim. The picker, a drop and both pastes all
* route here, and `accept` is re-applied since drops and pastes bypass the picker's filter.
*/
export async function addNonImageFiles(files: File[]) {
if (files.length === 0) return
// Same reason as in addImages.
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 +638,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 +662,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 +686,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 +863,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 +907,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 +936,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 +1166,23 @@
updateAppTooltipPosition(appTooltipCurrentViewNumber)
}
})
/**
* Clipboard files on the plain composer, as ContextTextarea does for the rich one. Only
* when the clipboard has no text: a spreadsheet copy carries a bitmap next to 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 +1203,7 @@
disabled ||
pendingImages > 0 ||
pendingFiles > 0 ||
pendingBlobs > 0 ||
ingestionHolds > 0 ||
needsText ||
(emptyDraft &&
@@ -1068,7 +1237,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 +1256,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 +1333,7 @@
draft.isEmpty &&
pendingImages === 0 &&
pendingFiles === 0 &&
pendingBlobs === 0 &&
ingestionHolds === 0
) {
// Shell-style recall: ArrowUp in the empty main composer pulls the
@@ -1167,6 +1349,7 @@
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
chatHost.queuedBlobs.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
@@ -1192,7 +1375,7 @@
? (pasted) => void addImages(pasted)
: undefined}
onTextFiles={chatHost.supportsMessageAttachments
? (pasted) => void addTextFiles(pasted)
? (pasted) => void addNonImageFiles(pasted)
: undefined}
{availableContext}
{selectedContext}
@@ -1299,6 +1482,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,19 @@
import { describe, expect, it } from 'vitest'
import { matchesAccept } from './blobUtils'
function file(name: string, type: string): File {
return new File(['x'], name, { type })
}
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,58 @@
/**
* Message attachments kept as their original bytes, such as a PDF, for a host that
* forwards them to object storage. Images are re-encoded and text files decoded instead,
* so neither lane can carry what the user picked unchanged.
*/
/** 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
}
}
@@ -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
@@ -127,12 +127,6 @@ describe('read/write split', () => {
expect(getTool('call_mcp_write_tool').requiresConfirmation).toBe(true)
})
it('admits search and reads in plan mode, never writes', () => {
expect(getTool('search_mcp_tools').planModeSafe).toBe(true)
expect(getTool('call_mcp_read_tool').planModeSafe).toBe(true)
expect(getTool('call_mcp_write_tool').planModeSafe).toBeFalsy()
})
// The rejection sends the model to the write tool, which classifies from the
// same cached listing: without dropping it, that retry is refused too and the
// model has nowhere to go until the entry expires.
@@ -345,9 +345,8 @@ const callMcpToolSchema = z.object({
/**
* The read and write call tools differ only in which side of the `readOnlyHint`
* split they accept, and that check is what keeps a mutating call out of plan
* mode and behind the user's confirmation building both from one body keeps
* them from drifting.
* split they accept, and that check is what keeps a mutating call behind the
* user's confirmation building both from one body keeps them from drifting.
*/
function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}> {
const isRead = mode === 'read'
@@ -361,7 +360,7 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
),
showDetails: true,
...(isRead
? { planModeSafe: true }
? {}
: {
requiresConfirmation: true,
confirmationMessage: (args: any) => `Call ${args?.tool ?? ''} on ${args?.server ?? ''}`
@@ -430,7 +429,6 @@ export function createMcpTools(servers: McpServer[]): Tool<{}>[] {
'search_mcp_tools',
'Search the tools exposed by the MCP servers connected to this workspace (listed in the system prompt). Returns server + tool names to pass to call_mcp_read_tool or call_mcp_write_tool, each with the input schema its arguments must follow.'
),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = searchMcpToolsSchema.parse(args)
toolCallbacks.setToolStatus(toolId, { content: 'Searching MCP tools...' })
@@ -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 = []
}
}
@@ -5,22 +5,49 @@ import { MessageDraft } from './messageDraft.svelte'
// the draft-level guarantees: lanes move together, restores respect occupancy,
// aggregation always applies the rules.
function blob(name: string) {
return {
name,
mediaType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERg==',
size: 4
}
}
describe('MessageDraft', () => {
it('take() snapshots and clears all four lanes atomically', () => {
it('take() snapshots and clears all five lanes atomically', () => {
const d = new MessageDraft({
text: 'hello',
pastes: [{ id: 'p1', content: 'x' } as any],
images: [{ dataUrl: 'i1' } as any],
files: [{ name: 'a.md', content: 'a' }]
files: [{ name: 'a.md', content: 'a' }],
blobs: [blob('a.pdf')]
})
const snap = d.take()
expect(snap.text).toBe('hello')
expect(snap.pastes).toHaveLength(1)
expect(snap.images).toHaveLength(1)
expect(snap.files).toHaveLength(1)
expect(snap.blobs).toHaveLength(1)
expect(d.isEmpty).toBe(true)
})
it('treats a blob-only draft as occupied and caps blobs, keeping restored ones first', () => {
const d = new MessageDraft({ blobs: [blob('only.pdf')] })
expect(d.isEmpty).toBe(false)
expect(d.hasAttachments).toBe(true)
expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false)
const dropped = d.addBlobs(Array.from({ length: 8 }, (_, i) => blob(`new${i}.pdf`)))
expect(dropped).toBe(1)
expect(d.blobs).toHaveLength(8)
const res = d.prepend({ text: '', blobs: [blob('old.pdf')] })
expect(res.droppedBlobs).toBe(1)
expect(d.blobs[0].name).toBe('old.pdf')
expect(d.blobs).toHaveLength(8)
})
it('replaceIfEmpty declines when any lane is occupied', () => {
const d = new MessageDraft({ files: [{ name: 'a.md', content: 'a' }] })
expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false)
@@ -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'
@@ -170,7 +170,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
@@ -23,7 +23,8 @@
path: string
hideSidebar?: boolean
inputSchema?: Record<string, any>
/** The flow's modules, read for the provider wiring of its AI agent steps. */
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
* and the attachments input. */
flowModules?: FlowModule[]
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
@@ -11,6 +11,12 @@
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,
PER_TURN_AGENT_CHAT_INPUT_KEY,
resolveAgentChatInputs
} from './agentAttachmentInput'
import { deepEqual } from 'fast-equals'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
import {
@@ -25,7 +31,8 @@
chat: Chat
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
/** The flow's modules, read for the provider wiring of its AI agent steps. */
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
* and the attachments input. */
flowModules?: FlowModule[]
path: string
workspace?: string
@@ -58,9 +65,25 @@
return undefined
})
// The composer's attachments feed this input, and the paperclip is its whole editor.
const attachmentsTarget = $derived.by(() => {
const target = attachmentsTargetFor(
resolveAgentChatInputs(flowModules, additionalInputsSchema).find(
(input) => input.key === PER_TURN_AGENT_CHAT_INPUT_KEY
)
)
const required: unknown = additionalInputsSchema?.required
return target && Array.isArray(required) && required.includes(target.name)
? { ...target, required: true }
: target
})
// 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)
// The model gets its own button, shaped like the copilot's model settings, driven by
// whichever provider fields the flow exposes. Every other flow input is asked for in
// the Configure-inputs modal.
// whichever provider fields the flow exposes. Attachments are the paperclip's; every other
// flow input is asked for in the Configure-inputs modal.
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
// chat says what to go and do instead of offering controls that write nowhere.
@@ -152,6 +175,11 @@
untrack(() => chat),
{
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
attachmentsTarget: () => attachmentsTarget,
attachmentsUnavailable: () =>
workspaceStorage.current
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
workspace: () => workspace,
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
}
@@ -175,7 +203,7 @@
// edit itself.
const modalSchema = $derived.by(() => {
if (!additionalInputsSchema) return undefined
const promoted = new Set(composerOwnedInputs(modelWiring, undefined))
const promoted = new Set(composerOwnedInputs(modelWiring, attachmentsTarget))
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
)
@@ -0,0 +1,179 @@
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')
})
// A loop step reads its iteration from `flow_input.iter`, which is not a flow input.
it('ignores names the flow does not declare, such as a loop iteration', () => {
const transform = {
type: 'javascript' as const,
expr: 'flow_input.files.filter((_, i) => i === flow_input.iter.index)'
}
expect(flowInputRef(transform)).toBeUndefined()
expect(flowInputRef(transform, { files: {} })).toBe('files')
})
it('counts real reads only, in any access form and in a statement body', () => {
const ref = (expr: string) =>
flowInputRef({ type: 'javascript', expr }, { files: {}, docs: {} })
expect(ref("flow_input['files']")).toBe('files')
expect(ref('/* flow_input.docs */ flow_input?.files')).toBe('files')
expect(ref("'flow_input.docs' + flow_input.files")).toBe('files')
expect(ref('results.a.flow_input.docs ?? flow_input.files')).toBe('files')
expect(ref('const f = flow_input.files\nreturn f')).toBe('files')
expect(ref('flow_input.files.concat(')).toBeUndefined()
})
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 the input an agent inside a loop reads next to its iteration', () => {
const loop = {
id: 'loop',
value: {
type: 'forloopflow',
modules: [
agentWith({
user_attachments: {
type: 'javascript',
expr: 'flow_input.files.filter((_, i) => i === flow_input.iter.index)'
}
})
]
}
} as unknown as FlowModule
expect(resolveAgentChatInputs([loop], schema).map((i) => i.name)).toEqual(['files'])
})
it('gives no say to an agent that only mentions flow_input in a comment', () => {
const commented = agentWith({
user_attachments: { type: 'javascript', expr: '// flow_input.docs\nresults.a.files' }
})
expect(resolveAgentChatInputs([reader('files'), commented], schema).map((i) => i.name)).toEqual(
['files']
)
})
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,185 @@
import type { FlowModule, InputTransform } from '$lib/gen'
import { parse, parseExpressionAt } from 'acorn'
/**
* The flow's own AI agent steps, including those inside loops and branches. An agent carried
* as another agent's tool is left out, unlike in the graph (flowTree.ts): its inputs come from
* the agent calling it, not from the chat.
*/
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 drives, through the flow input the author wired to each:
* the composer writes run inputs, never the flow. Only what the person chatting owns turn to
* turn belongs here. `system_prompt`, `temperature` and the like shape the agent for everyone
* who runs the flow, so they stay in the Configure-inputs modal.
*/
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, cleared on send. */
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. */
property: Record<string, any>
}
/**
* The `flow_input` properties an expression reads, from its syntax tree rather than its text,
* so a mention in a comment, a string or a nested path (`results.a.flow_input.x`) is not a read.
* A transform may be a statement body with `return`. Undefined when it does not parse.
*/
export function flowInputReads(expr: string): Set<string> | undefined {
let root: unknown
try {
root = parseExpressionAt(`(\n${expr}\n)`, 0, { ecmaVersion: 'latest' })
} catch {
try {
root = parse(expr, {
ecmaVersion: 'latest',
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true
})
} catch {
return undefined
}
}
const names = new Set<string>()
const visit = (node: any) => {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) return node.forEach(visit)
if (
node.type === 'MemberExpression' &&
node.object?.type === 'Identifier' &&
node.object.name === 'flow_input'
) {
if (!node.computed && node.property?.type === 'Identifier') names.add(node.property.name)
else if (node.computed && typeof node.property?.value === 'string')
names.add(node.property.value)
}
for (const key in node) if (key !== 'type') visit(node[key])
}
visit(root)
return names
}
/**
* The flow input a transform reads, when it reads exactly one. The expression may reshape it
* (`(flow_input.files || []).map(...)`) and still counts. With `declared`, only the flow's own
* inputs count, so a loop's `flow_input.iter` does not make a read ambiguous.
*/
export function flowInputRef(
transform: InputTransform | undefined,
declared?: Record<string, unknown>
): string | undefined {
if (transform?.type !== 'javascript') return undefined
const reads = flowInputReads(transform.expr)
if (!reads) return undefined
const names = declared ? [...reads].filter((name) => name in declared) : [...reads]
return names.length === 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, whether it holds a list, and whether the
* flow requires it (so a message without a file cannot run). */
export type AttachmentsTarget = { name: string; multiple: boolean; required?: boolean }
/**
* Where the composer's attachments go: the promoted input, only when its schema holds s3
* objects. A transform may build the s3 object from a plain string input, and writing
* `{ s3, filename }` into that input would fail at run time.
*/
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
}
/**
* The flow inputs that an AI agent step reads directly into one of its chat-relevant
* fields. Several agents may read the same flow input; it is promoted once.
*/
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. An expression that does not parse is kept, as unreadable.
if (transform?.type !== 'javascript') continue
const reads = flowInputReads(transform.expr)
if (reads ? reads.size === 0 : !transform.expr.includes('flow_input')) continue
// Reading no declared input, or two of them, names none: this agent reads something
// the composer cannot drive, which is what disagreement means here.
const usable = flowInputRef(transform, properties)
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]
}))
}
@@ -422,6 +422,15 @@ describe('agents that do not read the message', () => {
expect(wiring?.fields.model).toBe('model')
})
// The worker runs a statement body too, so reading the message there is still reading it.
it('counts an agent that reads the message in a statement body', () => {
const wiring = resolveAgentModelWiring([
subAgent(wired, 'const m = flow_input.user_message\nreturn m'),
subAgent(fixed)
])
expect(wiring?.fields.model).toBe('model')
})
// Two agents both answering the reader still have to agree: either might be the one
// that replies, so a control moving one of them would be a lie about the other.
it('still needs agreement among the agents that do read the message', () => {
@@ -2,38 +2,7 @@ import type { AIProvider, FlowModule, InputTransform } from '$lib/gen'
import { explicitOffToken, getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
import { carriedReasoning } from '$lib/components/copilot/chatModelSettings'
import { parseExpressionAt } from 'acorn'
/**
* 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 provider belongs to the agent that
* calls it, not to the chat. Counting it would let a nested agent's fixed model defeat the
* composer's model control on the step the reader is actually talking to.
*/
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
}
import { agentSteps, flowInputReads } from './agentAttachmentInput'
/** Block and line comments removed, so what is left is only what affects the value. */
function withoutComments(source: string): string {
@@ -73,39 +42,9 @@ function chatFacingAgents(modules: FlowModule[] | undefined): FlowModule[] {
return facing.length > 0 ? facing : agents
}
/**
* Whether an expression reads `flow_input.<name>` anywhere in it.
*
* Parsed rather than matched: the author may write `flow_input['user_message']` as readily
* as the dot form the editor emits, and a mention inside a comment or a string is not a
* read. Reading two inputs is still a read of each, which is why this is not the question
* "which single input feeds a field" that the composer asks of a wired field.
*/
/** Whether an expression reads `flow_input.<name>`, as `flowInputReads` parses it. */
function readsFlowInput(expr: string, name: string): boolean {
let root: unknown
try {
root = parseExpressionAt(parenthesised(expr), 0, { ecmaVersion: 'latest' })
} catch {
return false
}
let found = false
const visit = (node: any) => {
if (found || !node || typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach(visit)
return
}
if (flowInputName(node) === name) {
found = true
return
}
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end') continue
visit(node[key])
}
}
visit(root)
return found
return flowInputReads(expr)?.has(name) ?? false
}
/** A provider value as the agent stores it. */
@@ -1,4 +1,4 @@
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
import type { AttachmentUpload, Chat, 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 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
@@ -197,11 +217,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()
}
@@ -222,6 +243,8 @@ export class FlowChatViewHost implements ChatViewHost {
if (previous.conversationId !== state.conversationId) {
// A conversation opens at its end, whatever the reader was doing in the last one.
this.#automaticScroll = true
// A conversation reopened later comes back from the server under other message ids.
this.#sentAttachments.clear()
// The queue was typed into the conversation that just went away; a message sent
// after the switch would ride out of the wrong one, so it goes back to the composer.
this.dequeueMessage()
@@ -284,25 +307,96 @@ 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?.()
// The inputs modal does not ask for this input, so a required one is enforced here.
if (target?.required && images.length === 0 && blobs.length === 0) {
sendUserToast('This chat needs a file with each message. Attach one to send.', true)
this.#aiChatInput?.prependText(text, images, [], blobs)
return false
}
const inputs = { ...(this.#options.additionalInputs?.() ?? {}) }
// The attachments are this input's only editor: a value stored for it in the inputs
// modal would otherwise ride along on every message.
if (target) delete inputs[target.name]
// The composer caps files as they are attached, but a queue merged over several turns
// arrives here as one send, and the chat refuses more than a single-file input holds.
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
)
}
const attachments: AttachmentUpload[] = target
? [...images, ...blobs].map((attachment, index) => ({
name: attachment.name ?? `attachment-${index + 1}`,
data: attachment.dataUrl,
mediaType: 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.
const turn = this.#chat
.sendMessage(text, { inputs: this.#options.additionalInputs?.() })
.catch(() => this.#aiChatInput?.prependText(text))
// 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 sending = this.#chat.sendMessage(text, {
inputs: this.#options.additionalInputs?.() ? inputs : undefined,
attachments,
attachmentsInput: target
})
// `sendMessage` shows the user message before its first await, so the last one is this
// turn's. Its id is stable across the server sync, which lets Retry resend the files.
const last = this.#chat.getState().messages.at(-1)
const sentId =
last?.role === 'user' && last.pending && last.content === text ? last.id : undefined
if (sentId && (images.length > 0 || blobs.length > 0)) {
this.#sentAttachments.set(sentId, { images, blobs })
}
const turn = sending.catch((e) => {
if (sentId) this.#sentAttachments.delete(sentId)
if (this.#disposed) return
if (attachments.length > 0 && !isAbort(e)) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
}
// What was queued behind it comes back too, after it: the chat publishes `idle`
// when it withdraws the turn, and a queue left in place would be flushed as if
// the turn had run.
this.dequeueMessage()
this.#aiChatInput?.prependText(text, images, [], blobs)
})
this.#turnDone = turn
await turn
// The files are kept only for a turn that failed, the one Retry is offered on: a base64
// payload per sent file would otherwise pile up for as long as the panel lives.
if (sentId) {
const index = this.#state.messages.findIndex((m) => m.id === sentId)
if (index === -1 || !turnFailed(this.#state.messages, index)) {
this.#sentAttachments.delete(sentId)
}
}
return true
}
/** Settles when the chat has released the last turn this host started. */
@@ -327,32 +421,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 = () => {}
@@ -360,12 +475,20 @@ export class FlowChatViewHost implements ChatViewHost {
// Per-message actions
storedImages = () => undefined
/** The files each user message of this session went out with, for Retry. A message loaded
* from history has none recorded here and retries with its text alone. */
#sentAttachments = new Map<string, { images: AttachedImage[]; blobs: AttachedBlob[] }>()
/** Send the user message at this transcript position again. The position is in
* `displayMessages`, which holds more entries than the chat's messages. */
retryRequest = (messageIndex: number) => {
const message = this.displayMessages[messageIndex]
if (!message || message.role !== 'user' || this.loading) return
void this.sendRequest({ instructions: message.content })
// A display message counts user messages in `index`; the files are kept by chat message id.
const sent = this.#state.messages.filter((m) => m.role === 'user')[message.index]
void this.sendRequest({
instructions: message.content,
...(sent ? this.#sentAttachments.get(sent.id) : undefined)
})
}
restartGeneration = () => {}
handleUserQuestionAnswer = () => false
@@ -378,11 +501,31 @@ 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 single-file flow input takes one attachment per message.
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.
@@ -237,7 +237,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)
@@ -266,7 +270,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()
})
@@ -286,7 +294,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()
})
@@ -303,7 +311,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()
})
@@ -332,7 +340,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()
})
@@ -344,16 +352,184 @@ 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, 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).toEqual([
{ name: 'shot.webp', data: PNG, mediaType: 'image/png' },
{ name: 'contract.pdf', data: pdf.dataUrl, mediaType: 'application/pdf' }
])
})
// A queue merged over several turns reaches the host as one send.
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()
})
// The chat settles a withdrawn turn as `idle`, which reads like a turn that ran; a queue
// left waiting would then go out with the failed draft merged in front of it.
it('does not send what was queued behind an upload that failed', async () => {
const { chat, set } = fakeChat(
idleState({ messages: [message({ role: 'user', content: 'earlier' })] })
)
let refuse = (_e: Error) => {}
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
void host.sendRequest({ instructions: 'A', blobs: [pdf] })
set({ status: 'submitted' })
host.queueMessage('B')
set({ status: 'idle' })
refuse(new Error('upload failed (500)'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
expect(host.queuedMessage).toBe('')
expect(prependText.mock.calls.map((c) => c[0])).toEqual(['B', 'A'])
expect(prependText).toHaveBeenLastCalledWith('A', [], [], [pdf])
host.dispose()
})
it('retries a failed turn with the files it was sent with', async () => {
const { chat, set } = fakeChat(idleState({ messages: [] }))
chat.sendMessage.mockImplementationOnce(async () => {
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
})
// The chat shows the user message before its first await.
chat.sendMessage.mockImplementationOnce(async () => {})
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const sending = host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
set({
messages: [
message({ id: 'u1', role: 'user', content: 'read' }),
message({ role: 'assistant', content: 'boom', success: false })
]
})
await sending
host.retryRequest(0)
await new Promise((resolve) => setTimeout(resolve, 0))
const [text, options] = chat.sendMessage.mock.calls[1] as any
expect(text).toBe('read')
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp', 'contract.pdf'])
host.dispose()
})
it('lets go of the files of a turn that succeeded', async () => {
const { chat, set } = fakeChat(idleState({ messages: [] }))
chat.sendMessage.mockImplementationOnce(async () => {
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
})
chat.sendMessage.mockImplementationOnce(async () => {})
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const sending = host.sendRequest({ instructions: 'read', blobs: [pdf] })
set({
messages: [
message({ id: 'u1', role: 'user', content: 'read' }),
message({ role: 'assistant', content: 'done' })
]
})
await sending
host.retryRequest(0)
await new Promise((resolve) => setTimeout(resolve, 0))
const [, options] = chat.sendMessage.mock.calls[1] as any
expect(options.attachments).toEqual([])
host.dispose()
})
it('refuses a message without a file when the flow requires one', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
attachmentsTarget: () => ({ ...listInput, required: true })
})
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
expect(await host.sendRequest({ instructions: 'no file' })).toBe(false)
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('no file', [], [], [])
expect(await host.sendRequest({ instructions: 'with file', blobs: [pdf] })).toBe(true)
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
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('offers no retry on a turn the reader stopped', () => {
const failedTool = message({
role: 'tool',
@@ -1,11 +1,28 @@
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; read `.current`. Assumed configured until
* this workspace's own answer lands, so "no storage" never flashes on navigation or shows
* merely because the fetch failed.
*/
export function useWorkspaceStorageConfigured(ws: () => string | undefined): {
readonly current: boolean
} {
const settings = resource(ws, async (ws, _previousWs, { onCleanup }) => {
@@ -24,12 +41,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
})