Files
windmill/chat-sdk/src/api.ts
T
GuilhemandClaude Opus 5 c4c9677982 feat: attach files to a flow chat message (#11185)
* feat: attach files to a flow chat message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: store chat uploads under windmill_uploads and withdraw refused sends cleanly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse extra files for a single-file input and keep attachments on retry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clean up partial upload batches, withdraw a stopped send once, free retry payloads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: list a chat conversation only once its run starts and require files where the flow does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: carry uploaded attachments on the pending chat user message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: route rich-composer file paste through the attachment lanes and tighten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a stopped turn's files for retry and let an explicit media type win

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse chat attachments sent without text and shorten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the attachments input from real flow input reads, ignoring loop iteration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read flow input references through one parser for the model and attachments controls

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: withdraw an attachment send stopped after its uploads answered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: discard uploads when a send is stopped as its attachments are announced

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep uploadAttachments' doc comment on uploadAttachments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: never delete chat uploads from workspace storage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that a failed upload aborts the rest of its batch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:16:10 +02:00

365 lines
12 KiB
TypeScript

import type { ChatAttachment, FetchLike, TokenSource } from './types'
export interface WindmillChatApiOptions {
baseUrl: string
workspace: string
/** Omit to rely on the session cookie of the Windmill origin. */
token?: TokenSource
fetch?: FetchLike
/** Server poll interval for a turn's stream (Enterprise; see `ChatOptions.pollDelayMs`). */
pollDelayMs?: number
}
export class WindmillApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'WindmillApiError'
}
}
export interface FlowConversation {
id: string
workspace_id: string
flow_path: string
title?: string | null
created_at: string
updated_at: string
created_by: string
/** Started from the flow editor's test panel rather than a deployed run. */
is_test: boolean
}
/**
* Which conversations a listing holds: the flow editor's test chats, the deployed flow's
* own (the server's default), or both.
*/
export type ConversationKind = 'test' | 'deployed' | 'all'
export interface FlowConversationMessage {
id: string
conversation_id: string
message_type: 'user' | 'assistant' | 'system' | 'tool'
content: string
job_id?: string | null
created_at: string
created_seq: number
step_name?: string | null
success?: boolean
/** On a tool row, the arguments the model wrote, without the inputs a step wires in; null for a web search. */
tool_arguments?: string | null
/** On a tool row, the text the model got back or what the call failed with; a web search's citations. */
tool_result?: string | null
/** On an answer, the thinking that produced it; on a tool row, the thinking that led to the call. */
reasoning?: string | null
/** The files a user message carried, as object-storage references. */
attachments?: ChatAttachment[] | null
}
export type JobUpdateEvent =
| {
type: 'update'
running?: boolean
completed?: boolean
new_result_stream?: string
stream_offset?: number
only_result?: unknown
flow_stream_job_id?: string
}
| { type: 'error'; error: string }
| { type: 'notfound' }
| { type: 'timeout' }
| { type: 'ping' }
export interface CompletedJobResult {
completed: boolean
success?: boolean
result?: unknown
}
/** The part of a flow job's status that names the jobs its steps ran as. */
export interface FlowJobStatus {
flow_status?: {
modules?: FlowStepStatus[] | null
failure_module?: FlowStepStatus | null
preprocessor_module?: FlowStepStatus | null
} | null
}
export interface FlowStepStatus {
job?: string | null
flow_jobs?: string[] | null
/** An agent step's rounds; a tool call ran as a job of its own, which its row is persisted under. */
agent_actions?: { type?: string; job_id?: string | null }[] | null
}
/** Thin client over the Windmill endpoints a chat-mode flow uses. */
export class WindmillChatApi {
readonly #baseUrl: string
readonly #workspace: string
readonly #token: TokenSource | undefined
readonly #fetch: FetchLike
readonly #pollDelayMs: number | undefined
constructor(options: WindmillChatApiOptions) {
this.#baseUrl = normalizeBaseUrl(options.baseUrl)
this.#workspace = options.workspace
this.#token = options.token
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
this.#pollDelayMs = options.pollDelayMs
}
/** Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the job id. */
async runFlow(
flowPath: string,
args: Record<string, unknown>,
options: { memoryId: string; signal?: AbortSignal }
): Promise<string> {
const res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, {
method: 'POST',
query: { memory_id: options.memoryId, skip_preprocessor: 'true' },
body: args,
signal: options.signal
})
return (await res.text()).trim()
}
/**
* One server-sent-events connection to a job's updates. The server closes it after
* `TIMEOUT_SSE_STREAM` (a `timeout` event); resume by calling again with the last
* `stream_offset`, never by re-running the flow.
*/
async *streamJob(
jobId: string,
options: { streamOffset?: number; signal?: AbortSignal } = {}
): AsyncGenerator<JobUpdateEvent> {
const query: Record<string, string> = { fast: 'true', only_result: 'true' }
if (this.#pollDelayMs !== undefined) query.poll_delay_ms = String(this.#pollDelayMs)
if (options.streamOffset !== undefined) {
query.stream_offset = String(options.streamOffset)
}
const res = await this.#request(`jobs_u/getupdate_sse/${encodeURIComponent(jobId)}`, {
query,
accept: 'text/event-stream',
signal: options.signal
})
if (!res.body) {
throw new WindmillApiError('The job update stream has no body', res.status)
}
for await (const data of readServerSentEvents(res.body)) {
try {
yield JSON.parse(data) as JobUpdateEvent
} catch {
// A frame that isn't JSON carries nothing the chat can use.
}
}
}
async getCompletedResult(jobId: string, signal?: AbortSignal): Promise<CompletedJobResult> {
const res = await this.#request(
`jobs_u/completed/get_result_maybe/${encodeURIComponent(jobId)}`,
{ signal }
)
return (await res.json()) as CompletedJobResult
}
/** A flow job with its status: the step job ids are what persisted messages carry as `job_id`. */
async getFlowJob(jobId: string, signal?: AbortSignal): Promise<FlowJobStatus> {
const res = await this.#request(`jobs_u/get/${encodeURIComponent(jobId)}`, {
query: { no_logs: 'true' },
signal
})
return (await res.json()) as FlowJobStatus
}
/**
* Where a message's attachment downloads from. The endpoint authenticates like every other
* request: a consumer holding a token must fetch it with that token, not put the URL in an
* `img src`, which would send only the Windmill session cookie.
*/
attachmentUrl(attachment: ChatAttachment): string {
const query = new URLSearchParams({ file_key: attachment.s3 })
if (attachment.storage) query.set('storage', attachment.storage)
return `${this.#baseUrl}/api/w/${encodeURIComponent(this.#workspace)}/job_helpers/download_s3_file?${query}`
}
async cancelJob(jobId: string, reason = 'Stopped from the chat'): Promise<void> {
await this.#request(`jobs_u/queue/cancel/${encodeURIComponent(jobId)}`, {
method: 'POST',
body: { reason }
})
}
async listConversations(
flowPath: string,
options: { page?: number; perPage?: number; kind?: ConversationKind; signal?: AbortSignal } = {}
): Promise<FlowConversation[]> {
const extra: Record<string, string> = { flow_path: flowPath }
if (options.kind !== undefined) extra.kind = options.kind
const res = await this.#request('flow_conversations/list', {
query: pagination(options, extra),
signal: options.signal
})
return (await res.json()) as FlowConversation[]
}
/** Sets a conversation's title. Its place in the list is kept: only a turn moves one. */
async renameConversation(conversationId: string, title: string): Promise<void> {
await this.#request(`flow_conversations/update/${encodeURIComponent(conversationId)}`, {
method: 'POST',
body: { title }
})
}
/**
* Without `afterSeq`: one page counted from the newest message, returned oldest first.
* With `afterSeq`: the messages created after that cursor, oldest first.
*/
async listMessages(
conversationId: string,
options: { page?: number; perPage?: number; afterSeq?: number; signal?: AbortSignal } = {}
): Promise<FlowConversationMessage[]> {
const extra: Record<string, string> = {}
if (options.afterSeq !== undefined) extra.after_seq = String(options.afterSeq)
const res = await this.#request(
`flow_conversations/${encodeURIComponent(conversationId)}/messages`,
{ query: pagination(options, extra), signal: options.signal }
)
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'
})
}
async #request(
path: string,
init: {
method?: string
query?: Record<string, string>
/** JSON-encoded. */
body?: unknown
/** Sent as is, under `contentType`. */
raw?: Blob
contentType?: string
accept?: string
signal?: AbortSignal
} = {}
): Promise<Response> {
const url = new URL(`${this.#baseUrl}/api/w/${encodeURIComponent(this.#workspace)}/${path}`)
for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v)
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 ? 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',
signal: init.signal
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new WindmillApiError(
`${init.method ?? 'GET'} ${path} failed (${res.status})${text ? `: ${text}` : ''}`,
res.status
)
}
return res
}
}
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, '').replace(/\/api$/, '')
}
function encodePath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/')
}
function pagination(
options: { page?: number; perPage?: number },
extra: Record<string, string>
): Record<string, string> {
const query = { ...extra }
if (options.page !== undefined) query.page = String(options.page)
if (options.perPage !== undefined) query.per_page = String(options.perPage)
return query
}
/** Yields the `data` payload of each event in a `text/event-stream` body. */
export async function* readServerSentEvents(
body: ReadableStream<Uint8Array>
): AsyncGenerator<string> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
// A CR ending a chunk may be half of a CRLF; it waits for the next chunk.
let carry = ''
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
let text = carry + decoder.decode(value, { stream: true })
carry = ''
if (text.endsWith('\r')) {
carry = '\r'
text = text.slice(0, -1)
}
buffer += text.replace(/\r\n?/g, '\n')
let end: number
while ((end = buffer.indexOf('\n\n')) !== -1) {
const data = eventData(buffer.slice(0, end))
buffer = buffer.slice(end + 2)
if (data !== undefined) yield data
}
}
if (carry) buffer += '\n'
const data = eventData(buffer)
if (data !== undefined) yield data
} finally {
// Closes the connection when the consumer stops early.
reader.cancel().catch(() => {})
}
}
function eventData(block: string): string | undefined {
const lines = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(line.startsWith('data: ') ? 6 : 5))
return lines.length > 0 ? lines.join('\n') : undefined
}