diff --git a/CLAUDE.md b/CLAUDE.md index faae472..2acba11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,9 @@ claudius/ │ ├── src/ │ │ ├── index.ts # Hono API routes │ │ ├── chat.ts # Claude API integration +│ │ ├── attachments.ts # Attachment parsing/validation/content blocks +│ │ ├── attachment-storage.ts # Passthrough vs R2 storage, signed URLs +│ │ ├── attachment-quota.ts # Per-IP / per-tenant daily upload quotas (KV) │ │ ├── system-prompt.ts # Bot personality/knowledge │ │ └── __tests__/ # Worker tests │ ├── wrangler.toml # Cloudflare config @@ -102,6 +105,7 @@ pnpm test # Run tests | `ChatMessage` | Renders individual messages with URL linking | | `ChatSources` | Slide-out sidebar displaying grouped source links | | `SourceIcon` | Icon button with badge count to trigger source sidebar | +| `AttachmentPreview` | Image thumbnail / file chip for pending and sent attachments | ### useChat Hook @@ -135,8 +139,9 @@ Worker can't stream. | Endpoint | Method | Description | |----------|--------|-------------| -| `/api/chat` | POST | Send message, get AI response | -| `/api/chat/stream` | POST | Same request; streams the reply as SSE (`chunk`/`done`/`error` events) | +| `/api/chat` | POST | Send message, get AI response (JSON, or multipart when uploading files) | +| `/api/chat/stream` | POST | Same request; streams the reply as SSE (`chunk`/`tool`/`done`/`error` events) | +| `/api/attachments/*` | GET | Serve a stored attachment via signed URL (R2 mode only) | | `/api/health` | GET | Health check | `/api/chat/stream` shares the rate limiter, validation, and error shapes with @@ -175,7 +180,14 @@ renders. Retrieval failures degrade to ungrounded replies. Ingestion: { messages: [ { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" } + { role: "assistant", content: "Hi there!" }, + { + role: "user", + content: "What's on this receipt?", + attachments?: [ + { id: "att-1", name: "receipt.png", mediaType: "image/png", size: 1234, data?: "", key?: "att//" } + ] + } ] } @@ -184,10 +196,17 @@ renders. Retrieval failures degrade to ungrounded replies. Ingestion: reply: "How can I help you today?", sources?: [ { url: "https://...", title: "...", type: "blog" | "page" | "external" } - ] + ], + attachments?: [ { id: "att-1", key: "att/...", url: "https://.../api/attachments/...?exp=&sig=", expiresAt: "..." } ] } ``` +Attachments (images + PDFs) are opt-in on the widget (`attachments` prop) and on by +default in the worker in passthrough mode (forward to Anthropic, store nothing). +Set `ATTACHMENT_STORAGE=r2` plus an `ATTACHMENTS` R2 binding and +`ATTACHMENT_SIGNING_SECRET` to keep uploads for `ATTACHMENT_RETENTION_HOURS`. +See `docs/src/content/docs/configuration/attachments.md`. + ## Customization ### System Prompt @@ -324,3 +343,6 @@ When finishing a development branch (via the `finishing-a-development-branch` sk |----------|-------------| | `ANTHROPIC_API_KEY` | Anthropic API key for Claude | | `ALLOWED_ORIGIN` | CORS allowed origin (set in wrangler.toml for local dev) | +| `ATTACHMENTS_ENABLED`, `ATTACHMENT_TYPES`, `ATTACHMENT_MAX_BYTES`, `ATTACHMENT_MAX_COUNT`, `ATTACHMENT_MAX_REQUEST_BYTES` | Attachment acceptance limits (all optional) | +| `ATTACHMENT_QUOTA_IP_BYTES`, `ATTACHMENT_QUOTA_TENANT_BYTES`, `TENANT_ID` | Daily upload quotas enforced via the `RATE_LIMIT` KV | +| `ATTACHMENT_STORAGE`, `ATTACHMENT_RETENTION_HOURS`, `ATTACHMENT_SIGNING_SECRET` | `passthrough` (default) or `r2` storage; R2 needs the `ATTACHMENTS` bucket binding and the secret | diff --git a/clients/_schema.json b/clients/_schema.json index fbb326d..f5c56e2 100644 --- a/clients/_schema.json +++ b/clients/_schema.json @@ -43,7 +43,26 @@ "type": "string", "enum": ["bottom-right", "bottom-left", "top-right", "top-left"] }, - "accentColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } + "accentColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "attachments": { + "description": "Let visitors attach images and PDFs. true enables the defaults (5 MB, 5 files per message).", + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "maxSizeBytes": { "type": "integer", "minimum": 1 }, + "maxCount": { "type": "integer", "minimum": 1 }, + "allowedTypes": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + } + } + ] + } } }, "worker": { @@ -57,6 +76,26 @@ "systemPrompt": { "type": "string", "description": "Relative path to a markdown file containing the system prompt" + }, + "attachments": { + "type": "object", + "description": "Worker-side attachment settings; map to the ATTACHMENT_* variables", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "maxBytes": { "type": "integer", "minimum": 1 }, + "maxCount": { "type": "integer", "minimum": 1 }, + "maxRequestBytes": { "type": "integer", "minimum": 1 }, + "allowedTypes": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "storage": { "type": "string", "enum": ["passthrough", "r2"] }, + "retentionHours": { "type": "integer", "minimum": 1 }, + "quotaIpBytesPerDay": { "type": "integer", "minimum": 0 }, + "quotaTenantBytesPerDay": { "type": "integer", "minimum": 0 } + } } } } diff --git a/docs/src/content/docs/api/rest.md b/docs/src/content/docs/api/rest.md index e511d70..7f80fda 100644 --- a/docs/src/content/docs/api/rest.md +++ b/docs/src/content/docs/api/rest.md @@ -8,7 +8,7 @@ sidebar: Base URL: your deployed worker, e.g. `https://claudius-chat-worker..workers.dev`. CORS restricts callers to the configured [`ALLOWED_ORIGIN`](/configuration/worker/) list (plus -`http://localhost:*`). Allowed methods: `POST`, `OPTIONS`; +`http://localhost:*`). Allowed methods: `GET`, `POST`, `OPTIONS`; allowed header: `Content-Type`. ## POST /api/chat @@ -32,8 +32,61 @@ Send the conversation so far; receive the assistant's reply. |-------|------|-------| | `messages` | array, required | Full conversation history, oldest first. Max **100** messages; each `content` is truncated to **2,000** characters | | `messages[].role` | `"user" \| "assistant"` | Other roles are rejected | +| `messages[].content` | string | May be empty only when the message has attachments | +| `messages[].attachments` | array, optional | Files on a **user** message; see below | | `conversationId` | string, optional | Opaque id used only for [analytics](/deployment/worker/#analytics-with-d1-optional) correlation | +#### Attachments + +Each entry in `messages[].attachments` is: + +```json +{ + "id": "att-1", + "name": "receipt.png", + "mediaType": "image/png", + "size": 48213, + "data": "", + "key": "att// (optional)" +} +``` + +| Field | Notes | +|-------|-------| +| `id` | Client-generated, `[A-Za-z0-9_-]{1,64}`, unique per request. Names the multipart file part | +| `name` | Filename; shown to the model as the document title | +| `mediaType` | Must be on the worker's allowlist **and** match the file's leading bytes | +| `size` | Bytes. Recomputed server-side whenever bytes are present | +| `data` | Inline base64 (no `data:` prefix). Omit when sending multipart or referencing a stored file | +| `key` | Storage key returned by a previous response (R2 mode). The worker loads the file itself | + +An attachment with neither `data` nor `key` (or whose stored copy expired) is +described to the model as "no longer available" rather than failing the +request. Attachments are forwarded to Claude as `image` blocks (JPEG, PNG, +GIF, WebP) or `document` blocks (PDF), placed before the message text. + +#### Multipart requests + +To avoid base64 overhead, send `multipart/form-data` instead of JSON: + +- a `payload` text field containing the JSON body above, with attachment + entries **without** `data`; +- one file part per new upload, whose field name is the attachment `id`. + +```bash +curl https:///api/chat \ + -F 'payload={"messages":[{"role":"user","content":"What is the total?","attachments":[{"id":"f1","name":"receipt.png","mediaType":"image/png","size":48213}]}]}' \ + -F 'f1=@receipt.png;type=image/png' +``` + +Stray file parts that match no attachment are rejected. The widget's client +switches to multipart automatically whenever a message carries inline bytes. + +`POST /api/chat/stream` accepts the same JSON or multipart body. Attachment +errors are returned as plain JSON before the stream opens, and when the R2 +backend stored uploads the `done` event carries the same `attachments` array +as the non-streaming response below. + ### Response `200` ```json @@ -41,6 +94,14 @@ Send the conversation so far; receive the assistant's reply. "reply": "We're available Monday through Friday, 9am to 5pm.", "sources": [ { "url": "https://example.com/contact", "title": "Contact", "type": "page" } + ], + "attachments": [ + { + "id": "att-1", + "key": "att/example.com/6f1c…", + "url": "https:///api/attachments/att/example.com/6f1c…?exp=1750000000&sig=…", + "expiresAt": "2026-06-16T12:00:00.000Z" + } ] } ``` @@ -48,6 +109,11 @@ Send the conversation so far; receive the assistant's reply. `sources` is optional and reserved for retrieval-backed backends — the bundled worker returns only `reply` today (see [RAG](/rag/)). +`attachments` is present only when the worker's +[R2 storage backend](/configuration/attachments/#r2) stored new uploads +during this request. Reference `key` on later turns instead of re-sending the +bytes; `url` is an HMAC-signed download link valid until `expiresAt`. + ### Errors All errors share one envelope: @@ -58,12 +124,28 @@ All errors share one envelope: | Status | `code` | When | Extra | |--------|--------|------|-------| -| `400` | `VALIDATION_ERROR` | Empty/missing `messages`, more than 100 messages, invalid role | | +| `400` | `VALIDATION_ERROR` | Empty/missing `messages`, more than 100 messages, invalid role, message with neither text nor attachments | | +| `400` | `ATTACHMENTS_DISABLED` | Request carried attachments but `ATTACHMENTS_ENABLED=false` | | +| `400` | `ATTACHMENT_INVALID` | Disallowed or mismatched type, too many files, malformed id/key, or Claude could not process the file | | +| `413` | `ATTACHMENT_TOO_LARGE` | File over `ATTACHMENT_MAX_BYTES`, or this message's uploads over `ATTACHMENT_MAX_REQUEST_BYTES` | | +| `413` | `ATTACHMENT_QUOTA_EXCEEDED` | Daily per-IP or per-tenant upload quota reached | `Retry-After` header (seconds to next UTC midnight) | | `429` | `RATE_LIMITED` | Per-IP limit exceeded (default 10/min, 50/hr) | `Retry-After` header (seconds); `limitType`: `"minute"` or `"hour"` | -| `500` | `CONFIG_ERROR` | Worker misconfiguration (e.g. bad API key) | | +| `500` | `CONFIG_ERROR` | Worker misconfiguration (e.g. bad API key, R2 mode without bucket/secret) | | | `503` | `SERVICE_ERROR` | Claude temporarily unavailable/overloaded | | | `500` | `UNKNOWN_ERROR` | Anything else | | +## GET /api/attachments/{key} + +Serves a stored attachment (R2 mode only). The full URL, including the `exp` +and `sig` query parameters, comes from a chat response's `attachments[].url`; +it cannot be constructed by hand. + +| Status | When | +|--------|------| +| `200` | Bytes with the original `Content-Type`, `Content-Disposition: inline`, and `Cache-Control: private` | +| `403` | Signature invalid or link expired | +| `404` | Unknown key, object expired/deleted, or storage is passthrough | + ## GET /api/health ```json diff --git a/docs/src/content/docs/configuration/attachments.md b/docs/src/content/docs/configuration/attachments.md new file mode 100644 index 0000000..82e9bb1 --- /dev/null +++ b/docs/src/content/docs/configuration/attachments.md @@ -0,0 +1,174 @@ +--- +title: Attachments +description: Let visitors attach images and PDFs, and control where those files go. +sidebar: + order: 7 +--- + +Visitors can attach images (JPEG, PNG, GIF, WebP) and PDFs to a message by +clicking the paperclip, dragging files onto the composer, or pasting from the +clipboard. The worker forwards them to Claude as native `image` / `document` +content blocks, so the model can read a screenshot of an error, a receipt, or +a multi-page PDF. + +Attachments are **off in the widget by default** and **on in the worker by +default** (passthrough mode). Enable the widget side to start using them. + +## Enable in the widget + +```tsx + +``` + +```html + +``` + +```html + +``` + +Pass an object instead of `true` to tune the client-side limits: + +| Option | Default | Description | +|--------|---------|-------------| +| `maxSizeBytes` | `5242880` (5 MB) | Largest file the composer accepts | +| `maxCount` | `5` | Files per message | +| `allowedTypes` | JPEG, PNG, GIF, WebP, PDF | Accepted MIME types | + +```tsx + +``` + +The widget validates type, size, and count before anything is uploaded and +shows an inline message for rejected files. Keep these limits at or below the +worker's so a file the composer accepts is never refused later. + +## Worker settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `ATTACHMENTS_ENABLED` | `true` | Set to `false` to reject any request carrying attachments (`400 ATTACHMENTS_DISABLED`) | +| `ATTACHMENT_TYPES` | `image/jpeg,image/png,image/gif,image/webp,application/pdf` | Comma-separated allowlist. Only these five are forwarded natively; others are refused | +| `ATTACHMENT_MAX_BYTES` | `5242880` | Per-file cap (5 MB). Larger files return `413 ATTACHMENT_TOO_LARGE` | +| `ATTACHMENT_MAX_COUNT` | `5` | Files per message | +| `ATTACHMENT_MAX_REQUEST_BYTES` | `20971520` | Raw bytes forwarded to Claude per request (20 MB). Older attachments in the history are dropped first to stay under it | +| `ATTACHMENT_QUOTA_IP_BYTES` | `52428800` | Upload bytes per client IP per UTC day (50 MB). `0` disables | +| `ATTACHMENT_QUOTA_TENANT_BYTES` | `524288000` | Upload bytes per tenant per UTC day (500 MB). `0` disables | +| `TENANT_ID` | Origin host | Quota tenant. Defaults to the embedding site's host, so each site in `ALLOWED_ORIGIN` gets its own budget | +| `ATTACHMENT_STORAGE` | `passthrough` | `passthrough` or `r2` (see below) | +| `ATTACHMENT_RETENTION_HOURS` | `24` | R2 only: how long stored files (and their signed URLs) live | +| `ATTACHMENT_SIGNING_SECRET` | — | R2 only: secret used to sign download URLs. Set with `npx wrangler secret put ATTACHMENT_SIGNING_SECRET` | + +The worker independently re-validates every attachment: the declared MIME +type must be on the allowlist **and** match the file's leading bytes, the +decoded size is recomputed, and only user messages may carry files. + +Quotas use the same KV namespace as rate limiting and count only the new +uploads on the latest message. When a quota is hit the worker returns +`413 ATTACHMENT_QUOTA_EXCEEDED` with a `Retry-After` pointing at the next UTC +midnight. + +## Storage backends + +### Passthrough (default) + +Bytes are base64-encoded, sent to Anthropic inside the chat request, and +discarded. Nothing is written to Cloudflare storage. Because the API is +stateless, the widget keeps the bytes in memory and re-sends them with the +history on every later turn of that session. That costs upload bandwidth and +input tokens for image-heavy conversations, and a page reload loses the bytes +(the model then sees a short "no longer available" note in place of the file). + +Good for low-volume support chat where you'd rather not hold visitor files at +all. + +### R2 + +Set `ATTACHMENT_STORAGE=r2`, bind an R2 bucket as `ATTACHMENTS`, and set +`ATTACHMENT_SIGNING_SECRET`: + +```bash +cd worker +npx wrangler r2 bucket create claudius-attachments +npx wrangler secret put ATTACHMENT_SIGNING_SECRET +``` + +```toml +# wrangler.toml +[vars] +ATTACHMENT_STORAGE = "r2" +ATTACHMENT_RETENTION_HOURS = "24" + +[[r2_buckets]] +binding = "ATTACHMENTS" +bucket_name = "claudius-attachments" +``` + +In R2 mode a new upload is written under `att//`, forwarded to +Claude, and returned to the widget as a storage `key` plus an HMAC-signed +download URL. Later turns reference the key, so the file is uploaded once per +conversation and previews survive a reload. The worker refuses to serve or +forward an object past its `expiresAt` and deletes it lazily on the next read; +add a bucket lifecycle rule as the hard backstop: + +```bash +npx wrangler r2 bucket lifecycle add claudius-attachments --expire-days 2 +``` + +(Pick a value at least as long as `ATTACHMENT_RETENTION_HOURS`.) + +## Privacy posture + +**Where attachments live** + +- *Passthrough:* in the visitor's browser tab for the session, in transit to + your worker, and in the request to Anthropic. The worker keeps nothing. +- *R2:* additionally in your R2 bucket, under a key that names the tenant + (embedding site) but not the visitor, for `ATTACHMENT_RETENTION_HOURS`. + +**How long** + +- Browser: until the tab closes. Persisted history + (`sessionStorage`) stores filenames, sizes, keys, and signed URLs, never the + bytes. +- Anthropic: per their [data retention policy](https://privacy.anthropic.com/) + for API inputs. +- R2: `ATTACHMENT_RETENTION_HOURS` (default 24 h), enforced by the worker and + by your lifecycle rule. +- KV: quota counters expire after 24 h and hold byte totals only. + +**Who can read them** + +- Signed URLs grant read access to anyone who holds the link until it expires. + They appear only in chat responses to the widget that uploaded the file; + treat them like the conversation itself. +- Storage keys are unguessable UUIDs. A key alone is enough to reference a + stored file on a later turn from the same worker, which is how follow-up + questions work. +- Analytics (`ANALYTICS_DB`) never records attachment contents or names. + +Cover attachments in your own privacy notice: tell visitors that uploaded +files are sent to Anthropic for processing and, if you use R2, how long you +keep them. + +## Limits and errors + +| Status | `code` | Meaning | +|--------|--------|---------| +| `400` | `ATTACHMENTS_DISABLED` | `ATTACHMENTS_ENABLED=false` | +| `400` | `ATTACHMENT_INVALID` | Disallowed type, type/bytes mismatch, too many files, malformed reference, or Claude could not process the file | +| `413` | `ATTACHMENT_TOO_LARGE` | A file exceeds `ATTACHMENT_MAX_BYTES`, or the message's uploads exceed `ATTACHMENT_MAX_REQUEST_BYTES` | +| `413` | `ATTACHMENT_QUOTA_EXCEEDED` | Daily per-IP or per-tenant byte quota reached | + +The widget shows a localized message for each, removes the rejected message +from the conversation, and does not retry. See the +[REST API reference](/api/rest/) for the wire format. diff --git a/docs/src/content/docs/configuration/clients.md b/docs/src/content/docs/configuration/clients.md index a5f4c31..31bff65 100644 --- a/docs/src/content/docs/configuration/clients.md +++ b/docs/src/content/docs/configuration/clients.md @@ -47,8 +47,13 @@ pnpm claudius snippet acme # generate the embed snippet(s) | `slug` | Yes | URL-safe identifier; must match the filename | | `apiUrl` | Yes | The client's worker chat endpoint | | `allowedDomains` | Yes | Domains where the widget may be embedded | -| `widget` | No | Appearance: `title`, `subtitle`, `welcomeMessage`, `placeholder`, `theme`, `position`, `accentColor` | -| `worker` | No | `model`, `maxTokens` (1–8192), `rateLimitMinute`, `rateLimitHour`, `systemPrompt` (path to a markdown file) | +| `widget` | No | Appearance: `title`, `subtitle`, `welcomeMessage`, `placeholder`, `theme`, `position`, `accentColor`; `attachments` (`true` or `{ maxSizeBytes, maxCount, allowedTypes }`) | +| `worker` | No | `model`, `maxTokens` (1–8192), `rateLimitMinute`, `rateLimitHour`, `systemPrompt` (path to a markdown file), `attachments` (`enabled`, `maxBytes`, `maxCount`, `maxRequestBytes`, `allowedTypes`, `storage`, `retentionHours`, `quotaIpBytesPerDay`, `quotaTenantBytesPerDay`) | + +`widget.attachments` flows into the generated embed snippet. `worker.attachments` +documents the values to set as worker variables (see +[Attachments](/configuration/attachments/)); the CLI validates them but does +not deploy the worker. See `clients/example.json` and `clients/example-system-prompt.md` in the repo for a complete worked example. Referencing `_schema.json` from `$schema` gives diff --git a/docs/src/content/docs/configuration/widget.md b/docs/src/content/docs/configuration/widget.md index 8b005f0..667e553 100644 --- a/docs/src/content/docs/configuration/widget.md +++ b/docs/src/content/docs/configuration/widget.md @@ -26,13 +26,15 @@ attributes on the `` web component. | `translations` | `Partial` | built-in | Override individual UI strings | | `triggers` | `Trigger[]` | `undefined` | Proactive triggers; see [Proactive triggers](/configuration/triggers/) | | `plugins` | `ClaudiusPlugin[]` | `undefined` | Message middleware run around each send (`onBeforeSend` / `onAfterReceive` / `onError`); see [Plugins](/plugins/) | +| `attachments` | `boolean \| AttachmentsOptions` | `false` | Let visitors attach images and PDFs; `true` for the defaults (5 MB, 5 files) or an object with `maxSizeBytes`, `maxCount`, `allowedTypes`. See [Attachments](/configuration/attachments/) | ## Web component attributes `` supports the scalar options as kebab-case attributes: `api-url`, `title`, `subtitle`, `welcome-message`, `placeholder`, `persist-messages`, `storage-key-prefix`, `request-timeout-ms`, `theme`, -`accent-color`, `position`. +`accent-color`, `position`, `attachments` (`attachments` or +`attachments="true"` enables the defaults). ```html = {}): Record { + return { + name: "Test Client", + slug: "test-client", + apiUrl: "https://api.example.com", + allowedDomains: ["example.com"], + ...overrides, + }; +} + +function fields(errors: ValidationError[]): string[] { + return errors.map((e) => e.field); +} + +const SCRIPT_URL = "https://cdn.example.com/claudius.js"; + +describe("validateConfig: attachments", () => { + it("accepts widget.attachments as a boolean or a limits object", () => { + expect(validateConfig(base({ widget: { attachments: true } }), "test-client")).toEqual([]); + expect(validateConfig(base({ widget: { attachments: false } }), "test-client")).toEqual([]); + expect( + validateConfig( + base({ + widget: { + attachments: { maxSizeBytes: 1024, maxCount: 2, allowedTypes: ["image/png"] }, + }, + }), + "test-client", + ), + ).toEqual([]); + }); + + it("rejects malformed widget.attachments", () => { + const errors = validateConfig( + base({ + widget: { + attachments: { maxSizeBytes: 0, maxCount: 1.5, allowedTypes: [] }, + }, + }), + "test-client", + ); + expect(fields(errors)).toEqual([ + "widget.attachments.maxSizeBytes", + "widget.attachments.maxCount", + "widget.attachments.allowedTypes", + ]); + + const wrongType = validateConfig( + base({ widget: { attachments: "yes" as unknown as boolean } }), + "test-client", + ); + expect(fields(wrongType)).toEqual(["widget.attachments"]); + }); + + it("accepts a full worker.attachments block", () => { + const errors = validateConfig( + base({ + worker: { + attachments: { + enabled: true, + maxBytes: 5242880, + maxCount: 5, + maxRequestBytes: 20971520, + allowedTypes: ["image/png", "application/pdf"], + storage: "r2", + retentionHours: 48, + quotaIpBytesPerDay: 0, + quotaTenantBytesPerDay: 1048576, + }, + }, + }), + "test-client", + ); + expect(errors).toEqual([]); + }); + + it("rejects bad worker.attachments values", () => { + const errors = validateConfig( + base({ + worker: { + attachments: { + enabled: "true" as unknown as boolean, + storage: "s3" as unknown as "r2", + retentionHours: 0, + quotaIpBytesPerDay: -1, + }, + }, + }), + "test-client", + ); + expect(fields(errors)).toEqual([ + "worker.attachments.enabled", + "worker.attachments.storage", + "worker.attachments.retentionHours", + "worker.attachments.quotaIpBytesPerDay", + ]); + + const notObject = validateConfig( + base({ worker: { attachments: true as unknown as Record } }), + "test-client", + ); + expect(fields(notObject)).toEqual(["worker.attachments"]); + }); +}); + +describe("snippets: attachments", () => { + const config = base({ widget: { title: "Acme", attachments: true } }) as unknown as ClientConfig; + + it("passes `attachments: true` through to ClaudiusConfig", () => { + const out = generateScriptSnippet(config, SCRIPT_URL); + expect(out).toContain('"attachments": true'); + }); + + it("passes a limits object through to ClaudiusConfig", () => { + const limits = base({ + widget: { attachments: { maxCount: 2 } }, + }) as unknown as ClientConfig; + const out = generateScriptSnippet(limits, SCRIPT_URL); + expect(out).toContain('"attachments": {'); + expect(out).toContain('"maxCount": 2'); + }); + + it("omits attachments when disabled or unset", () => { + const off = base({ widget: { attachments: false } }) as unknown as ClientConfig; + expect(generateScriptSnippet(off, SCRIPT_URL)).not.toContain("attachments"); + expect(generateScriptSnippet(base() as unknown as ClientConfig, SCRIPT_URL)).not.toContain( + "attachments", + ); + }); + + it("emits the attachments attribute on the web component", () => { + const out = generateWebComponentSnippet(config, SCRIPT_URL); + expect(out).toContain('attachments="true"'); + const off = base({ widget: { attachments: false } }) as unknown as ClientConfig; + expect(generateWebComponentSnippet(off, SCRIPT_URL)).not.toContain("attachments"); + }); +}); diff --git a/scripts/lib/config.ts b/scripts/lib/config.ts index b819fe8..a30e2c8 100644 --- a/scripts/lib/config.ts +++ b/scripts/lib/config.ts @@ -3,6 +3,12 @@ import { resolve } from "node:path"; // --- Types --- +export interface WidgetAttachmentsConfig { + maxSizeBytes?: number; + maxCount?: number; + allowedTypes?: string[]; +} + export interface WidgetConfig { title?: string; subtitle?: string; @@ -11,6 +17,19 @@ export interface WidgetConfig { theme?: "light" | "dark" | "auto"; position?: "bottom-right" | "bottom-left" | "top-right" | "top-left"; accentColor?: string; + attachments?: boolean | WidgetAttachmentsConfig; +} + +export interface WorkerAttachmentsConfig { + enabled?: boolean; + maxBytes?: number; + maxCount?: number; + maxRequestBytes?: number; + allowedTypes?: string[]; + storage?: "passthrough" | "r2"; + retentionHours?: number; + quotaIpBytesPerDay?: number; + quotaTenantBytesPerDay?: number; } export interface WorkerConfig { @@ -19,6 +38,7 @@ export interface WorkerConfig { rateLimitMinute?: number; rateLimitHour?: number; systemPrompt?: string; + attachments?: WorkerAttachmentsConfig; } export interface ClientConfig { @@ -47,6 +67,66 @@ const VALID_POSITIONS = [ "top-right", "top-left", ] as const; +const VALID_STORAGE = ["passthrough", "r2"] as const; + +function isPositiveInt(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 1; +} + +function isNonNegativeInt(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isStringList(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === "string" && v.trim() !== "") + ); +} + +function validateAttachmentLimits( + obj: Record, + prefix: string, + errors: ValidationError[], + fields: Record, +): void { + for (const [key, kind] of Object.entries(fields)) { + const value = obj[key]; + if (value === undefined) continue; + const field = `${prefix}.${key}`; + switch (kind) { + case "positive": + if (!isPositiveInt(value)) { + errors.push({ field, message: `${field} must be a positive integer` }); + } + break; + case "nonNegative": + if (!isNonNegativeInt(value)) { + errors.push({ field, message: `${field} must be an integer >= 0 (0 disables)` }); + } + break; + case "types": + if (!isStringList(value)) { + errors.push({ field, message: `${field} must be a non-empty array of MIME types` }); + } + break; + case "boolean": + if (typeof value !== "boolean") { + errors.push({ field, message: `${field} must be a boolean` }); + } + break; + case "storage": + if (!(VALID_STORAGE as readonly string[]).includes(value as string)) { + errors.push({ + field, + message: `${field} must be one of: ${VALID_STORAGE.join(", ")}`, + }); + } + break; + } + } +} // --- Validation --- @@ -131,6 +211,51 @@ export function validateConfig( }); } } + + if (widget.attachments !== undefined) { + const att = widget.attachments; + if (typeof att === "boolean") { + // fine + } else if (att && typeof att === "object" && !Array.isArray(att)) { + validateAttachmentLimits(att as Record, "widget.attachments", errors, { + maxSizeBytes: "positive", + maxCount: "positive", + allowedTypes: "types", + }); + } else { + errors.push({ + field: "widget.attachments", + message: "widget.attachments must be a boolean or an object", + }); + } + } + } + + // worker (optional) + if (config.worker !== undefined) { + const worker = config.worker as Record; + + if (worker.attachments !== undefined) { + const att = worker.attachments; + if (att && typeof att === "object" && !Array.isArray(att)) { + validateAttachmentLimits(att as Record, "worker.attachments", errors, { + enabled: "boolean", + maxBytes: "positive", + maxCount: "positive", + maxRequestBytes: "positive", + allowedTypes: "types", + storage: "storage", + retentionHours: "positive", + quotaIpBytesPerDay: "nonNegative", + quotaTenantBytesPerDay: "nonNegative", + }); + } else { + errors.push({ + field: "worker.attachments", + message: "worker.attachments must be an object", + }); + } + } } return errors; diff --git a/scripts/lib/snippet.ts b/scripts/lib/snippet.ts index 35133d7..fd6338b 100644 --- a/scripts/lib/snippet.ts +++ b/scripts/lib/snippet.ts @@ -24,7 +24,7 @@ export function generateScriptSnippet( config: ClientConfig, scriptUrl: string, ): string { - const configObj: Record = { apiUrl: config.apiUrl }; + const configObj: Record = { apiUrl: config.apiUrl }; if (config.widget) { for (const field of WIDGET_FIELDS) { @@ -33,6 +33,10 @@ export function generateScriptSnippet( configObj[field] = value; } } + // `true` or a limits object both pass straight through to ClaudiusConfig. + if (config.widget.attachments !== undefined && config.widget.attachments !== false) { + configObj.attachments = config.widget.attachments; + } } // Build indented JSON: each line of the JSON body is indented to align under @@ -67,6 +71,11 @@ export function generateWebComponentSnippet( attrs.push([toKebab(field), value]); } } + // The attribute form only toggles the defaults; custom limits need the + // script snippet (ClaudiusConfig) or the React prop. + if (config.widget.attachments !== undefined && config.widget.attachments !== false) { + attrs.push(["attachments", "true"]); + } } const attrLines = attrs.map(([key, val]) => ` ${key}="${val}"`).join("\n"); diff --git a/widget/.size-limit.json b/widget/.size-limit.json index 54ed1dd..b41ea01 100644 --- a/widget/.size-limit.json +++ b/widget/.size-limit.json @@ -3,51 +3,51 @@ "name": "claudius.iife.js (raw)", "path": "dist/claudius.iife.js", "brotli": false, - "limit": "193179 B" + "limit": "208224 B" }, { "name": "claudius.iife.js (gzip)", "path": "dist/claudius.iife.js", "gzip": true, - "limit": "61908 B" + "limit": "66627 B" }, { "name": "claudius.iife.js (brotli)", "path": "dist/claudius.iife.js", - "limit": "54264 B" + "limit": "58357 B" }, { "name": "claudius.js (raw)", "path": "dist/claudius.js", "brotli": false, - "limit": "68849 B" + "limit": "86025 B" }, { "name": "claudius.js (gzip)", "path": "dist/claudius.js", "gzip": true, - "limit": "18774 B" + "limit": "23188 B" }, { "name": "claudius.js (brotli)", "path": "dist/claudius.js", - "limit": "16349 B" + "limit": "20221 B" }, { "name": "claudius.css (raw)", "path": "dist/claudius.css", "brotli": false, - "limit": "20736 B" + "limit": "23215 B" }, { "name": "claudius.css (gzip)", "path": "dist/claudius.css", "gzip": true, - "limit": "4669 B" + "limit": "5030 B" }, { "name": "claudius.css (brotli)", "path": "dist/claudius.css", - "limit": "4075 B" + "limit": "4407 B" } ] diff --git a/widget/src/__tests__/embed.test.tsx b/widget/src/__tests__/embed.test.tsx index 1fc15a2..7e733a3 100644 --- a/widget/src/__tests__/embed.test.tsx +++ b/widget/src/__tests__/embed.test.tsx @@ -39,3 +39,41 @@ describe("embed init via window.ClaudiusConfig", () => { ).toBeInTheDocument(); }); }); + +describe("embed attachments option", () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ""; + window.sessionStorage.clear(); + window.ClaudiusConfig = undefined; + }); + + afterEach(() => { + document.body.innerHTML = ""; + window.ClaudiusConfig = undefined; + }); + + it("enables the attach button from ClaudiusConfig", async () => { + window.ClaudiusConfig = { + apiUrl: "https://test.example/api", + attachments: true, + }; + await import("../embed"); + (await screen.findByRole("button", { name: /open chat/i })).click(); + expect( + await screen.findByRole("button", { name: /attach a file/i }), + ).toBeInTheDocument(); + }); + + it("enables attachments via the web component attribute", async () => { + await import("../embed"); + const el = document.createElement("claudius-chat"); + el.setAttribute("api-url", "https://test.example/api"); + el.setAttribute("attachments", "true"); + document.body.appendChild(el); + (await screen.findByRole("button", { name: /open chat/i })).click(); + expect( + await screen.findByRole("button", { name: /attach a file/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/widget/src/api/__tests__/client.attachments.test.ts b/widget/src/api/__tests__/client.attachments.test.ts new file mode 100644 index 0000000..986d7ed --- /dev/null +++ b/widget/src/api/__tests__/client.attachments.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChatApiClient } from "../client"; +import type { ChatMessage } from "../types"; + +const BASE_URL = "https://test.workers.dev"; +const PNG_B64 = btoa( + String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), +); + +function okResponse(body: Record) { + return { + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve(body), + }; +} + +describe("ChatApiClient with attachments", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("posts multipart/form-data when a message carries inline bytes", async () => { + mockFetch.mockResolvedValueOnce(okResponse({ reply: "A receipt." })); + const messages: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "What is this?", + attachments: [ + { + id: "att-1", + name: "shot.png", + mediaType: "image/png", + size: 8, + data: PNG_B64, + }, + { + id: "att-2", + name: "old.pdf", + mediaType: "application/pdf", + size: 5, + key: "att/t/k", + }, + ], + }, + ]; + + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + await client.sendMessage(messages); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/api/chat`); + expect(init.method).toBe("POST"); + // fetch sets the multipart boundary; no explicit Content-Type. + expect(init.headers).toBeUndefined(); + expect(init.body).toBeInstanceOf(FormData); + + const form = init.body as FormData; + const payload = JSON.parse(form.get("payload") as string); + expect(payload.messages[0].attachments).toEqual([ + { id: "att-1", name: "shot.png", mediaType: "image/png", size: 8 }, + { + id: "att-2", + name: "old.pdf", + mediaType: "application/pdf", + size: 5, + key: "att/t/k", + }, + ]); + + const part = form.get("att-1") as File; + expect(part).toBeInstanceOf(Blob); + expect(part.name).toBe("shot.png"); + expect(part.type).toBe("image/png"); + expect(part.size).toBe(8); + expect(form.get("att-2")).toBeNull(); + + // The caller's messages are not mutated. + expect(messages[0].attachments![0].data).toBe(PNG_B64); + }); + + it("keeps posting JSON when attachments have no inline bytes", async () => { + mockFetch.mockResolvedValueOnce(okResponse({ reply: "ok" })); + const messages: ChatMessage[] = [ + { + id: "1", + role: "user", + content: "again?", + attachments: [ + { + id: "a", + name: "x.png", + mediaType: "image/png", + size: 1, + key: "att/t/k", + }, + ], + }, + ]; + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + await client.sendMessage(messages); + + const init = mockFetch.mock.calls[0][1]; + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + expect(init.body).toBe(JSON.stringify({ messages })); + }); + + it("returns the worker's stored-attachment metadata", async () => { + mockFetch.mockResolvedValueOnce( + okResponse({ + reply: "Stored.", + attachments: [ + { + id: "att-1", + key: "att/t/u", + url: "https://w/a", + expiresAt: "2026-01-01T00:00:00Z", + }, + ], + }), + ); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + const result = await client.sendMessage([ + { + id: "1", + role: "user", + content: "", + attachments: [ + { + id: "att-1", + name: "s.png", + mediaType: "image/png", + size: 8, + data: PNG_B64, + }, + ], + }, + ]); + expect(result.attachments?.[0].key).toBe("att/t/u"); + }); +}); + +describe("ChatApiClient.streamMessage with attachments", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends the same multipart body to the stream endpoint", async () => { + // A non-SSE OK response makes streamMessage resolve via the JSON path. + mockFetch.mockResolvedValueOnce(okResponse({ reply: "ok" })); + const client = new ChatApiClient(BASE_URL, { debounceMs: 0 }); + const result = await client.streamMessage([ + { + id: "1", + role: "user", + content: "", + attachments: [ + { + id: "att-1", + name: "s.png", + mediaType: "image/png", + size: 8, + data: PNG_B64, + }, + ], + }, + ]); + + expect(result.reply).toBe("ok"); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/api/chat/stream`); + expect(init.headers).toBeUndefined(); + expect(init.body).toBeInstanceOf(FormData); + expect((init.body as FormData).get("att-1")).toBeInstanceOf(Blob); + }); +}); diff --git a/widget/src/api/client.ts b/widget/src/api/client.ts index 1e1752b..b8fc71c 100644 --- a/widget/src/api/client.ts +++ b/widget/src/api/client.ts @@ -35,11 +35,24 @@ export interface ChatApiClientOptions { const DEFAULT_TIMEOUT_MS = 30_000; +function base64ToBlob(data: string, type: string): Blob { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Blob([bytes], { type }); +} + /** * Typed client for the Claudius Worker chat API. Handles debouncing, * per-attempt timeouts, and automatic retries with backoff for transient * failures (HTTP 429/503, network errors, timeouts). * + * Conversations without inline attachment bytes are posted as JSON. When any + * message carries an attachment with `data`, the request is sent as + * `multipart/form-data`: a `payload` field holding the JSON body (attachment + * refs without their bytes) plus one file part per attachment, named after + * the attachment id. + * * @example * ```ts * const client = new ChatApiClient("https://api.example.com"); @@ -149,6 +162,49 @@ export class ChatApiClient { throw lastError!; } + /** + * Build the request body: JSON by default, multipart when any attachment + * still carries inline bytes. A fresh body is built per attempt so retries + * never reuse a consumed stream. + */ + private buildRequest(messages: ChatMessage[]): { + headers?: Record; + body: BodyInit; + } { + const needsMultipart = messages.some((m) => + m.attachments?.some((a) => !!a.data), + ); + if (!needsMultipart) { + return { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + }; + } + + const form = new FormData(); + const payloadMessages = messages.map((m) => { + if (!m.attachments) return m; + return { + ...m, + attachments: m.attachments.map((a) => { + const ref = { ...a }; + delete ref.data; + return ref; + }), + }; + }); + form.append("payload", JSON.stringify({ messages: payloadMessages })); + for (const m of messages) { + for (const a of m.attachments ?? []) { + if (a.data) { + form.append(a.id, base64ToBlob(a.data, a.mediaType), a.name); + } + } + } + // No Content-Type: fetch sets the multipart boundary itself. + return { body: form }; + } + /** * Send the conversation to the streaming chat endpoint and resolve with the * assistant's full reply once the stream completes. Text deltas are @@ -331,6 +387,12 @@ export class ChatApiClient { : fullText, sources: parsed.data.sources as ChatStreamResult["sources"], ...(doneToolUses.length > 0 ? { toolUses: doneToolUses } : {}), + ...(Array.isArray(parsed.data.attachments) + ? { + attachments: parsed.data + .attachments as ChatStreamResult["attachments"], + } + : {}), }; } else if (parsed.event === "error") { throw new ChatApiError( @@ -386,11 +448,14 @@ export class ChatApiClient { }, this.timeoutMs) : undefined; + // Same JSON-or-multipart body as the non-streaming endpoint. + const { headers, body } = this.buildRequest(messages); + try { return await fetch(`${this.baseUrl}/api/chat/stream`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages }), + headers, + body, signal: controller.signal, }); } catch (err) { @@ -409,11 +474,13 @@ export class ChatApiClient { } private async fetchWithTimeout(messages: ChatMessage[]): Promise { + const { headers, body } = this.buildRequest(messages); + if (this.timeoutMs <= 0) { return fetch(`${this.baseUrl}/api/chat`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages }), + headers, + body, }); } @@ -422,8 +489,8 @@ export class ChatApiClient { try { return await fetch(`${this.baseUrl}/api/chat`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages }), + headers, + body, signal: controller.signal, }); } catch (err) { diff --git a/widget/src/api/types.ts b/widget/src/api/types.ts index 809550f..0687b76 100644 --- a/widget/src/api/types.ts +++ b/widget/src/api/types.ts @@ -11,6 +11,49 @@ export interface Source { type: "blog" | "page" | "external"; } +/** + * A file (image or PDF) attached to a user message. + * + * Bytes travel inline as base64 `data` until the worker stores them (R2 + * backend), after which the widget keeps only the storage `key` and a signed + * preview `url`. Persisted history never includes `data`; an attachment with + * neither `data` nor `key` is shown by name only and described to the model + * as no longer available. + */ +export interface ChatAttachment { + /** Client-generated id, unique within the conversation. */ + id: string; + /** Original filename. */ + name: string; + /** MIME type, e.g. `"image/png"` or `"application/pdf"`. */ + mediaType: string; + /** Size in bytes. */ + size: number; + /** Base64-encoded bytes without a `data:` prefix. */ + data?: string; + /** Worker storage key, present once the worker's R2 backend stored the file. */ + key?: string; + /** Signed preview/download URL for a stored file, valid until {@link ChatAttachment.expiresAt}. */ + url?: string; + /** ISO 8601 timestamp after which the stored file and its `url` expire. */ + expiresAt?: string; +} + +/** + * Storage metadata the worker returns for each attachment it persisted while + * handling a request (R2 backend only). + */ +export interface StoredAttachment { + /** The {@link ChatAttachment.id} this entry describes. */ + id: string; + /** Worker storage key to reference the file on later turns. */ + key: string; + /** Signed preview/download URL, when the worker can serve the file. */ + url?: string; + /** ISO 8601 expiry of the stored file. */ + expiresAt: string; +} + /** * One tool call the assistant made while producing a reply. Rendered as a * compact "used tool" affordance with an optional details disclosure. @@ -34,10 +77,12 @@ export interface ChatMessage { id: string; /** Who authored the message. */ role: "user" | "assistant"; - /** Plain-text message body. */ + /** Plain-text message body. May be empty when the message only carries attachments. */ content: string; /** Sources cited by the assistant for this message, when any. */ sources?: Source[]; + /** Files attached by the user to this message, when any. */ + attachments?: ChatAttachment[]; /** Tools the assistant called while producing this message, when any. */ toolUses?: ToolUse[]; } @@ -60,6 +105,8 @@ export interface ChatResponse { sources?: Source[]; /** Tools the assistant called while producing the reply, when any. */ toolUses?: ToolUse[]; + /** Attachments the worker stored while handling this request, when any. */ + attachments?: StoredAttachment[]; } /** diff --git a/widget/src/components/AttachmentPreview.tsx b/widget/src/components/AttachmentPreview.tsx new file mode 100644 index 0000000..618b0ad --- /dev/null +++ b/widget/src/components/AttachmentPreview.tsx @@ -0,0 +1,153 @@ +import { memo } from "react"; +import type { ChatAttachment } from "../api/types"; +import { attachmentPreviewSrc, formatBytes } from "../utils/attachments"; +import { sanitizeUrl } from "../utils/sanitize"; + +interface AttachmentPreviewProps { + attachment: ChatAttachment; + /** + * `"composer"` renders a compact chip/thumbnail in the input area; + * `"message"` renders a larger preview inside a message bubble. + */ + variant: "composer" | "message"; + /** When provided, a remove button is rendered (composer only). */ + onRemove?: () => void; + /** Accessible label for the remove button. */ + removeLabel?: string; +} + +function FileIcon() { + return ( + + ); +} + +function RemoveButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +/** + * Renders one attachment: an image thumbnail when the bytes (or a signed URL) + * are available, otherwise a filename chip. Used both for pending files in the + * composer and for sent files inside message bubbles. + */ +export const AttachmentPreview = memo(function AttachmentPreview({ + attachment, + variant, + onRemove, + removeLabel, +}: AttachmentPreviewProps) { + const src = attachmentPreviewSrc(attachment); + const isComposer = variant === "composer"; + const label = `${attachment.name} (${formatBytes(attachment.size)})`; + const safeUrl = attachment.url ? sanitizeUrl(attachment.url) : null; + + if (src) { + const img = ( + {attachment.name} + ); + return ( +
+ {!isComposer && safeUrl ? ( + + {img} + {label} (opens in a new tab) + + ) : ( + img + )} + {onRemove && removeLabel && ( + + )} +
+ ); + } + + const chip = ( + + + {attachment.name} + + {formatBytes(attachment.size)} + + + ); + + return ( +
+ {!isComposer && safeUrl ? ( + + {chip} + (opens in a new tab) + + ) : ( + chip + )} + {onRemove && removeLabel && ( + + )} +
+ ); +}); diff --git a/widget/src/components/ChatInput.stories.tsx b/widget/src/components/ChatInput.stories.tsx index 285df4f..db8dcdc 100644 --- a/widget/src/components/ChatInput.stories.tsx +++ b/widget/src/components/ChatInput.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn, userEvent, within } from "storybook/test"; import { ChatInput } from "./ChatInput"; import { locales, type LocaleCode } from "../locales"; +import { DEFAULT_ATTACHMENT_OPTIONS } from "../utils/attachments"; const meta = { title: "Widget/ChatInput", @@ -45,3 +46,35 @@ export const AtLimit: Story = { await userEvent.paste("a".repeat(2000)); }, }; + +// With attachments enabled a paperclip button appears; files can also be +// dropped on the composer or pasted from the clipboard. +export const WithAttachments: Story = { + args: { attachments: DEFAULT_ATTACHMENT_OPTIONS }, +}; + +// A pending image and PDF shown as removable previews above the input. +export const WithPendingAttachments: Story = { + args: { attachments: DEFAULT_ATTACHMENT_OPTIONS }, + play: async ({ canvasElement }) => { + const picker = within(canvasElement).getByLabelText(/attach a file/i, { + selector: "input", + }); + const png = new File( + [ + Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + ), + (c) => c.charCodeAt(0), + ), + ], + "screenshot.png", + { type: "image/png" }, + ); + const pdf = new File(["%PDF-1.4 demo"], "invoice.pdf", { + type: "application/pdf", + }); + await userEvent.upload(picker, [png, pdf]); + }, +}; diff --git a/widget/src/components/ChatInput.tsx b/widget/src/components/ChatInput.tsx index 6dc2fbb..da12693 100644 --- a/widget/src/components/ChatInput.tsx +++ b/widget/src/components/ChatInput.tsx @@ -1,11 +1,30 @@ -import { useState, useRef, useEffect, FormEvent } from "react"; -import type { ClaudiusTranslations } from "../i18n"; +import { + useState, + useRef, + useEffect, + useCallback, + type FormEvent, + type ChangeEvent, + type ClipboardEvent, + type DragEvent, +} from "react"; +import { defaultTranslations, type ClaudiusTranslations } from "../i18n"; +import type { ChatAttachment } from "../api/types"; +import { AttachmentPreview } from "./AttachmentPreview"; +import { + fileToAttachment, + formatBytes, + validateFiles, + type FileRejectionReason, + type ResolvedAttachmentsConfig, +} from "../utils/attachments"; +import { interpolate } from "../utils/interpolate"; const MAX_MESSAGE_LENGTH = 2000; const WARNING_THRESHOLD = 1800; interface ChatInputProps { - onSend: (message: string) => void; + onSend: (message: string, attachments?: ChatAttachment[]) => void; isLoading: boolean; /** True while an assistant reply is streaming; swaps send for a stop button. */ isStreaming?: boolean; @@ -13,6 +32,16 @@ interface ChatInputProps { onStop?: () => void; placeholder?: string; translations?: ClaudiusTranslations; + /** + * Attachment limits. When omitted or `null`, the attach button is hidden and + * pasted or dropped files are ignored. + */ + attachments?: ResolvedAttachmentsConfig | null; +} + +function dragHasFiles(e: DragEvent): boolean { + const types = e.dataTransfer?.types; + return !!types && Array.from(types).includes("Files"); } export function ChatInput({ @@ -22,19 +51,23 @@ export function ChatInput({ onStop, placeholder, translations, + attachments = null, }: ChatInputProps) { + const t = translations ?? defaultTranslations; const [value, setValue] = useState(""); + const [pending, setPending] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); const inputRef = useRef(null); + const fileInputRef = useRef(null); + const pendingRef = useRef([]); + const dragDepthRef = useRef(0); const charCount = value.length; const isNearLimit = charCount >= WARNING_THRESHOLD; const isAtLimit = charCount >= MAX_MESSAGE_LENGTH; - - const placeholderText = - placeholder ?? translations?.placeholder ?? "Type your message..."; - const sendLabel = translations?.sendMessage ?? "Send message"; - const stopLabel = translations?.stopGenerating ?? "Stop generating"; - const inputLabel = translations?.typeYourMessage ?? "Type your message"; + const placeholderText = placeholder ?? t.placeholder; + const canAttachMore = !!attachments && pending.length < attachments.maxCount; const showStop = isStreaming && !!onStop; useEffect(() => { @@ -43,43 +76,210 @@ export function ChatInput({ } }, [isLoading]); + const describeRejection = useCallback( + (name: string, reason: FileRejectionReason): string => { + if (!attachments) return ""; + switch (reason) { + case "size": + return interpolate(t.attachmentTooLarge, { + name, + max: formatBytes(attachments.maxSizeBytes), + }); + case "type": + return interpolate(t.attachmentTypeNotAllowed, { name }); + case "count": + return interpolate(t.attachmentTooMany, { + max: attachments.maxCount, + }); + } + }, + [attachments, t], + ); + + const addFiles = useCallback( + async (files: File[]) => { + if (!attachments || files.length === 0) return; + const { accepted, rejected } = validateFiles( + files, + pendingRef.current.length, + attachments, + ); + setAttachmentError( + rejected.length > 0 + ? describeRejection(rejected[0].file.name, rejected[0].reason) + : null, + ); + if (accepted.length === 0) return; + const converted = await Promise.all(accepted.map(fileToAttachment)); + pendingRef.current = [...pendingRef.current, ...converted]; + setPending(pendingRef.current); + }, + [attachments, describeRejection], + ); + + const removePending = useCallback((id: string) => { + pendingRef.current = pendingRef.current.filter((a) => a.id !== id); + setPending(pendingRef.current); + setAttachmentError(null); + }, []); + const handleSubmit = (e: FormEvent) => { e.preventDefault(); const trimmed = value.trim(); - if (!trimmed || isAtLimit) return; - onSend(trimmed); + const files = pendingRef.current; + if ((!trimmed && files.length === 0) || isAtLimit) return; + if (files.length > 0) { + onSend(trimmed, files); + } else { + onSend(trimmed); + } setValue(""); + pendingRef.current = []; + setPending([]); + setAttachmentError(null); }; - const handleChange = (e: React.ChangeEvent) => { + const handleChange = (e: ChangeEvent) => { const newValue = e.target.value; if (newValue.length <= MAX_MESSAGE_LENGTH) { setValue(newValue); } }; + const handleFileChange = (e: ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + // Reset so picking the same file twice fires change again. + e.target.value = ""; + void addFiles(files); + }; + + const handlePaste = (e: ClipboardEvent) => { + if (!attachments) return; + const files = Array.from(e.clipboardData?.files ?? []); + if (files.length === 0) return; + e.preventDefault(); + void addFiles(files); + }; + + const handleDragEnter = (e: DragEvent) => { + if (!attachments || !dragHasFiles(e)) return; + e.preventDefault(); + dragDepthRef.current += 1; + setIsDragOver(true); + }; + + const handleDragOver = (e: DragEvent) => { + if (!attachments || !dragHasFiles(e)) return; + e.preventDefault(); + }; + + const handleDragLeave = () => { + if (!attachments) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragOver(false); + }; + + const handleDrop = (e: DragEvent) => { + if (!attachments) return; + e.preventDefault(); + dragDepthRef.current = 0; + setIsDragOver(false); + void addFiles(Array.from(e.dataTransfer?.files ?? [])); + }; + return (
+ {isDragOver && ( + + )} + + {attachments && pending.length > 0 && ( +
    + {pending.map((att) => ( +
  • + removePending(att.id)} + removeLabel={`${t.removeAttachment}: ${att.name}`} + /> +
  • + ))} +
+ )} +
+ {attachments && ( + <> + + + + )} {showStop ? (
+ {attachmentError && ( +
+ {attachmentError} +
+ )} {isNearLimit && (
0; const displayContent = isStreaming ? stabilizeStreamingMarkdown(content) : content; @@ -219,7 +224,20 @@ export const ChatMessage = memo(function ChatMessage({ : "bg-claudius-assistant-bubble text-claudius-assistant-bubble-text rounded-bl-claudius-tail" }`} > - {renderFormattedContent(displayContent)} + {hasAttachments && ( +
    + {attachments.map((att) => ( +
  • + +
  • + ))} +
+ )} + {(content !== "" || !hasAttachments) && + renderFormattedContent(displayContent)}
)} {!isUser && toolUses && toolUses.length > 0 && ( diff --git a/widget/src/components/ChatWidget.tsx b/widget/src/components/ChatWidget.tsx index 2830b6f..073bdf2 100644 --- a/widget/src/components/ChatWidget.tsx +++ b/widget/src/components/ChatWidget.tsx @@ -14,6 +14,10 @@ import { resolveTranslations, type LocaleCode } from "../locales"; import { useTheme } from "../theme/useTheme"; import type { ClaudiusThemeInput } from "../theme/types"; import type { ClaudiusPlugin } from "../plugins/types"; +import { + resolveAttachmentsConfig, + type AttachmentsOptions, +} from "../utils/attachments"; /** Corner of the viewport the widget docks to. */ export type WidgetPosition = @@ -81,6 +85,14 @@ export interface ChatWidgetProps { * @defaultValue `true` */ streaming?: boolean; + /** + * Let visitors attach images and PDFs (click, drag-and-drop, or paste). + * `true` enables the defaults (5 MB per file, 5 files per message, JPEG / + * PNG / GIF / WebP / PDF); pass an {@link AttachmentsOptions} to tune them. + * The worker must allow attachments too (it does by default). + * @defaultValue `false` + */ + attachments?: boolean | AttachmentsOptions; } function readDismissed(): boolean { @@ -128,8 +140,13 @@ export function ChatWidget({ triggers, plugins, streaming = true, + attachments = false, }: ChatWidgetProps) { const [isOpen, setIsOpen] = useState(false); + const attachmentsConfig = useMemo( + () => resolveAttachmentsConfig(attachments), + [attachments], + ); const [greeting, setGreeting] = useState(null); const [triggersDismissed, setTriggersDismissed] = useState(readDismissed); const openedByTriggerRef = useRef(false); @@ -279,6 +296,7 @@ export function ChatWidget({ position={position} translations={translations} isMobile={isMobile} + attachments={attachmentsConfig} /> )} {!(isOpen && isMobile) && ( diff --git a/widget/src/components/ChatWindow.tsx b/widget/src/components/ChatWindow.tsx index aacc82c..4e39706 100644 --- a/widget/src/components/ChatWindow.tsx +++ b/widget/src/components/ChatWindow.tsx @@ -10,7 +10,12 @@ import { useFocusTrap } from "../hooks/useFocusTrap"; import { stripAnnouncementFormatting } from "../utils/stripAnnouncementFormatting"; import type { WidgetPosition } from "./ChatWidget"; import type { ClaudiusTranslations } from "../i18n"; -import type { ChatMessage as ChatMessageData, Source } from "../api/types"; +import type { + ChatAttachment, + ChatMessage as ChatMessageData, + Source, +} from "../api/types"; +import type { ResolvedAttachmentsConfig } from "../utils/attachments"; interface ChatWindowProps { messages: ChatMessageData[]; @@ -21,7 +26,7 @@ interface ChatWindowProps { streamingMessageId?: string | null; error: string | null; canRetry?: boolean; - onSend: (message: string) => void; + onSend: (message: string, attachments?: ChatAttachment[]) => void; /** Cancels the in-flight stream (renders the stop button when provided). */ onStop?: () => void; onRetry?: () => void; @@ -33,6 +38,8 @@ interface ChatWindowProps { position?: WidgetPosition; translations?: ClaudiusTranslations; isMobile?: boolean; + /** Attachment limits, or `null` to hide file controls. */ + attachments?: ResolvedAttachmentsConfig | null; } const windowPositionClasses: Record = { @@ -60,6 +67,7 @@ export function ChatWindow({ position = "bottom-right", translations, isMobile = false, + attachments = null, }: ChatWindowProps) { const titleId = useId(); const dialogRef = useRef(null); @@ -169,6 +177,7 @@ export function ChatWindow({ content={msg.content} isStreaming={msg.id === streamingMessageId} sources={msg.sources} + attachments={msg.attachments} toolUses={msg.toolUses} toolUsedLabel={translations?.toolUsed} toolDetailsLabel={translations?.toolDetails} @@ -234,6 +243,7 @@ export function ChatWindow({ onStop={onStop} placeholder={placeholder} translations={translations} + attachments={attachments} /> ); diff --git a/widget/src/components/__tests__/ChatInput.attachments.test.tsx b/widget/src/components/__tests__/ChatInput.attachments.test.tsx new file mode 100644 index 0000000..8188ebe --- /dev/null +++ b/widget/src/components/__tests__/ChatInput.attachments.test.tsx @@ -0,0 +1,234 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatInput } from "../ChatInput"; +import { DEFAULT_ATTACHMENT_OPTIONS } from "../../utils/attachments"; + +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]); + +function pngFile(name = "shot.png") { + return new File([PNG], name, { type: "image/png" }); +} + +function pdfFile(name = "doc.pdf") { + return new File(["%PDF-1.4"], name, { type: "application/pdf" }); +} + +function picker() { + return screen.getByLabelText(/attach a file/i, { selector: "input" }); +} + +describe("ChatInput attachments", () => { + it("hides the attach button when attachments are disabled", () => { + render(); + expect( + screen.queryByRole("button", { name: /attach a file/i }), + ).not.toBeInTheDocument(); + }); + + it("shows the attach button and file picker when enabled", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: /attach a file/i }), + ).toBeInTheDocument(); + expect(picker()).toHaveAttribute( + "accept", + DEFAULT_ATTACHMENT_OPTIONS.allowedTypes.join(","), + ); + }); + + it("previews picked files and sends them with the message", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render( + , + ); + + await user.upload(picker(), [pngFile(), pdfFile()]); + + expect( + await screen.findByRole("img", { name: "shot.png" }), + ).toHaveAttribute("src", expect.stringMatching(/^data:image\/png;base64,/)); + expect(screen.getByText("doc.pdf")).toBeInTheDocument(); + + await user.type(screen.getByRole("textbox"), "What is this?{enter}"); + + expect(onSend).toHaveBeenCalledTimes(1); + const [text, attachments] = onSend.mock.calls[0]; + expect(text).toBe("What is this?"); + expect(attachments).toHaveLength(2); + expect(attachments[0]).toMatchObject({ + name: "shot.png", + mediaType: "image/png", + size: PNG.byteLength, + }); + expect(attachments[0].data).toBeTruthy(); + expect(attachments[1]).toMatchObject({ + name: "doc.pdf", + mediaType: "application/pdf", + }); + + // The composer resets after sending. + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("allows sending an attachment without any text", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render( + , + ); + await user.upload(picker(), pngFile()); + await screen.findByRole("img"); + await user.click(screen.getByRole("button", { name: /send/i })); + + expect(onSend).toHaveBeenCalledWith("", [ + expect.objectContaining({ name: "shot.png" }), + ]); + }); + + it("removes a pending attachment", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.upload(picker(), pngFile()); + await user.click( + await screen.findByRole("button", { + name: "Remove attachment: shot.png", + }), + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("rejects disallowed types and oversized files with an alert", async () => { + const user = userEvent.setup({ applyAccept: false }); + render( + , + ); + + await user.upload( + picker(), + new File(["hello"], "notes.txt", { type: "text/plain" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "notes.txt is not a supported file type.", + ); + + await user.upload(picker(), pngFile("huge.png")); + expect(await screen.findByRole("alert")).toHaveTextContent( + "huge.png is too large. The maximum size is 4 B.", + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("enforces the per-message count and disables the attach button", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.upload(picker(), [pngFile("a.png"), pngFile("b.png")]); + + expect( + await screen.findByRole("img", { name: "a.png" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("img", { name: "b.png" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent( + "You can attach up to 1 files per message.", + ); + expect( + screen.getByRole("button", { name: /attach a file/i }), + ).toBeDisabled(); + }); + + it("accepts files pasted into the input", async () => { + render( + , + ); + fireEvent.paste(screen.getByRole("textbox"), { + clipboardData: { + files: [pngFile("pasted.png")], + types: ["Files"], + getData: () => "", + }, + }); + expect( + await screen.findByRole("img", { name: "pasted.png" }), + ).toBeInTheDocument(); + }); + + it("highlights on drag and accepts dropped files", async () => { + render( + , + ); + const form = screen.getByRole("textbox").closest("form")!; + + fireEvent.dragEnter(form, { + dataTransfer: { types: ["Files"], files: [] }, + }); + expect(form).toHaveAttribute("data-drag-over", "true"); + expect(screen.getByText("Drop files to attach")).toBeInTheDocument(); + + fireEvent.drop(form, { + dataTransfer: { types: ["Files"], files: [pngFile("dropped.png")] }, + }); + expect(form).not.toHaveAttribute("data-drag-over"); + expect( + await screen.findByRole("img", { name: "dropped.png" }), + ).toBeInTheDocument(); + }); + + it("ignores pasted and dropped files when attachments are disabled", async () => { + render(); + const input = screen.getByRole("textbox"); + fireEvent.paste(input, { + clipboardData: { + files: [pngFile()], + types: ["Files"], + getData: () => "", + }, + }); + fireEvent.drop(input.closest("form")!, { + dataTransfer: { types: ["Files"], files: [pngFile()] }, + }); + await waitFor(() => { + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/widget/src/components/__tests__/ChatMessage.attachments.test.tsx b/widget/src/components/__tests__/ChatMessage.attachments.test.tsx new file mode 100644 index 0000000..bc0e78f --- /dev/null +++ b/widget/src/components/__tests__/ChatMessage.attachments.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { ChatMessage } from "../ChatMessage"; +import type { ChatAttachment } from "../../api/types"; + +const image: ChatAttachment = { + id: "a1", + name: "receipt.png", + mediaType: "image/png", + size: 2048, + data: "AA==", +}; + +const pdf: ChatAttachment = { + id: "a2", + name: "invoice.pdf", + mediaType: "application/pdf", + size: 3 * 1024 * 1024, +}; + +describe("ChatMessage attachments", () => { + it("renders an inline image preview and a PDF chip", () => { + render( + , + ); + expect(screen.getByRole("img", { name: "receipt.png" })).toHaveAttribute( + "src", + "data:image/png;base64,AA==", + ); + expect(screen.getByText("invoice.pdf")).toBeInTheDocument(); + expect(screen.getByText("3 MB")).toBeInTheDocument(); + expect(screen.getByText("Here you go")).toBeInTheDocument(); + }); + + it("renders an attachment-only message without empty text", () => { + const { container } = render( + , + ); + expect(screen.getByRole("img")).toBeInTheDocument(); + expect(container.querySelectorAll("br")).toHaveLength(0); + }); + + it("links stored attachments to their signed URL", () => { + render( + , + ); + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute( + "href", + "https://worker.example/api/attachments/att/t/x?exp=1&sig=2", + ); + expect(links[0]).toHaveAttribute("target", "_blank"); + expect(screen.getByRole("img", { name: "receipt.png" })).toHaveAttribute( + "src", + "https://worker.example/api/attachments/att/t/x?exp=1&sig=2", + ); + }); + + it("falls back to a filename chip when the image bytes are gone", () => { + render( + , + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("receipt.png")).toBeInTheDocument(); + }); +}); diff --git a/widget/src/embed.tsx b/widget/src/embed.tsx index e427051..f21e33c 100644 --- a/widget/src/embed.tsx +++ b/widget/src/embed.tsx @@ -4,6 +4,7 @@ import type { Trigger } from "./hooks/useTriggers"; import type { LocaleCode } from "./locales"; import type { ClaudiusTranslations } from "./i18n"; import type { ClaudiusThemeInput } from "./theme/types"; +import type { AttachmentsOptions } from "./utils/attachments"; import "./styles.css"; // Injected at build time by vite.config.embed.ts; undefined under unit tests. @@ -27,6 +28,7 @@ interface ClaudiusConfig { locale?: LocaleCode; translations?: Partial; triggers?: Trigger[]; + attachments?: boolean | AttachmentsOptions; } declare global { @@ -68,6 +70,7 @@ function init() { locale={config.locale} translations={config.translations} triggers={config.triggers} + attachments={config.attachments} />, ); } @@ -90,6 +93,7 @@ class ClaudiusChat extends HTMLElement { "theme", "accent-color", "position", + "attachments", ]; } @@ -132,6 +136,11 @@ class ClaudiusChat extends HTMLElement { const requestTimeoutMs = timeoutAttr === null ? undefined : Number(timeoutAttr); + // `attachments` / `attachments="true"` enable the defaults; "false" disables. + const attachmentsAttr = this.getAttribute("attachments"); + const attachments = + attachmentsAttr === null ? undefined : attachmentsAttr !== "false"; + this.root.render( , ); } diff --git a/widget/src/hooks/__tests__/useChat.attachments.test.ts b/widget/src/hooks/__tests__/useChat.attachments.test.ts new file mode 100644 index 0000000..34cf108 --- /dev/null +++ b/widget/src/hooks/__tests__/useChat.attachments.test.ts @@ -0,0 +1,132 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useChat } from "../useChat"; +import type { ChatAttachment } from "../../api/types"; +import { defaultTranslations } from "../../i18n"; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +const API_URL = "https://test.workers.dev"; +const STORAGE_KEY = "claudius:messages:test.workers.dev"; +const PNG_B64 = btoa( + String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), +); + +function attachment(): ChatAttachment { + return { + id: "att-1", + name: "shot.png", + mediaType: "image/png", + size: 8, + data: PNG_B64, + }; +} + +function response(status: number, body: Record) { + return { + ok: status < 400, + status, + headers: new Headers(), + json: () => Promise.resolve(body), + }; +} + +describe("useChat attachments", () => { + beforeEach(() => { + mockFetch.mockReset(); + sessionStorage.clear(); + }); + + it("sends an attachment-only message and keeps inline bytes in memory (passthrough)", async () => { + mockFetch.mockResolvedValueOnce(response(200, { reply: "A receipt." })); + const { result } = renderHook(() => useChat({ apiUrl: API_URL })); + + await act(async () => { + await result.current.sendMessage("", [attachment()]); + }); + + expect(result.current.messages).toHaveLength(2); + const user = result.current.messages[0]; + expect(user.content).toBe(""); + expect(user.attachments?.[0].data).toBe(PNG_B64); + + // Persisted history never contains the bytes. + const persisted = JSON.parse(sessionStorage.getItem(STORAGE_KEY)!); + expect(persisted[0].attachments[0]).toEqual({ + id: "att-1", + name: "shot.png", + mediaType: "image/png", + size: 8, + }); + }); + + it("ignores an empty send with no attachments", async () => { + const { result } = renderHook(() => useChat({ apiUrl: API_URL })); + await act(async () => { + await result.current.sendMessage(" ", []); + }); + expect(result.current.messages).toHaveLength(0); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("swaps inline bytes for storage metadata when the worker stored the upload", async () => { + mockFetch.mockResolvedValueOnce( + response(200, { + reply: "Stored.", + attachments: [ + { + id: "att-1", + key: "att/t/u", + url: "https://w/api/attachments/att/t/u?exp=1&sig=2", + expiresAt: "2026-01-02T00:00:00.000Z", + }, + ], + }), + ); + const { result } = renderHook(() => useChat({ apiUrl: API_URL })); + + await act(async () => { + await result.current.sendMessage("keep this", [attachment()]); + }); + + const att = result.current.messages[0].attachments![0]; + expect(att.data).toBeUndefined(); + expect(att.key).toBe("att/t/u"); + expect(att.url).toBe("https://w/api/attachments/att/t/u?exp=1&sig=2"); + expect(att.expiresAt).toBe("2026-01-02T00:00:00.000Z"); + }); + + it("rolls back the user message and shows a translated error when the worker rejects an attachment", async () => { + mockFetch.mockResolvedValueOnce( + response(413, { error: "too big", code: "ATTACHMENT_TOO_LARGE" }), + ); + const { result } = renderHook(() => + useChat({ apiUrl: API_URL, translations: defaultTranslations }), + ); + + await act(async () => { + await result.current.sendMessage("look", [attachment()]); + }); + + expect(result.current.messages).toHaveLength(0); + expect(result.current.error).toBe( + defaultTranslations.errorAttachmentRejected, + ); + expect(result.current.canRetry).toBe(false); + expect(sessionStorage.getItem(STORAGE_KEY)).toBe("[]"); + }); + + it("maps the quota error code to its translation", async () => { + mockFetch.mockResolvedValueOnce( + response(413, { error: "quota", code: "ATTACHMENT_QUOTA_EXCEEDED" }), + ); + const { result } = renderHook(() => + useChat({ apiUrl: API_URL, translations: defaultTranslations }), + ); + await act(async () => { + await result.current.sendMessage("look", [attachment()]); + }); + expect(result.current.error).toBe(defaultTranslations.errorAttachmentQuota); + }); +}); diff --git a/widget/src/hooks/useChat.ts b/widget/src/hooks/useChat.ts index 584c126..f892e0b 100644 --- a/widget/src/hooks/useChat.ts +++ b/widget/src/hooks/useChat.ts @@ -1,10 +1,18 @@ import { useState, useCallback, useRef, useMemo } from "react"; import type { ClaudiusTranslations } from "../i18n"; -import type { ChatMessage } from "../api/types"; +import type { + ChatAttachment, + ChatMessage, + StoredAttachment, +} from "../api/types"; import { ChatApiClient } from "../api/client"; import { ChatApiError, DebounceError } from "../api/errors"; import type { ClaudiusPlugin } from "../plugins/types"; import { runBeforeSend, runAfterReceive, runError } from "../plugins/runner"; +import { + applyStoredAttachments, + stripAttachmentData, +} from "../utils/attachments"; interface UseChatOptions { apiUrl: string; @@ -30,7 +38,10 @@ interface UseChatReturn { streamingMessageId: string | null; error: string | null; canRetry: boolean; - sendMessage: (content: string) => Promise; + sendMessage: ( + content: string, + attachments?: ChatAttachment[], + ) => Promise; /** Cancels the in-flight stream, keeping any partial reply. */ stop: () => void; retry: () => Promise; @@ -115,7 +126,9 @@ export function useChat({ const storage = getSessionStorage(); if (!storage) return; try { - const toSave = msgs.slice(-MAX_PERSISTED_MESSAGES); + // Inline attachment bytes are never persisted: they would blow the + // storage quota and are unnecessary once the worker has seen them. + const toSave = stripAttachmentData(msgs.slice(-MAX_PERSISTED_MESSAGES)); storage.setItem(storageKey, JSON.stringify(toSave)); } catch { // sessionStorage may be unavailable or quota-exceeded @@ -145,6 +158,12 @@ export function useChat({ return translations.errorRateLimitMinute; } return translations.errorRateLimitHour; + case "ATTACHMENT_QUOTA_EXCEEDED": + return translations.errorAttachmentQuota; + case "ATTACHMENTS_DISABLED": + case "ATTACHMENT_INVALID": + case "ATTACHMENT_TOO_LARGE": + return translations.errorAttachmentRejected; case "VALIDATION_ERROR": case "CONFIG_ERROR": case "SERVICE_ERROR": @@ -222,6 +241,7 @@ export function useChat({ let reply: string; let sources: ChatMessage["sources"]; let toolUses: ChatMessage["toolUses"]; + let storedAttachments: StoredAttachment[] | undefined; let aborted = false; if (canStream) { @@ -238,12 +258,14 @@ export function useChat({ reply = result.reply; sources = result.sources; toolUses = result.toolUses; + storedAttachments = result.attachments; aborted = result.aborted ?? false; } else { const result = await client.sendMessage(msgsToSend); reply = result.reply; sources = result.sources; toolUses = result.toolUses; + storedAttachments = result.attachments; } // Cancelled before any reply text arrived: drop the send silently @@ -269,18 +291,37 @@ export function useChat({ { messages: msgsToSend, apiUrl }, ); } - const withReply = + const settled = placeholderId !== null ? messagesRef.current.map((m) => m.id === assistantMessage.id ? assistantMessage : m, ) : [...msgsToSend, assistantMessage]; + // When the worker stored uploads (R2 backend), swap inline bytes for + // the storage key + signed URL so later turns reference the copy. + const withReply = applyStoredAttachments(settled, storedAttachments); messagesRef.current = withReply; setMessages(withReply); saveMessages(withReply); } catch (err) { if (err instanceof DebounceError) return; + // An attachment the worker refused would poison every later turn, so + // drop the offending user message rather than keep it in history. + if ( + err instanceof ChatApiError && + err.code?.startsWith("ATTACHMENT") && + msgsToSend.length > 0 + ) { + const last = msgsToSend[msgsToSend.length - 1]; + if (last.role === "user" && last.attachments?.length) { + const rolledBack = msgsToSend.slice(0, -1); + messagesRef.current = rolledBack; + setMessages(rolledBack); + saveMessages(rolledBack); + } + } + // The stream broke after partial content rendered: keep the partial // text and surface the error beneath it. No retry button — retrying // would resend the conversation and duplicate the partial reply. @@ -356,14 +397,16 @@ export function useChat({ }, []); const sendMessage = useCallback( - async (content: string) => { + async (content: string, attachments?: ChatAttachment[]) => { const trimmed = content.trim(); - if (!trimmed || isLoadingRef.current) return; + const files = attachments?.filter((a) => a && a.id) ?? []; + if ((!trimmed && files.length === 0) || isLoadingRef.current) return; const userMessage: ChatMessage = { id: nextId(), role: "user", content: trimmed, + ...(files.length > 0 ? { attachments: files } : {}), }; let outgoing = userMessage; diff --git a/widget/src/i18n.ts b/widget/src/i18n.ts index 81575a1..7c9532f 100644 --- a/widget/src/i18n.ts +++ b/widget/src/i18n.ts @@ -5,6 +5,9 @@ import { en } from "./locales/en"; * Every user-facing string the widget renders. Pass a {@link ClaudiusTranslations} * (or a partial override) to localize the UI. English is the source of truth; * see {@link defaultTranslations}. + * + * Strings containing `{name}` / `{max}` placeholders are interpolated at + * render time. */ export interface ClaudiusTranslations { /** Chat window header title. */ @@ -35,6 +38,20 @@ export interface ClaudiusTranslations { openChat: string; /** Accessible label for dismissing the greeting bubble. */ dismissGreeting: string; + /** Accessible label for the attach-file button and file picker. */ + attachFile: string; + /** Accessible label prefix for removing a pending attachment (filename is appended). */ + removeAttachment: string; + /** Accessible label for the list of pending attachments. */ + attachmentsLabel: string; + /** Overlay hint shown while dragging files over the composer. */ + dropFilesHint: string; + /** Validation message for an oversized file; supports `{name}` and `{max}`. */ + attachmentTooLarge: string; + /** Validation message for a disallowed file type; supports `{name}`. */ + attachmentTypeNotAllowed: string; + /** Validation message when the per-message file limit is hit; supports `{max}`. */ + attachmentTooMany: string; /** Generic fallback error message. */ errorGeneric: string; /** Error shown when the network request fails. */ @@ -45,6 +62,10 @@ export interface ClaudiusTranslations { errorRateLimitMinute: string; /** Error shown when rate-limited (per-hour limit). */ errorRateLimitHour: string; + /** Error shown when the worker rejects an attachment (type, size, count, or content). */ + errorAttachmentRejected: string; + /** Error shown when the worker's daily upload quota is exhausted. */ + errorAttachmentQuota: string; /** Label for the retry action on a failed message. */ errorRetry: string; } diff --git a/widget/src/index.ts b/widget/src/index.ts index aa1dcca..5c76ba4 100644 --- a/widget/src/index.ts +++ b/widget/src/index.ts @@ -25,12 +25,19 @@ export type { Source, ToolUse, ChatMessage, + ChatAttachment, + StoredAttachment, ChatRequest, ChatResponse, ChatErrorResponse, ChatStreamOptions, ChatStreamResult, } from "./api/types"; +export { DEFAULT_ATTACHMENT_OPTIONS } from "./utils/attachments"; +export type { + AttachmentsOptions, + ResolvedAttachmentsConfig, +} from "./utils/attachments"; // Plugin SDK: the ClaudiusPlugin interface, supporting types, and the three // reference plugins. diff --git a/widget/src/locales/de.ts b/widget/src/locales/de.ts index 971730f..d643d8e 100644 --- a/widget/src/locales/de.ts +++ b/widget/src/locales/de.ts @@ -23,6 +23,15 @@ export const de: ClaudiusTranslations = { // GreetingBubble dismissGreeting: "Begrüßung schließen", + // Attachments + attachFile: "Datei anhängen", + removeAttachment: "Anhang entfernen", + attachmentsLabel: "Anhänge", + dropFilesHint: "Dateien hier ablegen, um sie anzuhängen", + attachmentTooLarge: "{name} ist zu groß. Die maximale Größe beträgt {max}.", + attachmentTypeNotAllowed: "{name} ist kein unterstützter Dateityp.", + attachmentTooMany: "Sie können bis zu {max} Dateien pro Nachricht anhängen.", + // Errors errorGeneric: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", errorConnection: "Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.", @@ -31,5 +40,9 @@ export const de: ClaudiusTranslations = { errorRateLimitMinute: "Zu viele Anfragen. Bitte warten Sie eine Minute.", errorRateLimitHour: "Stündliches Limit erreicht. Bitte versuchen Sie es später erneut.", + errorAttachmentRejected: + "Ein Anhang wurde abgelehnt. Bitte entfernen Sie ihn und versuchen Sie es erneut.", + errorAttachmentQuota: + "Upload-Limit erreicht. Bitte versuchen Sie es später erneut.", errorRetry: "Erneut versuchen", }; diff --git a/widget/src/locales/en.ts b/widget/src/locales/en.ts index 4a946f2..dfb2463 100644 --- a/widget/src/locales/en.ts +++ b/widget/src/locales/en.ts @@ -23,11 +23,23 @@ export const en: ClaudiusTranslations = { // GreetingBubble dismissGreeting: "Dismiss greeting", + // Attachments + attachFile: "Attach a file", + removeAttachment: "Remove attachment", + attachmentsLabel: "Attachments", + dropFilesHint: "Drop files to attach", + attachmentTooLarge: "{name} is too large. The maximum size is {max}.", + attachmentTypeNotAllowed: "{name} is not a supported file type.", + attachmentTooMany: "You can attach up to {max} files per message.", + // Errors errorGeneric: "Something went wrong. Please try again.", errorConnection: "Failed to connect. Please try again.", errorTimeout: "Request timed out. Please try again.", errorRateLimitMinute: "Too many requests. Please wait a minute.", errorRateLimitHour: "Hourly limit reached. Please try again later.", + errorAttachmentRejected: + "An attachment was rejected. Please remove it and try again.", + errorAttachmentQuota: "Upload limit reached. Please try again later.", errorRetry: "Retry", }; diff --git a/widget/src/locales/es.ts b/widget/src/locales/es.ts index 196c7e9..8e9b1b6 100644 --- a/widget/src/locales/es.ts +++ b/widget/src/locales/es.ts @@ -23,11 +23,23 @@ export const es: ClaudiusTranslations = { // GreetingBubble dismissGreeting: "Descartar saludo", + // Attachments + attachFile: "Adjuntar un archivo", + removeAttachment: "Quitar adjunto", + attachmentsLabel: "Adjuntos", + dropFilesHint: "Suelta los archivos para adjuntarlos", + attachmentTooLarge: "{name} es demasiado grande. El tamaño máximo es {max}.", + attachmentTypeNotAllowed: "{name} no es un tipo de archivo compatible.", + attachmentTooMany: "Puedes adjuntar hasta {max} archivos por mensaje.", + // Errors errorGeneric: "Algo salió mal. Inténtalo de nuevo.", errorConnection: "No se pudo conectar. Inténtalo de nuevo.", errorTimeout: "La solicitud tardó demasiado. Inténtalo de nuevo.", errorRateLimitMinute: "Demasiadas solicitudes. Espera un minuto.", errorRateLimitHour: "Has alcanzado el límite por hora. Inténtalo más tarde.", + errorAttachmentRejected: + "Se rechazó un adjunto. Quítalo e inténtalo de nuevo.", + errorAttachmentQuota: "Límite de subida alcanzado. Inténtalo más tarde.", errorRetry: "Reintentar", }; diff --git a/widget/src/locales/fr.ts b/widget/src/locales/fr.ts index c2c4b98..ca63ec3 100644 --- a/widget/src/locales/fr.ts +++ b/widget/src/locales/fr.ts @@ -23,11 +23,26 @@ export const fr: ClaudiusTranslations = { // GreetingBubble dismissGreeting: "Ignorer le message d'accueil", + // Attachments + attachFile: "Joindre un fichier", + removeAttachment: "Retirer la pièce jointe", + attachmentsLabel: "Pièces jointes", + dropFilesHint: "Déposez les fichiers pour les joindre", + attachmentTooLarge: + "{name} est trop volumineux. La taille maximale est de {max}.", + attachmentTypeNotAllowed: + "{name} n'est pas un type de fichier pris en charge.", + attachmentTooMany: "Vous pouvez joindre jusqu'à {max} fichiers par message.", + // Errors errorGeneric: "Une erreur s'est produite. Veuillez réessayer.", errorConnection: "Échec de la connexion. Veuillez réessayer.", errorTimeout: "La requête a expiré. Veuillez réessayer.", errorRateLimitMinute: "Trop de requêtes. Veuillez patienter une minute.", errorRateLimitHour: "Limite horaire atteinte. Veuillez réessayer plus tard.", + errorAttachmentRejected: + "Une pièce jointe a été refusée. Retirez-la et réessayez.", + errorAttachmentQuota: + "Limite d'envoi atteinte. Veuillez réessayer plus tard.", errorRetry: "Réessayer", }; diff --git a/widget/src/main.tsx b/widget/src/main.tsx index fab16ad..d24965e 100644 --- a/widget/src/main.tsx +++ b/widget/src/main.tsx @@ -5,6 +5,6 @@ import "./styles.css"; createRoot(document.getElementById("root")!).render( - + , ); diff --git a/widget/src/utils/__tests__/attachments.test.ts b/widget/src/utils/__tests__/attachments.test.ts new file mode 100644 index 0000000..6a7b587 --- /dev/null +++ b/widget/src/utils/__tests__/attachments.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect } from "vitest"; +import { + applyStoredAttachments, + attachmentPreviewSrc, + DEFAULT_ATTACHMENT_OPTIONS, + detectMediaType, + fileToAttachment, + formatBytes, + resolveAttachmentsConfig, + stripAttachmentData, + validateFiles, +} from "../attachments"; +import type { ChatMessage } from "../../api/types"; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function file(name: string, type: string, size = 8): File { + return new File([new Uint8Array(size)], name, { type }); +} + +describe("resolveAttachmentsConfig", () => { + it("returns null when disabled", () => { + expect(resolveAttachmentsConfig(undefined)).toBeNull(); + expect(resolveAttachmentsConfig(false)).toBeNull(); + expect(resolveAttachmentsConfig(null)).toBeNull(); + }); + + it("returns the defaults for `true`", () => { + expect(resolveAttachmentsConfig(true)).toEqual(DEFAULT_ATTACHMENT_OPTIONS); + }); + + it("fills in missing fields and normalizes types", () => { + expect( + resolveAttachmentsConfig({ maxCount: 2, allowedTypes: [" Image/PNG "] }), + ).toEqual({ + maxSizeBytes: DEFAULT_ATTACHMENT_OPTIONS.maxSizeBytes, + maxCount: 2, + allowedTypes: ["image/png"], + }); + }); +}); + +describe("formatBytes", () => { + it("formats bytes, kilobytes, and megabytes", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2 KB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5 MB"); + expect(formatBytes(1.5 * 1024 * 1024)).toBe("1.5 MB"); + expect(formatBytes(-1)).toBe("0 B"); + }); +}); + +describe("detectMediaType", () => { + it("prefers the browser-reported type and falls back to the extension", () => { + expect(detectMediaType(file("a.png", "image/png"))).toBe("image/png"); + expect(detectMediaType(file("photo.JPG", ""))).toBe("image/jpeg"); + expect(detectMediaType(file("doc.pdf", ""))).toBe("application/pdf"); + expect(detectMediaType(file("mystery", ""))).toBe(""); + }); +}); + +describe("validateFiles", () => { + const config = { + ...DEFAULT_ATTACHMENT_OPTIONS, + maxSizeBytes: 100, + maxCount: 2, + }; + + it("accepts allowed files within limits", () => { + const png = file("a.png", "image/png"); + const pdf = file("b.pdf", "application/pdf"); + expect(validateFiles([png, pdf], 0, config)).toEqual({ + accepted: [png, pdf], + rejected: [], + }); + }); + + it("rejects by type, size, and count with the first failing reason", () => { + const txt = file("notes.txt", "text/plain"); + const big = file("big.png", "image/png", 101); + const empty = file("empty.png", "image/png", 0); + const ok1 = file("1.png", "image/png"); + const ok2 = file("2.png", "image/png"); + const result = validateFiles([txt, big, empty, ok1, ok2], 1, config); + expect(result.accepted).toEqual([ok1]); + expect(result.rejected).toEqual([ + { file: txt, reason: "type" }, + { file: big, reason: "size" }, + { file: empty, reason: "size" }, + { file: ok2, reason: "count" }, + ]); + }); +}); + +describe("fileToAttachment", () => { + it("reads the file into a base64 attachment with a unique id", async () => { + const f = new File([PNG_BYTES], "shot.png", { type: "image/png" }); + const a = await fileToAttachment(f); + const b = await fileToAttachment(f); + expect(a.id).not.toBe(b.id); + expect(a.name).toBe("shot.png"); + expect(a.mediaType).toBe("image/png"); + expect(a.size).toBe(PNG_BYTES.byteLength); + expect(a.data).toBe(btoa(String.fromCharCode(...PNG_BYTES))); + }); +}); + +describe("attachmentPreviewSrc", () => { + const base = { id: "a", name: "x.png", mediaType: "image/png", size: 1 }; + + it("uses a safe signed URL first, then inline data, else nothing", () => { + expect( + attachmentPreviewSrc({ ...base, url: "https://w/x", data: "AA==" }), + ).toBe("https://w/x"); + expect( + attachmentPreviewSrc({ + ...base, + url: "javascript:alert(1)", + data: "AA==", + }), + ).toBe("data:image/png;base64,AA=="); + expect(attachmentPreviewSrc({ ...base, data: "AA==" })).toBe( + "data:image/png;base64,AA==", + ); + expect(attachmentPreviewSrc(base)).toBeUndefined(); + }); + + it("returns nothing for non-images", () => { + expect( + attachmentPreviewSrc({ + ...base, + mediaType: "application/pdf", + data: "AA==", + }), + ).toBeUndefined(); + }); +}); + +describe("stripAttachmentData / applyStoredAttachments", () => { + const messages: ChatMessage[] = [ + { + id: "m1", + role: "user", + content: "look", + attachments: [ + { + id: "a1", + name: "x.png", + mediaType: "image/png", + size: 1, + data: "AA==", + }, + { + id: "a2", + name: "y.pdf", + mediaType: "application/pdf", + size: 1, + key: "att/t/k", + }, + ], + }, + { id: "m2", role: "assistant", content: "ok" }, + ]; + + it("strips inline bytes but keeps everything else", () => { + const stripped = stripAttachmentData(messages); + expect(stripped[0].attachments).toEqual([ + { id: "a1", name: "x.png", mediaType: "image/png", size: 1 }, + { + id: "a2", + name: "y.pdf", + mediaType: "application/pdf", + size: 1, + key: "att/t/k", + }, + ]); + expect(stripped[1]).toBe(messages[1]); + // The input is not mutated. + expect(messages[0].attachments![0].data).toBe("AA=="); + }); + + it("records storage metadata and drops the bytes for matched ids only", () => { + const applied = applyStoredAttachments(messages, [ + { + id: "a1", + key: "att/t/new", + url: "https://w/att/t/new?sig=1", + expiresAt: "2026-01-02T00:00:00.000Z", + }, + ]); + expect(applied[0].attachments![0]).toEqual({ + id: "a1", + name: "x.png", + mediaType: "image/png", + size: 1, + key: "att/t/new", + url: "https://w/att/t/new?sig=1", + expiresAt: "2026-01-02T00:00:00.000Z", + }); + expect(applied[0].attachments![1]).toBe(messages[0].attachments![1]); + expect(applyStoredAttachments(messages, undefined)).toBe(messages); + expect(applyStoredAttachments(messages, [])).toBe(messages); + }); +}); diff --git a/widget/src/utils/attachments.ts b/widget/src/utils/attachments.ts new file mode 100644 index 0000000..d6c6b19 --- /dev/null +++ b/widget/src/utils/attachments.ts @@ -0,0 +1,254 @@ +import type { ChatAttachment, ChatMessage } from "../api/types"; + +/** + * Client-side attachment limits. Pass to {@link ChatWidget} via the + * `attachments` prop (or `true` for the defaults). The worker enforces its own + * limits independently; keep the two in sync so uploads aren't rejected late. + */ +export interface AttachmentsOptions { + /** + * Largest file accepted, in bytes. + * @defaultValue `5 * 1024 * 1024` (5 MB) + */ + maxSizeBytes?: number; + /** + * Maximum number of files per message. + * @defaultValue `5` + */ + maxCount?: number; + /** + * Accepted MIME types. + * @defaultValue `["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"]` + */ + allowedTypes?: string[]; +} + +/** {@link AttachmentsOptions} with every field filled in. */ +export type ResolvedAttachmentsConfig = Required; + +/** Defaults applied when attachments are enabled with `true` or a partial config. */ +export const DEFAULT_ATTACHMENT_OPTIONS: ResolvedAttachmentsConfig = { + maxSizeBytes: 5 * 1024 * 1024, + maxCount: 5, + allowedTypes: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + ], +}; + +/** + * Turn the `attachments` prop into a full config, or `null` when attachments + * are disabled (`false` / `undefined`). + */ +export function resolveAttachmentsConfig( + input: boolean | AttachmentsOptions | undefined | null, +): ResolvedAttachmentsConfig | null { + if (!input) return null; + if (input === true) return DEFAULT_ATTACHMENT_OPTIONS; + return { + maxSizeBytes: input.maxSizeBytes ?? DEFAULT_ATTACHMENT_OPTIONS.maxSizeBytes, + maxCount: input.maxCount ?? DEFAULT_ATTACHMENT_OPTIONS.maxCount, + allowedTypes: ( + input.allowedTypes ?? DEFAULT_ATTACHMENT_OPTIONS.allowedTypes + ) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean), + }; +} + +/** Human-readable size, e.g. `"512 KB"` or `"1.5 MB"`. */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${Math.round(kb)} KB`; + const mb = kb / 1024; + return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`; +} + +const EXTENSION_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + pdf: "application/pdf", +}; + +/** + * Best-effort MIME type for a file: the browser-reported type, falling back to + * the extension (some drag sources and clipboards omit `File.type`). + */ +export function detectMediaType(file: File): string { + const reported = (file.type || "").trim().toLowerCase(); + if (reported) return reported; + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + return EXTENSION_TYPES[ext] ?? ""; +} + +/** Whether a MIME type is one of the image types the widget previews. */ +export function isImageType(mediaType: string): boolean { + return mediaType.startsWith("image/"); +} + +/** + * Source usable in an `` for an image attachment: its signed `url` + * (http/https only) or an inline data URL. Undefined for non-images and for + * attachments whose bytes are gone. + */ +export function attachmentPreviewSrc(att: ChatAttachment): string | undefined { + if (!isImageType(att.mediaType)) return undefined; + if (att.url) { + try { + const parsed = new URL(att.url); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return att.url; + } + } catch { + // fall through to inline data + } + } + if (att.data) return `data:${att.mediaType};base64,${att.data}`; + return undefined; +} + +/** Reason a file was refused by {@link validateFiles}. */ +export type FileRejectionReason = "type" | "size" | "count"; + +/** Outcome of {@link validateFiles}. */ +export interface FileValidationResult { + /** Files that passed every check, in input order. */ + accepted: File[]; + /** Files that were refused, with the first failing check. */ + rejected: Array<{ file: File; reason: FileRejectionReason }>; +} + +/** + * Apply the type allowlist, size cap, and per-message count to a batch of + * files. `existingCount` is the number of attachments already pending. + */ +export function validateFiles( + files: readonly File[], + existingCount: number, + config: ResolvedAttachmentsConfig, +): FileValidationResult { + const accepted: File[] = []; + const rejected: FileValidationResult["rejected"] = []; + let room = Math.max(0, config.maxCount - existingCount); + + for (const file of files) { + const type = detectMediaType(file); + if (!type || !config.allowedTypes.includes(type)) { + rejected.push({ file, reason: "type" }); + } else if (file.size > config.maxSizeBytes || file.size === 0) { + rejected.push({ file, reason: "size" }); + } else if (room <= 0) { + rejected.push({ file, reason: "count" }); + } else { + accepted.push(file); + room -= 1; + } + } + + return { accepted, rejected }; +} + +/** Read a file's bytes as base64 (no `data:` prefix). */ +export function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => + reject(reader.error ?? new Error("Failed to read file")); + reader.onload = () => { + const result = String(reader.result ?? ""); + const comma = result.indexOf(","); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.readAsDataURL(file); + }); +} + +let attachmentCounter = 0; + +/** Unique, URL-safe attachment id (also the multipart part name). */ +export function createAttachmentId(): string { + attachmentCounter += 1; + return `att-${Date.now().toString(36)}-${attachmentCounter}`; +} + +/** Convert a browser `File` into an inline {@link ChatAttachment}. */ +export async function fileToAttachment(file: File): Promise { + const data = await readFileAsBase64(file); + return { + id: createAttachmentId(), + name: file.name || "attachment", + mediaType: detectMediaType(file), + size: file.size, + data, + }; +} + +/** + * Copy of `messages` with inline attachment bytes removed, for storage. Keeps + * names, keys, and signed URLs so previews and follow-ups still work. + */ +export function stripAttachmentData(messages: ChatMessage[]): ChatMessage[] { + return messages.map((m) => { + if (!m.attachments?.some((a) => a.data)) return m; + return { + ...m, + attachments: m.attachments.map((a) => { + if (!a.data) return a; + const copy = { ...a }; + delete copy.data; + return copy; + }), + }; + }); +} + +/** + * Apply the worker's storage metadata to the matching user-message + * attachments: record key/url/expiry and drop the inline bytes so the next + * turn references the stored copy instead of re-uploading. + */ +export function applyStoredAttachments( + messages: ChatMessage[], + stored: readonly ChatAttachmentStorageInfo[] | undefined, +): ChatMessage[] { + if (!stored || stored.length === 0) return messages; + const byId = new Map(stored.map((s) => [s.id, s])); + return messages.map((m) => { + if (!m.attachments?.some((a) => byId.has(a.id))) return m; + return { + ...m, + attachments: m.attachments.map((a) => { + const info = byId.get(a.id); + if (!info) return a; + const next: ChatAttachment = { + ...a, + key: info.key, + url: info.url, + expiresAt: info.expiresAt, + }; + delete next.data; + return next; + }), + }; + }); +} + +/** Minimal shape of the worker's stored-attachment metadata. */ +export interface ChatAttachmentStorageInfo { + /** Attachment id the metadata belongs to. */ + id: string; + /** Worker storage key. */ + key: string; + /** Signed preview URL, when available. */ + url?: string; + /** ISO 8601 expiry. */ + expiresAt: string; +} diff --git a/widget/src/utils/interpolate.ts b/widget/src/utils/interpolate.ts new file mode 100644 index 0000000..ef14e2f --- /dev/null +++ b/widget/src/utils/interpolate.ts @@ -0,0 +1,12 @@ +/** + * Replace `{name}` placeholders in a translation string. Unknown placeholders + * are left untouched so a partially localized string still renders. + */ +export function interpolate( + template: string, + vars: Record, +): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => + key in vars ? String(vars[key]) : match, + ); +} diff --git a/worker/.dev.vars.example b/worker/.dev.vars.example index bcd2e33..7132382 100644 --- a/worker/.dev.vars.example +++ b/worker/.dev.vars.example @@ -1 +1,3 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here +# Only needed when ATTACHMENT_STORAGE=r2 (signs attachment download URLs). +# ATTACHMENT_SIGNING_SECRET=change-me-to-a-long-random-string diff --git a/worker/src/__tests__/attachment-quota.test.ts b/worker/src/__tests__/attachment-quota.test.ts new file mode 100644 index 0000000..f152282 --- /dev/null +++ b/worker/src/__tests__/attachment-quota.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { + checkAttachmentQuota, + DEFAULT_IP_BYTES_PER_DAY, + DEFAULT_TENANT_BYTES_PER_DAY, + quotaConfigFromEnv, +} from "../attachment-quota"; + +function createMockKV() { + const store = new Map(); + const kv = { + store, + get: async (key: string) => store.get(key) ?? null, + put: async (key: string, value: string) => { + store.set(key, value); + }, + }; + return kv as unknown as KVNamespace & { store: Map }; +} + +const NOW = Date.UTC(2026, 5, 15, 12, 0, 0); // noon UTC + +describe("quotaConfigFromEnv", () => { + it("uses defaults and accepts overrides (0 disables)", () => { + expect(quotaConfigFromEnv({})).toEqual({ + ipBytesPerDay: DEFAULT_IP_BYTES_PER_DAY, + tenantBytesPerDay: DEFAULT_TENANT_BYTES_PER_DAY, + }); + expect( + quotaConfigFromEnv({ + ATTACHMENT_QUOTA_IP_BYTES: "0", + ATTACHMENT_QUOTA_TENANT_BYTES: "1234", + }) + ).toEqual({ ipBytesPerDay: 0, tenantBytesPerDay: 1234 }); + }); +}); + +describe("checkAttachmentQuota", () => { + const config = { ipBytesPerDay: 100, tenantBytesPerDay: 150 }; + + it("is a no-op for zero bytes", async () => { + const kv = createMockKV(); + const result = await checkAttachmentQuota(kv, { ip: "1.1.1.1", tenant: "t", bytes: 0 }, config, NOW); + expect(result).toEqual({ allowed: true }); + expect(kv.store.size).toBe(0); + }); + + it("accumulates usage per IP and per tenant under date-scoped keys", async () => { + const kv = createMockKV(); + await checkAttachmentQuota(kv, { ip: "1.1.1.1", tenant: "t", bytes: 40 }, config, NOW); + await checkAttachmentQuota(kv, { ip: "1.1.1.1", tenant: "t", bytes: 30 }, config, NOW); + expect(kv.store.get("attq:ip:1.1.1.1:2026-06-15")).toBe("70"); + expect(kv.store.get("attq:tenant:t:2026-06-15")).toBe("70"); + }); + + it("rejects when the per-IP cap would be exceeded", async () => { + const kv = createMockKV(); + await checkAttachmentQuota(kv, { ip: "1.1.1.1", tenant: "t", bytes: 90 }, config, NOW); + const result = await checkAttachmentQuota(kv, { ip: "1.1.1.1", tenant: "t", bytes: 20 }, config, NOW); + expect(result.allowed).toBe(false); + expect(result.scope).toBe("ip"); + expect(result.retryAfter).toBe(12 * 3600); + // Rejected uploads are not counted. + expect(kv.store.get("attq:ip:1.1.1.1:2026-06-15")).toBe("90"); + }); + + it("rejects when the tenant cap would be exceeded across IPs", async () => { + const kv = createMockKV(); + await checkAttachmentQuota(kv, { ip: "1.1.1.1", tenant: "t", bytes: 80 }, config, NOW); + await checkAttachmentQuota(kv, { ip: "2.2.2.2", tenant: "t", bytes: 60 }, config, NOW); + const result = await checkAttachmentQuota(kv, { ip: "3.3.3.3", tenant: "t", bytes: 20 }, config, NOW); + expect(result.allowed).toBe(false); + expect(result.scope).toBe("tenant"); + }); + + it("skips a check whose cap is 0", async () => { + const kv = createMockKV(); + const result = await checkAttachmentQuota( + kv, + { ip: "1.1.1.1", tenant: "t", bytes: 10_000 }, + { ipBytesPerDay: 0, tenantBytesPerDay: 0 }, + NOW + ); + expect(result.allowed).toBe(true); + expect(kv.store.size).toBe(0); + }); +}); diff --git a/worker/src/__tests__/attachment-storage.test.ts b/worker/src/__tests__/attachment-storage.test.ts new file mode 100644 index 0000000..5df6a7e --- /dev/null +++ b/worker/src/__tests__/attachment-storage.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from "vitest"; +import { + AttachmentStorageConfigError, + createR2Storage, + resolveAttachments, + signAttachmentUrl, + storageFromEnv, + verifyAttachmentSignature, +} from "../attachment-storage"; +import { bytesToBase64, type AttachmentRef } from "../attachments"; + +// --- Mock R2 --------------------------------------------------------------- + +interface StoredObject { + bytes: Uint8Array; + httpMetadata?: { contentType?: string }; + customMetadata?: Record; +} + +function createMockBucket() { + const objects = new Map(); + const bucket = { + objects, + put: async ( + key: string, + value: Uint8Array, + opts?: { httpMetadata?: { contentType?: string }; customMetadata?: Record } + ) => { + objects.set(key, { + bytes: new Uint8Array(value), + httpMetadata: opts?.httpMetadata, + customMetadata: opts?.customMetadata, + }); + }, + get: async (key: string) => { + const obj = objects.get(key); + if (!obj) return null; + return { + httpMetadata: obj.httpMetadata, + customMetadata: obj.customMetadata, + arrayBuffer: async () => obj.bytes.buffer.slice(0), + }; + }, + delete: async (key: string) => { + objects.delete(key); + }, + }; + return bucket as unknown as R2Bucket & { objects: Map }; +} + +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); +const PNG_B64 = bytesToBase64(PNG); +const SECRET = "test-secret"; +const BASE = "https://worker.example"; + +function ref(overrides: Partial = {}): AttachmentRef { + return { + id: "a1", + name: "shot.png", + mediaType: "image/png", + size: PNG.byteLength, + data: PNG_B64, + ...overrides, + }; +} + +// --- Signed URLs ----------------------------------------------------------- + +describe("signed attachment URLs", () => { + const key = "att/example.com/123e4567-e89b-12d3-a456-426614174000"; + + it("round-trips a valid signature", async () => { + const exp = Math.floor(Date.now() / 1000) + 60; + const url = await signAttachmentUrl(BASE, key, exp, SECRET); + const parsed = new URL(url); + expect(parsed.pathname).toBe(`/api/attachments/${key}`); + const ok = await verifyAttachmentSignature( + key, + parsed.searchParams.get("exp")!, + parsed.searchParams.get("sig")!, + SECRET + ); + expect(ok).toBe(true); + }); + + it("rejects tampered keys, wrong secrets, and expired links", async () => { + const exp = Math.floor(Date.now() / 1000) + 60; + const sig = new URL(await signAttachmentUrl(BASE, key, exp, SECRET)).searchParams.get("sig")!; + + expect(await verifyAttachmentSignature("att/other/123e4567-e89b-12d3-a456-426614174000", String(exp), sig, SECRET)).toBe(false); + expect(await verifyAttachmentSignature(key, String(exp), sig, "other")).toBe(false); + expect(await verifyAttachmentSignature(key, String(exp + 1), sig, SECRET)).toBe(false); + expect(await verifyAttachmentSignature(key, String(exp), sig, SECRET, (exp + 10) * 1000)).toBe(false); + expect(await verifyAttachmentSignature(key, undefined, sig, SECRET)).toBe(false); + expect(await verifyAttachmentSignature(key, String(exp), undefined, SECRET)).toBe(false); + }); +}); + +// --- R2 backend -------------------------------------------------------------- + +describe("createR2Storage", () => { + it("stores bytes with metadata and returns a signed URL", async () => { + const bucket = createMockBucket(); + const now = Date.UTC(2026, 0, 1); + const storage = createR2Storage(bucket, { + retentionHours: 2, + signingSecret: SECRET, + baseUrl: BASE, + now: () => now, + }); + + const stored = await storage.store(ref(), "Example.COM"); + expect(stored.id).toBe("a1"); + expect(stored.key).toMatch(/^att\/example\.com\/[0-9a-f-]{36}$/); + expect(stored.expiresAt).toBe(new Date(now + 2 * 3600 * 1000).toISOString()); + expect(stored.url).toContain(`${BASE}/api/attachments/${stored.key}?exp=`); + + const obj = bucket.objects.get(stored.key)!; + expect(obj.bytes).toEqual(PNG); + expect(obj.httpMetadata?.contentType).toBe("image/png"); + expect(obj.customMetadata?.name).toBe("shot.png"); + expect(obj.customMetadata?.expiresAt).toBe(stored.expiresAt); + }); + + it("loads a stored object and purges it once expired", async () => { + const bucket = createMockBucket(); + let now = Date.UTC(2026, 0, 1); + const storage = createR2Storage(bucket, { + retentionHours: 1, + signingSecret: SECRET, + baseUrl: BASE, + now: () => now, + }); + const { key } = await storage.store(ref(), "t"); + + const loaded = await storage.load(key); + expect(loaded?.bytes).toEqual(PNG); + expect(loaded?.mediaType).toBe("image/png"); + expect(loaded?.name).toBe("shot.png"); + + now += 2 * 3600 * 1000; + expect(await storage.load(key)).toBeNull(); + expect(bucket.objects.has(key)).toBe(false); + expect(await storage.load("att/t/missing")).toBeNull(); + }); + + it("refuses to store a ref without data", async () => { + const storage = createR2Storage(createMockBucket(), { + retentionHours: 1, + signingSecret: SECRET, + baseUrl: BASE, + }); + await expect(storage.store(ref({ data: undefined }), "t")).rejects.toThrow(/without data/); + }); +}); + +// --- storageFromEnv ------------------------------------------------------ + +describe("storageFromEnv", () => { + it("returns null for passthrough (default)", () => { + expect(storageFromEnv({}, BASE)).toBeNull(); + expect(storageFromEnv({ ATTACHMENT_STORAGE: "passthrough" }, BASE)).toBeNull(); + }); + + it("requires the bucket binding and signing secret in r2 mode", () => { + expect(() => storageFromEnv({ ATTACHMENT_STORAGE: "r2" }, BASE)).toThrow(AttachmentStorageConfigError); + expect(() => + storageFromEnv({ ATTACHMENT_STORAGE: "r2", ATTACHMENTS: createMockBucket() }, BASE) + ).toThrow(/SIGNING_SECRET/); + expect(() => storageFromEnv({ ATTACHMENT_STORAGE: "s3" }, BASE)).toThrow(/Unknown/); + }); + + it("builds an R2 backend when fully configured", () => { + const storage = storageFromEnv( + { + ATTACHMENT_STORAGE: "r2", + ATTACHMENTS: createMockBucket(), + ATTACHMENT_SIGNING_SECRET: SECRET, + ATTACHMENT_RETENTION_HOURS: "48", + }, + BASE + ); + expect(storage).not.toBeNull(); + }); +}); + +// --- resolveAttachments --------------------------------------------------- + +describe("resolveAttachments", () => { + it("stores new uploads and hydrates key references", async () => { + const bucket = createMockBucket(); + const storage = createR2Storage(bucket, { + retentionHours: 1, + signingSecret: SECRET, + baseUrl: BASE, + }); + + // First turn: a new upload. + const first = ref(); + const stored = await resolveAttachments( + [{ role: "user", attachments: [first] }], + storage, + "tenant" + ); + expect(stored).toHaveLength(1); + expect(first.key).toBe(stored[0].key); + expect(first.data).toBe(PNG_B64); // still forwarded this turn + + // Second turn: the widget references it by key only. + const byKey = ref({ data: undefined, size: 0, key: stored[0].key }); + const again = await resolveAttachments( + [ + { role: "user", attachments: [byKey] }, + { role: "assistant" }, + ], + storage, + "tenant" + ); + expect(again).toEqual([]); + expect(byKey.data).toBe(PNG_B64); + expect(byKey.size).toBe(PNG.byteLength); + + // A stale key stays unavailable rather than failing the request. + const stale = ref({ data: undefined, key: "att/tenant/00000000-0000-0000-0000-000000000000" }); + await resolveAttachments([{ role: "user", attachments: [stale] }], storage, "tenant"); + expect(stale.data).toBeUndefined(); + }); +}); diff --git a/worker/src/__tests__/attachments.test.ts b/worker/src/__tests__/attachments.test.ts new file mode 100644 index 0000000..3781138 --- /dev/null +++ b/worker/src/__tests__/attachments.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect } from "vitest"; +import { + AttachmentError, + attachmentConfigFromEnv, + attachmentToBlock, + base64DecodedLength, + base64ToBytes, + bytesToBase64, + DEFAULT_ATTACHMENT_TYPES, + DEFAULT_MAX_ATTACHMENT_BYTES, + enforceRequestBudget, + hasAttachments, + newUploadBytes, + parseChatRequest, + sniffMediaType, + validateAttachments, + type AttachmentRef, +} from "../attachments"; + +// --- Fixtures ------------------------------------------------------------- + +const PNG_HEADER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const PDF_HEADER = Array.from(new TextEncoder().encode("%PDF-1.4\n")); + +function bytes(header: number[], length = 64): Uint8Array { + const out = new Uint8Array(length); + out.set(header); + for (let i = header.length; i < length; i++) out[i] = i & 0xff; + return out; +} + +const PNG_BYTES = bytes(PNG_HEADER); +const PDF_BYTES = bytes(PDF_HEADER); +const PNG_B64 = bytesToBase64(PNG_BYTES); +const PDF_B64 = bytesToBase64(PDF_BYTES); + +function png(overrides: Partial = {}): AttachmentRef { + return { + id: "a1", + name: "shot.png", + mediaType: "image/png", + size: 0, + data: PNG_B64, + ...overrides, + }; +} + +const config = attachmentConfigFromEnv({}); + +// --- base64 helpers ------------------------------------------------------- + +describe("base64 helpers", () => { + it("round-trips bytes", () => { + expect(base64ToBytes(bytesToBase64(PNG_BYTES))).toEqual(PNG_BYTES); + }); + + it("computes decoded length without decoding", () => { + for (const len of [1, 2, 3, 4, 63, 64, 65]) { + const b64 = bytesToBase64(bytes([], len)); + expect(base64DecodedLength(b64)).toBe(len); + } + expect(base64DecodedLength("")).toBe(0); + }); + + it("encodes buffers larger than one chunk", () => { + const big = bytes([], 0x8000 * 2 + 17); + expect(base64ToBytes(bytesToBase64(big))).toEqual(big); + }); +}); + +// --- sniffMediaType ------------------------------------------------------- + +describe("sniffMediaType", () => { + it("recognizes the supported signatures", () => { + expect(sniffMediaType(PNG_BYTES)).toBe("image/png"); + expect(sniffMediaType(bytes([0xff, 0xd8, 0xff, 0xe0]))).toBe("image/jpeg"); + expect(sniffMediaType(bytes([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]))).toBe( + "image/gif" + ); + const webp = bytes([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]); + expect(sniffMediaType(webp)).toBe("image/webp"); + expect(sniffMediaType(PDF_BYTES)).toBe("application/pdf"); + }); + + it("returns null for unknown bytes", () => { + expect(sniffMediaType(bytes([0x00, 0x01, 0x02]))).toBeNull(); + expect(sniffMediaType(new Uint8Array(0))).toBeNull(); + }); +}); + +// --- attachmentConfigFromEnv --------------------------------------------- + +describe("attachmentConfigFromEnv", () => { + it("uses documented defaults", () => { + expect(config).toEqual({ + enabled: true, + allowedTypes: DEFAULT_ATTACHMENT_TYPES, + maxBytes: DEFAULT_MAX_ATTACHMENT_BYTES, + maxPerMessage: 5, + maxRequestBytes: 20 * 1024 * 1024, + }); + }); + + it("reads overrides and ignores garbage", () => { + const c = attachmentConfigFromEnv({ + ATTACHMENTS_ENABLED: "false", + ATTACHMENT_TYPES: " image/png , APPLICATION/PDF ", + ATTACHMENT_MAX_BYTES: "1000", + ATTACHMENT_MAX_COUNT: "not-a-number", + }); + expect(c.enabled).toBe(false); + expect(c.allowedTypes).toEqual(["image/png", "application/pdf"]); + expect(c.maxBytes).toBe(1000); + expect(c.maxPerMessage).toBe(5); + }); +}); + +// --- validateAttachments -------------------------------------------------- + +describe("validateAttachments", () => { + it("accepts a valid inline PNG and recomputes its size", () => { + const att = png({ size: 999 }); + const messages = [{ role: "user", content: "look", attachments: [att] }]; + validateAttachments(messages, config); + expect(att.size).toBe(PNG_BYTES.byteLength); + }); + + it("accepts a valid PDF", () => { + const att = png({ name: "doc.pdf", mediaType: "application/pdf", data: PDF_B64 }); + validateAttachments([{ role: "user", content: "", attachments: [att] }], config); + expect(att.size).toBe(PDF_BYTES.byteLength); + }); + + it("drops empty attachment arrays", () => { + const message = { role: "user", content: "hi", attachments: [] }; + validateAttachments([message], config); + expect(message.attachments).toBeUndefined(); + }); + + it("rejects attachments on assistant messages", () => { + expect(() => + validateAttachments( + [{ role: "assistant", content: "x", attachments: [png()] }], + config + ) + ).toThrow(/Only user messages/); + }); + + it("rejects disallowed media types", () => { + const att = png({ mediaType: "image/svg+xml" }); + expect(() => + validateAttachments([{ role: "user", content: "", attachments: [att] }], config) + ).toThrow(/not allowed/); + }); + + it("rejects a declared type that does not match the bytes", () => { + const att = png({ mediaType: "image/jpeg" }); + expect(() => + validateAttachments([{ role: "user", content: "", attachments: [att] }], config) + ).toThrow(/does not look like image\/jpeg/); + }); + + it("rejects oversized files with a 413 code", () => { + const small = attachmentConfigFromEnv({ ATTACHMENT_MAX_BYTES: "16" }); + let caught: unknown; + try { + validateAttachments( + [{ role: "user", content: "", attachments: [png()] }], + small + ); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(AttachmentError); + expect((caught as AttachmentError).code).toBe("ATTACHMENT_TOO_LARGE"); + expect((caught as AttachmentError).status).toBe(413); + }); + + it("rejects more than maxPerMessage attachments", () => { + const atts = Array.from({ length: 6 }, (_, i) => png({ id: `a${i}` })); + expect(() => + validateAttachments([{ role: "user", content: "", attachments: atts }], config) + ).toThrow(/Too many attachments/); + }); + + it("rejects bad ids, duplicate ids, empty names, and bad keys", () => { + const run = (att: Partial[]) => + validateAttachments( + [{ role: "user", content: "", attachments: att }], + config + ); + expect(() => run([png({ id: "has space" })])).toThrow(/id/); + expect(() => run([png(), png()])).toThrow(/Duplicate/); + expect(() => run([png({ name: " " })])).toThrow(/name/); + expect(() => + run([{ id: "k1", name: "x.png", mediaType: "image/png", size: 1, key: "../etc" }]) + ).toThrow(/key/); + }); + + it("accepts a well-formed key reference without data", () => { + const att: AttachmentRef = { + id: "k1", + name: "x.png", + mediaType: "image/png", + size: 12, + key: "att/example.com/123e4567-e89b-12d3-a456-426614174000", + }; + validateAttachments([{ role: "user", content: "", attachments: [att] }], config); + expect(att.size).toBe(12); + }); + + it("rejects non-base64 data and empty payloads", () => { + expect(() => + validateAttachments( + [{ role: "user", content: "", attachments: [png({ data: "not base64!" })] }], + config + ) + ).toThrow(/base64/); + expect(() => + validateAttachments( + [{ role: "user", content: "", attachments: [png({ data: "" })] }], + config + ) + ).toThrow(/empty/); + }); +}); + +// --- newUploadBytes / enforceRequestBudget / hasAttachments ------------- + +describe("request-level helpers", () => { + it("hasAttachments detects any non-empty list", () => { + expect(hasAttachments([{ role: "user", content: "x" }])).toBe(false); + expect(hasAttachments([{ role: "user", content: "x", attachments: [] }])).toBe(false); + expect(hasAttachments([{ role: "user", content: "x", attachments: [png()] }])).toBe(true); + }); + + it("newUploadBytes counts inline data on the newest user message only", () => { + const messages = [ + { role: "user", content: "a", attachments: [png({ size: 100 })] }, + { role: "assistant", content: "b" }, + { + role: "user", + content: "c", + attachments: [png({ id: "x", size: 30 }), png({ id: "y", size: 20, data: undefined, key: "att/t/123e4567-e89b-12d3-a456-426614174000" })], + }, + ]; + expect(newUploadBytes(messages)).toBe(30); + }); + + it("enforceRequestBudget drops bytes from the oldest messages first", () => { + const old = png({ id: "old", size: 60 }); + const mid = png({ id: "mid", size: 30 }); + const latest = png({ id: "new", size: 30 }); + const messages = [ + { role: "user", content: "1", attachments: [old] }, + { role: "user", content: "2", attachments: [mid] }, + { role: "user", content: "3", attachments: [latest] }, + ]; + enforceRequestBudget(messages, 70); + expect(latest.data).toBeDefined(); + expect(mid.data).toBeDefined(); + expect(old.data).toBeUndefined(); + expect(old.key).toBeUndefined(); + expect(old.name).toBe("shot.png"); + }); +}); + +// --- parseChatRequest ---------------------------------------------------- + +describe("parseChatRequest", () => { + it("parses a plain JSON body unchanged", async () => { + const req = new Request("http://x/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + }); + expect(await parseChatRequest(req)).toEqual({ + messages: [{ role: "user", content: "hi" }], + }); + }); + + it("merges multipart file parts into their attachment refs", async () => { + const form = new FormData(); + form.append( + "payload", + JSON.stringify({ + conversationId: "conv-1", + messages: [ + { + role: "user", + content: "what is this?", + attachments: [ + { id: "f1", name: "shot.png", mediaType: "image/png", size: 0 }, + ], + }, + ], + }) + ); + form.append("f1", new Blob([PNG_BYTES], { type: "image/png" }), "shot.png"); + const req = new Request("http://x/api/chat", { method: "POST", body: form }); + + const parsed = await parseChatRequest(req); + expect(parsed.conversationId).toBe("conv-1"); + const att = parsed.messages[0].attachments![0]; + expect(att.data).toBe(PNG_B64); + expect(att.size).toBe(PNG_BYTES.byteLength); + }); + + it("rejects a multipart body without a payload field", async () => { + const form = new FormData(); + form.append("f1", new Blob([PNG_BYTES]), "x.png"); + const req = new Request("http://x/api/chat", { method: "POST", body: form }); + await expect(parseChatRequest(req)).rejects.toThrow(/payload/); + }); + + it("rejects file parts that match no attachment", async () => { + const form = new FormData(); + form.append("payload", JSON.stringify({ messages: [{ role: "user", content: "x" }] })); + form.append("stray", new Blob([PNG_BYTES]), "x.png"); + const req = new Request("http://x/api/chat", { method: "POST", body: form }); + await expect(parseChatRequest(req)).rejects.toThrow(/no matching attachment/); + }); +}); + +// --- attachmentToBlock --------------------------------------------------- + +describe("attachmentToBlock", () => { + it("maps images to base64 image blocks", () => { + expect(attachmentToBlock(png())).toEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: PNG_B64 }, + }); + }); + + it("maps PDFs to document blocks with the filename as title", () => { + expect( + attachmentToBlock(png({ name: "invoice.pdf", mediaType: "application/pdf", data: PDF_B64 })) + ).toEqual({ + type: "document", + title: "invoice.pdf", + source: { type: "base64", media_type: "application/pdf", data: PDF_B64 }, + }); + }); + + it("renders a text note when bytes are unavailable", () => { + expect(attachmentToBlock(png({ data: undefined }))).toEqual({ + type: "text", + text: '[Attachment "shot.png" (image/png) is no longer available]', + }); + }); +}); diff --git a/worker/src/__tests__/chat-attachments.test.ts b/worker/src/__tests__/chat-attachments.test.ts new file mode 100644 index 0000000..dfbdac0 --- /dev/null +++ b/worker/src/__tests__/chat-attachments.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from "vitest"; + +const createMock = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "I see a receipt." }], + usage: { input_tokens: 100, output_tokens: 5 }, +}); + +vi.mock("@anthropic-ai/sdk", () => ({ + default: class MockAnthropic { + messages = { create: createMock }; + }, +})); + +import { handleChat } from "../chat"; +import { bytesToBase64 } from "../attachments"; + +const PNG_B64 = bytesToBase64( + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 9]) +); +const PDF_B64 = bytesToBase64(new TextEncoder().encode("%PDF-1.4 tiny")); + +describe("handleChat with attachments", () => { + it("forwards images and PDFs as content blocks before the text", async () => { + createMock.mockClear(); + await handleChat( + { + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + { + role: "user", + content: "What is the total?", + attachments: [ + { id: "i", name: "shot.png", mediaType: "image/png", size: 10, data: PNG_B64 }, + { id: "p", name: "bill.pdf", mediaType: "application/pdf", size: 13, data: PDF_B64 }, + ], + }, + ], + }, + "key" + ); + + const params = createMock.mock.calls[0][0]; + // Plain messages keep the string wire shape. + expect(params.messages[0]).toEqual({ role: "user", content: "hello" }); + expect(params.messages[1]).toEqual({ role: "assistant", content: "hi" }); + expect(params.messages[2]).toEqual({ + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: PNG_B64 }, + }, + { + type: "document", + title: "bill.pdf", + source: { type: "base64", media_type: "application/pdf", data: PDF_B64 }, + }, + { type: "text", text: "What is the total?" }, + ], + }); + }); + + it("allows an attachment-only message and omits the empty text block", async () => { + createMock.mockClear(); + const result = await handleChat( + { + messages: [ + { + role: "user", + content: "", + attachments: [ + { id: "i", name: "shot.png", mediaType: "image/png", size: 10, data: PNG_B64 }, + ], + }, + ], + }, + "key" + ); + expect(result.response.reply).toBe("I see a receipt."); + const content = createMock.mock.calls[0][0].messages[0].content; + expect(content).toHaveLength(1); + expect(content[0].type).toBe("image"); + }); + + it("renders a note for attachments whose bytes are gone", async () => { + createMock.mockClear(); + await handleChat( + { + messages: [ + { + role: "user", + content: "and this one?", + attachments: [{ id: "i", name: "old.png", mediaType: "image/png", size: 10 }], + }, + ], + }, + "key" + ); + const content = createMock.mock.calls[0][0].messages[0].content; + expect(content[0]).toEqual({ + type: "text", + text: '[Attachment "old.png" (image/png) is no longer available]', + }); + }); + + it("rejects a message with neither text nor attachments", async () => { + await expect( + handleChat({ messages: [{ role: "user", content: " " }] }, "key") + ).rejects.toThrow("Message content is required"); + }); + + it("ignores attachments on assistant messages", async () => { + createMock.mockClear(); + await handleChat( + { + messages: [ + { role: "user", content: "x" }, + { + role: "assistant", + content: "y", + attachments: [{ id: "i", name: "a.png", mediaType: "image/png", size: 1, data: PNG_B64 }], + }, + { role: "user", content: "z" }, + ], + }, + "key" + ); + expect(createMock.mock.calls[0][0].messages[1]).toEqual({ role: "assistant", content: "y" }); + }); +}); diff --git a/worker/src/__tests__/chat-route-attachments.test.ts b/worker/src/__tests__/chat-route-attachments.test.ts new file mode 100644 index 0000000..0ae0b95 --- /dev/null +++ b/worker/src/__tests__/chat-route-attachments.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi } from "vitest"; + +const createMock = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Looks like a receipt for $42." }], + usage: { input_tokens: 50, output_tokens: 8 }, +}); + +vi.mock("@anthropic-ai/sdk", () => ({ + default: class MockAnthropic { + messages = { create: createMock }; + }, +})); + +import app from "../index"; +import { bytesToBase64 } from "../attachments"; + +/** + * Route-level coverage for attachments on POST /api/chat and the signed + * download route, driving the Hono app through `app.fetch` with mocked KV, + * R2, and Anthropic. + */ + +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); +const PNG_B64 = bytesToBase64(PNG); + +function createMockKV() { + const store = new Map(); + return { + store, + get: async (key: string) => store.get(key) ?? null, + put: async (key: string, value: string) => void store.set(key, value), + } as unknown as KVNamespace & { store: Map }; +} + +function createMockBucket() { + const objects = new Map< + string, + { bytes: Uint8Array; httpMetadata?: { contentType?: string }; customMetadata?: Record } + >(); + return { + objects, + put: async (key: string, value: Uint8Array, opts?: Record) => { + objects.set(key, { bytes: new Uint8Array(value), ...(opts ?? {}) }); + }, + get: async (key: string) => { + const obj = objects.get(key); + if (!obj) return null; + return { + httpMetadata: obj.httpMetadata, + customMetadata: obj.customMetadata, + arrayBuffer: async () => obj.bytes.buffer.slice(0), + }; + }, + delete: async (key: string) => void objects.delete(key), + } as unknown as R2Bucket & { objects: Map }; +} + +function createMockCtx(): ExecutionContext { + return { + waitUntil: () => {}, + passThroughOnException: () => {}, + } as unknown as ExecutionContext; +} + +function baseEnv(extra: Record = {}) { + return { + ANTHROPIC_API_KEY: "test-key", + ALLOWED_ORIGIN: "https://site.example", + RATE_LIMIT: createMockKV(), + ...extra, + }; +} + +function jsonRequest(body: unknown, headers: Record = {}) { + return new Request("http://localhost/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + "cf-connecting-ip": "1.2.3.4", + Origin: "https://site.example", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +function multipartRequest(payload: unknown, files: Array<[string, Uint8Array, string]>) { + const form = new FormData(); + form.append("payload", JSON.stringify(payload)); + for (const [id, bytes, name] of files) { + form.append(id, new Blob([bytes], { type: "image/png" }), name); + } + return new Request("http://localhost/api/chat", { + method: "POST", + headers: { "cf-connecting-ip": "1.2.3.4", Origin: "https://site.example" }, + body: form, + }); +} + +const attachedMessage = (data?: string) => ({ + messages: [ + { + role: "user", + content: "How much was this?", + attachments: [ + { id: "f1", name: "receipt.png", mediaType: "image/png", size: PNG.byteLength, ...(data ? { data } : {}) }, + ], + }, + ], +}); + +describe("POST /api/chat with attachments", () => { + it("accepts a multipart upload and forwards it to the model", async () => { + createMock.mockClear(); + const res = await app.fetch( + multipartRequest(attachedMessage(), [["f1", PNG, "receipt.png"]]), + baseEnv(), + createMockCtx() + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ reply: "Looks like a receipt for $42." }); + + const content = createMock.mock.calls[0][0].messages[0].content; + expect(content[0]).toEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: PNG_B64 }, + }); + }); + + it("accepts inline base64 in a JSON body", async () => { + const res = await app.fetch(jsonRequest(attachedMessage(PNG_B64)), baseEnv(), createMockCtx()); + expect(res.status).toBe(200); + }); + + it("returns 400 ATTACHMENTS_DISABLED when switched off", async () => { + const res = await app.fetch( + jsonRequest(attachedMessage(PNG_B64)), + baseEnv({ ATTACHMENTS_ENABLED: "false" }), + createMockCtx() + ); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("ATTACHMENTS_DISABLED"); + }); + + it("returns 400 ATTACHMENT_INVALID for a disallowed type", async () => { + const body = attachedMessage(PNG_B64); + body.messages[0].attachments[0].mediaType = "text/plain"; + const res = await app.fetch(jsonRequest(body), baseEnv(), createMockCtx()); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("ATTACHMENT_INVALID"); + }); + + it("returns 413 ATTACHMENT_TOO_LARGE when a file exceeds the cap", async () => { + const res = await app.fetch( + jsonRequest(attachedMessage(PNG_B64)), + baseEnv({ ATTACHMENT_MAX_BYTES: "4" }), + createMockCtx() + ); + expect(res.status).toBe(413); + expect((await res.json()).code).toBe("ATTACHMENT_TOO_LARGE"); + }); + + it("returns 413 ATTACHMENT_QUOTA_EXCEEDED with Retry-After once the daily quota is spent", async () => { + const env = baseEnv({ ATTACHMENT_QUOTA_IP_BYTES: String(PNG.byteLength + 1) }); + const first = await app.fetch(jsonRequest(attachedMessage(PNG_B64)), env, createMockCtx()); + expect(first.status).toBe(200); + + const second = await app.fetch(jsonRequest(attachedMessage(PNG_B64)), env, createMockCtx()); + expect(second.status).toBe(413); + expect((await second.json()).code).toBe("ATTACHMENT_QUOTA_EXCEEDED"); + expect(Number(second.headers.get("Retry-After"))).toBeGreaterThan(0); + + const tenantKeys = [...env.RATE_LIMIT.store.keys()].filter((k) => k.startsWith("attq:tenant:site.example:")); + expect(tenantKeys).toHaveLength(1); + }); + + it("maps an upstream 400 to ATTACHMENT_INVALID", async () => { + createMock.mockRejectedValueOnce(Object.assign(new Error("Could not process image"), { status: 400 })); + const res = await app.fetch(jsonRequest(attachedMessage(PNG_B64)), baseEnv(), createMockCtx()); + expect(res.status).toBe(400); + expect((await res.json()).code).toBe("ATTACHMENT_INVALID"); + }); + + it("returns 500 CONFIG_ERROR when r2 mode is missing its bucket", async () => { + const res = await app.fetch( + jsonRequest(attachedMessage(PNG_B64)), + baseEnv({ ATTACHMENT_STORAGE: "r2" }), + createMockCtx() + ); + expect(res.status).toBe(500); + expect((await res.json()).code).toBe("CONFIG_ERROR"); + }); +}); + +describe("R2 storage mode", () => { + function r2Env() { + return baseEnv({ + ATTACHMENT_STORAGE: "r2", + ATTACHMENTS: createMockBucket(), + ATTACHMENT_SIGNING_SECRET: "s3cret", + ATTACHMENT_RETENTION_HOURS: "1", + }); + } + + it("stores new uploads, returns their keys/URLs, and serves them via the signed route", async () => { + const env = r2Env(); + const res = await app.fetch( + multipartRequest(attachedMessage(), [["f1", PNG, "receipt.png"]]), + env, + createMockCtx() + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reply).toBeDefined(); + expect(body.attachments).toHaveLength(1); + const stored = body.attachments[0]; + expect(stored.id).toBe("f1"); + expect(stored.key).toMatch(/^att\/site\.example\/[0-9a-f-]{36}$/); + expect(stored.url).toContain("http://localhost/api/attachments/"); + expect(env.ATTACHMENTS.objects.has(stored.key)).toBe(true); + + // The signed URL serves the bytes back. + const download = await app.fetch(new Request(stored.url), env, createMockCtx()); + expect(download.status).toBe(200); + expect(download.headers.get("Content-Type")).toBe("image/png"); + expect(download.headers.get("Content-Disposition")).toContain('filename="receipt.png"'); + expect(new Uint8Array(await download.arrayBuffer())).toEqual(PNG); + + // Tampering with the signature is rejected; a bad key 404s. Flip the + // first hex digit so the result always differs from the real signature. + const tampered = stored.url.replace(/sig=(\w)/, (_m: string, ch: string) => + `sig=${ch === "0" ? "1" : "0"}` + ); + const denied = await app.fetch(new Request(tampered), env, createMockCtx()); + expect(denied.status).toBe(403); + const missing = await app.fetch(new Request("http://localhost/api/attachments/not-a-key"), env, createMockCtx()); + expect(missing.status).toBe(404); + + // A later turn referencing the key only is hydrated from R2. + createMock.mockClear(); + const followUp = await app.fetch( + jsonRequest({ + messages: [ + { + role: "user", + content: "How much?", + attachments: [{ id: "f1", name: "receipt.png", mediaType: "image/png", size: 0, key: stored.key }], + }, + { role: "assistant", content: "A receipt." }, + { role: "user", content: "Total?" }, + ], + }), + env, + createMockCtx() + ); + expect(followUp.status).toBe(200); + expect((await followUp.json()).attachments).toBeUndefined(); + const content = createMock.mock.calls[0][0].messages[0].content; + expect(content[0].source.data).toBe(PNG_B64); + }); + + it("404s the download route when storage is passthrough", async () => { + const res = await app.fetch( + new Request("http://localhost/api/attachments/att/t/123e4567-e89b-12d3-a456-426614174000?exp=1&sig=a"), + baseEnv(), + createMockCtx() + ); + expect(res.status).toBe(404); + }); +}); + +describe("POST /api/chat/stream with attachments", () => { + // Minimal Anthropic streaming shape: one text block then end_turn. + async function* fakeStream() { + yield { type: "message_start", message: { usage: { input_tokens: 9 } } }; + yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }; + yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "A receipt." } }; + yield { type: "content_block_stop", index: 0 }; + yield { type: "message_delta", usage: { output_tokens: 3 }, delta: { stop_reason: "end_turn" } }; + } + + function streamRequest(payload: unknown, files: Array<[string, Uint8Array, string]>) { + const form = new FormData(); + form.append("payload", JSON.stringify(payload)); + for (const [id, bytes, name] of files) { + form.append(id, new Blob([bytes], { type: "image/png" }), name); + } + return new Request("http://localhost/api/chat/stream", { + method: "POST", + headers: { "cf-connecting-ip": "1.2.3.4", Origin: "https://site.example" }, + body: form, + }); + } + + it("accepts multipart uploads and reports stored attachments on the done event", async () => { + createMock.mockImplementationOnce(async (params: { stream?: boolean }) => + params.stream ? fakeStream() : { content: [], usage: {} } + ); + const env = baseEnv({ + ATTACHMENT_STORAGE: "r2", + ATTACHMENTS: createMockBucket(), + ATTACHMENT_SIGNING_SECRET: "s3cret", + }); + + const res = await app.fetch( + streamRequest(attachedMessage(), [["f1", PNG, "receipt.png"]]), + env, + createMockCtx() + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/event-stream"); + + const text = await res.text(); + expect(text).toContain("event: chunk"); + const doneLine = text + .split("\n") + .find((line, i, lines) => lines[i - 1]?.trim() === "event: done" && line.startsWith("data:")); + const done = JSON.parse(doneLine!.replace(/^data:\s*/, "")); + expect(done.reply).toBe("A receipt."); + expect(done.attachments).toHaveLength(1); + expect(done.attachments[0].key).toMatch(/^att\/site\.example\//); + + // The model saw the image block, not just text. + const content = createMock.mock.calls.at(-1)![0].messages[0].content; + expect(content[0].type).toBe("image"); + }); + + it("rejects attachment problems as plain JSON before the stream opens", async () => { + const res = await app.fetch( + streamRequest(attachedMessage(), [["f1", PNG, "receipt.png"]]), + baseEnv({ ATTACHMENT_MAX_BYTES: "4" }), + createMockCtx() + ); + expect(res.status).toBe(413); + expect((await res.json()).code).toBe("ATTACHMENT_TOO_LARGE"); + }); +}); diff --git a/worker/src/attachment-quota.ts b/worker/src/attachment-quota.ts new file mode 100644 index 0000000..ad2b14f --- /dev/null +++ b/worker/src/attachment-quota.ts @@ -0,0 +1,112 @@ +/** + * Daily upload-byte quotas backed by Workers KV, enforced per client IP and + * per tenant. Counters are keyed by UTC date and expire on their own, mirroring + * the fixed-window approach in `rate-limit.ts`. + */ + +export const DEFAULT_IP_BYTES_PER_DAY = 50 * 1024 * 1024; +export const DEFAULT_TENANT_BYTES_PER_DAY = 500 * 1024 * 1024; +const DAY_SECONDS = 86400; + +export interface AttachmentQuotaConfig { + /** Bytes per IP per UTC day; 0 disables the check. */ + ipBytesPerDay: number; + /** Bytes per tenant per UTC day; 0 disables the check. */ + tenantBytesPerDay: number; +} + +export interface AttachmentQuotaResult { + allowed: boolean; + scope?: "ip" | "tenant"; + /** Seconds until the UTC day rolls over. */ + retryAfter?: number; +} + +interface QuotaEnv { + ATTACHMENT_QUOTA_IP_BYTES?: string; + ATTACHMENT_QUOTA_TENANT_BYTES?: string; +} + +function intFromEnv(value: string | undefined, fallback: number): number { + if (value === undefined || value === "") return fallback; + const n = parseInt(value, 10); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +export function quotaConfigFromEnv(env: QuotaEnv): AttachmentQuotaConfig { + return { + ipBytesPerDay: intFromEnv( + env.ATTACHMENT_QUOTA_IP_BYTES, + DEFAULT_IP_BYTES_PER_DAY + ), + tenantBytesPerDay: intFromEnv( + env.ATTACHMENT_QUOTA_TENANT_BYTES, + DEFAULT_TENANT_BYTES_PER_DAY + ), + }; +} + +function utcDay(now: number): string { + return new Date(now).toISOString().slice(0, 10); +} + +function secondsUntilUtcMidnight(now: number): number { + const d = new Date(now); + const next = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate() + 1 + ); + return Math.max(1, Math.ceil((next - now) / 1000)); +} + +/** + * Check whether `bytes` of new uploads fit under both daily quotas, and if so + * record them. Rejects when either counter would exceed its cap. + */ +export async function checkAttachmentQuota( + kv: KVNamespace, + usage: { ip: string; tenant: string; bytes: number }, + config: AttachmentQuotaConfig, + now: number = Date.now() +): Promise { + if (usage.bytes <= 0) return { allowed: true }; + + const day = utcDay(now); + const ipKey = `attq:ip:${usage.ip}:${day}`; + const tenantKey = `attq:tenant:${usage.tenant}:${day}`; + const checkIp = config.ipBytesPerDay > 0; + const checkTenant = config.tenantBytesPerDay > 0; + + const [ipUsed, tenantUsed] = await Promise.all([ + checkIp ? kv.get(ipKey).then((v) => parseInt(v || "0", 10)) : 0, + checkTenant ? kv.get(tenantKey).then((v) => parseInt(v || "0", 10)) : 0, + ]); + + const retryAfter = secondsUntilUtcMidnight(now); + if (checkIp && ipUsed + usage.bytes > config.ipBytesPerDay) { + return { allowed: false, scope: "ip", retryAfter }; + } + if (checkTenant && tenantUsed + usage.bytes > config.tenantBytesPerDay) { + return { allowed: false, scope: "tenant", retryAfter }; + } + + const writes: Promise[] = []; + if (checkIp) { + writes.push( + kv.put(ipKey, String(ipUsed + usage.bytes), { + expirationTtl: DAY_SECONDS, + }) + ); + } + if (checkTenant) { + writes.push( + kv.put(tenantKey, String(tenantUsed + usage.bytes), { + expirationTtl: DAY_SECONDS, + }) + ); + } + await Promise.all(writes); + + return { allowed: true }; +} diff --git a/worker/src/attachment-storage.ts b/worker/src/attachment-storage.ts new file mode 100644 index 0000000..dd69596 --- /dev/null +++ b/worker/src/attachment-storage.ts @@ -0,0 +1,252 @@ +/** + * Attachment storage backends. + * + * - **passthrough** (default): bytes are forwarded to Anthropic and dropped. + * Nothing is written anywhere; the widget re-sends history each turn. + * - **r2**: new uploads are written to an R2 bucket under a tenant-scoped, + * unguessable key, forwarded to Anthropic, and referenced by key on later + * turns. Each stored object gets an HMAC-signed download URL that expires + * with the retention window so the widget can render previews. + */ +import { + bytesToBase64, + type AttachmentRef, + AttachmentError, +} from "./attachments"; + +/** Metadata returned to the client for each newly stored attachment. */ +export interface StoredAttachment { + id: string; + key: string; + url?: string; + expiresAt: string; +} + +export interface LoadedAttachment { + bytes: Uint8Array; + mediaType: string; + name: string; + expiresAt: string; +} + +export interface AttachmentStorage { + /** Persist an inline upload and return its key/URL metadata. */ + store(att: AttachmentRef, tenant: string): Promise; + /** Fetch a stored attachment, or null when missing or expired. */ + load(key: string): Promise; +} + +export interface R2StorageOptions { + retentionHours: number; + signingSecret: string; + /** Origin of this worker, used to build signed download URLs. */ + baseUrl: string; + /** Deletes expired objects lazily on read; injectable for tests. */ + now?: () => number; +} + +export const DEFAULT_RETENTION_HOURS = 24; + +/** Thrown when the storage backend is misconfigured (maps to 500 CONFIG_ERROR). */ +export class AttachmentStorageConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "AttachmentStorageConfigError"; + } +} + +// --------------------------------------------------------------------------- +// Signed URLs (HMAC-SHA256 over ":") +// --------------------------------------------------------------------------- + +async function hmacHex(secret: string, payload: string): Promise { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export async function signAttachmentUrl( + baseUrl: string, + key: string, + expiresAtSeconds: number, + secret: string +): Promise { + const sig = await hmacHex(secret, `${key}:${expiresAtSeconds}`); + return `${baseUrl}/api/attachments/${key}?exp=${expiresAtSeconds}&sig=${sig}`; +} + +export async function verifyAttachmentSignature( + key: string, + exp: string | undefined, + sig: string | undefined, + secret: string, + now: number = Date.now() +): Promise { + if (!exp || !sig) return false; + const expiresAt = parseInt(exp, 10); + if (!Number.isFinite(expiresAt) || expiresAt * 1000 < now) return false; + const expected = await hmacHex(secret, `${key}:${expiresAt}`); + return constantTimeEqual(expected, sig); +} + +// --------------------------------------------------------------------------- +// R2 backend +// --------------------------------------------------------------------------- + +function safeTenant(tenant: string): string { + const cleaned = tenant.toLowerCase().replace(/[^a-z0-9._-]/g, "-"); + return (cleaned || "default").slice(0, 64); +} + +export function createR2Storage( + bucket: R2Bucket, + options: R2StorageOptions +): AttachmentStorage { + const now = options.now ?? (() => Date.now()); + const retentionMs = options.retentionHours * 60 * 60 * 1000; + + return { + async store(att, tenant) { + if (!att.data) { + throw new AttachmentError( + "Cannot store an attachment without data", + "ATTACHMENT_INVALID", + 400 + ); + } + const key = `att/${safeTenant(tenant)}/${crypto.randomUUID()}`; + const expiresAtMs = now() + retentionMs; + const expiresAt = new Date(expiresAtMs).toISOString(); + + // Decode via atob to avoid pulling Buffer into the Workers bundle. + const binary = atob(att.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + + await bucket.put(key, bytes, { + httpMetadata: { contentType: att.mediaType }, + customMetadata: { name: att.name, expiresAt, tenant }, + }); + + const url = await signAttachmentUrl( + options.baseUrl, + key, + Math.floor(expiresAtMs / 1000), + options.signingSecret + ); + return { id: att.id, key, url, expiresAt }; + }, + + async load(key) { + const object = await bucket.get(key); + if (!object) return null; + const expiresAt = object.customMetadata?.expiresAt ?? ""; + const expiresAtMs = Date.parse(expiresAt); + if (Number.isFinite(expiresAtMs) && expiresAtMs < now()) { + // Retention elapsed: purge lazily and behave as if it were gone. + await bucket.delete(key); + return null; + } + return { + bytes: new Uint8Array(await object.arrayBuffer()), + mediaType: + object.httpMetadata?.contentType ?? "application/octet-stream", + name: object.customMetadata?.name ?? key.split("/").pop() ?? key, + expiresAt, + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Env wiring +// --------------------------------------------------------------------------- + +export interface StorageEnv { + ATTACHMENT_STORAGE?: string; + ATTACHMENTS?: R2Bucket; + ATTACHMENT_RETENTION_HOURS?: string; + ATTACHMENT_SIGNING_SECRET?: string; +} + +/** + * Build the configured storage backend, or null for passthrough. Throws + * {@link AttachmentStorageConfigError} when `ATTACHMENT_STORAGE=r2` is set + * without the bucket binding or signing secret. + */ +export function storageFromEnv( + env: StorageEnv, + baseUrl: string +): AttachmentStorage | null { + const mode = (env.ATTACHMENT_STORAGE ?? "passthrough").toLowerCase(); + if (mode === "passthrough") return null; + if (mode !== "r2") { + throw new AttachmentStorageConfigError( + `Unknown ATTACHMENT_STORAGE "${env.ATTACHMENT_STORAGE}"` + ); + } + if (!env.ATTACHMENTS) { + throw new AttachmentStorageConfigError( + "ATTACHMENT_STORAGE=r2 requires an ATTACHMENTS R2 bucket binding" + ); + } + if (!env.ATTACHMENT_SIGNING_SECRET) { + throw new AttachmentStorageConfigError( + "ATTACHMENT_STORAGE=r2 requires the ATTACHMENT_SIGNING_SECRET secret" + ); + } + const hours = parseInt(env.ATTACHMENT_RETENTION_HOURS ?? "", 10); + return createR2Storage(env.ATTACHMENTS, { + retentionHours: + Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_RETENTION_HOURS, + signingSecret: env.ATTACHMENT_SIGNING_SECRET, + baseUrl, + }); +} + +/** + * Persist new inline uploads and hydrate key-only references from storage. + * Mutates the attachment refs in place (adds `key` to new uploads, fills + * `data` for loaded ones) and returns metadata for everything stored. + */ +export async function resolveAttachments( + messages: Array<{ role: string; attachments?: AttachmentRef[] }>, + storage: AttachmentStorage, + tenant: string +): Promise { + const stored: StoredAttachment[] = []; + for (const message of messages) { + if (message.role !== "user" || !message.attachments) continue; + for (const att of message.attachments) { + if (att.data && !att.key) { + const meta = await storage.store(att, tenant); + att.key = meta.key; + stored.push(meta); + } else if (att.key && !att.data) { + const loaded = await storage.load(att.key); + if (loaded) { + att.data = bytesToBase64(loaded.bytes); + att.size = loaded.bytes.byteLength; + att.mediaType = loaded.mediaType; + } + } + } + } + return stored; +} diff --git a/worker/src/attachments.ts b/worker/src/attachments.ts new file mode 100644 index 0000000..500d0ea --- /dev/null +++ b/worker/src/attachments.ts @@ -0,0 +1,448 @@ +/** + * File and image attachments: request parsing (JSON + multipart), validation + * (type allowlist, size, count, magic bytes), and Anthropic content-block + * construction. + * + * Attachments ride on user messages as `attachments: AttachmentRef[]`. Each + * ref carries either inline base64 `data` (a new upload, or the widget + * re-sending history in passthrough mode) or a storage `key` (R2 backend) that + * the worker resolves before calling Claude. A ref with neither is rendered to + * the model as a short "no longer available" note so follow-up questions still + * make sense. + */ + +/** Media types forwarded to Claude when no `ATTACHMENT_TYPES` override is set. */ +export const DEFAULT_ATTACHMENT_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", +]; + +/** Media types the Anthropic API accepts as `image` blocks. */ +export const IMAGE_MEDIA_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + +/** Media types the Anthropic API accepts as `document` blocks. */ +export const DOCUMENT_MEDIA_TYPES = new Set(["application/pdf"]); + +export const DEFAULT_MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; +export const DEFAULT_MAX_ATTACHMENTS_PER_MESSAGE = 5; +/** Raw (decoded) bytes forwarded to Claude per request; base64 adds ~33%. */ +export const DEFAULT_MAX_REQUEST_ATTACHMENT_BYTES = 20 * 1024 * 1024; + +export type AttachmentErrorCode = + | "ATTACHMENTS_DISABLED" + | "ATTACHMENT_INVALID" + | "ATTACHMENT_TOO_LARGE" + | "ATTACHMENT_QUOTA_EXCEEDED"; + +/** Thrown for any attachment problem the client caused; maps to a 4xx. */ +export class AttachmentError extends Error { + readonly code: AttachmentErrorCode; + readonly status: 400 | 413; + /** Seconds until the client may retry (quota errors only). */ + readonly retryAfter?: number; + + constructor( + message: string, + code: AttachmentErrorCode, + status: 400 | 413, + retryAfter?: number + ) { + super(message); + this.name = "AttachmentError"; + this.code = code; + this.status = status; + this.retryAfter = retryAfter; + } +} + +/** An attachment as it appears on a request message. */ +export interface AttachmentRef { + /** Client-generated id; multipart file parts are named after it. */ + id: string; + /** Original filename, shown to the model as the document title. */ + name: string; + /** MIME type, e.g. `image/png` or `application/pdf`. */ + mediaType: string; + /** Decoded size in bytes. Recomputed server-side whenever bytes are present. */ + size: number; + /** Inline base64 payload (no `data:` prefix). */ + data?: string; + /** Storage key returned by a previous request when the R2 backend is on. */ + key?: string; +} + +export interface AttachmentConfig { + enabled: boolean; + allowedTypes: string[]; + maxBytes: number; + maxPerMessage: number; + maxRequestBytes: number; +} + +interface AttachmentEnv { + ATTACHMENTS_ENABLED?: string; + ATTACHMENT_TYPES?: string; + ATTACHMENT_MAX_BYTES?: string; + ATTACHMENT_MAX_COUNT?: string; + ATTACHMENT_MAX_REQUEST_BYTES?: string; +} + +function intFromEnv(value: string | undefined, fallback: number): number { + if (value === undefined || value === "") return fallback; + const n = parseInt(value, 10); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +export function attachmentConfigFromEnv(env: AttachmentEnv): AttachmentConfig { + const types = env.ATTACHMENT_TYPES + ? env.ATTACHMENT_TYPES.split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean) + : DEFAULT_ATTACHMENT_TYPES; + return { + enabled: env.ATTACHMENTS_ENABLED !== "false", + allowedTypes: types, + maxBytes: intFromEnv(env.ATTACHMENT_MAX_BYTES, DEFAULT_MAX_ATTACHMENT_BYTES), + maxPerMessage: intFromEnv( + env.ATTACHMENT_MAX_COUNT, + DEFAULT_MAX_ATTACHMENTS_PER_MESSAGE + ), + maxRequestBytes: intFromEnv( + env.ATTACHMENT_MAX_REQUEST_BYTES, + DEFAULT_MAX_REQUEST_ATTACHMENT_BYTES + ), + }; +} + +// --------------------------------------------------------------------------- +// base64 helpers (Workers has btoa/atob but no Buffer) +// --------------------------------------------------------------------------- + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +export function base64ToBytes(data: string): Uint8Array { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; + +/** Decoded byte length of a base64 string without decoding it. */ +export function base64DecodedLength(data: string): number { + if (data.length === 0) return 0; + let padding = 0; + if (data.endsWith("==")) padding = 2; + else if (data.endsWith("=")) padding = 1; + return Math.floor((data.length * 3) / 4) - padding; +} + +// --------------------------------------------------------------------------- +// Magic-byte sniffing: the declared media type must match the payload. +// --------------------------------------------------------------------------- + +function startsWith(bytes: Uint8Array, sig: number[], offset = 0): boolean { + if (bytes.length < offset + sig.length) return false; + return sig.every((b, i) => bytes[offset + i] === b); +} + +/** Returns the media type implied by the leading bytes, or null if unknown. */ +export function sniffMediaType(head: Uint8Array): string | null { + if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return "image/png"; + } + if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg"; + if ( + startsWith(head, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || + startsWith(head, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + ) { + return "image/gif"; + } + if ( + startsWith(head, [0x52, 0x49, 0x46, 0x46]) && + startsWith(head, [0x57, 0x45, 0x42, 0x50], 8) + ) { + return "image/webp"; + } + if (startsWith(head, [0x25, 0x50, 0x44, 0x46])) return "application/pdf"; + return null; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +interface MessageLike { + role: string; + content: unknown; + attachments?: unknown; +} + +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; +/** Shape of an R2 storage key: `att//`. */ +export const ATTACHMENT_KEY_RE = /^att\/[a-z0-9._-]{1,64}\/[0-9a-f-]{36}$/; +const KEY_RE = ATTACHMENT_KEY_RE; +const MAX_NAME_LENGTH = 200; + +export function hasAttachments(messages: readonly MessageLike[]): boolean { + return messages.some( + (m) => Array.isArray(m.attachments) && m.attachments.length > 0 + ); +} + +function invalid(message: string): AttachmentError { + return new AttachmentError(message, "ATTACHMENT_INVALID", 400); +} + +/** + * Validate every attachment on every message in place. Assistant messages may + * not carry attachments. For inline payloads the decoded size is recomputed + * and the leading bytes are checked against the declared media type. + * + * Throws {@link AttachmentError} on the first violation. + */ +export function validateAttachments( + messages: MessageLike[], + config: AttachmentConfig +): void { + for (const message of messages) { + if (message.attachments === undefined) continue; + if (!Array.isArray(message.attachments)) { + throw invalid("attachments must be an array"); + } + if (message.attachments.length === 0) { + delete message.attachments; + continue; + } + if (message.role !== "user") { + throw invalid("Only user messages may carry attachments"); + } + if (message.attachments.length > config.maxPerMessage) { + throw invalid( + `Too many attachments: at most ${config.maxPerMessage} per message` + ); + } + + const seen = new Set(); + for (const raw of message.attachments as unknown[]) { + const att = raw as Partial | null; + if (!att || typeof att !== "object") { + throw invalid("Each attachment must be an object"); + } + if (typeof att.id !== "string" || !ID_RE.test(att.id)) { + throw invalid("Attachment id is missing or invalid"); + } + if (seen.has(att.id)) throw invalid("Duplicate attachment id"); + seen.add(att.id); + + if (typeof att.name !== "string" || att.name.trim() === "") { + throw invalid("Attachment name is required"); + } + att.name = att.name.trim().slice(0, MAX_NAME_LENGTH); + + if (typeof att.mediaType !== "string") { + throw invalid("Attachment mediaType is required"); + } + att.mediaType = att.mediaType.trim().toLowerCase(); + if (!config.allowedTypes.includes(att.mediaType)) { + throw invalid(`Attachment type "${att.mediaType}" is not allowed`); + } + + if (att.key !== undefined) { + if (typeof att.key !== "string" || !KEY_RE.test(att.key)) { + throw invalid("Attachment key is invalid"); + } + } + + if (att.data !== undefined) { + if (typeof att.data !== "string" || !BASE64_RE.test(att.data)) { + throw invalid("Attachment data must be base64"); + } + const size = base64DecodedLength(att.data); + if (size === 0) throw invalid("Attachment is empty"); + if (size > config.maxBytes) { + throw new AttachmentError( + `Attachment "${att.name}" exceeds the ${config.maxBytes} byte limit`, + "ATTACHMENT_TOO_LARGE", + 413 + ); + } + att.size = size; + + // 24 base64 chars decode to 18 bytes, enough for every signature. + const sniffed = sniffMediaType(base64ToBytes(att.data.slice(0, 24))); + if (sniffed !== att.mediaType) { + throw invalid( + `Attachment "${att.name}" does not look like ${att.mediaType}` + ); + } + } else if (typeof att.size !== "number" || !Number.isFinite(att.size)) { + att.size = 0; + } + } + } +} + +/** Total decoded bytes of inline uploads on the newest user message. */ +export function newUploadBytes(messages: readonly MessageLike[]): number { + const last = [...messages].reverse().find((m) => m.role === "user"); + if (!last || !Array.isArray(last.attachments)) return 0; + return (last.attachments as AttachmentRef[]).reduce( + (sum, att) => sum + (att.data ? att.size : 0), + 0 + ); +} + +/** + * Keep the total inline payload forwarded to Claude under `maxRequestBytes` + * by dropping bytes from the oldest messages first. Dropped attachments keep + * their metadata and are rendered as "no longer available". + */ +export function enforceRequestBudget( + messages: MessageLike[], + maxRequestBytes: number +): void { + let remaining = maxRequestBytes; + for (let i = messages.length - 1; i >= 0; i--) { + const atts = messages[i].attachments; + if (!Array.isArray(atts)) continue; + for (const att of atts as AttachmentRef[]) { + if (!att.data) continue; + if (att.size <= remaining) { + remaining -= att.size; + } else { + delete att.data; + } + } + } +} + +// --------------------------------------------------------------------------- +// Request parsing: JSON, or multipart/form-data with a `payload` JSON field +// plus one file part per attachment (part name = attachment id). +// --------------------------------------------------------------------------- + +export interface ParsedChatRequest { + messages: Array<{ + role: "user" | "assistant"; + content: string; + attachments?: AttachmentRef[]; + }>; + conversationId?: string; +} + +export async function parseChatRequest( + request: Request +): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("multipart/form-data")) { + return (await request.json()) as ParsedChatRequest; + } + + const form = await request.formData(); + const payload = form.get("payload"); + if (typeof payload !== "string") { + throw invalid("Multipart request is missing the payload field"); + } + const body = JSON.parse(payload) as ParsedChatRequest; + if (!Array.isArray(body.messages)) { + throw new Error("Messages array is required"); + } + + const refsById = new Map(); + for (const message of body.messages) { + if (!Array.isArray(message?.attachments)) continue; + for (const att of message.attachments) { + if (att && typeof att.id === "string") refsById.set(att.id, att); + } + } + + for (const [field, value] of form.entries()) { + if (field === "payload") continue; + if (typeof value === "string") { + throw invalid(`Unexpected form field "${field}"`); + } + const ref = refsById.get(field); + if (!ref) { + throw invalid(`File part "${field}" has no matching attachment`); + } + const bytes = new Uint8Array(await value.arrayBuffer()); + ref.data = bytesToBase64(bytes); + ref.size = bytes.byteLength; + if (!ref.mediaType && value.type) ref.mediaType = value.type; + } + + return body; +} + +// --------------------------------------------------------------------------- +// Anthropic content blocks +// --------------------------------------------------------------------------- + +/** Minimal shapes of the SDK's content-block params we emit. */ +export type AttachmentContentBlock = + | { type: "text"; text: string } + | { + type: "image"; + source: { + type: "base64"; + media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + data: string; + }; + } + | { + type: "document"; + title: string; + source: { type: "base64"; media_type: "application/pdf"; data: string }; + }; + +export function attachmentToBlock(att: AttachmentRef): AttachmentContentBlock { + if (!att.data) { + return { + type: "text", + text: `[Attachment "${att.name}" (${att.mediaType}) is no longer available]`, + }; + } + if (IMAGE_MEDIA_TYPES.has(att.mediaType)) { + return { + type: "image", + source: { + type: "base64", + media_type: att.mediaType as + | "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp", + data: att.data, + }, + }; + } + if (DOCUMENT_MEDIA_TYPES.has(att.mediaType)) { + return { + type: "document", + title: att.name, + source: { type: "base64", media_type: "application/pdf", data: att.data }, + }; + } + // Allowed by config but not a type Claude can ingest natively. + return { + type: "text", + text: `[Attachment "${att.name}" (${att.mediaType}) could not be forwarded]`, + }; +} diff --git a/worker/src/chat.ts b/worker/src/chat.ts index 0b37d51..c12e35b 100644 --- a/worker/src/chat.ts +++ b/worker/src/chat.ts @@ -1,5 +1,7 @@ import Anthropic from "@anthropic-ai/sdk"; import { SYSTEM_PROMPT } from "./system-prompt"; +import { attachmentToBlock, type AttachmentRef } from "./attachments"; +import type { StoredAttachment } from "./attachment-storage"; import { toAnthropicTools, executeTool } from "./tools"; import type { ClaudiusTool, ToolContext, ToolUseSummary } from "./tools"; import { @@ -12,6 +14,8 @@ import type { RagConfig, ChatSource } from "./rag"; export interface ChatMessage { role: "user" | "assistant"; content: string; + /** Files attached to a user message. See `attachments.ts`. */ + attachments?: AttachmentRef[]; } export interface ChatRequest { @@ -25,6 +29,8 @@ export interface ChatResponse { toolUses?: ToolUseSummary[]; /** Source links for retrieved documents the reply was grounded in. */ sources?: ChatSource[]; + /** Storage metadata for attachments persisted by this request (R2 mode). */ + attachments?: StoredAttachment[]; } export interface ChatTelemetry { @@ -78,6 +84,35 @@ const DEFAULT_MAX_TOKENS = 1024; // text answer instead of an unbounded tool loop. const MAX_TOOL_ROUNDS = 5; +/** + * Convert a sanitized message into the SDK's `content` shape. Plain-text + * messages stay strings (unchanged wire format); messages with attachments + * become a block array with the files first and the text last, which is the + * ordering Anthropic recommends for vision and document prompts. + */ +function toContent( + message: ChatMessage +): string | Anthropic.Messages.ContentBlockParam[] { + const attachments = message.attachments ?? []; + if (message.role !== "user" || attachments.length === 0) { + return message.content; + } + const blocks: Anthropic.Messages.ContentBlockParam[] = attachments.map( + (att) => attachmentToBlock(att) as Anthropic.Messages.ContentBlockParam + ); + if (message.content) { + blocks.push({ type: "text", text: message.content }); + } + return blocks; +} + +/** Sanitized messages → SDK message params (attachments become blocks). */ +function toConversation( + messages: readonly ChatMessage[] +): Anthropic.Messages.MessageParam[] { + return messages.map((msg) => ({ role: msg.role, content: toContent(msg) })); +} + /** * Retrieves grounding context for the latest user message and renders it * into a system-prompt suffix plus widget source links. No-ops (empty @@ -133,7 +168,11 @@ async function executeToolBlocks( return { results, summaries }; } -/** Validates the request shape and returns role-checked, length-capped messages. */ +/** + * Validates the request shape and returns role-checked, length-capped + * messages. Attachments are kept on user messages only; a message must carry + * text, attachments, or both. + */ function validateMessages(request: ChatRequest): ChatMessage[] { if (!request.messages || request.messages.length === 0) { throw new Error("Messages array is required"); @@ -148,10 +187,20 @@ function validateMessages(request: ChatRequest): ChatMessage[] { if (!validRoles.has(msg.role)) { throw new Error("Invalid message role"); } - return { - role: msg.role, - content: msg.content.slice(0, MAX_MESSAGE_LENGTH).trim(), - }; + const content = + typeof msg.content === "string" + ? msg.content.slice(0, MAX_MESSAGE_LENGTH).trim() + : ""; + const attachments = + msg.role === "user" && msg.attachments && msg.attachments.length > 0 + ? msg.attachments + : undefined; + if (!content && !attachments) { + throw new Error("Message content is required"); + } + return attachments + ? { role: msg.role, content, attachments } + : { role: msg.role, content }; }); } @@ -168,9 +217,7 @@ export async function handleChat( const anthropicTools = tools.length > 0 ? toAnthropicTools(tools) : undefined; const toolCtx = config.toolContext ?? {}; - const conversation: Anthropic.Messages.MessageParam[] = [ - ...sanitizedMessages, - ]; + const conversation = toConversation(sanitizedMessages); const rag = await prepareRag(config, sanitizedMessages); const system = (config.systemPrompt ?? SYSTEM_PROMPT) + rag.systemSuffix; const toolUses: ToolUseSummary[] = []; @@ -263,9 +310,7 @@ export async function* streamChat( const anthropicTools = tools.length > 0 ? toAnthropicTools(tools) : undefined; const toolCtx = config.toolContext ?? {}; - const conversation: Anthropic.Messages.MessageParam[] = [ - ...sanitizedMessages, - ]; + const conversation = toConversation(sanitizedMessages); const rag = await prepareRag(config, sanitizedMessages); const system = (config.systemPrompt ?? SYSTEM_PROMPT) + rag.systemSuffix; const toolUses: ToolUseSummary[] = []; diff --git a/worker/src/index.ts b/worker/src/index.ts index e4711d4..d36c6bf 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { cors } from "hono/cors"; import { streamSSE } from "hono/streaming"; import { handleChat, streamChat, ChatRequest, ChatTelemetry } from "./chat"; @@ -9,6 +9,25 @@ import { checkRateLimit } from "./rate-limit"; import { recordEvent } from "./analytics"; import { chatPlugins } from "./plugins"; import type { ClaudiusServerPlugin } from "./plugins"; +import { + ATTACHMENT_KEY_RE, + AttachmentError, + attachmentConfigFromEnv, + enforceRequestBudget, + hasAttachments, + newUploadBytes, + parseChatRequest, + validateAttachments, + type AttachmentErrorCode, +} from "./attachments"; +import { + AttachmentStorageConfigError, + resolveAttachments, + storageFromEnv, + verifyAttachmentSignature, + type StoredAttachment, +} from "./attachment-storage"; +import { checkAttachmentQuota, quotaConfigFromEnv } from "./attachment-quota"; interface Env { ANTHROPIC_API_KEY: string; @@ -21,6 +40,19 @@ interface Env { SYSTEM_PROMPT?: string; RATE_LIMIT_MINUTE?: string; RATE_LIMIT_HOUR?: string; + // Attachments (see attachments.ts / attachment-storage.ts / attachment-quota.ts) + ATTACHMENTS_ENABLED?: string; + ATTACHMENT_TYPES?: string; + ATTACHMENT_MAX_BYTES?: string; + ATTACHMENT_MAX_COUNT?: string; + ATTACHMENT_MAX_REQUEST_BYTES?: string; + ATTACHMENT_QUOTA_IP_BYTES?: string; + ATTACHMENT_QUOTA_TENANT_BYTES?: string; + ATTACHMENT_STORAGE?: string; + ATTACHMENT_RETENTION_HOURS?: string; + ATTACHMENT_SIGNING_SECRET?: string; + ATTACHMENTS?: R2Bucket; + TENANT_ID?: string; // RAG (optional): both bindings present activates retrieval. See // wrangler.toml and the RAG docs page. VECTORIZE_INDEX?: VectorizeIndexLike; @@ -36,10 +68,10 @@ export interface ErrorResponse { limitType?: "minute" | "hour"; } -const app = new Hono<{ - Bindings: Env; - Variables: { chatRequest?: ChatRequest }; -}>(); +type AppEnv = { Bindings: Env; Variables: { chatRequest?: ChatRequest } }; +type AppContext = Context; + +const app = new Hono(); // Server-side plugins run around POST /api/chat as Hono middleware — the // equivalent of the widget's `plugins` prop. Empty by default (behavior @@ -48,13 +80,46 @@ const app = new Hono<{ const serverPlugins: ClaudiusServerPlugin[] = []; interface ClassifiedChatError { - status: 400 | 500 | 503; - code: "VALIDATION_ERROR" | "CONFIG_ERROR" | "SERVICE_ERROR" | "UNKNOWN_ERROR"; + status: 400 | 413 | 500 | 503; + code: + | "VALIDATION_ERROR" + | "CONFIG_ERROR" + | "SERVICE_ERROR" + | "UNKNOWN_ERROR" + | AttachmentErrorCode; error: string; + /** Seconds until the client may retry, when the failure is time-bound. */ + retryAfter?: number; +} + +/** Anthropic rejects malformed media with a 400; duck-type so SDK mocks work. */ +function isUpstreamBadRequest(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { status?: unknown }).status === 400 + ); } -/** Maps a thrown chat error to an HTTP status, machine code, and safe message. */ -function classifyChatError(error: unknown): ClassifiedChatError { +/** + * Maps a thrown chat error to an HTTP status, machine code, and safe message. + * `hadAttachments` lets an upstream 400 be reported as a media problem rather + * than a generic failure. + */ +function classifyChatError( + error: unknown, + hadAttachments = false +): ClassifiedChatError { + // Attachment problems the client can fix (type, size, count, quota) + if (error instanceof AttachmentError) { + return { + status: error.status, + code: error.code, + error: error.message, + ...(error.retryAfter !== undefined ? { retryAfter: error.retryAfter } : {}), + }; + } + const message = error instanceof Error ? error.message : ""; // Client errors (bad input) @@ -66,8 +131,12 @@ function classifyChatError(error: unknown): ClassifiedChatError { return { status: 400, code: "VALIDATION_ERROR", error: message }; } - // API key issues - if (message.includes("authentication") || message.includes("api_key")) { + // Storage misconfiguration or API key issues + if ( + error instanceof AttachmentStorageConfigError || + message.includes("authentication") || + message.includes("api_key") + ) { return { status: 500, code: "CONFIG_ERROR", @@ -75,6 +144,15 @@ function classifyChatError(error: unknown): ClassifiedChatError { }; } + // Claude rejected the media itself (corrupt file, unsupported PDF, ...) + if (hadAttachments && isUpstreamBadRequest(error)) { + return { + status: 400, + code: "ATTACHMENT_INVALID", + error: "An attachment could not be processed. Please try another file.", + }; + } + // Model/API errors if (message.includes("model") || message.includes("overloaded")) { return { @@ -91,6 +169,18 @@ function classifyChatError(error: unknown): ClassifiedChatError { }; } +function errorResponse(c: AppContext, classified: ClassifiedChatError) { + return c.json( + { error: classified.error, code: classified.code }, + { + status: classified.status, + ...(classified.retryAfter !== undefined + ? { headers: { "Retry-After": String(classified.retryAfter) } } + : {}), + } + ); +} + function getRateLimitConfig(env: Env) { return { minuteLimit: env.RATE_LIMIT_MINUTE @@ -119,6 +209,77 @@ function getChatConfig(env: Env, body?: ChatRequest) { }; } +/** Quota tenant: explicit TENANT_ID, else the embedding site's host. */ +function resolveTenant(env: Env, origin: string | undefined): string { + if (env.TENANT_ID) return env.TENANT_ID; + if (origin) { + try { + return new URL(origin).host; + } catch { + // fall through + } + } + return "default"; +} + +/** + * Validate, quota-check, store/hydrate, and budget the attachments on a + * request, mutating the message refs in place. Returns storage metadata for + * uploads persisted this turn (R2 mode). Throws {@link AttachmentError} or + * {@link AttachmentStorageConfigError}; a no-op for requests without files. + */ +async function prepareAttachments( + c: AppContext, + body: ChatRequest, + clientIp: string +): Promise { + if (!Array.isArray(body?.messages) || !hasAttachments(body.messages)) { + return []; + } + + const attachmentConfig = attachmentConfigFromEnv(c.env); + if (!attachmentConfig.enabled) { + throw new AttachmentError( + "Attachments are not enabled on this worker", + "ATTACHMENTS_DISABLED", + 400 + ); + } + validateAttachments(body.messages, attachmentConfig); + + const tenant = resolveTenant(c.env, c.req.header("origin")); + const uploadBytes = newUploadBytes(body.messages); + if (uploadBytes > attachmentConfig.maxRequestBytes) { + throw new AttachmentError( + "Attachments on this message exceed the per-request limit", + "ATTACHMENT_TOO_LARGE", + 413 + ); + } + if (uploadBytes > 0) { + const quota = await checkAttachmentQuota( + c.env.RATE_LIMIT, + { ip: clientIp, tenant, bytes: uploadBytes }, + quotaConfigFromEnv(c.env) + ); + if (!quota.allowed) { + throw new AttachmentError( + "Daily upload limit reached. Please try again later.", + "ATTACHMENT_QUOTA_EXCEEDED", + 413, + quota.retryAfter ?? 3600 + ); + } + } + + const storage = storageFromEnv(c.env, new URL(c.req.url).origin); + const stored = storage + ? await resolveAttachments(body.messages, storage, tenant) + : []; + enforceRequestBudget(body.messages, attachmentConfig.maxRequestBytes); + return stored; +} + app.use( "/api/*", cors({ @@ -133,7 +294,7 @@ app.use( } return origin && allowed.includes(origin) ? origin : allowed[0]; }, - allowMethods: ["POST", "OPTIONS"], + allowMethods: ["GET", "POST", "OPTIONS"], allowHeaders: ["Content-Type"], maxAge: 86400, }) @@ -149,6 +310,7 @@ app.post("/api/chat", async (c) => { let telemetry: ChatTelemetry | undefined; let statusCode = 200; let errorCode: string | undefined; + let hadAttachments = false; try { const clientIp = @@ -180,8 +342,15 @@ app.post("/api/chat", async (c) => { } // When the plugin middleware ran, it stashed the transformed request here; - // fall back to parsing the body when no plugins are configured. - body = c.get("chatRequest") ?? (await c.req.json()); + // fall back to parsing the body (JSON or multipart) when no plugins are + // configured. + body = + c.get("chatRequest") ?? + ((await parseChatRequest(c.req.raw)) as ChatRequest); + + hadAttachments = + Array.isArray(body?.messages) && hasAttachments(body.messages); + const storedAttachments = await prepareAttachments(c, body, clientIp); const result = await handleChat( body, @@ -189,15 +358,16 @@ app.post("/api/chat", async (c) => { getChatConfig(c.env, body) ); telemetry = result.telemetry; - return c.json(result.response); + return c.json( + storedAttachments.length > 0 + ? { ...result.response, attachments: storedAttachments } + : result.response + ); } catch (error) { - const classified = classifyChatError(error); + const classified = classifyChatError(error, hadAttachments); statusCode = classified.status; errorCode = classified.code; - return c.json( - { error: classified.error, code: classified.code }, - classified.status - ); + return errorResponse(c, classified); } finally { const lastUserMsg = body?.messages ?.slice() @@ -207,7 +377,7 @@ app.post("/api/chat", async (c) => { recordEvent(c.env.ANALYTICS_DB, { conversationId: body?.conversationId, messageCount: body?.messages?.length ?? 0, - lastUserMsgLength: lastUserMsg?.content.length ?? 0, + lastUserMsgLength: lastUserMsg?.content?.length ?? 0, model: telemetry?.model, inputTokens: telemetry?.inputTokens, outputTokens: telemetry?.outputTokens, @@ -221,17 +391,20 @@ app.post("/api/chat", async (c) => { // Streaming variant of /api/chat. Emits SSE events: // event: chunk data: {"text": "..."} one per model text delta +// event: tool data: {...ToolUseSummary} one per executed tool call // event: done data: {"reply": "..."} full assembled reply, stream end // event: error data: {"error": ..., "code"} failure after streaming began -// Failures before the first byte (rate limit, validation, bad API key, model -// errors) return plain JSON with the same status codes and shapes as -// /api/chat, so clients can share error handling and fall back cleanly. +// Failures before the first byte (rate limit, validation, attachments, bad +// API key, model errors) return plain JSON with the same status codes and +// shapes as /api/chat, so clients can share error handling and fall back +// cleanly. Accepts the same JSON or multipart bodies as /api/chat. app.post("/api/chat/stream", async (c) => { const startedAt = Date.now(); let body: ChatRequest | undefined; let telemetry: ChatTelemetry | undefined; let statusCode = 200; let errorCode: string | undefined; + let hadAttachments = false; const recordAnalytics = () => { const lastUserMsg = body?.messages @@ -242,7 +415,7 @@ app.post("/api/chat/stream", async (c) => { recordEvent(c.env.ANALYTICS_DB, { conversationId: body?.conversationId, messageCount: body?.messages?.length ?? 0, - lastUserMsgLength: lastUserMsg?.content.length ?? 0, + lastUserMsgLength: lastUserMsg?.content?.length ?? 0, model: telemetry?.model, inputTokens: telemetry?.inputTokens, outputTokens: telemetry?.outputTokens, @@ -286,7 +459,11 @@ app.post("/api/chat/stream", async (c) => { ); } - body = await c.req.json(); + body = (await parseChatRequest(c.req.raw)) as ChatRequest; + + hadAttachments = + Array.isArray(body?.messages) && hasAttachments(body.messages); + const storedAttachments = await prepareAttachments(c, body, clientIp); const stream = streamChat( body, @@ -322,6 +499,9 @@ app.post("/api/chat/stream", async (c) => { reply: event.reply, ...(event.toolUses ? { toolUses: event.toolUses } : {}), ...(event.sources ? { sources: event.sources } : {}), + ...(storedAttachments.length > 0 + ? { attachments: storedAttachments } + : {}), }), }); } @@ -331,7 +511,7 @@ app.post("/api/chat/stream", async (c) => { // The stream broke after bytes were sent; the HTTP status is already // 200, so signal the failure in-band. errorCode = "STREAM_ERROR"; - const classified = classifyChatError(error); + const classified = classifyChatError(error, hadAttachments); await sse.writeSSE({ event: "error", data: JSON.stringify({ error: classified.error, code: errorCode }), @@ -341,17 +521,71 @@ app.post("/api/chat/stream", async (c) => { } }); } catch (error) { - const classified = classifyChatError(error); + const classified = classifyChatError(error, hadAttachments); statusCode = classified.status; errorCode = classified.code; recordAnalytics(); - return c.json( - { error: classified.error, code: classified.code }, - classified.status - ); + return errorResponse(c, classified); } }); +/** + * Serve a stored attachment (R2 mode only). URLs are HMAC-signed by the chat + * response and expire with the retention window, so anyone holding a link + * can read the file until then — treat links like the conversation itself. + */ +app.get("/api/attachments/*", async (c) => { + const key = decodeURIComponent( + c.req.path.replace(/^\/api\/attachments\//, "") + ); + if (!ATTACHMENT_KEY_RE.test(key)) { + return c.json({ error: "Not found" }, 404); + } + + let storage; + try { + storage = storageFromEnv(c.env, new URL(c.req.url).origin); + } catch { + storage = null; + } + if (!storage || !c.env.ATTACHMENT_SIGNING_SECRET) { + return c.json({ error: "Not found" }, 404); + } + + const valid = await verifyAttachmentSignature( + key, + c.req.query("exp"), + c.req.query("sig"), + c.env.ATTACHMENT_SIGNING_SECRET + ); + if (!valid) { + return c.json({ error: "Link is invalid or expired" }, 403); + } + + const loaded = await storage.load(key); + if (!loaded) { + return c.json({ error: "Not found" }, 404); + } + + const asciiName = loaded.name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, ""); + const expiresIn = Math.max( + 0, + Math.floor((Date.parse(loaded.expiresAt) - Date.now()) / 1000) + ); + // `.slice()` yields a Uint8Array backed by a plain ArrayBuffer, which is + // what the Response body type expects. + return new Response(loaded.bytes.slice(), { + status: 200, + headers: { + "Content-Type": loaded.mediaType, + "Content-Length": String(loaded.bytes.byteLength), + "Content-Disposition": `inline; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(loaded.name)}`, + "Cache-Control": `private, max-age=${Math.min(expiresIn, 3600)}`, + "X-Content-Type-Options": "nosniff", + }, + }); +}); + app.get("/api/health", (c) => c.json({ ok: true })); export default app; diff --git a/worker/src/plugins/middleware.ts b/worker/src/plugins/middleware.ts index 0303406..d18dd61 100644 --- a/worker/src/plugins/middleware.ts +++ b/worker/src/plugins/middleware.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from "hono"; import type { ChatRequest, ChatResponse } from "../chat"; +import { parseChatRequest } from "../attachments"; import type { ClaudiusServerPlugin, ServerPluginContext } from "./types"; import { runServerBeforeSend, @@ -18,7 +19,7 @@ function asError(value: unknown): Error { * Hono middleware that runs a list of {@link ClaudiusServerPlugin}s around the * chat route — the server-side equivalent of the widget's `plugins` prop. * - * It parses the JSON body, runs `onBeforeSend` (which may transform the + * It parses the body (JSON or multipart with attachments), runs `onBeforeSend` (which may transform the * messages or short-circuit with `respondWith`), stashes the transformed * request under `c.get("chatRequest")` for the route handler, then runs * `onAfterReceive` over the reply in a successful JSON response. @@ -42,9 +43,9 @@ export function chatPlugins( let body: ChatRequest; try { - body = await c.req.json(); + body = (await parseChatRequest(c.req.raw)) as ChatRequest; } catch { - // Not a JSON body we can introspect; let the route handle it as usual. + // Not a body we can introspect; let the route handle it as usual. return next(); } diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 734aa27..0cc6d07 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -18,12 +18,36 @@ ALLOWED_ORIGIN = "http://localhost:5173" # RAG_TOP_K = "4" # RAG_SCORE_THRESHOLD = "0.5" # RAG_CONTEXT_TEMPLATE = "...{context}..." +# +# Attachments (images + PDFs on user messages). Enabled by default in +# "passthrough" mode: files are forwarded to Anthropic and never stored. +# ATTACHMENTS_ENABLED = "true" +# ATTACHMENT_TYPES = "image/jpeg,image/png,image/gif,image/webp,application/pdf" +# ATTACHMENT_MAX_BYTES = "5242880" # per file (5 MB) +# ATTACHMENT_MAX_COUNT = "5" # per message +# ATTACHMENT_MAX_REQUEST_BYTES = "20971520" # forwarded per request (20 MB) +# ATTACHMENT_QUOTA_IP_BYTES = "52428800" # per IP per UTC day; 0 disables +# ATTACHMENT_QUOTA_TENANT_BYTES = "524288000" # per tenant per UTC day; 0 disables +# TENANT_ID = "acme" # quota tenant; defaults to the Origin host +# +# Optional R2 storage: keeps uploads for ATTACHMENT_RETENTION_HOURS so later +# turns reference them by key instead of re-uploading. Requires the +# [[r2_buckets]] binding below and `wrangler secret put ATTACHMENT_SIGNING_SECRET`. +# ATTACHMENT_STORAGE = "r2" +# ATTACHMENT_RETENTION_HOURS = "24" [[kv_namespaces]] binding = "RATE_LIMIT" id = "placeholder" preview_id = "placeholder" +# R2 bucket for attachment storage (only when ATTACHMENT_STORAGE = "r2"). +# Create with `npx wrangler r2 bucket create claudius-attachments` and add an +# object lifecycle rule that deletes objects after your retention window. +# [[r2_buckets]] +# binding = "ATTACHMENTS" +# bucket_name = "claudius-attachments" + # D1 database for analytics events. Optional: if omitted, the chat # endpoint still works but no events are recorded. See DEPLOY.md for # the wrangler commands to create the DB and run migrations, then