mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-17 16:02:25 +00:00
Compare commits
20
Commits
+29
-3
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user