pi-roundtable-webchat 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +57 -7
- package/package.json +2 -2
- package/src/chat.ts +69 -2
- package/src/file-types.ts +50 -0
- package/src/plugin.ts +84 -0
- package/src/protocol.ts +39 -1
- package/src/rest.ts +19 -1
- package/src/uploads.ts +274 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.3] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Uploads that arrive together can no longer pass `unsentUploadBytesPerPrincipal`: the room an upload may fill is reserved per person before its body is read (its declared `Content-Length`, or the room left when none is declared), so ten slow uploads against a 64 MiB allowance take at most 64 MiB, and their request buffers are bounded by it. A body longer than its declared length is cut off and refused (413).
|
|
13
|
+
- A file name loses line and paragraph separators and bidi controls too (they become underscores), and a request `Content-Type` that is not a plain `type/subtype` is refused (415) even under a `/*` entry of `attachmentTypes`, so neither can add to the model's prompt.
|
|
14
|
+
- Long non-ASCII file names no longer fail on Linux (see pi-roundtable 0.9.3).
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- The limit `usedAttachmentBytesPerPrincipal` (1 GiB): what one person's messages may keep across all their conversations. A `send` that would pass it is refused whole with the error code `attachment_quota` (REST 429), and its files stay waiting. Deleting a conversation through the core gives its bytes back. It is enforced by the core of pi-roundtable 0.9.3; with 0.9.2 the limit is ignored.
|
|
19
|
+
|
|
20
|
+
## [0.9.2] - 2026-10-10
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- File attachments. A person uploads a file to one of their conversations with `POST <path>/conversations/<id>/files?name=<file name>` (the body is the file, its `Content-Type` the type; answers 201 `{ file, name, contentType, size }`), then names it in a `send` frame's new `attachments` field. The turn runs with the files as the core's attachments: images reach the model, every file is listed under `## Attachments`, and `read_attachment` and a tool's `turn.attachment()` read them. An rrweb recording is an ordinary `application/json` file.
|
|
25
|
+
- `ready` carries `attachments: { maxBytes, perMessage, types }`, so a client shows an upload control only when the server takes files; the subprotocol stays `roundtable.webchat.v1`, and a client that sends no `attachments` sees no change.
|
|
26
|
+
- The error code `unknown_attachment` (a `send` named a file that is not waiting for the person in that conversation; the whole message is refused) and the REST errors `payload_too_large` (413), `unsupported_media_type` (415) and `too_many_uploads` (429).
|
|
27
|
+
- The limits `attachmentBytes` (10 MiB, at most the core's 25 MiB), `attachmentsPerMessage` (8), `uploadsPerHour` (60), `unsentUploadBytesPerPrincipal` (64 MiB), `attachmentTypes` (PNG, JPEG, WebP, GIF, JSON, plain text, PDF; an entry may end in `/*`) and `unsentUploadTtlMs` (24 hours). The first bytes of an image or PDF must match its type. An upload no message used is deleted after its time; a swept file, a file of another conversation or person, or a file a message already used is `unknown_attachment`.
|
|
28
|
+
- Nothing needs configuring. The plugin needs `context.attachments` from pi-roundtable 0.9.2 and refuses to set up without it, and a host that has no `dataDir` fails at startup with a message naming it.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- The README named `>=0.8.0 <0.9.0` as the peer range of pi-roundtable; it is `>=0.9.0 <0.10.0`.
|
|
33
|
+
|
|
8
34
|
## [0.9.1] - 2026-10-10
|
|
9
35
|
|
|
10
36
|
- Release in lockstep with pi-roundtable 0.9.1; no package-specific behavior changes.
|
package/README.md
CHANGED
|
@@ -8,19 +8,20 @@ Source lives in [`packages/webchat`][source] in the pi-roundtable repository and
|
|
|
8
8
|
[roundtable]: https://www.npmjs.com/package/pi-roundtable
|
|
9
9
|
[source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/webchat
|
|
10
10
|
|
|
11
|
-
The plugin adds
|
|
11
|
+
The plugin adds five things to a host:
|
|
12
12
|
|
|
13
13
|
- a chat surface whose conversations have keys `web:<conversation>`;
|
|
14
14
|
- the claim that runs each message as a turn of its conversation's persona, through `context.turns` and the host's runtime;
|
|
15
15
|
- a REST API and a WebSocket under one path of the host's HTTP listener;
|
|
16
|
-
- a durable private inbox for `notify`, with live `notice` frames and an authenticated REST inbox
|
|
16
|
+
- a durable private inbox for `notify`, with live `notice` frames and an authenticated REST inbox;
|
|
17
|
+
- file uploads to a conversation (screenshots, a JSON recording, a PDF), which a message then references; see [Attachments](#attachments).
|
|
17
18
|
|
|
18
19
|
It needs no Discord: a host whose `roundtable.config.ts` has no `discord` key and lists this plugin is a web-only assistant.
|
|
19
20
|
`roundtable init --adapter web` creates such a project.
|
|
20
21
|
|
|
21
22
|
## Requirements
|
|
22
23
|
|
|
23
|
-
- Bun 1.3 or later, and pi-roundtable `>=0.
|
|
24
|
+
- Bun 1.3 or later, and pi-roundtable `>=0.9.0 <0.10.0` as a peer dependency. Uploading files needs `context.attachments`, which pi-roundtable added in 0.9.2: the plugin refuses to set up on an earlier core and names the version to install.
|
|
24
25
|
- An HTTP listener on the host (`http` in the configuration), behind a reverse proxy that serves it over HTTPS.
|
|
25
26
|
- An OpenID Connect provider that issues access tokens for this API, with signing keys published as a JWKS.
|
|
26
27
|
|
|
@@ -194,7 +195,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
194
195
|
| Frame | Meaning |
|
|
195
196
|
|---|---|
|
|
196
197
|
| `{ type: "send", id, persona, text }` | Opens a new conversation of `persona` with this message. `id` is your reference, echoed by `accepted` or `error`. |
|
|
197
|
-
| `{ type: "send", id, conversation, text }` | A message in one of your conversations. |
|
|
198
|
+
| `{ type: "send", id, conversation, text, attachments? }` | A message in one of your conversations. `attachments` lists files you [uploaded](#attachments) to this conversation, by the `file` each upload returned. |
|
|
198
199
|
| `{ type: "stop", conversation }` | Stops the conversation's running turn. |
|
|
199
200
|
| `{ type: "approval", prompt, approved }` | Approves or declines an approval prompt. |
|
|
200
201
|
| `{ type: "answer", prompt, choices, text? }` | Answers a question prompt: the chosen options' labels, and your own text where the question allows it. |
|
|
@@ -204,7 +205,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
204
205
|
|
|
205
206
|
| Frame | Meaning |
|
|
206
207
|
|---|---|
|
|
207
|
-
| `{ type: "ready", protocol, speaker, personas, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), and when the token expires. Open prompts follow it. |
|
|
208
|
+
| `{ type: "ready", protocol, speaker, personas, attachments, expiresAt }` | Sent first, and again after a fresh token: who you are (`{ id, name, tier, principalId }`), the personas you may open (`{ kind, label }`), what an upload may be (`attachments`: `{ maxBytes, perMessage, types }`), and when the token expires. Open prompts follow it. |
|
|
208
209
|
| `{ type: "accepted", id, conversation }` | Your message `id` was taken into `conversation`, a new one when you named none. |
|
|
209
210
|
| `{ type: "typing", conversation, on }` | The assistant is, or is no longer, working in the conversation. |
|
|
210
211
|
| `{ type: "stoppable", conversation, on }` | A stop applies, or no longer applies. |
|
|
@@ -217,7 +218,7 @@ The TypeScript types are `ClientFrame` and `ServerFrame`.
|
|
|
217
218
|
| `{ type: "reauth", expiresAt }` | Your token expires soon: send `auth` with a fresh one. |
|
|
218
219
|
| `{ type: "error", code, ref? }` | A frame was refused. `ref` is the `send` id or prompt id it was about. |
|
|
219
220
|
|
|
220
|
-
Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
|
|
221
|
+
Error codes: `bad_frame` (it does not parse, its text is blank or too long, or an answer the question does not allow), `unknown_conversation`, `forbidden` (someone else's conversation or prompt, or an approval above your tier), `unknown_persona` (none of that kind you may open), `unknown_prompt`, `too_many_conversations` (you hold `unusedConversationsPerPrincipal` conversations you have not written in, or opened `newConversationsPerHour` in the last hour), `unknown_attachment` (a `send` named a file that is not waiting for you in that conversation: never uploaded, uploaded to another conversation, already used by a message, or deleted after its time; the whole message is refused and nothing is run), `attachment_quota` (the files would take what your messages keep past `usedAttachmentBytesPerPrincipal`; the whole message is refused and the files stay waiting), and `busy` (you have `turnsPerPrincipal` turns running or queued, or the conversation already has a turn queued behind its running one; the message was not taken, so send it again once a turn ends).
|
|
221
222
|
|
|
222
223
|
Close codes: `4401` when the token expired without a fresh `auth`, or a fresh one was refused; `4403` when the person is no longer admitted, or a fresh token names someone else.
|
|
223
224
|
The host's own limits close with `1008` (too many frames), `1009` (a frame too big), and `1006` (a client that stopped reading).
|
|
@@ -232,6 +233,7 @@ A request from a browser origin not in `origins` gets 403; an allowed origin get
|
|
|
232
233
|
| `POST <path>/tickets` | 201 `{ ticket, expiresAt }`. |
|
|
233
234
|
| `GET <path>/conversations` | `{ conversations: [{ conversation, persona, title?, createdAt, lastActiveAt }] }`: your own, the most recently active first. |
|
|
234
235
|
| `POST <path>/conversations` with `{ persona, title? }` | 201 `{ conversation, persona }`: a new conversation to write in. 429 `too_many_conversations` past either conversation limit. |
|
|
236
|
+
| `POST <path>/conversations/<conversation>/files?name=<file name>` with the file's bytes as the body and its type as `Content-Type` | 201 `{ file, name, contentType, size }`: the file waits for your next message that names `file`. 400 `bad_request` (no name, or a name over 255 characters), 403 (not your conversation), 404 (unknown conversation), 413 `payload_too_large`, 415 `unsupported_media_type`, 429 `too_many_uploads`. See [Attachments](#attachments). |
|
|
235
237
|
| `GET <path>/conversations/<conversation>/messages?limit=50` | `{ messages: [{ role, text }] }`: its last messages, at most 500. Someone else's conversation is 403. |
|
|
236
238
|
| `GET <path>/notices?limit=50&before=<id>` | `{ notices: [{ id, text, createdAt, readAt }] }`: only your principal's entries on this surface, newest first, at most 100. Omit `before` for the first page; use its last id to fetch the next. |
|
|
237
239
|
| `POST <path>/notices/<id>/read` | `{ notice }` with `readAt` set. Idempotent; unknown ids or another principal's notice return 404. |
|
|
@@ -243,6 +245,45 @@ List, read acknowledgements, pagination cursors and retention are isolated by su
|
|
|
243
245
|
Offline inbox discovery requires a private conversation on this surface or a linked identity whose provider equals this surface; a generic `oidc:` link alone does not establish membership in every webchat inbox.
|
|
244
246
|
A REST notice page therefore contains at most 100 bounded entries, less than 2.5 MiB at the default text cap.
|
|
245
247
|
|
|
248
|
+
## Attachments
|
|
249
|
+
|
|
250
|
+
A person sends a file in two steps, because the socket carries JSON text only: they upload it over REST to a conversation they own, then name it in a `send` frame.
|
|
251
|
+
Open the conversation first (`POST <path>/conversations`, or an earlier message), since a message that opens a conversation cannot reference files that have no conversation to wait in.
|
|
252
|
+
`ready.attachments` tells the client the limits (`maxBytes`, `perMessage`, `types`) before it shows an upload control.
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
const open = await fetch("/chat/conversations", {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: { authorization: `Bearer ${token}` },
|
|
258
|
+
body: JSON.stringify({ persona: "helpdesk" }),
|
|
259
|
+
});
|
|
260
|
+
const { conversation } = await open.json();
|
|
261
|
+
const response = await fetch(`/chat/conversations/${conversation}/files?name=${encodeURIComponent(file.name)}`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: { authorization: `Bearer ${token}`, "content-type": file.type },
|
|
264
|
+
body: file,
|
|
265
|
+
});
|
|
266
|
+
const { file: id } = await response.json();
|
|
267
|
+
socket.send(JSON.stringify({ type: "send", id: "2", conversation, text: "Why did this fail?", attachments: [id] }));
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
What the server checks, in this order: the conversation is the caller's (403, 404); the file name is present and at most 255 characters, with control characters, line and paragraph separators and bidi controls replaced and quotes made apostrophes so a name cannot add lines to the model's prompt (400); the `Content-Type` is a plain `type/subtype` and one of `limits.attachmentTypes` (415); the person has not made `uploadsPerHour` uploads in the last hour (429); the file is within `attachmentBytes`, by `Content-Length` and again while the body streams, so a body that lies about its length is cut off (413); the person's uploads waiting for a message stay within `unsentUploadBytesPerPrincipal` (429), the bytes of uploads still arriving counted too, so a person who starts many at once cannot pass it (an upload declares its `Content-Length` to keep the room it needs small); and for PNG, JPEG, GIF, WebP and PDF the first bytes match the type (415), whatever the file name says.
|
|
271
|
+
JSON, plain text and other types have no such check, since any bytes may be labelled so.
|
|
272
|
+
The default types are `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain` and `application/pdf`; an entry ending in `/*` admits every type under it.
|
|
273
|
+
An rrweb recording is an ordinary `application/json` file.
|
|
274
|
+
|
|
275
|
+
A `send` that names files runs the turn with them: the core moves each file into the conversation's attachment directory, shows up to four images to the model, lists every file under `## Attachments` in the turn's prompt, and lets the model read text, JSON and PDF files with `read_attachment`.
|
|
276
|
+
A plugin's tool passes a file on with `turn.attachment(file)`, which returns the bytes (see the core's [plugin guide](https://github.com/wayne930242/pi-roundtable/blob/master/docs/plugins.md)).
|
|
277
|
+
A message may name at most `attachmentsPerMessage` files, a file belongs to one message, and a message that names a file that is not waiting for the person is refused whole with `unknown_attachment`.
|
|
278
|
+
A message that would take the files its person's messages keep, across all their conversations, past `usedAttachmentBytesPerPrincipal` is refused whole with `attachment_quota` and its files stay waiting.
|
|
279
|
+
|
|
280
|
+
An upload no message used is deleted after `unsentUploadTtlMs` (24 hours): the plugin sweeps every ten minutes, and once when the host starts.
|
|
281
|
+
Files a message used stay with their conversation, in the host's data directory, as long as the conversation does: when the conversation is deleted, the core removes them, the uploads waiting for it, and their share of the person's allowance.
|
|
282
|
+
This plugin has no way to delete a conversation itself; a host surface that deletes one does it through the core.
|
|
283
|
+
|
|
284
|
+
Uploads are as private as the transcript: files sit in the host's data directory under the private conversation, where the owner and the operator can read them, as they can the transcript.
|
|
285
|
+
Only the person who uploaded a file can use it, and only in the conversation it was uploaded to; a shared conversation (one recorded for no one in particular) takes no uploads.
|
|
286
|
+
|
|
246
287
|
## Security model
|
|
247
288
|
|
|
248
289
|
- **Private conversations.** A conversation belongs to the person who opened it.
|
|
@@ -258,6 +299,9 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
|
|
|
258
299
|
Each person has at most `turnsPerPrincipal` interactive turns running or queued at once, however many conversations or sockets they use, and a conversation at most its running interactive turn and one queued behind it; a browser message over either limit is refused with `busy` and never queued.
|
|
259
300
|
Personal schedules and delegated reports use the core runtime's conversation queue separately from this interactive admission budget, so a busy browser cannot discard them; runtime stop and shutdown still apply.
|
|
260
301
|
Each person opens at most `newConversationsPerHour` conversations an hour, over the socket or the REST API alike.
|
|
302
|
+
- **Uploads.** Only a conversation's own person may upload to it, a file waits for that person and conversation alone, and a message names it by an id nobody can guess.
|
|
303
|
+
Type, size, rate and the waiting total are limited per person, and the bytes of an image or PDF are checked against the type.
|
|
304
|
+
See [Attachments](#attachments).
|
|
261
305
|
- **Approvals.** A held call's card goes to the conversation's person only, and needs the tier the call needs.
|
|
262
306
|
A card whose tier the person lacks is never shown: its prompt resolves `expired`, without owner escalation.
|
|
263
307
|
- **Owner.** Nobody becomes the owner through a token's claims; grant owner in core configuration or the principal CLI.
|
|
@@ -279,10 +323,16 @@ A REST notice page therefore contains at most 100 bounded entries, less than 2.5
|
|
|
279
323
|
| `rate` | 60 frames a minute per socket |
|
|
280
324
|
| `maxBufferedBytes` | 4 MiB waiting for a slow client |
|
|
281
325
|
| `ticketTtlMs` | 30 seconds |
|
|
326
|
+
| `attachmentBytes` | 10 MiB per uploaded file; never above the core's 25 MiB |
|
|
327
|
+
| `attachmentsPerMessage` | 8 files named by one message |
|
|
328
|
+
| `uploadsPerHour` | 60 uploads per person in any hour |
|
|
329
|
+
| `unsentUploadBytesPerPrincipal` | 64 MiB per person uploaded and not yet used by a message; uploads arriving together are counted per web chat plugin instance, so two web chats on one host each hold a person to it separately |
|
|
330
|
+
| `usedAttachmentBytesPerPrincipal` | 1 GiB per person kept by their messages across all conversations until a conversation is deleted; needs pi-roundtable 0.9.3 to be enforced |
|
|
331
|
+
| `attachmentTypes` | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/json`, `text/plain`, `application/pdf` |
|
|
332
|
+
| `unsentUploadTtlMs` | 24 hours before an unused upload is deleted |
|
|
282
333
|
|
|
283
334
|
## What it does not do yet
|
|
284
335
|
|
|
285
|
-
- Attachments: messages carry text only.
|
|
286
336
|
- System error reports into a person's web conversation: the background claim accepts only `PERSONAL_TARGET` turns checked by core, whose author principal is the private conversation's principal.
|
|
287
337
|
The claim declares `takesSystemReports: false`: configuring `ops.conversation` on this surface (for example, `web:ops`) fails at startup with core `ConfigError`, rather than silently skipping every report.
|
|
288
338
|
Use a shared conversation on another surface, such as a Discord channel, or `ops.agent`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-webchat",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "A WebSocket chat adapter for pi-roundtable: private web conversations for people an OpenID Connect provider signs in, with live progress and approval cards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
44
44
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
45
45
|
"@types/bun": "1.4.2",
|
|
46
|
-
"pi-roundtable": "0.9.
|
|
46
|
+
"pi-roundtable": "0.9.3",
|
|
47
47
|
"typebox": "1.3.34",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
package/src/chat.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AgentRuntime,
|
|
3
|
+
type AttachmentPort,
|
|
4
|
+
AttachmentRefusal,
|
|
3
5
|
type ChannelClaim,
|
|
4
6
|
type ChannelKey,
|
|
5
7
|
type ConversationPort,
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
type Tier,
|
|
17
19
|
type ToolSelection,
|
|
18
20
|
type TranscriptEntry,
|
|
21
|
+
type TurnAttachments,
|
|
19
22
|
type TurnResult,
|
|
20
23
|
} from "pi-roundtable";
|
|
21
24
|
import { RateWindow, TurnBudget } from "./budget.ts";
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
WEBCHAT_PROTOCOL_VERSION,
|
|
38
41
|
} from "./protocol.ts";
|
|
39
42
|
import { WebSurface } from "./surface.ts";
|
|
43
|
+
import { type UploadedFile, type UploadLimits, Uploads } from "./uploads.ts";
|
|
40
44
|
|
|
41
45
|
/** A conversation kind a client may open. */
|
|
42
46
|
export interface WebPersona {
|
|
@@ -55,8 +59,8 @@ export interface WebPersona {
|
|
|
55
59
|
selection?: ToolSelection;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
/** The limits of one web chat. */
|
|
59
|
-
export interface WebChatLimits {
|
|
62
|
+
/** The limits of one web chat, those of uploads and attachments included. */
|
|
63
|
+
export interface WebChatLimits extends UploadLimits {
|
|
60
64
|
/** Connections one person may hold open at once; default 5. */
|
|
61
65
|
connectionsPerPrincipal: number;
|
|
62
66
|
/** New conversations one person may hold before writing in them; default 20. */
|
|
@@ -86,6 +90,8 @@ export interface WebChatDeps {
|
|
|
86
90
|
/** Read when used, after the host linked every plugin. */
|
|
87
91
|
registry(): ConversationRegistry;
|
|
88
92
|
conversations(): ConversationPort;
|
|
93
|
+
/** Where uploaded files wait for the message that uses them. */
|
|
94
|
+
attachments(): AttachmentPort;
|
|
89
95
|
turns(): ConversationTurns;
|
|
90
96
|
runtime(): AgentRuntime;
|
|
91
97
|
/** The clock, in milliseconds; default `Date.now`. */
|
|
@@ -108,6 +114,8 @@ interface Pending {
|
|
|
108
114
|
speaker: Speaker;
|
|
109
115
|
persona: WebPersona;
|
|
110
116
|
title: string;
|
|
117
|
+
/** The files the message references, moved into the conversation when it was accepted. */
|
|
118
|
+
attachments?: TurnAttachments;
|
|
111
119
|
/** Whether the conversation was new when the message was accepted. */
|
|
112
120
|
fresh: boolean;
|
|
113
121
|
/** Frees the message's place in the person's turn budget; called once its turn ends or never runs. */
|
|
@@ -181,6 +189,7 @@ export function checkPersonas(
|
|
|
181
189
|
*/
|
|
182
190
|
export class WebChat {
|
|
183
191
|
readonly surface: WebSurface;
|
|
192
|
+
readonly uploads: Uploads;
|
|
184
193
|
readonly connections: Connections;
|
|
185
194
|
readonly desk: PromptDesk;
|
|
186
195
|
readonly #deps: WebChatDeps;
|
|
@@ -200,6 +209,11 @@ export class WebChat {
|
|
|
200
209
|
|
|
201
210
|
constructor(deps: WebChatDeps) {
|
|
202
211
|
this.#deps = deps;
|
|
212
|
+
this.uploads = new Uploads({
|
|
213
|
+
limits: deps.limits,
|
|
214
|
+
attachments: deps.attachments,
|
|
215
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
216
|
+
});
|
|
203
217
|
this.#personas = checkPersonas(deps.personas);
|
|
204
218
|
this.#turns = new TurnBudget(deps.limits.turnsPerPrincipal);
|
|
205
219
|
this.#opened = new RateWindow(
|
|
@@ -319,6 +333,26 @@ export class WebChat {
|
|
|
319
333
|
return { persona, minted };
|
|
320
334
|
}
|
|
321
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Keeps the request's body as a file for one of the speaker's conversations until a message
|
|
338
|
+
* uses it; a Refusal when the conversation is not theirs, an UploadRefusal when the file is not
|
|
339
|
+
* accepted.
|
|
340
|
+
*/
|
|
341
|
+
async upload(
|
|
342
|
+
speaker: Speaker,
|
|
343
|
+
conversation: string,
|
|
344
|
+
request: Request,
|
|
345
|
+
name: string | null,
|
|
346
|
+
): Promise<UploadedFile> {
|
|
347
|
+
await this.own(speaker, conversation);
|
|
348
|
+
return this.uploads.receive(
|
|
349
|
+
request,
|
|
350
|
+
channelKey(this.#deps.surface, conversation),
|
|
351
|
+
speaker.principalId,
|
|
352
|
+
name,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
/** The speaker's web conversations, the most recently active first. */
|
|
323
357
|
async list(speaker: Speaker): Promise<ConversationRecord[]> {
|
|
324
358
|
const records = await this.#deps
|
|
@@ -361,6 +395,7 @@ export class WebChat {
|
|
|
361
395
|
protocol: WEBCHAT_PROTOCOL_VERSION,
|
|
362
396
|
speaker: { ...speaker },
|
|
363
397
|
personas: this.personasFor(speaker),
|
|
398
|
+
attachments: this.uploads.advertised,
|
|
364
399
|
expiresAt: identity.expiresAt.toISOString(),
|
|
365
400
|
});
|
|
366
401
|
for (const frame of this.desk.openFor(speaker.principalId))
|
|
@@ -520,6 +555,9 @@ export class WebChat {
|
|
|
520
555
|
const text = frame.text;
|
|
521
556
|
if (!text.trim() || text.length > this.#deps.limits.messageChars)
|
|
522
557
|
throw new Refusal("bad_frame");
|
|
558
|
+
const files = frame.attachments ?? [];
|
|
559
|
+
if (files.length > this.#deps.limits.attachmentsPerMessage)
|
|
560
|
+
throw new Refusal("bad_frame");
|
|
523
561
|
// Checked before a conversation is opened for it, so a refused message opens none.
|
|
524
562
|
if (!this.#turns.allows(speaker.principalId, frame.conversation))
|
|
525
563
|
throw new Refusal("busy");
|
|
@@ -532,12 +570,40 @@ export class WebChat {
|
|
|
532
570
|
if (!frame.conversation) this.#minted.delete(conversation);
|
|
533
571
|
throw new Refusal("busy");
|
|
534
572
|
}
|
|
573
|
+
let attachments: TurnAttachments | undefined;
|
|
574
|
+
if (files.length > 0) {
|
|
575
|
+
try {
|
|
576
|
+
attachments = await this.#deps
|
|
577
|
+
.attachments()
|
|
578
|
+
.turnAttachments(
|
|
579
|
+
channelKey(this.#deps.surface, conversation),
|
|
580
|
+
speaker.principalId,
|
|
581
|
+
files,
|
|
582
|
+
{
|
|
583
|
+
usedBytesLimit: this.#deps.limits.usedAttachmentBytesPerPrincipal,
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
// The refused message holds no place and opens no conversation.
|
|
588
|
+
release();
|
|
589
|
+
if (!frame.conversation) this.#minted.delete(conversation);
|
|
590
|
+
if (!(error instanceof AttachmentRefusal)) throw error;
|
|
591
|
+
throw new Refusal(
|
|
592
|
+
error.code === "unknown_file"
|
|
593
|
+
? "unknown_attachment"
|
|
594
|
+
: error.code === "quota_exceeded"
|
|
595
|
+
? "attachment_quota"
|
|
596
|
+
: "forbidden",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
535
600
|
const messageId = crypto.randomUUID();
|
|
536
601
|
this.#pending.set(messageId, {
|
|
537
602
|
speaker,
|
|
538
603
|
persona,
|
|
539
604
|
title: minted?.title ?? titleOf(text),
|
|
540
605
|
fresh: record === undefined,
|
|
606
|
+
...(attachments ? { attachments } : {}),
|
|
541
607
|
release,
|
|
542
608
|
});
|
|
543
609
|
this.connections.send(connection, {
|
|
@@ -740,6 +806,7 @@ export class WebChat {
|
|
|
740
806
|
text,
|
|
741
807
|
speaker,
|
|
742
808
|
interactive: true,
|
|
809
|
+
...(pending.attachments ? { attachments: pending.attachments } : {}),
|
|
743
810
|
...(persona.selection
|
|
744
811
|
? {
|
|
745
812
|
selection: {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The content type without parameters, lower case; undefined for a missing or empty header. */
|
|
2
|
+
export function baseType(header: string | null): string | undefined {
|
|
3
|
+
const type = header?.split(";")[0]?.trim().toLowerCase();
|
|
4
|
+
return type ? type : undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Whether `type` is one of `allowed`; an entry ending in `/*` admits every type under it. */
|
|
8
|
+
export function isAllowedType(
|
|
9
|
+
allowed: readonly string[],
|
|
10
|
+
type: string,
|
|
11
|
+
): boolean {
|
|
12
|
+
return allowed.some((entry) =>
|
|
13
|
+
entry.endsWith("/*")
|
|
14
|
+
? type.startsWith(entry.slice(0, -1)) && type.length > entry.length - 1
|
|
15
|
+
: entry === type,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
|
|
20
|
+
signature.every((byte, index) => bytes[at + index] === byte);
|
|
21
|
+
|
|
22
|
+
const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the bytes are what the type says, for the types whose first bytes say so: the four
|
|
26
|
+
* image formats the model reads, and PDF. Any other type has nothing to check and passes.
|
|
27
|
+
*/
|
|
28
|
+
export function bytesMatchType(type: string, bytes: Uint8Array): boolean {
|
|
29
|
+
switch (type) {
|
|
30
|
+
case "image/png":
|
|
31
|
+
return startsWith(
|
|
32
|
+
bytes,
|
|
33
|
+
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
34
|
+
);
|
|
35
|
+
case "image/jpeg":
|
|
36
|
+
return startsWith(bytes, [0xff, 0xd8, 0xff]);
|
|
37
|
+
case "image/gif":
|
|
38
|
+
return (
|
|
39
|
+
startsWith(bytes, ascii("GIF87a")) || startsWith(bytes, ascii("GIF89a"))
|
|
40
|
+
);
|
|
41
|
+
case "image/webp":
|
|
42
|
+
return (
|
|
43
|
+
startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8)
|
|
44
|
+
);
|
|
45
|
+
case "application/pdf":
|
|
46
|
+
return startsWith(bytes, ascii("%PDF-"));
|
|
47
|
+
default:
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { TokenRefused, type TokenVerifier } from "./oidc.ts";
|
|
|
24
24
|
import { TICKET_PROTOCOL_PREFIX, WEBCHAT_PROTOCOL } from "./protocol.ts";
|
|
25
25
|
import { restHandler } from "./rest.ts";
|
|
26
26
|
import { TicketBook } from "./tickets.ts";
|
|
27
|
+
import { DEFAULT_ATTACHMENT_TYPES } from "./uploads.ts";
|
|
27
28
|
|
|
28
29
|
/** Limits of the WebSocket route, besides the chat's own. */
|
|
29
30
|
export interface WebChatRouteLimits {
|
|
@@ -68,6 +69,13 @@ const DEFAULT_LIMITS: WebChatLimits & WebChatRouteLimits = {
|
|
|
68
69
|
messageChars: 32_000,
|
|
69
70
|
promptTimeoutMs: 30 * 60_000,
|
|
70
71
|
reauthLeadMs: 60_000,
|
|
72
|
+
attachmentBytes: 10 * 1024 * 1024,
|
|
73
|
+
attachmentsPerMessage: 8,
|
|
74
|
+
uploadsPerHour: 60,
|
|
75
|
+
unsentUploadBytesPerPrincipal: 64 * 1024 * 1024,
|
|
76
|
+
usedAttachmentBytesPerPrincipal: 1024 * 1024 * 1024,
|
|
77
|
+
attachmentTypes: DEFAULT_ATTACHMENT_TYPES,
|
|
78
|
+
unsentUploadTtlMs: 24 * 60 * 60_000,
|
|
71
79
|
maxConnections: 256,
|
|
72
80
|
maxMessageBytes: 64 * 1024,
|
|
73
81
|
rate: { messages: 60, perMs: 60_000 },
|
|
@@ -100,6 +108,42 @@ function checkOptions(options: WebChatOptions): {
|
|
|
100
108
|
return { path, surface };
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
/** The core keeps no file over this; a web chat's own limit may be lower, never higher. */
|
|
112
|
+
const CORE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
113
|
+
/** The longest wait between two sweeps of uploads no message used. */
|
|
114
|
+
const SWEEP_EVERY_MS = 10 * 60_000;
|
|
115
|
+
|
|
116
|
+
/** Refuses an attachment limit that is not a positive whole number, or a size the core cannot keep. */
|
|
117
|
+
function checkAttachmentLimits(limits: WebChatLimits): void {
|
|
118
|
+
for (const key of [
|
|
119
|
+
"attachmentBytes",
|
|
120
|
+
"attachmentsPerMessage",
|
|
121
|
+
"uploadsPerHour",
|
|
122
|
+
"unsentUploadBytesPerPrincipal",
|
|
123
|
+
"usedAttachmentBytesPerPrincipal",
|
|
124
|
+
"unsentUploadTtlMs",
|
|
125
|
+
] as const)
|
|
126
|
+
if (!Number.isInteger(limits[key]) || limits[key] < 1)
|
|
127
|
+
throw new Error(
|
|
128
|
+
`webChat: limits.${key} must be a positive whole number; got ${String(limits[key])}`,
|
|
129
|
+
);
|
|
130
|
+
if (limits.attachmentBytes > CORE_ATTACHMENT_BYTES)
|
|
131
|
+
throw new Error(
|
|
132
|
+
`webChat: limits.attachmentBytes may be at most ${CORE_ATTACHMENT_BYTES} (25 MiB), the most the core keeps; got ${limits.attachmentBytes}`,
|
|
133
|
+
);
|
|
134
|
+
if (
|
|
135
|
+
!Array.isArray(limits.attachmentTypes) ||
|
|
136
|
+
limits.attachmentTypes.some(
|
|
137
|
+
(type) =>
|
|
138
|
+
typeof type !== "string" ||
|
|
139
|
+
!/^[a-z0-9.+-]+\/(\*|[a-z0-9.+-]+)$/.test(type),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
throw new Error(
|
|
143
|
+
'webChat: limits.attachmentTypes must list content types in lower case, such as "image/png" or "image/*"',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
103
147
|
/** The subprotocols a WebSocket upgrade offers. */
|
|
104
148
|
function offered(request: Request): string[] {
|
|
105
149
|
return (request.headers.get("sec-websocket-protocol") ?? "")
|
|
@@ -122,11 +166,16 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
122
166
|
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
123
167
|
// The personas are checked now, so a broken list stops the configuration rather than the boot.
|
|
124
168
|
checkPersonas(options.personas);
|
|
169
|
+
checkAttachmentLimits(limits);
|
|
125
170
|
return definePlugin({
|
|
126
171
|
name: surface === "web" ? "webchat" : `webchat-${surface}`,
|
|
127
172
|
requires: [IDENTITY, CONVERSATIONS, RUNTIME],
|
|
128
173
|
migrations: [PgNotices.migration],
|
|
129
174
|
setup: (context: PluginContext) => {
|
|
175
|
+
if (!context.attachments)
|
|
176
|
+
throw new Error(
|
|
177
|
+
"webChat: this version keeps uploaded files with context.attachments, which pi-roundtable 0.9.2 added. Update pi-roundtable to 0.9.2 or later.",
|
|
178
|
+
);
|
|
130
179
|
const chat = new WebChat({
|
|
131
180
|
surface,
|
|
132
181
|
verifier: options.verifier,
|
|
@@ -136,6 +185,7 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
136
185
|
logger: context.logger,
|
|
137
186
|
registry: () => context.services.get(CONVERSATIONS),
|
|
138
187
|
conversations: () => context.conversations,
|
|
188
|
+
attachments: () => context.attachments,
|
|
139
189
|
turns: () => context.turns,
|
|
140
190
|
runtime: () => context.services.get(RUNTIME),
|
|
141
191
|
});
|
|
@@ -216,7 +266,41 @@ export function webChat(options: WebChatOptions): RoundtablePlugin {
|
|
|
216
266
|
handle: rest,
|
|
217
267
|
websocket,
|
|
218
268
|
};
|
|
269
|
+
// Uploads no message used are deleted once they are older than the limit's time.
|
|
270
|
+
let sweeper: ReturnType<typeof setInterval> | undefined;
|
|
271
|
+
const sweep = async () => {
|
|
272
|
+
try {
|
|
273
|
+
const dropped = await chat.uploads.sweep();
|
|
274
|
+
if (dropped > 0)
|
|
275
|
+
context.logger.info({ dropped }, "unused web chat uploads deleted");
|
|
276
|
+
} catch (error) {
|
|
277
|
+
context.logger.warn(
|
|
278
|
+
{ err: error },
|
|
279
|
+
"unused web chat uploads not deleted",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
219
283
|
return {
|
|
284
|
+
services: [
|
|
285
|
+
{
|
|
286
|
+
name: `${surface}-uploads`,
|
|
287
|
+
start: async () => {
|
|
288
|
+
// Not caught: a host without a `dataDir` stops here, naming it, instead of failing at the first upload.
|
|
289
|
+
await chat.uploads.sweep();
|
|
290
|
+
sweeper = setInterval(
|
|
291
|
+
sweep,
|
|
292
|
+
Math.min(
|
|
293
|
+
SWEEP_EVERY_MS,
|
|
294
|
+
Math.max(limits.unsentUploadTtlMs, 100),
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
stop: () => {
|
|
299
|
+
clearInterval(sweeper);
|
|
300
|
+
sweeper = undefined;
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
],
|
|
220
304
|
surfaces: [chat.surface],
|
|
221
305
|
channels: [chat.claim()],
|
|
222
306
|
directChannels: [
|
package/src/protocol.ts
CHANGED
|
@@ -65,7 +65,11 @@ export type ErrorCode =
|
|
|
65
65
|
| "unknown_persona"
|
|
66
66
|
| "unknown_prompt"
|
|
67
67
|
| "too_many_conversations"
|
|
68
|
-
| "busy"
|
|
68
|
+
| "busy"
|
|
69
|
+
/** A `send` named a file that is not waiting for this person in this conversation; the whole message is refused. */
|
|
70
|
+
| "unknown_attachment"
|
|
71
|
+
/** A `send` would take the person past `usedAttachmentBytesPerPrincipal`; the whole message is refused and its files stay waiting. */
|
|
72
|
+
| "attachment_quota";
|
|
69
73
|
|
|
70
74
|
/** What a client sends: one JSON object per WebSocket text message. */
|
|
71
75
|
export type ClientFrame =
|
|
@@ -81,6 +85,11 @@ export type ClientFrame =
|
|
|
81
85
|
conversation?: string;
|
|
82
86
|
persona?: string;
|
|
83
87
|
text: string;
|
|
88
|
+
/**
|
|
89
|
+
* Files uploaded to the conversation (`POST <path>/conversations/<id>/files`) for this
|
|
90
|
+
* message, by the `file` each upload returned; at most `ready.attachments.perMessage`.
|
|
91
|
+
*/
|
|
92
|
+
attachments?: string[];
|
|
84
93
|
}
|
|
85
94
|
/** Stops the conversation's running turn. */
|
|
86
95
|
| { type: "stop"; conversation: string }
|
|
@@ -97,6 +106,16 @@ export type ServerFrame =
|
|
|
97
106
|
protocol: typeof WEBCHAT_PROTOCOL_VERSION;
|
|
98
107
|
speaker: { id: string; name: string; tier: Tier; principalId: string };
|
|
99
108
|
personas: readonly PersonaSummary[];
|
|
109
|
+
/**
|
|
110
|
+
* What an upload may be: the most bytes in a file, the most files in a message, and the
|
|
111
|
+
* content types accepted, each exactly or as `<type>/*`. Absent from a server before 0.9.2,
|
|
112
|
+
* which takes no attachments: show no upload control then.
|
|
113
|
+
*/
|
|
114
|
+
attachments: {
|
|
115
|
+
maxBytes: number;
|
|
116
|
+
perMessage: number;
|
|
117
|
+
types: readonly string[];
|
|
118
|
+
};
|
|
100
119
|
/** When the token expires, as an ISO time. */
|
|
101
120
|
expiresAt: string;
|
|
102
121
|
}
|
|
@@ -135,6 +154,10 @@ export type ServerFrame =
|
|
|
135
154
|
|
|
136
155
|
/** The longest client reference, conversation id, or prompt id accepted. */
|
|
137
156
|
const ID_CHARS = 128;
|
|
157
|
+
/** The longest uploaded file reference accepted: a 36-character id, a dash, and a name cut to 120. */
|
|
158
|
+
const FILE_ID_CHARS = 200;
|
|
159
|
+
/** The most file references one frame may carry, whatever the server's own limit is. */
|
|
160
|
+
const MAX_FRAME_FILES = 32;
|
|
138
161
|
|
|
139
162
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
140
163
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -142,6 +165,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
142
165
|
const isId = (value: unknown): value is string =>
|
|
143
166
|
typeof value === "string" && value.length > 0 && value.length <= ID_CHARS;
|
|
144
167
|
|
|
168
|
+
const isFileList = (value: unknown): value is string[] =>
|
|
169
|
+
Array.isArray(value) &&
|
|
170
|
+
value.length <= MAX_FRAME_FILES &&
|
|
171
|
+
value.every(
|
|
172
|
+
(item) =>
|
|
173
|
+
typeof item === "string" &&
|
|
174
|
+
item.length > 0 &&
|
|
175
|
+
item.length <= FILE_ID_CHARS,
|
|
176
|
+
);
|
|
177
|
+
|
|
145
178
|
const optional = <T>(value: unknown, check: (v: unknown) => v is T) =>
|
|
146
179
|
value === undefined || check(value);
|
|
147
180
|
|
|
@@ -169,12 +202,17 @@ export function parseClientFrame(
|
|
|
169
202
|
if (!optional(conversation, isId) || !optional(persona, isId))
|
|
170
203
|
return undefined;
|
|
171
204
|
if (conversation === undefined && persona === undefined) return undefined;
|
|
205
|
+
if (!optional(value.attachments, isFileList)) return undefined;
|
|
206
|
+
const { attachments } = value;
|
|
172
207
|
return {
|
|
173
208
|
type: "send",
|
|
174
209
|
id,
|
|
175
210
|
text,
|
|
176
211
|
...(conversation === undefined ? {} : { conversation }),
|
|
177
212
|
...(persona === undefined ? {} : { persona }),
|
|
213
|
+
...(attachments === undefined || attachments.length === 0
|
|
214
|
+
? {}
|
|
215
|
+
: { attachments }),
|
|
178
216
|
};
|
|
179
217
|
}
|
|
180
218
|
case "stop":
|
package/src/rest.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type Admitted, Refusal, type WebChat } from "./chat.ts";
|
|
|
3
3
|
import { MAX_NOTICES, type PgNotices } from "./notices.ts";
|
|
4
4
|
import { TokenRefused } from "./oidc.ts";
|
|
5
5
|
import type { TicketBook } from "./tickets.ts";
|
|
6
|
+
import { UploadRefusal } from "./uploads.ts";
|
|
6
7
|
|
|
7
8
|
export interface RestOptions {
|
|
8
9
|
chat: WebChat;
|
|
@@ -62,13 +63,18 @@ const STATUS: Record<string, number> = {
|
|
|
62
63
|
unknown_conversation: 404,
|
|
63
64
|
unknown_persona: 404,
|
|
64
65
|
too_many_conversations: 429,
|
|
66
|
+
unknown_attachment: 400,
|
|
67
|
+
attachment_quota: 429,
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
/**
|
|
68
71
|
* The web chat's REST API, under its path, every call with `Authorization: Bearer <token>`:
|
|
69
72
|
* `POST tickets` (a one-time ticket for the WebSocket), `GET conversations` (the caller's own),
|
|
70
73
|
* `POST conversations` (`{ persona, title? }` opens one), and `GET conversations/<id>/messages`
|
|
71
|
-
* (`?limit=`, its last messages)
|
|
74
|
+
* (`?limit=`, its last messages), and `POST conversations/<id>/files?name=<file name>` (the body is
|
|
75
|
+
* the file's bytes, its `Content-Type` the file's type; answers 201 with `{ file, name, contentType,
|
|
76
|
+
* size }`, 413 over the size limit, 415 for a type that is not accepted, 429 over the person's
|
|
77
|
+
* allowance). A browser on another origin gets CORS headers when its origin
|
|
72
78
|
* is allowed and 403 otherwise.
|
|
73
79
|
*/
|
|
74
80
|
export function restHandler(options: RestOptions) {
|
|
@@ -143,6 +149,16 @@ export function restHandler(options: RestOptions) {
|
|
|
143
149
|
const entries = await chat.transcript(speaker, messages[1], limit);
|
|
144
150
|
return json({ messages: entries }, 200, headers);
|
|
145
151
|
}
|
|
152
|
+
const files = /^conversations\/([A-Za-z0-9-]{1,128})\/files$/.exec(rest);
|
|
153
|
+
if (files?.[1] && request.method === "POST") {
|
|
154
|
+
const uploaded = await chat.upload(
|
|
155
|
+
speaker,
|
|
156
|
+
files[1],
|
|
157
|
+
request,
|
|
158
|
+
url.searchParams.get("name"),
|
|
159
|
+
);
|
|
160
|
+
return json(uploaded, 201, headers);
|
|
161
|
+
}
|
|
146
162
|
return json({ error: "not_found" }, 404, headers);
|
|
147
163
|
}
|
|
148
164
|
return async (request: Request): Promise<Response> => {
|
|
@@ -179,6 +195,8 @@ export function restHandler(options: RestOptions) {
|
|
|
179
195
|
try {
|
|
180
196
|
return await route(request, url, who, headers);
|
|
181
197
|
} catch (error) {
|
|
198
|
+
if (error instanceof UploadRefusal)
|
|
199
|
+
return json({ error: error.code }, error.status, headers);
|
|
182
200
|
if (!(error instanceof Refusal)) throw error;
|
|
183
201
|
return json({ error: error.code }, STATUS[error.code] ?? 400, headers);
|
|
184
202
|
}
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { AttachmentPort, ChannelKey } from "pi-roundtable";
|
|
2
|
+
import { RateWindow } from "./budget.ts";
|
|
3
|
+
import { baseType, bytesMatchType, isAllowedType } from "./file-types.ts";
|
|
4
|
+
|
|
5
|
+
/** The types a web chat takes unless its `limits.attachmentTypes` says otherwise. */
|
|
6
|
+
export const DEFAULT_ATTACHMENT_TYPES: readonly string[] = [
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/jpeg",
|
|
9
|
+
"image/webp",
|
|
10
|
+
"image/gif",
|
|
11
|
+
"application/json",
|
|
12
|
+
"text/plain",
|
|
13
|
+
"application/pdf",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** The limits of uploads and of the attachments a message carries. */
|
|
17
|
+
export interface UploadLimits {
|
|
18
|
+
/** The largest file in bytes; default 10 MiB, and never over the core's 25 MiB. */
|
|
19
|
+
attachmentBytes: number;
|
|
20
|
+
/** The most files one message may reference; default 8. */
|
|
21
|
+
attachmentsPerMessage: number;
|
|
22
|
+
/** Uploads one person may make in any hour; default 60. */
|
|
23
|
+
uploadsPerHour: number;
|
|
24
|
+
/** The bytes one person may have uploaded and not yet sent in a message; default 64 MiB. */
|
|
25
|
+
unsentUploadBytesPerPrincipal: number;
|
|
26
|
+
/**
|
|
27
|
+
* The bytes one person's messages may keep in all their conversations, until a conversation is
|
|
28
|
+
* deleted and gives its bytes back; default 1 GiB.
|
|
29
|
+
*/
|
|
30
|
+
usedAttachmentBytesPerPrincipal: number;
|
|
31
|
+
/** The content types accepted, such as `image/png`; an entry ending in `/*` admits every type under it. */
|
|
32
|
+
attachmentTypes: readonly string[];
|
|
33
|
+
/** How long an upload no message referenced is kept before it is deleted; default 24 hours. */
|
|
34
|
+
unsentUploadTtlMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Why an upload was refused, as the HTTP status and error code the client reads. */
|
|
38
|
+
export class UploadRefusal extends Error {
|
|
39
|
+
override name = "UploadRefusal";
|
|
40
|
+
readonly status: number;
|
|
41
|
+
readonly code:
|
|
42
|
+
| "bad_request"
|
|
43
|
+
| "payload_too_large"
|
|
44
|
+
| "unsupported_media_type"
|
|
45
|
+
| "too_many_uploads";
|
|
46
|
+
|
|
47
|
+
constructor(status: number, code: UploadRefusal["code"]) {
|
|
48
|
+
super(code);
|
|
49
|
+
this.status = status;
|
|
50
|
+
this.code = code;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What an upload returns: `file` is what a `send` frame's `attachments` names. */
|
|
55
|
+
export interface UploadedFile {
|
|
56
|
+
file: string;
|
|
57
|
+
name: string;
|
|
58
|
+
contentType: string;
|
|
59
|
+
size: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UploadsDeps {
|
|
63
|
+
limits: UploadLimits;
|
|
64
|
+
/** Read when used, after the host linked every plugin. */
|
|
65
|
+
attachments(): AttachmentPort;
|
|
66
|
+
/** The clock, in milliseconds; default `Date.now`. */
|
|
67
|
+
now?(): number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const HOUR_MS = 60 * 60_000;
|
|
71
|
+
/** The longest file name accepted, in characters. */
|
|
72
|
+
const NAME_CHARS = 255;
|
|
73
|
+
|
|
74
|
+
/** Control characters, line and paragraph separators, and bidi controls: none reaches a prompt or a screen as it is. */
|
|
75
|
+
const UNSAFE_NAME_CHARS =
|
|
76
|
+
/[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
77
|
+
/** A plain type/subtype, the grammar a configured `attachmentTypes` entry has too. */
|
|
78
|
+
const PLAIN_TYPE = /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A name the model can read in its prompt: control and bidi characters and line separators become
|
|
82
|
+
* underscores and quotes become apostrophes, so a name cannot add lines or close the quotes
|
|
83
|
+
* around it.
|
|
84
|
+
*/
|
|
85
|
+
function safeName(name: string | null): string {
|
|
86
|
+
const cleaned = (name ?? "")
|
|
87
|
+
.replace(UNSAFE_NAME_CHARS, "_")
|
|
88
|
+
.replaceAll('"', "'")
|
|
89
|
+
.trim();
|
|
90
|
+
if (cleaned === "" || cleaned.length > NAME_CHARS)
|
|
91
|
+
throw new UploadRefusal(400, "bad_request");
|
|
92
|
+
return cleaned;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The request body, or undefined once it passes `max` bytes; the rest of the stream is not read. */
|
|
96
|
+
async function readLimited(
|
|
97
|
+
request: Request,
|
|
98
|
+
max: number,
|
|
99
|
+
): Promise<Uint8Array | undefined> {
|
|
100
|
+
const reader = request.body?.getReader();
|
|
101
|
+
if (!reader) return new Uint8Array();
|
|
102
|
+
const chunks: Uint8Array[] = [];
|
|
103
|
+
let size = 0;
|
|
104
|
+
try {
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (done) break;
|
|
108
|
+
size += value.byteLength;
|
|
109
|
+
if (size > max) {
|
|
110
|
+
await reader.cancel();
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
reader.releaseLock();
|
|
117
|
+
}
|
|
118
|
+
const bytes = new Uint8Array(size);
|
|
119
|
+
let offset = 0;
|
|
120
|
+
for (const chunk of chunks) {
|
|
121
|
+
bytes.set(chunk, offset);
|
|
122
|
+
offset += chunk.byteLength;
|
|
123
|
+
}
|
|
124
|
+
return bytes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The files people upload to their conversations before they send a message: it checks the
|
|
129
|
+
* content type, the bytes, the size and the person's allowance, and keeps the file with the
|
|
130
|
+
* core's attachment port until a message uses it. Who may upload to which conversation is the
|
|
131
|
+
* chat's to decide before it calls `receive`.
|
|
132
|
+
*/
|
|
133
|
+
export class Uploads {
|
|
134
|
+
readonly #deps: UploadsDeps;
|
|
135
|
+
readonly #rate: RateWindow;
|
|
136
|
+
/** The bytes each person's uploads in flight may still write, counted against their allowance. */
|
|
137
|
+
readonly #reserved = new Map<string, number>();
|
|
138
|
+
/** The last admission queued for each person, which the next one waits for. */
|
|
139
|
+
readonly #admissions = new Map<string, Promise<void>>();
|
|
140
|
+
|
|
141
|
+
constructor(deps: UploadsDeps) {
|
|
142
|
+
this.#deps = deps;
|
|
143
|
+
this.#rate = new RateWindow(
|
|
144
|
+
deps.limits.uploadsPerHour,
|
|
145
|
+
HOUR_MS,
|
|
146
|
+
deps.now ?? Date.now,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What `ready` tells a client about attachments. */
|
|
151
|
+
get advertised(): {
|
|
152
|
+
maxBytes: number;
|
|
153
|
+
perMessage: number;
|
|
154
|
+
types: readonly string[];
|
|
155
|
+
} {
|
|
156
|
+
const { limits } = this.#deps;
|
|
157
|
+
return {
|
|
158
|
+
maxBytes: limits.attachmentBytes,
|
|
159
|
+
perMessage: limits.attachmentsPerMessage,
|
|
160
|
+
types: limits.attachmentTypes,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Reserves the bytes an upload may write against the person's allowance, one admission at a
|
|
166
|
+
* time per person, so uploads in flight together count against it and not only the ones saved.
|
|
167
|
+
* Returns the bytes reserved, and the most the body may be.
|
|
168
|
+
*/
|
|
169
|
+
#admit(
|
|
170
|
+
principalId: string,
|
|
171
|
+
declared: number | undefined,
|
|
172
|
+
): Promise<{ reserved: number; cap: number }> {
|
|
173
|
+
const { limits } = this.#deps;
|
|
174
|
+
const run = async () => {
|
|
175
|
+
const holding = this.#reserved.get(principalId) ?? 0;
|
|
176
|
+
const waiting = await this.#deps.attachments().pendingBytes(principalId);
|
|
177
|
+
const room = Math.max(
|
|
178
|
+
limits.unsentUploadBytesPerPrincipal - waiting - holding,
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
181
|
+
const cap = Math.min(limits.attachmentBytes, room);
|
|
182
|
+
if (declared !== undefined && declared > room)
|
|
183
|
+
throw new UploadRefusal(429, "too_many_uploads");
|
|
184
|
+
const reserved = declared === undefined ? cap : Math.min(declared, cap);
|
|
185
|
+
this.#reserved.set(principalId, holding + reserved);
|
|
186
|
+
return { reserved, cap };
|
|
187
|
+
};
|
|
188
|
+
const result = (
|
|
189
|
+
this.#admissions.get(principalId) ?? Promise.resolve()
|
|
190
|
+
).then(run);
|
|
191
|
+
const tail = result.then(
|
|
192
|
+
() => undefined,
|
|
193
|
+
() => undefined,
|
|
194
|
+
);
|
|
195
|
+
this.#admissions.set(principalId, tail);
|
|
196
|
+
void tail.then(() => {
|
|
197
|
+
if (this.#admissions.get(principalId) === tail)
|
|
198
|
+
this.#admissions.delete(principalId);
|
|
199
|
+
});
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#release(principalId: string, reserved: number): void {
|
|
204
|
+
const left = (this.#reserved.get(principalId) ?? 0) - reserved;
|
|
205
|
+
if (left > 0) this.#reserved.set(principalId, left);
|
|
206
|
+
else this.#reserved.delete(principalId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Takes the request's body as a file for `principalId` in `channel`; throws an UploadRefusal. */
|
|
210
|
+
async receive(
|
|
211
|
+
request: Request,
|
|
212
|
+
channel: ChannelKey,
|
|
213
|
+
principalId: string,
|
|
214
|
+
name: string | null,
|
|
215
|
+
): Promise<UploadedFile> {
|
|
216
|
+
const { limits } = this.#deps;
|
|
217
|
+
const fileName = safeName(name);
|
|
218
|
+
const type = baseType(request.headers.get("content-type"));
|
|
219
|
+
if (
|
|
220
|
+
!type ||
|
|
221
|
+
!PLAIN_TYPE.test(type) ||
|
|
222
|
+
!isAllowedType(limits.attachmentTypes, type)
|
|
223
|
+
)
|
|
224
|
+
throw new UploadRefusal(415, "unsupported_media_type");
|
|
225
|
+
const header = request.headers.get("content-length");
|
|
226
|
+
const length = header === null ? Number.NaN : Number(header);
|
|
227
|
+
// A header that is no length is no declaration; the body is held to the room left instead.
|
|
228
|
+
const declared =
|
|
229
|
+
Number.isFinite(length) && length >= 0 ? length : undefined;
|
|
230
|
+
if (declared !== undefined && declared > limits.attachmentBytes)
|
|
231
|
+
throw new UploadRefusal(413, "payload_too_large");
|
|
232
|
+
if (!this.#rate.take(principalId))
|
|
233
|
+
throw new UploadRefusal(429, "too_many_uploads");
|
|
234
|
+
const { reserved, cap } = await this.#admit(principalId, declared);
|
|
235
|
+
let held = reserved;
|
|
236
|
+
const release = () => {
|
|
237
|
+
this.#release(principalId, held);
|
|
238
|
+
held = 0;
|
|
239
|
+
};
|
|
240
|
+
try {
|
|
241
|
+
const bytes = await readLimited(request, reserved);
|
|
242
|
+
if (!bytes)
|
|
243
|
+
// A body past its declared length lied; one past the room left is the allowance's.
|
|
244
|
+
throw declared === undefined && cap < limits.attachmentBytes
|
|
245
|
+
? new UploadRefusal(429, "too_many_uploads")
|
|
246
|
+
: new UploadRefusal(413, "payload_too_large");
|
|
247
|
+
if (!bytesMatchType(type, bytes))
|
|
248
|
+
throw new UploadRefusal(415, "unsupported_media_type");
|
|
249
|
+
const stored = await this.#deps.attachments().save(channel, principalId, {
|
|
250
|
+
name: fileName,
|
|
251
|
+
contentType: type,
|
|
252
|
+
data: bytes,
|
|
253
|
+
});
|
|
254
|
+
// The saved file counts as waiting from here on.
|
|
255
|
+
release();
|
|
256
|
+
return {
|
|
257
|
+
file: stored.file,
|
|
258
|
+
name: stored.name,
|
|
259
|
+
contentType: stored.contentType,
|
|
260
|
+
size: stored.size,
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
release();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Deletes the uploads no message used within the time to live; returns how many. */
|
|
268
|
+
sweep(): Promise<number> {
|
|
269
|
+
const now = (this.#deps.now ?? Date.now)();
|
|
270
|
+
return this.#deps
|
|
271
|
+
.attachments()
|
|
272
|
+
.discardPending(new Date(now - this.#deps.limits.unsentUploadTtlMs));
|
|
273
|
+
}
|
|
274
|
+
}
|