diff --git a/docs/api/rest.md b/docs/api/rest.md index 0506c37..2c86783 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -606,7 +606,7 @@ messages. ### `POST /v1/agent/messages` Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`. -Content must contain non-whitespace text and is limited to 40,000 characters; +Content is limited to 40,000 characters; it must contain non-whitespace text unless attachment IDs are included; `requestId` is required (1–256 characters). Preserve the ID when retrying uncertain network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and `wait: false`. @@ -614,9 +614,9 @@ network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and `messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not completion: watch the room for answers, including unsolicited later replies. -Multiple requests can be accepted while the agent works. Attachments are not yet supported. +Multiple requests can be accepted while the agent works. Optional `attachments: [{"id":"att_…"}]` accepts uploaded files as specified below; no client paths or inline file bytes are accepted. The proxy verifies successful receipts contain a positive submission ID, a known -status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. +status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. Attached sends also require matching ordered `attachmentIds` in the receipt. Invalid receipts return 502; delivery remains uncertain, so retry using the original `requestId` to avoid creating a duplicate message. @@ -632,3 +632,55 @@ after showing the corresponding history; poll it to reconcile unread indicators across clients. Agent REST responses use `Cache-Control: no-store`. See the persistent Agent WebSocket section in `streaming-chat.md` for live updates. + +### Agent attachments (v1) + +`POST /v1/agent/attachments`: multipart/form-data with exactly one `file` part (original UTF-8 filename in Content-Disposition) and one `uploadId` field (client-generated UUID, stable across retries). Either part order is accepted. Request limit 8 MiB + 64 KiB. 201 new / 200 identical retry: + +```json +{"attachment":{"id":"att_12345678-1234-4234-8234-123456789abc","kind":"text","filename":"notes.txt","mimeType":"text/plain","sizeBytes":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","workspacePath":"/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"}} +``` + +`workspacePath` is OPTIONAL for clients, canonical absolute runtime path when supplied. Never a host path, URL, or request parameter. Descriptors must retain filename/mimeType/sizeBytes/sha256. `kind` is `image`, `text`, or `file`. IDs are opaque. UUID retries must match normalized filename, effective MIME type and bytes, otherwise 409 `idempotency_conflict`. Filename is NFC normalized, 1–255 UTF-8 bytes, no slash/backslash/control characters, not `.`/`..`. A leading dot becomes `_` in the on-disk basename only; display filename remains unchanged. + +`POST /v1/agent/messages`: + +```json +{"content":"Read these notes","requestId":"client-stable-id","attachments":[{"id":"att_12345678-1234-4234-8234-123456789abc"}]} +``` + +`content` is required, <=40,000 characters; empty/whitespace is allowed with attachments. `requestId` is 1–256 characters and must survive retries. Omit attachments for text-only requests. At most 8 distinct references, 32 MiB total original bytes. References contain ONLY `id`; filenames, paths, URLs, base64 and forged workspacePath are rejected. Root delivery uses channel sybil, followUp and wait=false. 202 receipt: + +```json +{"id":123,"status":"queued","messageId":"inbound:sybil:client-stable-id","attachmentIds":["att_12345678-1234-4234-8234-123456789abc"]} +``` + +Receipt status also permits placed/done/unanswered. `attachmentIds` is present for attached input, in request order, and MUST be checked before treating delivery as acknowledged. Caption + ordered IDs are durably bound to requestId; conflicting replay 409. Native admission atomically stores the input payload, binding, quota charge and worker eligibility; a competing text-only input cannot falsely acknowledge attachment IDs. Network errors may be retried with the same request/upload IDs. Never auto-create a fresh ID on ambiguous failure. + +History, replay and queued activity use the existing public message schema with `metadata.agentAttachments` containing these descriptors. `content` is the original caption, or `Attached: notes.txt.` for a file-only message. No model-only manifest, text preview or base64 is inserted into public message content. Ordinary chat's `metadata.attachments` schema is unchanged. Raw tool rows stay in the protocol; client transcript filters stay unchanged. + +`GET /v1/agent/attachments/:id/content`: authenticated original bytes, Content-Length, Content-Disposition attachment (safe ASCII filename and UTF-8 filename*), Cache-Control private/no-store and nosniff. No URLs containing tokens, public static mount, redirects or inline serving. Browser previews use authenticated fetch + revocable blob URL. Native downloads hold one of four slots through response finish/close/error, with a 30-second outbound timeout and disconnect cleanup. Raster types retain their validated MIME type; text downloads use text/plain; other files application/octet-stream. Clients can display/copy workspacePath as text. + +### Attachment capability and errors + +Optional `GET /v1/agent` field: + +```json +{"capabilities":{"attachments":{"version":1,"maxCount":8,"maxTotalBytes":33554432,"maxImageBytes":6291456,"maxFileBytes":8388608,"imageMimeTypes":["image/png","image/jpeg","image/gif","image/webp"],"maxImagePixels":50000000,"maxImageDimension":16384,"quotaBytes":1073741824,"usedBytes":0,"remainingBytes":1073741824,"maxConcurrentUploads":2,"maxConcurrentDecodes":1}}} +``` + +Quota values are a snapshot, not a reservation. Absence disables attachment selection and sending. Backend rejects attachments against an old/unsafe upstream rather than allowing dropped references. Sybil also requires ADMIN_TOKEN for attachments; text-only open development behavior remains compatible. + +Errors: `{ "error": true, "message": "...", "statusCode": 507, "code": "attachment_quota_exceeded", "quotaBytes": 1073741824, "remainingBytes": 1200, "requiredBytes": 20000 }`. Quota numbers are included for 507; no host filesystem information. 400 invalid_upload/invalid_attachment; 401 authentication; 404 attachment_not_found; 409 idempotency_conflict; 413 attachment_limit_exceeded; 415 unsupported_media_type; 422 invalid_image/image_model_unsupported; 429 attachments_busy with Retry-After: 2; 503 attachments_unavailable; 507 attachment_quota_exceeded; 502 unavailable/invalid upstream at proxy. Clients show message and retain retry identity. Remove from composer does not delete completed durable uploads. + +### Runtime access and release requirements + +The runtime resolves IDs to persistent `WORKSPACE_DIR/uploads//` files (default `/data/workspace/uploads/...`), backed by the existing `/data` mount. Images are native model image blocks, never remote links: supported PNG/JPEG/GIF/WebP originals are at most 6 MiB and are prepared as a bounded static first frame (orientation corrected, at most 2000×2000 and 4.5 MiB base64, further limited by the actual model). Reject unsupported image models; no arbitrary document/file provider blocks. Other files are at most 8 MiB and are available through actual workspace read/bash tools. Valid recognized UTF-8 text receives a bounded preview of 16,000 Unicode characters per file / 64,000 per input. PDF/archive/binary files are paths plus metadata. Original hash, size and confinement are validated during resolution. Root and workers share the same workspace; explicit `subagent` spawn/send `attachmentIds` persist in durable Reporter tasks and use the same resolver. Preparation saturation retries through a durable checkpoint after two seconds using the same Reporter request ID. Permanent admission failures, including quota exhaustion, reach the parent through the durable worker-report path. Uploads never auto-execute. + +Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reservations cover originals, static renditions, staging, metadata and conservative attachment input copies. Quota is conservative accounting, not free disk space. No complete upload expiry or delete endpoint is enabled. Removed composer files stay stored; crashed/uncertain reservations stay charged across restart until deliberate operator reconciliation. Ordinary authorized workspace tool writes and unrelated conversation history are outside attachment-ingestion accounting. Runtime uploads default to two concurrently (`SYBIL_AGENT_ATTACHMENT_UPLOADS`, max8), image decodes one (`SYBIL_AGENT_ATTACHMENT_DECODES`, max4); bounded input preparation and proxy transfers return429 when saturated. Disconnected proxy transfers cancel upstream work and release slots. + +Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled. + +The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads. + +Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission. diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index 689f0cb..3d295d9 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -335,3 +335,40 @@ cursor. Missing agent configuration closes with 4503. The proxy closes upstream sockets when browsers leave or Sybil shuts down. History remains durable in Sybil Agent. Shared read position is separate from delivery: update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed. + +### Agent attachment history, queued activity and replay + +Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it. + +Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`: + +```json +{ + "id": "inbound:sybil:client-stable-id", + "role": "user", + "content": "Read these notes", + "createdAt": "2026-10-14T18:00:00.000Z", + "metadata": { + "kind": "message", + "channel": "sybil", + "status": "queued", + "agentAttachments": [{ + "id": "att_12345678-1234-4234-8234-123456789abc", + "kind": "text", + "filename": "notes.txt", + "mimeType": "text/plain", + "sizeBytes": 12, + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "workspacePath": "/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt" + }] + } +} +``` + +`workspacePath` is optional, authoritative runtime text for display/copy, never a download URL or send parameter. Only `attachments:[{id}]` goes into POST messages. Committed/replayed messages retain the same ordered descriptors and original caption; file-only content is `Attached: notes.txt.`. Internal model manifests/previews and native base64 are excluded from public user message content. Root/worker native inputs and raw authorized model endpoints may contain the real image blocks; native token auth must protect all of them. + +Merge queued and committed messages by stable message ID. A matching ID alone does not confirm an attachment-bearing optimistic message: compare the ordered `metadata.agentAttachments[].id` against its local IDs as well. Retain descriptors, error and retry identity on a mismatch or uncertain receipt; show the error inline for touch clients. Matching canonical echoes can acknowledge delivery even if HTTP was lost. Preserve raw tool rows/cursors in client room state and existing transcript filtering. Old clients can ignore the additive metadata and still see a meaningful caption. + +Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract. + +Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path. diff --git a/server/src/agent-attachments.ts b/server/src/agent-attachments.ts new file mode 100644 index 0000000..11c5f1c --- /dev/null +++ b/server/src/agent-attachments.ts @@ -0,0 +1,141 @@ +import { Readable } from 'node:stream'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { requireAdmin } from './auth.js'; +import { env } from './env.js'; + +export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024; +export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); +export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8) + .refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique'); +export const AgentAttachmentDescriptor = z.object({ + id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255), + mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024), + sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(), +}); +export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>; + +function apiError(statusCode: number, code: string, message: string) { + return Object.assign(new Error(message), { statusCode, code }); +} +export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) { + if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.'); + const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal); + const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null; + if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1) + throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.'); +} + +/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */ +export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) { + await app.register(async routes => { + let uploads = 0, downloads = 0; + type Transfer = { controller: AbortController; release(): void }; + const transfers = new WeakMap(); + const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; }; + routes.addHook('onRequest', async (req, reply) => { + reply.header('Cache-Control', 'private, no-store'); requireAdmin(req); + if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.'); + const uploading = req.method === 'POST'; + if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.'); + if (uploading) uploads++; else downloads++; + const controller = new AbortController(); let released = false; + const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000); + const release = () => { + if (released) return; released = true; + if (uploading) uploads--; else downloads--; + clearTimeout(timeout); controller.abort(); + req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release); + }; + transfers.set(req, { controller, release }); + req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release); + if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); } + if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT) + throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.'); + await requireAgentAttachments(options.proxyJson, controller.signal); + controller.signal.throwIfAborted(); + }); + routes.addHook('onResponse', async req => { transfers.get(req)?.release(); }); + routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => { + if (reply.raw.destroyed) return; + if (_req.method === 'POST') reply.header('Connection', 'close'); + const statusCode = error.statusCode || 502; + const code = typeof error.code === 'string' ? error.code : undefined; + if (statusCode === 429) reply.header('Retry-After', '2'); + reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message, + statusCode, code: code || 'agent_unavailable' }); + }); + routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => { + if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.')); + else done(null, payload); + }); + const json = async (response: Response) => { + let size = 0; const chunks: Uint8Array[] = []; + if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.'); + const reader = response.body.getReader(); + try { + for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length; + if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + } catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); } + finally { reader.releaseLock(); } + }; + routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => { + if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.'); + let oversized = false; + const incoming = req.body; + const { controller } = transfer(req); + const body = Readable.from((async function* () { + let count = 0; + for await (const chunk of incoming) { + count += chunk.length; + if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); } + yield chunk; + } + })()); + try { + const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), { + method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) }, + body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error', + } as RequestInit & { duplex: string }); + const value = await json(response); controller.signal.throwIfAborted(); + if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success)) + throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.'); + if (response.status === 429) reply.header('Retry-After', '2'); + return reply.code(response.status).send(value); + } catch (error) { + if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.'); + if (error && typeof error === 'object' && 'statusCode' in error) throw error; + throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.'); + } finally { body.destroy(); } + }); + routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => { + if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.'); + const { controller } = transfer(req); + let response: Response; + try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), { + headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error', + }); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); } + controller.signal.throwIfAborted(); + if (!response.ok) return reply.code(response.status).send(await json(response)); + const length = Number(response.headers.get('content-length')); + const disposition = response.headers.get('content-disposition'); + const type = response.headers.get('content-type') || ''; + if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024 + || !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) { + await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.'); + } + // Originals are bounded to 8 MiB. Verify the received length before sending a success response. + const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = []; + try { + for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length; + if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); } + if (size !== length) throw new Error('Download length mismatch'); + } catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); } + finally { reader.releaseLock(); } + controller.signal.throwIfAborted(); + reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff'); + return reply.send(Buffer.concat(chunks, size)); + }); + }); +} diff --git a/server/src/agent.ts b/server/src/agent.ts index 6743ce4..5c59985 100644 --- a/server/src/agent.ts +++ b/server/src/agent.ts @@ -6,17 +6,19 @@ import { z } from 'zod'; import { requireAdmin } from './auth.js'; import { prisma } from './db.js'; import { env } from './env.js'; +import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.js'; const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor') .transform(value => String(Number(value))); const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() }) .refine(value => !(value.after && value.before), 'Use after or before, not both'); -const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) }); +const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() }) + .refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required'); const ReadBody = z.object({ cursor: Cursor }); const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() }); const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() }); const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() }); + status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() }); type AgentOptions = { baseUrl?: string; apiKey?: string }; const parse = (schema: z.ZodType, value: unknown): T => { @@ -34,13 +36,26 @@ async function readState() { export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) { const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL; const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY; - const proxyJson = async (path: string, method = 'GET', body?: unknown) => { + // An open development proxy must not expose a token-protected attachment runtime's + // history/model context using its server-side credential. + const guardOpenProxy = async (signal?: AbortSignal) => { + if (env.ADMIN_TOKEN) return; + try { + const response = await fetch(new URL('/api/conversations/root', baseUrl), { + headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error', + }); + const descriptor = await response.json() as { capabilities?: { attachments?: unknown } }; + if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime'); + } catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); } + }; + const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => { if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured'); + await guardOpenProxy(signal); let response: Response; try { response = await fetch(new URL(path, baseUrl), { method, headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), }); } catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); } let data: unknown; @@ -55,7 +70,9 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp const result = await proxyJson('/api/conversations/root'); reply.code(result.status); if (result.status !== 200) return result.data; - return { ...result.data as object, websocket: '/v1/agent/ws' }; + const descriptor: Record = { ...result.data as Record, websocket: '/v1/agent/ws' }; + if (!env.ADMIN_TOKEN) delete descriptor.capabilities; + return descriptor; }); app.get('/v1/agent/messages', async (req, reply) => { reply.header('Cache-Control', 'no-store'); @@ -71,10 +88,12 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp reply.header('Cache-Control', 'no-store'); requireAdmin(req); const body = parse(MessageBody, req.body); + if (body.attachments?.length) await requireAgentAttachments(proxyJson); const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false }); if (result.status >= 200 && result.status < 300) { const receipt = SubmissionReceipt.safeParse(result.data); - if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) { + if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}` + || (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) { throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt'); } } @@ -99,10 +118,11 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp return readState(); }); - attachAgentSocket(app, baseUrl, apiKey); + await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson }); + attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy); } -function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) { +function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise) { const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 }); const cleanup = new Set<() => void>(); const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => { @@ -114,7 +134,7 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; } sockets.handleUpgrade(request, socket, head, browser => { let upstream: WebSocket | undefined; - let authenticated = false, disposed = false, alive = true; + let authenticated = false, authenticating = false, disposed = false, alive = true; const send = (value: unknown) => { if (browser.readyState !== WebSocket.OPEN) return; if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; } @@ -130,14 +150,18 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin }; cleanup.add(dispose); browser.on('close', dispose); browser.on('error', dispose); - browser.on('message', (raw, isBinary) => { + browser.on('message', async (raw, isBinary) => { try { if (isBinary) throw new Error('JSON text frames are required'); const frame = JSON.parse(raw.toString()); if (!authenticated) { + if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; } + authenticating = true; const auth = parse(AuthFrame, frame); if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; } if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; } + try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; } + if (disposed || browser.readyState !== WebSocket.OPEN) return; authenticated = true; clearTimeout(authTimeout); const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:'; upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 }); diff --git a/server/src/index.ts b/server/src/index.ts index 5476231..8e1a141 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -79,6 +79,7 @@ app.setErrorHandler((err, req, reply) => { error: true, message: e.message ?? String(e), statusCode, + ...(typeof e.code === 'string' ? { code: e.code } : {}), }); }); diff --git a/server/tests/agent-attachments.test.ts b/server/tests/agent-attachments.test.ts new file mode 100644 index 0000000..d591210 --- /dev/null +++ b/server/tests/agent-attachments.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { createServer, request as httpRequest, type ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import test from 'node:test'; +import Fastify from 'fastify'; +import sensible from '@fastify/sensible'; +process.env.ADMIN_TOKEN = 'attachments-browser-token-test'; +process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite'; +const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]); +const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key'; +const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' }; +const bodies: any[] = []; +let capable=true, badReceipt=false, errorStatus=0, uploadCalls=0; +let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[]; +const source=createServer(async(req,res)=>{ + assert.equal(req.headers.authorization,`Bearer ${key}`); + const chunks: Buffer[]=[]; + try { for await(const chunk of req) chunks.push(chunk); } catch { return; } + const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json'); + if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;} + if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;} + if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;} + if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;} + if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;} + res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false})); +}); +source.listen(0,'127.0.0.1');await once(source,'listening'); +const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key}); +await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`; +test.after(async()=>{await app.close();source.closeAllConnections();await new Promise(r=>source.close(()=>r()));}); +function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;} + +test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{ + const before=uploadCalls; + assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before); + capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503); + assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503); + capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined; + try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;} +}); + +test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{ + const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/); + errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;} + assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401); + const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff'); + assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404); +}); + +test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{ + const payload={content:'',requestId:'send',attachments:[{id:file.id}]}; + assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false}); + badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;} + for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400); + const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool'); +}); + +test('proxy rejects oversized declared bodies and chunked bodies',async()=>{ + const before=uploadCalls; + const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before); + const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})()); + const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string}); + assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded'); +}); + +async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');} +test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{ + const before=uploadCalls; + holdDescriptor=true; + for(let n=0;n<3;n++){ + const expected=held.length+1,terminal=closed.length+1; + const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--'); + await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal); + } + holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload'); + assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201); + holdDownload=true; + for(let n=0;n<5;n++){ + const expected=held.length+1,terminal=closed.length+1; + const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end(); + await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal); + } + holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200); +}); diff --git a/web/src/components/agent/agent-attachment.tsx b/web/src/components/agent/agent-attachment.tsx new file mode 100644 index 0000000..0f5b888 --- /dev/null +++ b/web/src/components/agent/agent-attachment.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact"; +import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments"; + +type Props = { file: AgentAttachment; download(file: AgentAttachment, signal?: AbortSignal): Promise }; +/** Authenticated bytes only; no native credential, remote href, or public file serving. */ +export function AgentAttachmentCard({ file, download }: Props) { + const [preview, setPreview] = useState(); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + const controller = useRef(); + useEffect(() => () => { controller.current?.abort(); }, []); + useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]); + const retrieve = async (show: boolean) => { + if (busy) return; + const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined); + try { + const blob = await download(file, request.signal); if (request.signal.aborted) return; + const url = URL.createObjectURL(blob); + if (show && file.kind === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(blob.type)) setPreview(url); + else { + const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.filename; anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + } catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); } + finally { if (!request.signal.aborted) setBusy(false); } + }; + return
+
{file.filename}{attachmentSize(file.sizeBytes)}
+ {file.workspacePath ?
{file.workspacePath}
: null} + {preview ? {file.filename} : null} +
+ {file.kind === "image" ? : null} + +
+ {error ? {error} Retry using the buttons above. : null} +
; +} diff --git a/web/src/components/agent/agent-conversation.css b/web/src/components/agent/agent-conversation.css index 22cede2..d85a45e 100644 --- a/web/src/components/agent/agent-conversation.css +++ b/web/src/components/agent/agent-conversation.css @@ -455,3 +455,24 @@ transition:none!important; } } + +.agent-room__attach { display: grid; place-items: center; align-self: end; width: 38px; height: 46px; color: #c4b5fd; border-radius: 12px; } +.agent-room__attach:hover { background: #a78bfa22; } +.agent-room__uploads { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; } +.agent-room__upload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; padding: 9px 12px; border: 1px solid #a78bfa44; border-radius: 12px; background: #211b36; font-size: 12px; } +.agent-room__upload strong { display: block; overflow-wrap: anywhere; font-weight: 500; } +.agent-room__upload small { color: #c4b5fd; } +.agent-room__upload button { padding: 5px; color: #ddd6fe; } +.agent-room__upload-path { flex-basis: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: #b9a7d1; } +.agent-attachment { margin-top: 10px; padding: 10px; border: 1px solid #a78bfa44; border-radius: 10px; background: #0e0a1933; font-size: 12px; min-width: 0; } +.agent-attachment__title { display: flex; align-items: center; gap: 6px; } +.agent-attachment__title span { overflow-wrap: anywhere; } +.agent-attachment__title small { margin-left: auto; white-space: nowrap; opacity: .7; } +.agent-attachment__path { display: flex; gap: 6px; margin-top: 6px; } +.agent-attachment__path code { min-width: 0; overflow-wrap: anywhere; font-size: 10px; opacity: .75; } +.agent-attachment__path button { flex-shrink: 0; } +.agent-attachment__actions { display: flex; gap: 14px; margin-top: 8px; } +.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; } +.agent-attachment__actions button:disabled { opacity: .5; } +.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; } +.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; } diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx index 88fbc24..fd8204f 100644 --- a/web/src/components/agent/agent-conversation.tsx +++ b/web/src/components/agent/agent-conversation.tsx @@ -1,10 +1,12 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; -import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact"; +import { ArrowDown, Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact"; import type { Message } from "@/lib/api"; import type { AgentRoomHookResult } from "@/hooks/use-agent-room"; import { agentTranscriptMessages } from "@/lib/agent-room-presentation"; import { MarkdownContent } from "@/components/markdown/markdown-content"; import { Button } from "@/components/ui/button"; +import { agentAttachments, attachmentSize } from "@/lib/agent-attachments"; +import { AgentAttachmentCard } from "./agent-attachment"; import "./agent-conversation.css"; type Props = { @@ -47,7 +49,7 @@ function toolName(value: unknown) { return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; } -function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) { +function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) { const own = message.role === "user"; const info = metadata(message); const status = typeof info.status === "string" ? info.status : "complete"; @@ -59,7 +61,9 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
{own ?

{message.content}

: } + {agentAttachments(message.metadata).map(file => )}
+ {own && failed && typeof info.error === "string" ?
{info.error}
: null}
{own ? "You" : "Sybil"} {date ? : null} @@ -78,6 +82,16 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate: export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) { const [draft, setDraft] = useState(savedDraft); const draftRef = useRef(draft); + const [uploadError, setUploadError] = useState(); + const pickerRef = useRef(null); + const reselectRef = useRef(null); + const reselectId = useRef(); + const uploads = room.uploadQueue.snapshot(); + const pendingUploads = uploads.some(file => file.status !== "ready"); + const addFiles = (files: Iterable) => { + try { room.uploadQueue.add(files); setUploadError(undefined); } catch (error) { setUploadError(error instanceof Error ? error.message : String(error)); } + }; + const removeFile = (id: string) => { try { room.uploadQueue.remove(id); setUploadError(undefined); } catch (error) { setUploadError(String(error)); } }; const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id))); const enteringMessageIdsRef = useRef(new Set()); const animatedEntryIdsRef = useRef(new Set()); @@ -223,11 +237,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro }; const submit = () => { const content = draftRef.current.trim(); - if (!content) return; - const delivery = room.sendMessage(content); - updateDraft(""); - jumpToLatest(); - void delivery.catch(() => {}); + if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return; + let attachments; + try { attachments = room.uploadQueue.ready(); } catch (error) { setUploadError(String(error)); return; } + const delivery = room.sendMessage(content, attachments, () => { updateDraft(""); setUploadError(undefined); jumpToLatest(); }); + void delivery.catch(error => { if (draftRef.current === content) setUploadError(error instanceof Error ? error.message : String(error)); }); textareaRef.current?.focus(); }; @@ -247,11 +261,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro return (
{separator ?
{dayLabel(date)}
: null} - +
); }); - }, [room.messages, room.isLoading, room.retryMessage]); + }, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]); const activity = room.activity; const running = room.isTyping; @@ -306,21 +320,42 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro {!atBottom ? : null}
-
+
{ if (room.attachmentCapability && event.dataTransfer?.types.includes("Files")) event.preventDefault(); }} + onDrop={event => { if (room.attachmentCapability && event.dataTransfer?.files.length) { event.preventDefault(); addFiles(event.dataTransfer.files); } }}>
{room.error ?
{room.error}
: null} + {uploadError ?
{uploadError}
: null} + { + const file = event.currentTarget.files?.[0], id = reselectId.current; event.currentTarget.value = ""; + if (file && id) void room.uploadQueue.reselect(id, file).then(() => setUploadError(undefined)).catch(error => setUploadError(error instanceof Error ? error.message : String(error))); + }} /> + {uploads.length ?
{uploads.map(row =>
+
{row.filename}{attachmentSize(row.sizeBytes)} · {row.status === "ready" ? "Uploaded" : row.status === "uploading" ? "Uploading…" : row.status === "waiting" ? "Waiting…" : row.status === "reselect" ? "Reselect file" : "Upload failed"}
+ {row.status === "reselect" ? : null} + {row.status === "failed" ? : null} + + {row.error ? {row.error} : null} + {row.attachment?.workspacePath ? {row.attachment.workspacePath} : null} +
)}
: null}
{ event.preventDefault(); submit(); }}> + {room.attachmentCapability ? <> + { if (event.currentTarget.files) addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} /> + + : null}