readyroom 0.1.8 → 0.2.0
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/README.md +106 -10
- package/dist/server.mjs +566 -18
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -71,6 +71,17 @@ moments, desktop first:
|
|
|
71
71
|
</tr>
|
|
72
72
|
</table>
|
|
73
73
|
|
|
74
|
+
<table>
|
|
75
|
+
<tr>
|
|
76
|
+
<td align="center"><b>Attached media, expanded</b></td>
|
|
77
|
+
<td align="center"><b>Marked up and annotated</b></td>
|
|
78
|
+
</tr>
|
|
79
|
+
<tr>
|
|
80
|
+
<td><img src="docs/screenshots/pending-media.png" width="280" alt="A ticket with an attached screenshot expanded for inspection: the image on a drawing canvas with Box, Draw, and Clear markup tools, above the optional annotations editor."></td>
|
|
81
|
+
<td><img src="docs/screenshots/media-annotated.png" width="280" alt="The attached image marked up with a red box, and a queued note reading 'nudge the title left by 8px' in the annotations editor."></td>
|
|
82
|
+
</tr>
|
|
83
|
+
</table>
|
|
84
|
+
|
|
74
85
|
<table>
|
|
75
86
|
<tr>
|
|
76
87
|
<td align="center"><b>Token gate</b></td>
|
|
@@ -144,8 +155,14 @@ ask_human({
|
|
|
144
155
|
// when it is safe for the agent to proceed without a human.
|
|
145
156
|
timeout_seconds?: number, // optional. Default 1800. Max 86400.
|
|
146
157
|
urgency?: "low" | "normal" | "blocking", // optional. Default "normal".
|
|
147
|
-
stream?: string
|
|
148
|
-
|
|
158
|
+
stream?: string, // optional. Overrides the env/basename default.
|
|
159
|
+
media?: Array<{ // optional. Screenshots, audio, or video for the human to inspect.
|
|
160
|
+
mime_type: string, // an image/*, audio/*, or video/* type
|
|
161
|
+
data_base64: string, // standard base64 (a `data:<mime>;base64,` prefix is accepted)
|
|
162
|
+
filename?: string,
|
|
163
|
+
caption?: string
|
|
164
|
+
}>
|
|
165
|
+
}) => { id: string, status: "pending", expires_at: string, media?: MediaRef[] }
|
|
149
166
|
```
|
|
150
167
|
|
|
151
168
|
### `wait_for_answer`
|
|
@@ -159,13 +176,19 @@ wait_for_answer({
|
|
|
159
176
|
timeout_seconds?: number // default 60, max 600
|
|
160
177
|
}) => {
|
|
161
178
|
id: string,
|
|
162
|
-
status: "pending" | "answered" | "expired",
|
|
179
|
+
status: "pending" | "answered" | "expired" | "cancelled",
|
|
163
180
|
answer?: string,
|
|
164
181
|
answered_by?: string, // "human" | "default"
|
|
165
|
-
answered_at?: string
|
|
182
|
+
answered_at?: string,
|
|
183
|
+
media?: MediaRef[], // the media you attached, echoed back
|
|
184
|
+
annotations?: Annotation[] // the human's notes and any rendered markup copy
|
|
166
185
|
}
|
|
167
186
|
```
|
|
168
187
|
|
|
188
|
+
When the human drew on an attached image, the annotated copy is returned **as MCP `image`
|
|
189
|
+
content** alongside the text, so an agent that only speaks MCP can see the markup without
|
|
190
|
+
fetching a URL. `annotations[].annotated_media` also names a blob under `GET /api/media/{id}`.
|
|
191
|
+
|
|
169
192
|
### `list_pending`
|
|
170
193
|
|
|
171
194
|
Lists the tickets still pending across every agent stream. Takes no arguments.
|
|
@@ -189,6 +212,30 @@ its own earlier guess. An answer that came from `default_answer` gets a clearly
|
|
|
189
212
|
prefix — `DEFAULT ANSWER (applied on expiry; no human responded):` — because labelling the
|
|
190
213
|
agent's own default as a human answer would be worse than no prefix at all.
|
|
191
214
|
|
|
215
|
+
## Media and annotations
|
|
216
|
+
|
|
217
|
+
Media is **optional**: most tickets have none, and a ticket with media is answered exactly like
|
|
218
|
+
one without. Attach it with `ask_human`'s `media` array (up to 8 items, 8 MiB each, 24 MiB per
|
|
219
|
+
ticket) when a decision needs a screenshot, a clip, or a recording rather than words.
|
|
220
|
+
|
|
221
|
+
- **Stored out of band.** The bytes live in a media store, keyed by an id; the ticket carries
|
|
222
|
+
only a `MediaRef` (kind, MIME type, filename, caption, size). `GET /api/tickets` and
|
|
223
|
+
`tickets.json` therefore stay small, and the page fetches a blob only when you expand a card.
|
|
224
|
+
- **Served same-origin.** `GET /api/media/{id}` returns the bytes under the same Bearer auth as
|
|
225
|
+
every other `/api/*` route. Nothing is fetched from an external origin.
|
|
226
|
+
- **Expand and inspect.** Each attachment is collapsed behind a *Show N attachments* toggle.
|
|
227
|
+
Images open on a drawing canvas (`Box`, `Draw`, `Clear markup`); audio and video get a player.
|
|
228
|
+
- **Annotate and send back.** The human can mark up an image and add notes (notes on audio/video
|
|
229
|
+
capture the playhead position). On **Answer**, a marked-up image is rendered to a PNG, uploaded
|
|
230
|
+
with `POST /api/media`, and referenced from the answer. Annotations are **optional**: the human
|
|
231
|
+
may answer with text, a chosen option, annotations, or any combination — but not nothing.
|
|
232
|
+
- **Back to the agent.** `wait_for_answer` returns the annotations as structured fields, and any
|
|
233
|
+
rendered markup as MCP `image` content, so the agent sees exactly what the human marked.
|
|
234
|
+
|
|
235
|
+
The three targets share the behaviour; only the storage differs. `FileTicketStore` writes blobs
|
|
236
|
+
under `${READYROOM_STATE_DIR}/media/`, and `DurableObjectTicketStore` chunks each blob across rows
|
|
237
|
+
because a Cloudflare SQLite row is capped at 2 MB.
|
|
238
|
+
|
|
192
239
|
## Three ways aboard
|
|
193
240
|
|
|
194
241
|
They run the same program. They differ in where it lives and who can reach it.
|
|
@@ -631,7 +678,8 @@ network otherwise cut. It is inlined at build time into each target's bundle.
|
|
|
631
678
|
- **Grouped by stream.** Tickets grouped under a stream heading, newest stream first. You need
|
|
632
679
|
to know which agent is asking.
|
|
633
680
|
- **Card contents.** Stream name, ticket age (`4m`), urgency badge, the question, the context,
|
|
634
|
-
|
|
681
|
+
any attached media behind an expand-for-inspection toggle (with drawing and notes), option
|
|
682
|
+
buttons, a free-text field, and an Answer button.
|
|
635
683
|
- **Keyboard-first.** `j`/`k` move between tickets, `1`-`5` pick an option, `Enter` submits, `u`
|
|
636
684
|
undoes the last answer. Draining a queue with a mouse is the slow path.
|
|
637
685
|
- **Tab title carries the count.** `(3) readyroom`.
|
|
@@ -655,8 +703,10 @@ GET /healthz -> 200, body "ok"
|
|
|
655
703
|
GET / -> the readyroom page (index.html)
|
|
656
704
|
GET /api/tickets -> { tickets: Ticket[] } pending + last 5 min of answered
|
|
657
705
|
GET /api/events -> text/event-stream of ticket changes (501 on the Worker)
|
|
658
|
-
POST /api/answer -> { id, answer
|
|
706
|
+
POST /api/answer -> { id, answer?, answered_by?, annotations? } -> 200 | 400 | 404 | 409
|
|
659
707
|
POST /api/undo -> { id } -> 200 | 409 outside the undo window
|
|
708
|
+
POST /api/media -> { mime_type, data_base64 } -> 201 { media: MediaRef }
|
|
709
|
+
GET /api/media/{id} -> the blob bytes (Content-Type from the stored MIME) | 404
|
|
660
710
|
POST /mcp/{stream} -> the Streamable HTTP MCP endpoint (Origin-guarded)
|
|
661
711
|
```
|
|
662
712
|
|
|
@@ -665,18 +715,22 @@ Unknown route -> `404`. Known route, wrong method -> `405`. The Node target addi
|
|
|
665
715
|
|
|
666
716
|
## CI/CD
|
|
667
717
|
|
|
668
|
-
|
|
718
|
+
Five GitHub Actions workflows:
|
|
669
719
|
|
|
720
|
+
- **`.github/workflows/release.yml`** — on every push to `main` (that is, every merged PR): runs
|
|
721
|
+
[semantic-release](https://semantic-release.gitbook.io) over the conventional-commit messages
|
|
722
|
+
since the last tag, then versions, changelogs, tags, and publishes the release. It is the
|
|
723
|
+
entry point; the four below are its downstream stages.
|
|
670
724
|
- **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build,
|
|
671
725
|
`wrangler deploy --dry-run`, `docker build`.
|
|
672
726
|
- **`.github/workflows/publish.yml`** — on the default branch and `v*` tags: buildx for
|
|
673
727
|
`linux/amd64,linux/arm64` (arm64 is required; the deploy target is a Pi), push to GHCR, auth
|
|
674
728
|
via the built-in `GITHUB_TOKEN` with `packages: write` (no PAT, no repo secrets),
|
|
675
|
-
`docker/metadata-action` tags (`latest` on the default branch
|
|
676
|
-
GHA build cache scoped per image, one matrix entry per image. Supply chain:
|
|
729
|
+
`docker/metadata-action` tags (`latest` on the default branch and on `v*`, semver on `v*`,
|
|
730
|
+
`sha-<short>`), GHA build cache scoped per image, one matrix entry per image. Supply chain:
|
|
677
731
|
`provenance: mode=max`, `sbom: true`, and `actions/attest-build-provenance` pushed to the
|
|
678
732
|
registry.
|
|
679
|
-
- **`.github/workflows/publish
|
|
733
|
+
- **`.github/workflows/npm-publish.yml`** — on `v*` tags: `npm publish` with provenance, using
|
|
680
734
|
npm Trusted Publishing (OIDC) rather than a stored token. It needs `id-token: write` and the
|
|
681
735
|
trusted-publisher entry configured on the package.
|
|
682
736
|
- **`.github/workflows/deploy-worker.yml`** — on `v*` tags: `wrangler deploy` with
|
|
@@ -687,6 +741,48 @@ Four GitHub Actions workflows:
|
|
|
687
741
|
|
|
688
742
|
`.github/dependabot.yml` covers `github-actions`, `npm`, and `docker`.
|
|
689
743
|
|
|
744
|
+
### Releasing
|
|
745
|
+
|
|
746
|
+
Merging a PR to `main` cuts the release. There is no version to bump and no tag to push:
|
|
747
|
+
`release.yml` runs [semantic-release](https://semantic-release.gitbook.io), which reads the
|
|
748
|
+
commit messages since the last release and picks the version by the usual convention:
|
|
749
|
+
|
|
750
|
+
| Commit | Version bump |
|
|
751
|
+
| --- | --- |
|
|
752
|
+
| `fix: …` | patch |
|
|
753
|
+
| `feat: …` | minor |
|
|
754
|
+
| any type carrying a `BREAKING CHANGE:` footer | minor while `0.x`, major from `1.0` |
|
|
755
|
+
| `docs:`, `chore:`, `refactor:`, `test:`, … | no release |
|
|
756
|
+
|
|
757
|
+
It writes `CHANGELOG.md` and the new version into `package.json`, commits them
|
|
758
|
+
(`chore(release): vX.Y.Z [skip ci]`), tags `vX.Y.Z`, and opens the GitHub Release. A merge whose
|
|
759
|
+
commits are all non-releasing finishes green and publishes nothing.
|
|
760
|
+
|
|
761
|
+
**Pre-1.0 behaviour.** While the version is still `0.x`, a breaking change bumps **minor** rather
|
|
762
|
+
than major, so a `BREAKING CHANGE:` never jumps you to 1.0.0 by surprise. That downgrade lives in
|
|
763
|
+
`release.config.js` and switches itself off the moment the current version's major is no longer
|
|
764
|
+
`0`. To reach 1.0 deliberately, run `npm run release:major` — it bumps and tags `v1.0.0` — and
|
|
765
|
+
from the next release on breaking changes bump major again.
|
|
766
|
+
|
|
767
|
+
**Why the release workflow dispatches the others instead of leaning on the tag.** A tag pushed
|
|
768
|
+
by a workflow's built-in `GITHUB_TOKEN` does not start other workflows: GitHub suppresses runs
|
|
769
|
+
for events caused by `GITHUB_TOKEN` to prevent recursion, so the `v*` triggers above would stay
|
|
770
|
+
silent. `workflow_dispatch` is the one event that rule exempts, so `release.yml` dispatches
|
|
771
|
+
`npm-publish.yml`, `publish.yml`, and (when enabled) `deploy-worker.yml` on the new tag itself.
|
|
772
|
+
The whole pipeline therefore runs on the built-in token — **no PAT and no repo secret**. If you
|
|
773
|
+
ever switch `release.yml` to a PAT or a GitHub App token, remove that dispatch step, or the tag
|
|
774
|
+
trigger and the dispatch will both fire.
|
|
775
|
+
|
|
776
|
+
The `npm run release:patch|minor|major` scripts still work as a manual fallback — they push a
|
|
777
|
+
tag, and the `v*` triggers pick it up — but with semantic-release they are no longer part of the
|
|
778
|
+
normal flow.
|
|
779
|
+
|
|
780
|
+
semantic-release, `@semantic-release/changelog`, and `@semantic-release/git` are
|
|
781
|
+
devDependencies; the other release plugins resolve from semantic-release itself. The release job
|
|
782
|
+
deliberately does **not** request `id-token: write`, so the OIDC token never sits beside
|
|
783
|
+
installed dependency code: publishing stays in `npm-publish.yml`, whose two-job split keeps the
|
|
784
|
+
token out of the build.
|
|
785
|
+
|
|
690
786
|
### npm publishing
|
|
691
787
|
|
|
692
788
|
Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish
|
package/dist/server.mjs
CHANGED
|
@@ -7322,6 +7322,13 @@ var UNDO_WINDOW_MS = 5 * 60 * 1e3;
|
|
|
7322
7322
|
var RECENT_WINDOW_MS = 5 * 60 * 1e3;
|
|
7323
7323
|
var POLL_INTERVAL_MS = 250;
|
|
7324
7324
|
var DEFAULT_STATE_DIR = "./.state";
|
|
7325
|
+
var MEDIA_DIR = "media";
|
|
7326
|
+
var MAX_MEDIA_FILES = 8;
|
|
7327
|
+
var MAX_MEDIA_BYTES_PER_FILE = 8 * 1024 * 1024;
|
|
7328
|
+
var MAX_MEDIA_BYTES_PER_TICKET = 24 * 1024 * 1024;
|
|
7329
|
+
var MAX_ANNOTATION_BYTES = 8 * 1024 * 1024;
|
|
7330
|
+
var MAX_ANNOTATIONS = 32;
|
|
7331
|
+
var MAX_BODY_BYTES = 48 * 1024 * 1024;
|
|
7325
7332
|
|
|
7326
7333
|
// src/config.ts
|
|
7327
7334
|
var ENV_ALLOWED_ORIGINS = "READYROOM_ALLOWED_ORIGINS";
|
|
@@ -7501,7 +7508,80 @@ async function acquireDaemonRole(stateDir, port) {
|
|
|
7501
7508
|
}
|
|
7502
7509
|
|
|
7503
7510
|
// public/index.html
|
|
7504
|
-
var public_default = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>readyroom</title>\n<!-- Suppress the /favicon.ico request. The page ships no icon and fetches nothing\n over the network; an inline empty data URI keeps the console free of a 404. -->\n<link rel="icon" href="data:,">\n<style>\n /* No external fonts, no external origins, no images fetched over the network.\n Everything below is self-contained so the page renders with the network cut. */\n\n :root {\n --ink: #111;\n --paper: #fff;\n --muted: #555;\n --line: #111;\n --line-soft: #999;\n --blocking: #000;\n --low: #666;\n }\n\n * { box-sizing: border-box; }\n\n html, body {\n margin: 0;\n padding: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;\n font-size: 16px;\n line-height: 1.45;\n }\n\n body { padding: 16px; max-width: 1100px; margin: 0 auto; }\n\n h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }\n h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }\n h3 { font-size: 0.95rem; margin: 0 0 8px 0; }\n\n .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }\n\n /* Status + error banners: text carries the meaning, not colour. */\n .banner {\n border: 2px solid var(--line);\n padding: 8px 10px;\n margin: 8px 0;\n font-size: 0.85rem;\n }\n .banner[hidden] { display: none; }\n .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }\n\n #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }\n #tokenbar[hidden] { display: none; }\n #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }\n #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }\n\n button {\n font: inherit;\n min-height: 44px;\n min-width: 44px;\n padding: 8px 12px;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n cursor: pointer;\n }\n button:focus-visible, input:focus-visible, a:focus-visible {\n outline: 3px solid var(--ink);\n outline-offset: 2px;\n }\n /* No hover-only affordances: hover is a bonus, never the only signal. */\n button:hover { background: #eee; }\n\n .stream-group { margin: 0 0 20px 0; }\n .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }\n .stream-name { font-weight: bold; }\n .stream-count { color: var(--muted); font-size: 0.85rem; }\n\n .card {\n border: 2px solid var(--line);\n padding: 12px;\n margin: 10px 0;\n background: var(--paper);\n }\n /* Selection is carried by a thick left border AND a text marker, not colour alone. */\n .card.selected { border-left-width: 10px; }\n .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }\n .card.selected .selmark { display: inline; }\n\n .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }\n .age { color: var(--muted); font-size: 0.85rem; }\n\n .badge {\n display: inline-block;\n border: 2px solid var(--line);\n padding: 1px 6px;\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: bold;\n }\n .badge.urgency-blocking { border-style: double; border-width: 4px; }\n .badge.urgency-low { border-style: dotted; }\n .badge.urgency-normal { border-style: solid; }\n .badge.status-answered { border-style: dashed; }\n\n .question { font-weight: bold; margin: 6px 0; }\n .context {\n white-space: pre-wrap;\n border-left: 3px solid var(--line-soft);\n padding: 6px 10px;\n margin: 8px 0;\n color: var(--ink);\n font-size: 0.9rem;\n }\n .context[hidden] { display: none; }\n\n .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }\n .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n\n .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .answer-row label { font-size: 0.85rem; }\n .answer-row input[type="text"] {\n flex: 1 1 240px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n\n .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }\n .card-error[hidden] { display: none; }\n .card-error .tag { font-weight: bold; text-transform: uppercase; }\n\n .recent .card { border-style: dashed; }\n .empty { color: var(--muted); font-style: italic; }\n\n .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }\n kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }\n\n /* No animation on the critical path. Respect reduced motion. */\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n @media (prefers-reduced-motion: no-preference) {\n .card.selected { transition: border-left-width 80ms linear; }\n }\n\n @media print {\n body { max-width: none; }\n button { display: none; }\n .card { break-inside: avoid; }\n }\n</style>\n</head>\n<body>\n <header>\n <h1>readyroom</h1>\n <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>-<kbd>5</kbd> pick · <kbd>Enter</kbd> answer · <kbd>u</kbd> undo</p>\n <div id="status" class="banner" role="status" aria-live="polite">\n <span class="tag">Status:</span> <span id="status-text">starting</span>\n </div>\n <div id="error" class="banner" role="alert" hidden>\n <span class="tag">Error:</span> <span id="error-text"></span>\n </div>\n </header>\n\n <section id="tokenbar" hidden aria-labelledby="token-label">\n <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>\n <div class="row">\n <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">\n <button id="token-save" type="button">Save token</button>\n </div>\n <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>\n </section>\n\n <main id="app">\n <section id="pending-section" aria-labelledby="pending-heading">\n <h2 id="pending-heading">Pending</h2>\n <div id="pending"></div>\n </section>\n\n <section id="recent-section" class="recent" aria-labelledby="recent-heading">\n <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>\n <div id="recent"></div>\n </section>\n </main>\n\n <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved.</p>\n\n <noscript>\n <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>\n </noscript>\n\n<script>\n(function () {\n "use strict";\n\n var TOKEN_KEY = "readyroom_token";\n var POLL_MS = 5000;\n var UNDO_WINDOW_MS = 5 * 60 * 1000;\n\n var state = {\n tickets: [],\n selectedId: null,\n token: "",\n abort: null,\n pollTimer: null,\n live: false,\n error: "",\n choices: {},\n drafts: {}\n };\n\n var els = {\n status: document.getElementById("status"),\n statusText: document.getElementById("status-text"),\n error: document.getElementById("error"),\n errorText: document.getElementById("error-text"),\n tokenbar: document.getElementById("tokenbar"),\n tokenInput: document.getElementById("token-input"),\n tokenSave: document.getElementById("token-save"),\n pending: document.getElementById("pending"),\n recent: document.getElementById("recent")\n };\n\n /* ---------------- token handling (requirement 11) ---------------- */\n\n function readToken() {\n try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }\n }\n function writeToken(t) {\n try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }\n }\n function dropToken() {\n try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }\n }\n function showTokenBar(show) {\n els.tokenbar.hidden = !show;\n if (show) { try { els.tokenInput.focus(); } catch (e) {} }\n }\n\n /* ---------------- HTTP with Bearer on every request ---------------- */\n\n function api(path, opts) {\n opts = opts || {};\n var headers = { "Authorization": "Bearer " + state.token };\n if (opts.body) { headers["Content-Type"] = "application/json"; }\n return fetch(path, {\n method: opts.method || "GET",\n headers: headers,\n body: opts.body\n }).then(function (res) {\n if (res.status === 401) {\n dropToken();\n state.token = "";\n state.error = "Token rejected (401). Enter it again.";\n showTokenBar(true);\n render();\n var err = new Error("unauthorized");\n err.unauthorized = true;\n throw err;\n }\n return res;\n });\n }\n\n function fetchTickets() {\n if (!state.token) { return Promise.resolve(); }\n return api("/api/tickets").then(function (res) {\n if (!res.ok) { throw new Error("tickets " + res.status); }\n return res.json();\n }).then(function (data) {\n state.tickets = (data && data.tickets) || [];\n state.error = "";\n render();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n /* ---------------- live updates (requirement 1) ---------------- */\n\n function startPolling() {\n if (state.pollTimer) { return; }\n state.live = false;\n fetchTickets();\n state.pollTimer = setInterval(fetchTickets, POLL_MS);\n render();\n }\n function stopPolling() {\n if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }\n }\n\n /* Fetch-based SSE reader.\n EventSource cannot attach an Authorization header, so a Bearer-protected\n /api/events is unreachable through it. Instead we fetch the endpoint with the\n token, read response.body through a ReadableStream reader, decode with a\n TextDecoder, and parse text/event-stream frames: frames are separated by a blank\n line, and the payload rides on `data:` lines. Any failure \u2014 a network error, a\n non-200 status, or a Worker\'s 501 \u2014 falls back to polling /api/tickets every 5 s,\n which is the Worker target\'s live-update path. */\n function handleFrame(frame) {\n var lines = frame.split("\\n");\n var sawData = false;\n for (var i = 0; i < lines.length; i++) {\n if (lines[i].indexOf("data:") === 0) { sawData = true; }\n }\n if (sawData) { fetchTickets(); }\n }\n\n function readSseStream(body) {\n var reader = body.getReader();\n var decoder = new TextDecoder();\n var buffer = "";\n function pump() {\n return reader.read().then(function (chunk) {\n if (chunk.done) { throw new Error("sse stream closed"); }\n buffer += decoder.decode(chunk.value, { stream: true });\n var idx;\n while ((idx = buffer.indexOf("\\n\\n")) !== -1) {\n var frame = buffer.slice(0, idx);\n buffer = buffer.slice(idx + 2);\n handleFrame(frame);\n }\n return pump();\n });\n }\n return pump();\n }\n\n function startLive() {\n stopLive();\n if (!state.token) { return; }\n if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }\n\n var controller = null;\n try { controller = new AbortController(); } catch (e) { controller = null; }\n if (controller) { state.abort = controller; }\n\n var options = { headers: { "Authorization": "Bearer " + state.token } };\n if (controller) { options.signal = controller.signal; }\n\n fetch("/api/events", options).then(function (res) {\n if (!res.ok || !res.body) {\n /* 401, 404, a Worker\'s 501, or a body we cannot stream: poll instead. */\n startPolling();\n return;\n }\n state.live = true;\n stopPolling();\n render();\n return readSseStream(res.body);\n }).catch(function () {\n if (controller && controller.signal.aborted) { return; }\n state.live = false;\n startPolling();\n });\n }\n function stopLive() {\n if (state.abort) {\n try { state.abort.abort(); } catch (e) { /* ignore */ }\n state.abort = null;\n }\n state.live = false;\n }\n\n /* ---------------- derived data ---------------- */\n\n function pendingTickets() {\n return state.tickets.filter(function (t) { return t.status === "pending"; });\n }\n function recentTickets() {\n var now = Date.now();\n return state.tickets.filter(function (t) {\n if (t.status !== "answered" || !t.answered_at) { return false; }\n return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;\n }).sort(function (a, b) {\n return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();\n });\n }\n\n function groupByStream(tickets) {\n var groups = {};\n var order = [];\n tickets.forEach(function (t) {\n if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }\n groups[t.stream].push(t);\n });\n order.forEach(function (s) {\n groups[s].sort(function (a, b) {\n return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n });\n });\n order.sort(function (a, b) {\n return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();\n });\n return order.map(function (s) { return { stream: s, tickets: groups[s] }; });\n }\n\n function orderedPending() {\n var out = [];\n groupByStream(pendingTickets()).forEach(function (g) {\n g.tickets.forEach(function (t) { out.push(t); });\n });\n return out;\n }\n\n function age(iso) {\n var ms = Date.now() - new Date(iso).getTime();\n if (!isFinite(ms) || ms < 0) { ms = 0; }\n var s = Math.floor(ms / 1000);\n if (s < 60) { return s + "s"; }\n var m = Math.floor(s / 60);\n if (m < 60) { return m + "m"; }\n var h = Math.floor(m / 60);\n if (h < 24) { return h + "h"; }\n return Math.floor(h / 24) + "d";\n }\n\n /* ---------------- actions ---------------- */\n\n function setCardError(id, msg) {\n var el = document.getElementById("err-" + id);\n if (el) {\n el.hidden = false;\n var span = el.querySelector(".err-text");\n if (span) { span.textContent = msg; }\n }\n }\n\n function submitAnswer(ticket) {\n var input = document.getElementById("free-" + ticket.id);\n var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();\n var choice = state.choices[ticket.id] || "";\n var answer = text || choice;\n if (!answer) {\n setCardError(ticket.id, "Type an answer or pick an option first.");\n return;\n }\n api("/api/answer", {\n method: "POST",\n body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human" })\n }).then(function (res) {\n if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }\n if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }\n if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }\n delete state.choices[ticket.id];\n delete state.drafts[ticket.id];\n return fetchTickets();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));\n });\n }\n\n function undo(ticketId) {\n api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })\n .then(function (res) {\n if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }\n if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }\n return fetchTickets();\n })\n .catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Undo failed: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n function undoLast() {\n var recent = recentTickets();\n if (recent.length === 0) { return; }\n undo(recent[0].id);\n }\n\n /* ---------------- keyboard (requirement 4) ---------------- */\n\n function isTypingTarget(el) {\n if (!el) { return false; }\n var tag = el.tagName;\n return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;\n }\n\n function moveSelection(delta) {\n var list = orderedPending();\n if (list.length === 0) { return; }\n var idx = -1;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }\n if (idx === -1) { idx = delta > 0 ? -1 : 0; }\n var next = idx + delta;\n if (next < 0) { next = 0; }\n if (next > list.length - 1) { next = list.length - 1; }\n state.selectedId = list[next].id;\n render();\n var card = document.getElementById("card-" + state.selectedId);\n if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }\n }\n\n function pickOption(n) {\n var list = orderedPending();\n var ticket = null;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }\n if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }\n state.choices[ticket.id] = ticket.options[n - 1];\n render();\n }\n\n function submitSelected() {\n var list = orderedPending();\n for (var i = 0; i < list.length; i++) {\n if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }\n }\n }\n\n document.addEventListener("keydown", function (e) {\n if (isTypingTarget(e.target)) { return; }\n if (e.metaKey || e.ctrlKey || e.altKey) { return; }\n if (e.key === "j") { e.preventDefault(); moveSelection(1); }\n else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }\n else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }\n else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }\n else if (e.key === "u") { e.preventDefault(); undoLast(); }\n });\n\n /* ---------------- rendering ---------------- */\n\n function captureDrafts() {\n var inputs = document.querySelectorAll("[data-draft]");\n for (var i = 0; i < inputs.length; i++) {\n state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;\n }\n }\n\n function el(tag, cls, text) {\n var node = document.createElement(tag);\n if (cls) { node.className = cls; }\n if (text !== undefined && text !== null) { node.textContent = text; }\n return node;\n }\n\n function urgencyBadge(urgency) {\n var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);\n return b;\n }\n\n function buildCard(ticket, isRecent) {\n // The `selected` marker is a keyboard-navigation affordance for the pending queue\n // only. An answered card in "recent" carries no selection target (`j`/`k`/`Enter`\n // never act on it), so marking it "selected" would be misleading.\n var card = el("article", "card" + (!isRecent && ticket.id === state.selectedId ? " selected" : ""));\n card.id = "card-" + ticket.id;\n card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);\n\n var head = el("div", "card-head");\n head.appendChild(el("span", "selmark", "selected"));\n head.appendChild(el("span", "stream-name", ticket.stream));\n head.appendChild(el("span", "age", age(ticket.created_at) + " old"));\n head.appendChild(urgencyBadge(ticket.urgency));\n if (isRecent) {\n head.appendChild(el("span", "badge status-answered", "status: answered"));\n if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }\n }\n card.appendChild(head);\n\n card.appendChild(el("p", "question", ticket.question));\n\n if (ticket.context) {\n var ctx = el("div", "context", ticket.context);\n card.appendChild(ctx);\n }\n\n if (isRecent) {\n var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));\n card.appendChild(ans);\n var undoBtn = el("button", null, "Undo");\n undoBtn.type = "button";\n undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);\n undoBtn.addEventListener("click", function () { undo(ticket.id); });\n var row = el("div", "answer-row");\n row.appendChild(undoBtn);\n card.appendChild(row);\n return card;\n }\n\n if (ticket.options && ticket.options.length) {\n var opts = el("div", "options");\n opts.setAttribute("role", "group");\n opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);\n ticket.options.forEach(function (opt, i) {\n var b = el("button", null, (i + 1) + ". " + opt);\n b.type = "button";\n b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");\n b.addEventListener("click", function () {\n state.choices[ticket.id] = opt;\n state.selectedId = ticket.id;\n render();\n });\n opts.appendChild(b);\n });\n card.appendChild(opts);\n }\n\n var form = el("form", "answer-row");\n form.setAttribute("novalidate", "novalidate");\n var label = el("label", null, "Answer");\n label.setAttribute("for", "free-" + ticket.id);\n form.appendChild(label);\n var input = document.createElement("input");\n input.type = "text";\n input.id = "free-" + ticket.id;\n input.setAttribute("data-draft", ticket.id);\n input.setAttribute("autocomplete", "off");\n input.value = state.drafts[ticket.id] || "";\n input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n form.appendChild(input);\n var submit = el("button", null, "Answer");\n submit.type = "submit";\n form.appendChild(submit);\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n submitAnswer(ticket);\n });\n card.appendChild(form);\n\n var err = el("div", "card-error");\n err.id = "err-" + ticket.id;\n err.hidden = true;\n err.setAttribute("role", "alert");\n err.appendChild(el("span", "tag", "Error: "));\n err.appendChild(el("span", "err-text", ""));\n card.appendChild(err);\n\n return card;\n }\n\n function render() {\n captureDrafts();\n\n var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;\n var selStart = null, selEnd = null;\n if (activeId && document.activeElement.setSelectionRange) {\n try {\n selStart = document.activeElement.selectionStart;\n selEnd = document.activeElement.selectionEnd;\n } catch (e) { /* not a text input */ }\n }\n\n var pending = pendingTickets();\n var recent = recentTickets();\n\n /* requirement 5: tab title carries the count */\n document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";\n\n /* status line */\n var statusText = "idle";\n if (state.live) { statusText = "live (server-sent events)"; }\n else if (state.pollTimer) { statusText = "polling every 5s"; }\n else if (!state.token) { statusText = "waiting for token"; }\n els.statusText.textContent = statusText;\n\n if (state.error) {\n els.error.hidden = false;\n els.errorText.textContent = state.error;\n } else {\n els.error.hidden = true;\n els.errorText.textContent = "";\n }\n\n /* pending, grouped by stream, newest stream first */\n els.pending.textContent = "";\n if (pending.length === 0) {\n els.pending.appendChild(el("p", "empty", "No pending tickets."));\n } else {\n groupByStream(pending).forEach(function (group) {\n var section = el("section", "stream-group");\n section.setAttribute("aria-label", "Stream " + group.stream);\n var head = el("div", "stream-head");\n head.appendChild(el("h3", "stream-name", group.stream));\n head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));\n section.appendChild(head);\n group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });\n els.pending.appendChild(section);\n });\n }\n\n /* recent, undoable for 5 minutes */\n els.recent.textContent = "";\n if (recent.length === 0) {\n els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));\n } else {\n recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });\n }\n\n /* restore focus + caret so polling re-renders do not interrupt typing */\n if (activeId) {\n var again = document.getElementById(activeId);\n if (again && again.focus) {\n try {\n again.focus();\n if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }\n } catch (e) { /* ignore */ }\n }\n }\n }\n\n /* ---------------- wiring ---------------- */\n\n els.tokenSave.addEventListener("click", function () {\n var t = els.tokenInput.value.trim();\n if (!t) { return; }\n state.token = t;\n writeToken(t);\n state.error = "";\n showTokenBar(false);\n startLive();\n fetchTickets();\n });\n els.tokenInput.addEventListener("keydown", function (e) {\n if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }\n });\n\n /* requirement 9: re-render on window load */\n window.addEventListener("load", render);\n\n /* boot */\n state.token = readToken();\n if (state.token) {\n showTokenBar(false);\n startLive();\n fetchTickets();\n } else {\n showTokenBar(true);\n }\n\n /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */\n render();\n})();\n</script>\n</body>\n</html>\n';
|
|
7511
|
+
var public_default = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>readyroom</title>\n<!-- Suppress the /favicon.ico request. The page ships no icon and fetches nothing\n over the network; an inline empty data URI keeps the console free of a 404. -->\n<link rel="icon" href="data:,">\n<style>\n /* No external fonts, no external origins, no images fetched over the network.\n Everything below is self-contained so the page renders with the network cut. */\n\n :root {\n --ink: #111;\n --paper: #fff;\n --muted: #555;\n --line: #111;\n --line-soft: #999;\n --blocking: #000;\n --low: #666;\n }\n\n * { box-sizing: border-box; }\n\n html, body {\n margin: 0;\n padding: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;\n font-size: 16px;\n line-height: 1.45;\n }\n\n body { padding: 16px; max-width: 1100px; margin: 0 auto; }\n\n h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }\n h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }\n h3 { font-size: 0.95rem; margin: 0 0 8px 0; }\n\n .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }\n\n /* Status + error banners: text carries the meaning, not colour. */\n .banner {\n border: 2px solid var(--line);\n padding: 8px 10px;\n margin: 8px 0;\n font-size: 0.85rem;\n }\n .banner[hidden] { display: none; }\n .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }\n\n #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }\n #tokenbar[hidden] { display: none; }\n #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }\n #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }\n\n button {\n font: inherit;\n min-height: 44px;\n min-width: 44px;\n padding: 8px 12px;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n cursor: pointer;\n }\n button:focus-visible, input:focus-visible, a:focus-visible {\n outline: 3px solid var(--ink);\n outline-offset: 2px;\n }\n /* No hover-only affordances: hover is a bonus, never the only signal. */\n button:hover { background: #eee; }\n\n .stream-group { margin: 0 0 20px 0; }\n .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }\n .stream-name { font-weight: bold; }\n .stream-count { color: var(--muted); font-size: 0.85rem; }\n\n .card {\n border: 2px solid var(--line);\n padding: 12px;\n margin: 10px 0;\n background: var(--paper);\n }\n /* Selection is carried by a thick left border AND a text marker, not colour alone. */\n .card.selected { border-left-width: 10px; }\n .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }\n .card.selected .selmark { display: inline; }\n\n .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }\n .age { color: var(--muted); font-size: 0.85rem; }\n\n .badge {\n display: inline-block;\n border: 2px solid var(--line);\n padding: 1px 6px;\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: bold;\n }\n .badge.urgency-blocking { border-style: double; border-width: 4px; }\n .badge.urgency-low { border-style: dotted; }\n .badge.urgency-normal { border-style: solid; }\n .badge.status-answered { border-style: dashed; }\n\n .question { font-weight: bold; margin: 6px 0; }\n .context {\n white-space: pre-wrap;\n border-left: 3px solid var(--line-soft);\n padding: 6px 10px;\n margin: 8px 0;\n color: var(--ink);\n font-size: 0.9rem;\n }\n .context[hidden] { display: none; }\n\n .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }\n .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n\n .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .answer-row label { font-size: 0.85rem; }\n .answer-row input[type="text"] {\n flex: 1 1 240px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n\n .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }\n .card-error[hidden] { display: none; }\n .card-error .tag { font-weight: bold; text-transform: uppercase; }\n\n .recent .card { border-style: dashed; }\n .empty { color: var(--muted); font-style: italic; }\n\n /* Media is fetched with the Bearer token (fetch, not <img src>), then shown from an\n object URL. Expansion is a real <button> (keyboard-operable); drawing never relies\n on hover; nothing is colour-only. */\n .media-section { margin: 10px 0; }\n .media-toggle { width: 100%; text-align: left; }\n .media-list[hidden] { display: none; }\n .media-item { border: 1px solid var(--line-soft); padding: 8px; margin: 8px 0; }\n .media-item figcaption { font-size: 0.8rem; color: var(--muted); margin-bottom: 6px; }\n .media-stage { margin: 6px 0; }\n .media-stage img, .media-stage video, .media-stage audio { max-width: 100%; display: block; }\n .media-stage video { background: #000; }\n .media-stage canvas {\n max-width: 100%;\n height: auto;\n display: block;\n touch-action: none;\n cursor: crosshair;\n border: 1px solid var(--line-soft);\n }\n .media-tools { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }\n .media-tools button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n .media-status { font-size: 0.8rem; color: var(--muted); }\n .annotations { border-top: 1px dashed var(--line-soft); margin-top: 10px; padding-top: 8px; }\n .annotations h4 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 6px 0; }\n .note { border: 1px solid var(--line-soft); padding: 6px 8px; margin: 6px 0; font-size: 0.9rem; }\n .note-text { white-space: pre-wrap; }\n .note-meta { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }\n .note-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .note-add input[type="text"] {\n flex: 1 1 200px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n .note-add select {\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n .annotation-image { margin: 8px 0; }\n .annotation-image img { max-width: 100%; border: 1px solid var(--line-soft); }\n\n .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }\n kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }\n\n /* No animation on the critical path. Respect reduced motion. */\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n @media (prefers-reduced-motion: no-preference) {\n .card.selected { transition: border-left-width 80ms linear; }\n }\n\n @media print {\n body { max-width: none; }\n button { display: none; }\n .card { break-inside: avoid; }\n }\n</style>\n</head>\n<body>\n <header>\n <h1>readyroom</h1>\n <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>-<kbd>5</kbd> pick · <kbd>Enter</kbd> answer · <kbd>u</kbd> undo</p>\n <div id="status" class="banner" role="status" aria-live="polite">\n <span class="tag">Status:</span> <span id="status-text">starting</span>\n </div>\n <div id="error" class="banner" role="alert" hidden>\n <span class="tag">Error:</span> <span id="error-text"></span>\n </div>\n </header>\n\n <section id="tokenbar" hidden aria-labelledby="token-label">\n <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>\n <div class="row">\n <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">\n <button id="token-save" type="button">Save token</button>\n </div>\n <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>\n </section>\n\n <main id="app">\n <section id="pending-section" aria-labelledby="pending-heading">\n <h2 id="pending-heading">Pending</h2>\n <div id="pending"></div>\n </section>\n\n <section id="recent-section" class="recent" aria-labelledby="recent-heading">\n <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>\n <div id="recent"></div>\n </section>\n </main>\n\n <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved. Attached media is fetched with your token only when you expand a ticket, and never from an external origin.</p>\n\n <noscript>\n <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>\n </noscript>\n\n<script>\n(function () {\n "use strict";\n\n var TOKEN_KEY = "readyroom_token";\n var POLL_MS = 5000;\n var UNDO_WINDOW_MS = 5 * 60 * 1000;\n\n var state = {\n tickets: [],\n selectedId: null,\n token: "",\n abort: null,\n pollTimer: null,\n live: false,\n error: "",\n choices: {},\n drafts: {},\n /* Media and annotation state, all keyed so it survives the polling re-render:\n notes[ticketId] -> [{ text, media_id?, at_seconds? }]\n noteDrafts[key] -> in-progress note text (key = ticketId + ":" + (mediaId||""))\n shapes[mediaId] -> [{ tool: "box"|"draw", ... }] in the image\'s natural pixels\n expanded[ticketId] -> whether the attachment list is open\n mediaUrls[mediaId] -> object URL of the fetched blob (never revoked; ids are immutable) */\n notes: {},\n noteDrafts: {},\n noteTargets: {},\n shapes: {},\n canvasScale: {},\n tool: "box",\n expanded: {},\n mediaUrls: {},\n mediaPending: {},\n mediaErrors: {},\n /* True while a stroke is in progress, so a poll does not replace the canvas\n mid-drag; the pending render runs when the stroke ends. */\n drawing: false,\n renderQueued: false\n };\n\n var els = {\n status: document.getElementById("status"),\n statusText: document.getElementById("status-text"),\n error: document.getElementById("error"),\n errorText: document.getElementById("error-text"),\n tokenbar: document.getElementById("tokenbar"),\n tokenInput: document.getElementById("token-input"),\n tokenSave: document.getElementById("token-save"),\n pending: document.getElementById("pending"),\n recent: document.getElementById("recent")\n };\n\n /* ---------------- token handling (requirement 11) ---------------- */\n\n function readToken() {\n try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }\n }\n function writeToken(t) {\n try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }\n }\n function dropToken() {\n try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }\n }\n function showTokenBar(show) {\n els.tokenbar.hidden = !show;\n if (show) { try { els.tokenInput.focus(); } catch (e) {} }\n }\n\n /* ---------------- HTTP with Bearer on every request ---------------- */\n\n function api(path, opts) {\n opts = opts || {};\n var headers = { "Authorization": "Bearer " + state.token };\n if (opts.body) { headers["Content-Type"] = "application/json"; }\n return fetch(path, {\n method: opts.method || "GET",\n headers: headers,\n body: opts.body\n }).then(function (res) {\n if (res.status === 401) {\n dropToken();\n state.token = "";\n state.error = "Token rejected (401). Enter it again.";\n showTokenBar(true);\n render();\n var err = new Error("unauthorized");\n err.unauthorized = true;\n throw err;\n }\n return res;\n });\n }\n\n function fetchTickets() {\n if (!state.token) { return Promise.resolve(); }\n return api("/api/tickets").then(function (res) {\n if (!res.ok) { throw new Error("tickets " + res.status); }\n return res.json();\n }).then(function (data) {\n state.tickets = (data && data.tickets) || [];\n state.error = "";\n // Do not replace the DOM mid-stroke; queue the render for when the stroke ends.\n if (state.drawing) { state.renderQueued = true; } else { render(); }\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n /* ---------------- live updates (requirement 1) ---------------- */\n\n function startPolling() {\n if (state.pollTimer) { return; }\n state.live = false;\n fetchTickets();\n state.pollTimer = setInterval(fetchTickets, POLL_MS);\n render();\n }\n function stopPolling() {\n if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }\n }\n\n /* Fetch-based SSE reader.\n EventSource cannot attach an Authorization header, so a Bearer-protected\n /api/events is unreachable through it. Instead we fetch the endpoint with the\n token, read response.body through a ReadableStream reader, decode with a\n TextDecoder, and parse text/event-stream frames: frames are separated by a blank\n line, and the payload rides on `data:` lines. Any failure \u2014 a network error, a\n non-200 status, or a Worker\'s 501 \u2014 falls back to polling /api/tickets every 5 s,\n which is the Worker target\'s live-update path. */\n function handleFrame(frame) {\n var lines = frame.split("\\n");\n var sawData = false;\n for (var i = 0; i < lines.length; i++) {\n if (lines[i].indexOf("data:") === 0) { sawData = true; }\n }\n if (sawData) { fetchTickets(); }\n }\n\n function readSseStream(body) {\n var reader = body.getReader();\n var decoder = new TextDecoder();\n var buffer = "";\n function pump() {\n return reader.read().then(function (chunk) {\n if (chunk.done) { throw new Error("sse stream closed"); }\n buffer += decoder.decode(chunk.value, { stream: true });\n var idx;\n while ((idx = buffer.indexOf("\\n\\n")) !== -1) {\n var frame = buffer.slice(0, idx);\n buffer = buffer.slice(idx + 2);\n handleFrame(frame);\n }\n return pump();\n });\n }\n return pump();\n }\n\n function startLive() {\n stopLive();\n if (!state.token) { return; }\n if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }\n\n var controller = null;\n try { controller = new AbortController(); } catch (e) { controller = null; }\n if (controller) { state.abort = controller; }\n\n var options = { headers: { "Authorization": "Bearer " + state.token } };\n if (controller) { options.signal = controller.signal; }\n\n fetch("/api/events", options).then(function (res) {\n if (!res.ok || !res.body) {\n /* 401, 404, a Worker\'s 501, or a body we cannot stream: poll instead. */\n startPolling();\n return;\n }\n state.live = true;\n stopPolling();\n render();\n return readSseStream(res.body);\n }).catch(function () {\n if (controller && controller.signal.aborted) { return; }\n state.live = false;\n startPolling();\n });\n }\n function stopLive() {\n if (state.abort) {\n try { state.abort.abort(); } catch (e) { /* ignore */ }\n state.abort = null;\n }\n state.live = false;\n }\n\n /* ---------------- derived data ---------------- */\n\n function pendingTickets() {\n return state.tickets.filter(function (t) { return t.status === "pending"; });\n }\n function recentTickets() {\n var now = Date.now();\n return state.tickets.filter(function (t) {\n if (t.status !== "answered" || !t.answered_at) { return false; }\n return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;\n }).sort(function (a, b) {\n return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();\n });\n }\n\n function groupByStream(tickets) {\n var groups = {};\n var order = [];\n tickets.forEach(function (t) {\n if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }\n groups[t.stream].push(t);\n });\n order.forEach(function (s) {\n groups[s].sort(function (a, b) {\n return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n });\n });\n order.sort(function (a, b) {\n return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();\n });\n return order.map(function (s) { return { stream: s, tickets: groups[s] }; });\n }\n\n function orderedPending() {\n var out = [];\n groupByStream(pendingTickets()).forEach(function (g) {\n g.tickets.forEach(function (t) { out.push(t); });\n });\n return out;\n }\n\n function age(iso) {\n var ms = Date.now() - new Date(iso).getTime();\n if (!isFinite(ms) || ms < 0) { ms = 0; }\n var s = Math.floor(ms / 1000);\n if (s < 60) { return s + "s"; }\n var m = Math.floor(s / 60);\n if (m < 60) { return m + "m"; }\n var h = Math.floor(m / 60);\n if (h < 24) { return h + "h"; }\n return Math.floor(h / 24) + "d";\n }\n\n /* ---------------- media + annotations ---------------- */\n\n function formatBytes(n) {\n if (typeof n !== "number" || !isFinite(n)) { return "?"; }\n if (n < 1024) { return n + " B"; }\n if (n < 1024 * 1024) { return (n / 1024).toFixed(1) + " KiB"; }\n return (n / (1024 * 1024)).toFixed(1) + " MiB";\n }\n\n function formatTime(seconds) {\n var s = Math.max(0, Math.floor(seconds));\n var m = Math.floor(s / 60);\n var rest = s % 60;\n return m + ":" + (rest < 10 ? "0" : "") + rest;\n }\n\n function findMedia(ticket, id) {\n var media = ticket.media || [];\n for (var i = 0; i < media.length; i++) { if (media[i].id === id) { return media[i]; } }\n return null;\n }\n\n function mediaName(ticket, id) {\n var media = ticket.media || [];\n for (var i = 0; i < media.length; i++) {\n if (media[i].id === id) { return media[i].filename || ("attachment " + (i + 1) + " (" + media[i].kind + ")"); }\n }\n return "attachment";\n }\n\n /* Media is fetched with the Bearer token, then shown from an object URL: <img src>\n and <audio src> cannot attach an Authorization header, exactly like EventSource. */\n function loadMediaObjectUrl(ref) {\n if (state.mediaUrls[ref.id]) { return Promise.resolve(state.mediaUrls[ref.id]); }\n if (state.mediaPending[ref.id]) { return state.mediaPending[ref.id]; }\n var promise = api("/api/media/" + encodeURIComponent(ref.id)).then(function (res) {\n if (!res.ok) { throw new Error("media " + res.status); }\n return res.blob();\n }).then(function (blob) {\n var url = URL.createObjectURL(blob);\n state.mediaUrls[ref.id] = url;\n delete state.mediaPending[ref.id];\n return url;\n }).catch(function (err) {\n delete state.mediaPending[ref.id];\n state.mediaErrors[ref.id] = err && err.message ? err.message : "load failed";\n throw err;\n });\n state.mediaPending[ref.id] = promise;\n return promise;\n }\n\n function drawShape(ctx, shape) {\n ctx.save();\n ctx.lineWidth = 3;\n ctx.strokeStyle = "#d40000";\n ctx.lineJoin = "round";\n ctx.lineCap = "round";\n if (shape.tool === "box") {\n var x = Math.min(shape.x0, shape.x1);\n var y = Math.min(shape.y0, shape.y1);\n ctx.strokeRect(x, y, Math.abs(shape.x1 - shape.x0), Math.abs(shape.y1 - shape.y0));\n } else if (shape.tool === "draw" && shape.points && shape.points.length > 1) {\n ctx.beginPath();\n ctx.moveTo(shape.points[0].x, shape.points[0].y);\n for (var i = 1; i < shape.points.length; i++) { ctx.lineTo(shape.points[i].x, shape.points[i].y); }\n ctx.stroke();\n }\n ctx.restore();\n }\n\n function toolButton(tool, label) {\n var b = el("button", null, label);\n b.type = "button";\n b.setAttribute("aria-pressed", state.tool === tool ? "true" : "false");\n b.addEventListener("click", function () { state.tool = tool; render(); });\n return b;\n }\n\n /* The expanded image is a canvas you draw on. The base image lives offscreen so a\n repaint can restore it under the committed shapes plus the in-progress stroke. */\n function buildImageCanvas(ref, url) {\n var wrap = el("div", "media-canvas-wrap");\n var tools = el("div", "media-tools");\n tools.appendChild(toolButton("box", "Box"));\n tools.appendChild(toolButton("draw", "Draw"));\n var clear = el("button", null, "Clear markup");\n clear.type = "button";\n clear.addEventListener("click", function () {\n state.shapes[ref.id] = [];\n render();\n });\n tools.appendChild(clear);\n tools.appendChild(el("span", "media-status", "Draw to mark up; markup is sent with your answer."));\n wrap.appendChild(tools);\n\n var canvas = document.createElement("canvas");\n canvas.id = "canvas-" + ref.id;\n canvas.setAttribute("aria-label", (ref.filename || "attached image") + ". Draw on the image to annotate it.");\n wrap.appendChild(canvas);\n\n var base = document.createElement("canvas");\n var current = null;\n var drawing = false;\n\n function repaint() {\n var ctx = canvas.getContext("2d");\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n ctx.drawImage(base, 0, 0);\n var shapes = state.shapes[ref.id] || [];\n for (var i = 0; i < shapes.length; i++) { drawShape(ctx, shapes[i]); }\n if (current) { drawShape(ctx, current); }\n }\n\n function toCanvas(e) {\n var rect = canvas.getBoundingClientRect();\n return {\n x: (e.clientX - rect.left) * (canvas.width / rect.width),\n y: (e.clientY - rect.top) * (canvas.height / rect.height)\n };\n }\n\n function endStroke() {\n if (!drawing) { return; }\n drawing = false;\n state.drawing = false;\n if (current) {\n state.shapes[ref.id] = (state.shapes[ref.id] || []).concat([current]);\n current = null;\n }\n repaint();\n if (state.renderQueued) { state.renderQueued = false; render(); }\n }\n\n canvas.addEventListener("pointerdown", function (e) {\n if (e.button !== undefined && e.button !== 0) { return; }\n drawing = true;\n state.drawing = true;\n try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* not fatal */ }\n var p = toCanvas(e);\n current = state.tool === "box"\n ? { tool: "box", x0: p.x, y0: p.y, x1: p.x, y1: p.y }\n : { tool: "draw", points: [p] };\n repaint();\n e.preventDefault();\n });\n canvas.addEventListener("pointermove", function (e) {\n if (!drawing || !current) { return; }\n var p = toCanvas(e);\n if (current.tool === "box") { current.x1 = p.x; current.y1 = p.y; }\n else { current.points.push(p); }\n repaint();\n e.preventDefault();\n });\n canvas.addEventListener("pointerup", endStroke);\n canvas.addEventListener("pointercancel", endStroke);\n\n var img = new Image();\n img.onload = function () {\n var maxDim = 2400;\n var scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));\n canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));\n canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));\n // Shapes are captured in canvas pixels; record the ratio so an annotated copy\n // rendered at natural size can scale them back up.\n state.canvasScale[ref.id] = canvas.width / img.naturalWidth;\n base.width = canvas.width;\n base.height = canvas.height;\n base.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);\n repaint();\n };\n img.src = url;\n return wrap;\n }\n\n /* A player/stage for one attachment. Loads lazily on first mount; re-mounts are free\n because the object URL is cached in state. */\n function mountMedia(ref, ticket, isRecent) {\n var stage = el("div", "media-stage");\n stage.setAttribute("data-media-stage", "1");\n\n function build() {\n var url = state.mediaUrls[ref.id];\n if (ref.kind === "image" && !isRecent) {\n return buildImageCanvas(ref, url);\n }\n if (ref.kind === "image") {\n var img = document.createElement("img");\n img.alt = ref.filename || "attached image";\n img.src = url;\n return img;\n }\n var node = document.createElement(ref.kind === "audio" ? "audio" : "video");\n node.controls = true;\n node.id = "player-" + ref.id;\n node.preload = "metadata";\n node.src = url;\n return node;\n }\n\n function refresh() {\n stage.textContent = "";\n if (state.mediaUrls[ref.id]) {\n stage.appendChild(build());\n return;\n }\n if (state.mediaErrors[ref.id]) {\n stage.appendChild(el("div", "media-status", "Could not load attachment: " + state.mediaErrors[ref.id]));\n return;\n }\n stage.appendChild(el("div", "media-status", "Loading " + ref.kind + "\u2026"));\n }\n\n refresh();\n if (!state.mediaUrls[ref.id] && !state.mediaErrors[ref.id]) {\n loadMediaObjectUrl(ref).then(refresh, refresh);\n }\n return stage;\n }\n\n function buildMediaItem(ref, ticket, isRecent, index) {\n var fig = el("figure", "media-item");\n fig.setAttribute("data-media-item", ref.id);\n var name = ref.filename || ("attachment " + (index + 1) + " (" + ref.kind + ")");\n fig.appendChild(el("figcaption", null, name + " \xB7 " + ref.mime_type + " \xB7 " + formatBytes(ref.size_bytes)));\n if (ref.caption) { fig.appendChild(el("div", "media-status", ref.caption)); }\n fig.appendChild(mountMedia(ref, ticket, isRecent));\n return fig;\n }\n\n function addNote(ticketId, note) {\n if (!state.notes[ticketId]) { state.notes[ticketId] = []; }\n state.notes[ticketId].push(note);\n // Clear the field itself, not just the draft: render() re-captures the input\'s\n // value at the top of the pass, so clearing only the draft would be undone.\n var input = document.getElementById("note-" + ticketId);\n if (input) { input.value = ""; }\n state.noteDrafts[ticketId + ":note"] = "";\n render();\n }\n\n function removeNote(ticketId, index) {\n if (state.notes[ticketId]) { state.notes[ticketId].splice(index, 1); }\n render();\n }\n\n function buildNotesList(ticket, isRecent) {\n var list = el("div", "notes-list");\n var notes = state.notes[ticket.id] || [];\n if (notes.length === 0) {\n list.appendChild(el("p", "media-status", "No annotations yet. Markup you draw and notes you add are sent back with your answer."));\n return list;\n }\n notes.forEach(function (note, index) {\n var item = el("div", "note");\n item.appendChild(el("div", "note-text", note.text));\n var meta = [];\n if (note.media_id) { meta.push(mediaName(ticket, note.media_id)); }\n if (note.at_seconds !== undefined && note.at_seconds !== null) { meta.push(formatTime(note.at_seconds)); }\n if (meta.length) { item.appendChild(el("div", "note-meta", meta.join(" \xB7 "))); }\n if (!isRecent) {\n var row = el("div", "note-add");\n var rm = el("button", null, "Remove");\n rm.type = "button";\n rm.addEventListener("click", function () { removeNote(ticket.id, index); });\n row.appendChild(rm);\n item.appendChild(row);\n }\n list.appendChild(item);\n });\n return list;\n }\n\n function buildNoteAdder(ticket, media) {\n var form = el("form", "note-add");\n form.setAttribute("novalidate", "novalidate");\n var key = ticket.id + ":note";\n\n var input = document.createElement("input");\n input.type = "text";\n input.id = "note-" + ticket.id;\n input.setAttribute("data-notedraft", key);\n input.setAttribute("autocomplete", "off");\n input.setAttribute("placeholder", "Add a note");\n input.value = state.noteDrafts[key] || "";\n input.addEventListener("input", function () { state.noteDrafts[key] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n\n var select = document.createElement("select");\n select.id = "notetarget-" + ticket.id;\n select.setAttribute("aria-label", "Which attachment the note is about");\n var whole = document.createElement("option");\n whole.value = "";\n whole.textContent = "Whole ticket";\n select.appendChild(whole);\n media.forEach(function (ref, i) {\n var option = document.createElement("option");\n option.value = ref.id;\n option.textContent = ref.filename || ("attachment " + (i + 1) + " (" + ref.kind + ")");\n select.appendChild(option);\n });\n var targetKey = ticket.id + ":notetarget";\n if (state.noteTargets[targetKey]) { select.value = state.noteTargets[targetKey]; }\n select.addEventListener("change", function () { state.noteTargets[targetKey] = select.value; });\n\n var add = el("button", null, "Add note");\n add.type = "submit";\n form.appendChild(input);\n form.appendChild(select);\n form.appendChild(add);\n\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n var text = input.value.trim();\n if (!text) { return; }\n var note = { text: text };\n if (select.value) {\n note.media_id = select.value;\n var ref = findMedia(ticket, select.value);\n if (ref && (ref.kind === "audio" || ref.kind === "video")) {\n var player = document.getElementById("player-" + ref.id);\n if (player && isFinite(player.currentTime)) { note.at_seconds = Math.round(player.currentTime * 10) / 10; }\n }\n }\n addNote(ticket.id, note);\n });\n return form;\n }\n\n function buildAnnotationEditor(ticket) {\n var media = ticket.media || [];\n if (media.length === 0) { return null; }\n var box = el("div", "annotations");\n box.appendChild(el("h4", null, "Annotations (optional)"));\n box.appendChild(buildNotesList(ticket, false));\n box.appendChild(buildNoteAdder(ticket, media));\n return box;\n }\n\n function buildAnnotationsReadonly(ticket) {\n var annotations = ticket.annotations || [];\n if (annotations.length === 0) { return null; }\n var box = el("div", "annotations");\n box.appendChild(el("h4", null, "Annotations (" + annotations.length + ")"));\n annotations.forEach(function (a) {\n var item = el("div", "note");\n if (a.text) { item.appendChild(el("div", "note-text", a.text)); }\n var meta = [];\n if (a.media_id) { meta.push(mediaName(ticket, a.media_id)); }\n if (a.at_seconds !== undefined && a.at_seconds !== null) { meta.push(formatTime(a.at_seconds)); }\n if (a.annotated_media) { meta.push("annotated image"); }\n if (meta.length) { item.appendChild(el("div", "note-meta", meta.join(" \xB7 "))); }\n box.appendChild(item);\n if (a.annotated_media) {\n var fig = el("div", "annotation-image");\n fig.appendChild(mountMedia(a.annotated_media, ticket, true));\n box.appendChild(fig);\n }\n });\n return box;\n }\n\n function buildMediaSection(ticket, isRecent) {\n var media = ticket.media || [];\n var annotations = ticket.annotations || [];\n if (media.length === 0 && annotations.length === 0) { return null; }\n\n var section = el("div", "media-section");\n\n if (media.length > 0) {\n var expanded = !!state.expanded[ticket.id];\n var toggle = el("button", "media-toggle",\n (expanded ? "Hide " : "Show ") + media.length + " attachment" + (media.length === 1 ? "" : "s") +\n (isRecent ? "" : " for inspection"));\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", expanded ? "true" : "false");\n var list = el("div", "media-list");\n list.hidden = !expanded;\n if (expanded) {\n media.forEach(function (ref, i) { list.appendChild(buildMediaItem(ref, ticket, isRecent, i)); });\n }\n toggle.addEventListener("click", function () {\n state.expanded[ticket.id] = !state.expanded[ticket.id];\n render();\n });\n section.appendChild(toggle);\n section.appendChild(list);\n }\n\n if (isRecent) {\n var readOnly = buildAnnotationsReadonly(ticket);\n if (readOnly) { section.appendChild(readOnly); }\n } else {\n var editor = buildAnnotationEditor(ticket);\n if (editor) { section.appendChild(editor); }\n }\n\n return section;\n }\n\n /* Render an annotated copy offscreen, independent of whether the canvas is mounted.\n Shapes are in canvas pixels, so a fresh canvas at the image\'s natural size needs\n them scaled up to match. */\n function renderAnnotatedBlob(ref) {\n return loadMediaObjectUrl(ref).then(function (url) {\n return new Promise(function (resolve, reject) {\n var img = new Image();\n img.onload = function () {\n var canvas = document.createElement("canvas");\n canvas.width = img.naturalWidth;\n canvas.height = img.naturalHeight;\n var ctx = canvas.getContext("2d");\n ctx.drawImage(img, 0, 0);\n // Shapes are in on-screen canvas pixels; the copy is at natural size, so scale\n // by the inverse of the on-screen ratio (1 when the image was not downscaled).\n var baseScale = state.canvasScale[ref.id] || 1;\n var shapes = state.shapes[ref.id] || [];\n ctx.save();\n ctx.scale(1 / baseScale, 1 / baseScale);\n for (var i = 0; i < shapes.length; i++) { drawShape(ctx, shapes[i]); }\n ctx.restore();\n canvas.toBlob(function (blob) { blob ? resolve(blob) : reject(new Error("could not render image")); }, "image/png");\n };\n img.onerror = function () { reject(new Error("could not load image")); };\n img.src = url;\n });\n });\n }\n\n function blobToBase64(blob) {\n return new Promise(function (resolve, reject) {\n var reader = new FileReader();\n reader.onload = function () {\n var result = String(reader.result || "");\n var comma = result.indexOf(",");\n resolve(comma >= 0 ? result.slice(comma + 1) : result);\n };\n reader.onerror = function () { reject(new Error("could not read image")); };\n reader.readAsDataURL(blob);\n });\n }\n\n function uploadImageBlob(blob) {\n return blobToBase64(blob).then(function (data_base64) {\n return api("/api/media", {\n method: "POST",\n body: JSON.stringify({ mime_type: "image/png", data_base64: data_base64 })\n });\n }).then(function (res) {\n if (!res.ok) { throw new Error("media upload " + res.status); }\n return res.json();\n }).then(function (data) {\n if (!data || !data.media || !data.media.id) { throw new Error("media upload returned no id"); }\n return data.media.id;\n });\n }\n\n /* ---------------- actions ---------------- */\n\n function setCardError(id, msg) {\n var el = document.getElementById("err-" + id);\n if (el) {\n el.hidden = false;\n var span = el.querySelector(".err-text");\n if (span) { span.textContent = msg; }\n }\n }\n\n function submitAnswer(ticket) {\n var input = document.getElementById("free-" + ticket.id);\n var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();\n var choice = state.choices[ticket.id] || "";\n var answer = text || choice;\n\n var notes = (state.notes[ticket.id] || []).slice();\n var media = ticket.media || [];\n // A media item counts as markup only if at least one shape was committed on it.\n var marked = media.filter(function (ref) { return (state.shapes[ref.id] || []).length > 0; });\n\n if (!answer && notes.length === 0 && marked.length === 0) {\n setCardError(ticket.id, "Type an answer, pick an option, or add an annotation first.");\n return;\n }\n\n var annotations = notes.map(function (note) {\n var a = { text: note.text };\n if (note.media_id) { a.media_id = note.media_id; }\n if (note.at_seconds !== undefined && note.at_seconds !== null) { a.at_seconds = note.at_seconds; }\n return a;\n });\n\n // Upload each annotated image first (with POST /api/media), then reference it by id\n // in the answer. A missing or non-image reference is a 400, so this order matters.\n Promise.all(marked.map(function (ref) {\n return renderAnnotatedBlob(ref).then(uploadImageBlob).then(function (mediaId) {\n annotations.push({ media_id: ref.id, annotated_media_id: mediaId });\n });\n })).then(function () {\n return api("/api/answer", {\n method: "POST",\n body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human", annotations: annotations })\n });\n }).then(function (res) {\n if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }\n if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }\n if (res.status === 400) { setCardError(ticket.id, "Answer rejected (400)."); return; }\n if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }\n delete state.choices[ticket.id];\n delete state.drafts[ticket.id];\n delete state.notes[ticket.id];\n media.forEach(function (ref) { delete state.shapes[ref.id]; });\n return fetchTickets();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));\n });\n }\n\n function undo(ticketId) {\n api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })\n .then(function (res) {\n if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }\n if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }\n return fetchTickets();\n })\n .catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Undo failed: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n function undoLast() {\n var recent = recentTickets();\n if (recent.length === 0) { return; }\n undo(recent[0].id);\n }\n\n /* ---------------- keyboard (requirement 4) ---------------- */\n\n function isTypingTarget(el) {\n if (!el) { return false; }\n var tag = el.tagName;\n return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;\n }\n\n function moveSelection(delta) {\n var list = orderedPending();\n if (list.length === 0) { return; }\n var idx = -1;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }\n if (idx === -1) { idx = delta > 0 ? -1 : 0; }\n var next = idx + delta;\n if (next < 0) { next = 0; }\n if (next > list.length - 1) { next = list.length - 1; }\n state.selectedId = list[next].id;\n render();\n var card = document.getElementById("card-" + state.selectedId);\n if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }\n }\n\n function pickOption(n) {\n var list = orderedPending();\n var ticket = null;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }\n if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }\n state.choices[ticket.id] = ticket.options[n - 1];\n render();\n }\n\n function submitSelected() {\n var list = orderedPending();\n for (var i = 0; i < list.length; i++) {\n if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }\n }\n }\n\n document.addEventListener("keydown", function (e) {\n if (isTypingTarget(e.target)) { return; }\n // While the pointer/keyboard focus is inside a media stage (the drawing canvas, a\n // player), the single-key shortcuts would fight the media interaction.\n if (e.target && e.target.closest && e.target.closest("[data-media-stage]")) { return; }\n if (e.metaKey || e.ctrlKey || e.altKey) { return; }\n if (e.key === "j") { e.preventDefault(); moveSelection(1); }\n else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }\n else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }\n else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }\n else if (e.key === "u") { e.preventDefault(); undoLast(); }\n });\n\n /* ---------------- rendering ---------------- */\n\n function captureDrafts() {\n var inputs = document.querySelectorAll("[data-draft]");\n for (var i = 0; i < inputs.length; i++) {\n state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;\n }\n }\n\n function captureNoteDrafts() {\n var inputs = document.querySelectorAll("[data-notedraft]");\n for (var i = 0; i < inputs.length; i++) {\n state.noteDrafts[inputs[i].getAttribute("data-notedraft")] = inputs[i].value;\n }\n }\n\n function el(tag, cls, text) {\n var node = document.createElement(tag);\n if (cls) { node.className = cls; }\n if (text !== undefined && text !== null) { node.textContent = text; }\n return node;\n }\n\n function urgencyBadge(urgency) {\n var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);\n return b;\n }\n\n function buildCard(ticket, isRecent) {\n // The `selected` marker is a keyboard-navigation affordance for the pending queue\n // only. An answered card in "recent" carries no selection target (`j`/`k`/`Enter`\n // never act on it), so marking it "selected" would be misleading.\n var card = el("article", "card" + (!isRecent && ticket.id === state.selectedId ? " selected" : ""));\n card.id = "card-" + ticket.id;\n card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);\n\n var head = el("div", "card-head");\n head.appendChild(el("span", "selmark", "selected"));\n head.appendChild(el("span", "stream-name", ticket.stream));\n head.appendChild(el("span", "age", age(ticket.created_at) + " old"));\n head.appendChild(urgencyBadge(ticket.urgency));\n if (isRecent) {\n head.appendChild(el("span", "badge status-answered", "status: answered"));\n if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }\n }\n card.appendChild(head);\n\n card.appendChild(el("p", "question", ticket.question));\n\n if (ticket.context) {\n var ctx = el("div", "context", ticket.context);\n card.appendChild(ctx);\n }\n\n var mediaSection = buildMediaSection(ticket, isRecent);\n if (mediaSection) { card.appendChild(mediaSection); }\n\n if (isRecent) {\n var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));\n card.appendChild(ans);\n var undoBtn = el("button", null, "Undo");\n undoBtn.type = "button";\n undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);\n undoBtn.addEventListener("click", function () { undo(ticket.id); });\n var row = el("div", "answer-row");\n row.appendChild(undoBtn);\n card.appendChild(row);\n return card;\n }\n\n if (ticket.options && ticket.options.length) {\n var opts = el("div", "options");\n opts.setAttribute("role", "group");\n opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);\n ticket.options.forEach(function (opt, i) {\n var b = el("button", null, (i + 1) + ". " + opt);\n b.type = "button";\n b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");\n b.addEventListener("click", function () {\n state.choices[ticket.id] = opt;\n state.selectedId = ticket.id;\n render();\n });\n opts.appendChild(b);\n });\n card.appendChild(opts);\n }\n\n var form = el("form", "answer-row");\n form.setAttribute("novalidate", "novalidate");\n var label = el("label", null, "Answer");\n label.setAttribute("for", "free-" + ticket.id);\n form.appendChild(label);\n var input = document.createElement("input");\n input.type = "text";\n input.id = "free-" + ticket.id;\n input.setAttribute("data-draft", ticket.id);\n input.setAttribute("autocomplete", "off");\n input.value = state.drafts[ticket.id] || "";\n input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n form.appendChild(input);\n var submit = el("button", null, "Answer");\n submit.type = "submit";\n form.appendChild(submit);\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n submitAnswer(ticket);\n });\n card.appendChild(form);\n\n var err = el("div", "card-error");\n err.id = "err-" + ticket.id;\n err.hidden = true;\n err.setAttribute("role", "alert");\n err.appendChild(el("span", "tag", "Error: "));\n err.appendChild(el("span", "err-text", ""));\n card.appendChild(err);\n\n return card;\n }\n\n function render() {\n captureDrafts();\n captureNoteDrafts();\n\n var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;\n var selStart = null, selEnd = null;\n if (activeId && document.activeElement.setSelectionRange) {\n try {\n selStart = document.activeElement.selectionStart;\n selEnd = document.activeElement.selectionEnd;\n } catch (e) { /* not a text input */ }\n }\n\n var pending = pendingTickets();\n var recent = recentTickets();\n\n /* requirement 5: tab title carries the count */\n document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";\n\n /* status line */\n var statusText = "idle";\n if (state.live) { statusText = "live (server-sent events)"; }\n else if (state.pollTimer) { statusText = "polling every 5s"; }\n else if (!state.token) { statusText = "waiting for token"; }\n els.statusText.textContent = statusText;\n\n if (state.error) {\n els.error.hidden = false;\n els.errorText.textContent = state.error;\n } else {\n els.error.hidden = true;\n els.errorText.textContent = "";\n }\n\n /* pending, grouped by stream, newest stream first */\n els.pending.textContent = "";\n if (pending.length === 0) {\n els.pending.appendChild(el("p", "empty", "No pending tickets."));\n } else {\n groupByStream(pending).forEach(function (group) {\n var section = el("section", "stream-group");\n section.setAttribute("aria-label", "Stream " + group.stream);\n var head = el("div", "stream-head");\n head.appendChild(el("h3", "stream-name", group.stream));\n head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));\n section.appendChild(head);\n group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });\n els.pending.appendChild(section);\n });\n }\n\n /* recent, undoable for 5 minutes */\n els.recent.textContent = "";\n if (recent.length === 0) {\n els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));\n } else {\n recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });\n }\n\n /* restore focus + caret so polling re-renders do not interrupt typing */\n if (activeId) {\n var again = document.getElementById(activeId);\n if (again && again.focus) {\n try {\n again.focus();\n if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }\n } catch (e) { /* ignore */ }\n }\n }\n }\n\n /* ---------------- wiring ---------------- */\n\n els.tokenSave.addEventListener("click", function () {\n var t = els.tokenInput.value.trim();\n if (!t) { return; }\n state.token = t;\n writeToken(t);\n state.error = "";\n showTokenBar(false);\n startLive();\n fetchTickets();\n });\n els.tokenInput.addEventListener("keydown", function (e) {\n if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }\n });\n\n /* requirement 9: re-render on window load */\n window.addEventListener("load", render);\n\n /* boot */\n state.token = readToken();\n if (state.token) {\n showTokenBar(false);\n startLive();\n fetchTickets();\n } else {\n showTokenBar(true);\n }\n\n /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */\n render();\n})();\n</script>\n</body>\n</html>\n';
|
|
7512
|
+
|
|
7513
|
+
// src/core/media.ts
|
|
7514
|
+
function mediaKind(mimeType) {
|
|
7515
|
+
const base = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
7516
|
+
if (base.startsWith("image/")) {
|
|
7517
|
+
return "image";
|
|
7518
|
+
}
|
|
7519
|
+
if (base.startsWith("audio/")) {
|
|
7520
|
+
return "audio";
|
|
7521
|
+
}
|
|
7522
|
+
if (base.startsWith("video/")) {
|
|
7523
|
+
return "video";
|
|
7524
|
+
}
|
|
7525
|
+
return void 0;
|
|
7526
|
+
}
|
|
7527
|
+
function dataUriMime(input) {
|
|
7528
|
+
const match = /^data:([^;,]+);base64,/i.exec(input.trim());
|
|
7529
|
+
return match === null ? void 0 : match[1].toLowerCase();
|
|
7530
|
+
}
|
|
7531
|
+
function stripDataUri(input) {
|
|
7532
|
+
return input.trim().replace(/^data:[^;,]+;base64,/i, "");
|
|
7533
|
+
}
|
|
7534
|
+
function normalizeBase64(input) {
|
|
7535
|
+
return input.replace(/\s+/g, "");
|
|
7536
|
+
}
|
|
7537
|
+
function base64ToBytes(base642) {
|
|
7538
|
+
const binary = atob(base642);
|
|
7539
|
+
const bytes = new Uint8Array(binary.length);
|
|
7540
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
7541
|
+
bytes[index] = binary.charCodeAt(index);
|
|
7542
|
+
}
|
|
7543
|
+
return bytes;
|
|
7544
|
+
}
|
|
7545
|
+
function bytesToBase64(bytes) {
|
|
7546
|
+
let binary = "";
|
|
7547
|
+
const chunk = 32768;
|
|
7548
|
+
for (let offset = 0; offset < bytes.length; offset += chunk) {
|
|
7549
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
|
|
7550
|
+
}
|
|
7551
|
+
return btoa(binary);
|
|
7552
|
+
}
|
|
7553
|
+
function base64ByteLength(base642) {
|
|
7554
|
+
const length = base642.length;
|
|
7555
|
+
if (length === 0) {
|
|
7556
|
+
return 0;
|
|
7557
|
+
}
|
|
7558
|
+
let padding = 0;
|
|
7559
|
+
if (base642.endsWith("==")) {
|
|
7560
|
+
padding = 2;
|
|
7561
|
+
} else if (base642.endsWith("=")) {
|
|
7562
|
+
padding = 1;
|
|
7563
|
+
}
|
|
7564
|
+
return Math.floor(length * 3 / 4) - padding;
|
|
7565
|
+
}
|
|
7566
|
+
function buildMediaRef(id, input, now2) {
|
|
7567
|
+
const ref = {
|
|
7568
|
+
id,
|
|
7569
|
+
kind: mediaKind(input.mime_type) ?? "image",
|
|
7570
|
+
mime_type: input.mime_type,
|
|
7571
|
+
size_bytes: base64ByteLength(input.data_base64),
|
|
7572
|
+
created_at: now2
|
|
7573
|
+
};
|
|
7574
|
+
if (input.filename !== void 0) {
|
|
7575
|
+
ref.filename = input.filename;
|
|
7576
|
+
}
|
|
7577
|
+
if (input.caption !== void 0) {
|
|
7578
|
+
ref.caption = input.caption;
|
|
7579
|
+
}
|
|
7580
|
+
return ref;
|
|
7581
|
+
}
|
|
7582
|
+
function isSafeMediaId(id) {
|
|
7583
|
+
return /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
7584
|
+
}
|
|
7505
7585
|
|
|
7506
7586
|
// src/core/stream.ts
|
|
7507
7587
|
function resolveStream(explicit, defaultStream) {
|
|
@@ -7549,6 +7629,11 @@ function makeAudit(ticketId, action, at, by, answer2) {
|
|
|
7549
7629
|
}
|
|
7550
7630
|
return entry;
|
|
7551
7631
|
}
|
|
7632
|
+
async function uploadMedia(store, input, now2) {
|
|
7633
|
+
const id = newId();
|
|
7634
|
+
await store.putMedia({ id, mime_type: input.mime_type, data_base64: input.data_base64 });
|
|
7635
|
+
return buildMediaRef(id, input, now2);
|
|
7636
|
+
}
|
|
7552
7637
|
async function enqueue(store, input, options) {
|
|
7553
7638
|
const stream = resolveStream(input.stream, options.defaultStream);
|
|
7554
7639
|
const timeoutSeconds = input.timeout_seconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
@@ -7572,8 +7657,19 @@ async function enqueue(store, input, options) {
|
|
|
7572
7657
|
if (input.default_answer !== void 0) {
|
|
7573
7658
|
ticket.default_answer = input.default_answer;
|
|
7574
7659
|
}
|
|
7660
|
+
const media = [];
|
|
7661
|
+
if (input.media !== void 0 && input.media.length > 0) {
|
|
7662
|
+
for (const item of input.media) {
|
|
7663
|
+
media.push(await uploadMedia(store, item, created_at));
|
|
7664
|
+
}
|
|
7665
|
+
ticket.media = media;
|
|
7666
|
+
}
|
|
7575
7667
|
await store.put(ticket);
|
|
7576
|
-
|
|
7668
|
+
const createdAudit = makeAudit(ticket.id, "created", created_at, stream);
|
|
7669
|
+
if (media.length > 0) {
|
|
7670
|
+
createdAudit.media_count = media.length;
|
|
7671
|
+
}
|
|
7672
|
+
await store.appendAudit(createdAudit);
|
|
7577
7673
|
return ticket;
|
|
7578
7674
|
}
|
|
7579
7675
|
function resolveExpiry(ticket, now2) {
|
|
@@ -7619,7 +7715,7 @@ async function listResolved(store, filter, now2) {
|
|
|
7619
7715
|
}
|
|
7620
7716
|
return out;
|
|
7621
7717
|
}
|
|
7622
|
-
async function answer(store, id, value, answeredBy, now2) {
|
|
7718
|
+
async function answer(store, id, value, answeredBy, now2, annotations = []) {
|
|
7623
7719
|
const existing = await getResolved(store, id, now2);
|
|
7624
7720
|
if (existing === void 0) {
|
|
7625
7721
|
throw notFound(id);
|
|
@@ -7627,15 +7723,62 @@ async function answer(store, id, value, answeredBy, now2) {
|
|
|
7627
7723
|
if (existing.status !== "pending") {
|
|
7628
7724
|
throw conflict(`ticket ${id} is ${existing.status}; only a pending ticket can be answered`);
|
|
7629
7725
|
}
|
|
7726
|
+
const resolved = [];
|
|
7727
|
+
for (const input of annotations) {
|
|
7728
|
+
const annotation = { id: newId() };
|
|
7729
|
+
if (input.text !== void 0) {
|
|
7730
|
+
annotation.text = input.text;
|
|
7731
|
+
}
|
|
7732
|
+
if (input.media_id !== void 0) {
|
|
7733
|
+
annotation.media_id = input.media_id;
|
|
7734
|
+
}
|
|
7735
|
+
if (input.at_seconds !== void 0) {
|
|
7736
|
+
annotation.at_seconds = input.at_seconds;
|
|
7737
|
+
}
|
|
7738
|
+
if (input.annotated_media_id !== void 0) {
|
|
7739
|
+
const blob = await store.getMedia(input.annotated_media_id);
|
|
7740
|
+
if (blob === void 0) {
|
|
7741
|
+
throw new QueueError(
|
|
7742
|
+
400,
|
|
7743
|
+
`annotation media ${input.annotated_media_id} not found; upload it with POST /api/media first`
|
|
7744
|
+
);
|
|
7745
|
+
}
|
|
7746
|
+
const kind = mediaKind(blob.mime_type);
|
|
7747
|
+
if (kind !== "image") {
|
|
7748
|
+
throw new QueueError(400, `annotation media ${input.annotated_media_id} must be an image`);
|
|
7749
|
+
}
|
|
7750
|
+
annotation.annotated_media = {
|
|
7751
|
+
id: blob.id,
|
|
7752
|
+
kind,
|
|
7753
|
+
mime_type: blob.mime_type,
|
|
7754
|
+
size_bytes: base64ByteLength(blob.data_base64),
|
|
7755
|
+
created_at: now2
|
|
7756
|
+
};
|
|
7757
|
+
}
|
|
7758
|
+
resolved.push(annotation);
|
|
7759
|
+
}
|
|
7760
|
+
const trimmed = value.trim();
|
|
7761
|
+
if (trimmed === "" && resolved.length === 0) {
|
|
7762
|
+
throw new QueueError(400, "an answer must include text, a chosen option, or at least one annotation");
|
|
7763
|
+
}
|
|
7630
7764
|
const ticket = {
|
|
7631
7765
|
...existing,
|
|
7632
7766
|
status: "answered",
|
|
7633
|
-
answer: value,
|
|
7634
7767
|
answered_by: answeredBy,
|
|
7635
7768
|
answered_at: now2
|
|
7636
7769
|
};
|
|
7770
|
+
if (trimmed !== "") {
|
|
7771
|
+
ticket.answer = value;
|
|
7772
|
+
}
|
|
7773
|
+
if (resolved.length > 0) {
|
|
7774
|
+
ticket.annotations = resolved;
|
|
7775
|
+
}
|
|
7637
7776
|
await store.put(ticket);
|
|
7638
|
-
|
|
7777
|
+
const answeredAudit = makeAudit(id, "answered", now2, answeredBy, trimmed !== "" ? value : void 0);
|
|
7778
|
+
if (resolved.length > 0) {
|
|
7779
|
+
answeredAudit.annotation_count = resolved.length;
|
|
7780
|
+
}
|
|
7781
|
+
await store.appendAudit(answeredAudit);
|
|
7639
7782
|
return ticket;
|
|
7640
7783
|
}
|
|
7641
7784
|
async function undo(store, id, now2) {
|
|
@@ -7657,6 +7800,7 @@ async function undo(store, id, now2) {
|
|
|
7657
7800
|
}
|
|
7658
7801
|
const reverted = { ...ticket, status: "pending" };
|
|
7659
7802
|
delete reverted.answer;
|
|
7803
|
+
delete reverted.annotations;
|
|
7660
7804
|
delete reverted.answered_by;
|
|
7661
7805
|
delete reverted.answered_at;
|
|
7662
7806
|
await store.put(reverted);
|
|
@@ -7702,8 +7846,20 @@ var ASK_HUMAN_KEYS = [
|
|
|
7702
7846
|
"default_answer",
|
|
7703
7847
|
"timeout_seconds",
|
|
7704
7848
|
"urgency",
|
|
7705
|
-
"stream"
|
|
7849
|
+
"stream",
|
|
7850
|
+
"media"
|
|
7706
7851
|
];
|
|
7852
|
+
var MEDIA_KEYS = ["mime_type", "data_base64", "filename", "caption"];
|
|
7853
|
+
var ANNOTATION_KEYS = ["text", "media_id", "at_seconds", "annotated_media_id"];
|
|
7854
|
+
function formatBytes(bytes) {
|
|
7855
|
+
if (bytes < 1024) {
|
|
7856
|
+
return `${bytes} B`;
|
|
7857
|
+
}
|
|
7858
|
+
if (bytes < 1024 * 1024) {
|
|
7859
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
7860
|
+
}
|
|
7861
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
7862
|
+
}
|
|
7707
7863
|
function fail(tool, message) {
|
|
7708
7864
|
throw new ValidationError(tool, message);
|
|
7709
7865
|
}
|
|
@@ -7756,6 +7912,174 @@ function optionalInteger(tool, args, key, min, max) {
|
|
|
7756
7912
|
}
|
|
7757
7913
|
return value;
|
|
7758
7914
|
}
|
|
7915
|
+
function normalizeMediaData(tool, label, rawData, cap) {
|
|
7916
|
+
if (rawData.trim() === "") {
|
|
7917
|
+
fail(tool, `\`${label}\` must be a non-empty base64 string`);
|
|
7918
|
+
}
|
|
7919
|
+
const base642 = normalizeBase64(stripDataUri(rawData));
|
|
7920
|
+
const size = base64ByteLength(base642);
|
|
7921
|
+
if (size > cap) {
|
|
7922
|
+
fail(tool, `\`${label}\` is ${formatBytes(size)}; the limit is ${formatBytes(cap)}`);
|
|
7923
|
+
}
|
|
7924
|
+
try {
|
|
7925
|
+
base64ToBytes(base642);
|
|
7926
|
+
} catch {
|
|
7927
|
+
fail(tool, `\`${label}\` is not valid base64`);
|
|
7928
|
+
}
|
|
7929
|
+
return { base64: base642, size };
|
|
7930
|
+
}
|
|
7931
|
+
function validateAskHumanMedia(tool, raw) {
|
|
7932
|
+
const value = raw["media"];
|
|
7933
|
+
if (value === void 0 || value === null) {
|
|
7934
|
+
return void 0;
|
|
7935
|
+
}
|
|
7936
|
+
if (!Array.isArray(value)) {
|
|
7937
|
+
fail(tool, "`media` must be an array of attachments when present");
|
|
7938
|
+
}
|
|
7939
|
+
if (value.length === 0) {
|
|
7940
|
+
return void 0;
|
|
7941
|
+
}
|
|
7942
|
+
if (value.length > MAX_MEDIA_FILES) {
|
|
7943
|
+
fail(tool, `\`media\` may contain at most ${MAX_MEDIA_FILES} attachments (got ${value.length})`);
|
|
7944
|
+
}
|
|
7945
|
+
const out = [];
|
|
7946
|
+
let totalBytes = 0;
|
|
7947
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
7948
|
+
const item = value[index];
|
|
7949
|
+
const label = `media[${index}]`;
|
|
7950
|
+
if (!isRecord(item)) {
|
|
7951
|
+
fail(tool, `\`${label}\` must be an object`);
|
|
7952
|
+
}
|
|
7953
|
+
for (const key of Object.keys(item)) {
|
|
7954
|
+
if (!MEDIA_KEYS.includes(key)) {
|
|
7955
|
+
fail(
|
|
7956
|
+
tool,
|
|
7957
|
+
`unknown argument \`${label}.${key}\`. Allowed arguments: ${MEDIA_KEYS.join(", ")}`
|
|
7958
|
+
);
|
|
7959
|
+
}
|
|
7960
|
+
}
|
|
7961
|
+
const rawMime = item["mime_type"];
|
|
7962
|
+
const declaredMime = typeof rawMime === "string" && rawMime.trim() !== "" ? rawMime.trim().toLowerCase() : void 0;
|
|
7963
|
+
const rawData = item["data_base64"];
|
|
7964
|
+
if (typeof rawData !== "string") {
|
|
7965
|
+
fail(tool, `\`${label}.data_base64\` is required and must be a base64 string`);
|
|
7966
|
+
}
|
|
7967
|
+
const effectiveMime = declaredMime ?? dataUriMime(rawData);
|
|
7968
|
+
if (effectiveMime === void 0) {
|
|
7969
|
+
fail(tool, `\`${label}.mime_type\` is required (e.g. "image/png", "audio/mpeg", "video/mp4")`);
|
|
7970
|
+
}
|
|
7971
|
+
if (mediaKind(effectiveMime) === void 0) {
|
|
7972
|
+
fail(tool, `\`${label}.mime_type\` must be an image/*, audio/*, or video/* type (got "${effectiveMime}")`);
|
|
7973
|
+
}
|
|
7974
|
+
const { base64: base642, size } = normalizeMediaData(tool, `${label}.data_base64`, rawData, MAX_MEDIA_BYTES_PER_FILE);
|
|
7975
|
+
totalBytes += size;
|
|
7976
|
+
const entry = { mime_type: effectiveMime, data_base64: base642 };
|
|
7977
|
+
const filename = optionalString(tool, item, "filename");
|
|
7978
|
+
if (filename !== void 0 && filename.trim() !== "") {
|
|
7979
|
+
entry.filename = filename.trim();
|
|
7980
|
+
}
|
|
7981
|
+
const caption = optionalString(tool, item, "caption");
|
|
7982
|
+
if (caption !== void 0 && caption.trim() !== "") {
|
|
7983
|
+
entry.caption = caption.trim();
|
|
7984
|
+
}
|
|
7985
|
+
out.push(entry);
|
|
7986
|
+
}
|
|
7987
|
+
if (totalBytes > MAX_MEDIA_BYTES_PER_TICKET) {
|
|
7988
|
+
fail(
|
|
7989
|
+
tool,
|
|
7990
|
+
`\`media\` totals ${formatBytes(totalBytes)}; the per-ticket limit is ${formatBytes(MAX_MEDIA_BYTES_PER_TICKET)}`
|
|
7991
|
+
);
|
|
7992
|
+
}
|
|
7993
|
+
return out;
|
|
7994
|
+
}
|
|
7995
|
+
function validateMediaUpload(args) {
|
|
7996
|
+
const tool = "media_upload";
|
|
7997
|
+
const raw = requireRecord(tool, args);
|
|
7998
|
+
rejectUnknownKeys(tool, raw, MEDIA_KEYS);
|
|
7999
|
+
const rawMime = raw["mime_type"];
|
|
8000
|
+
const declaredMime = typeof rawMime === "string" && rawMime.trim() !== "" ? rawMime.trim().toLowerCase() : void 0;
|
|
8001
|
+
const rawData = raw["data_base64"];
|
|
8002
|
+
if (typeof rawData !== "string") {
|
|
8003
|
+
fail(tool, "`data_base64` is required and must be a base64 string");
|
|
8004
|
+
}
|
|
8005
|
+
const effectiveMime = declaredMime ?? dataUriMime(rawData);
|
|
8006
|
+
if (effectiveMime === void 0) {
|
|
8007
|
+
fail(tool, '`mime_type` is required (e.g. "image/png")');
|
|
8008
|
+
}
|
|
8009
|
+
if (mediaKind(effectiveMime) === void 0) {
|
|
8010
|
+
fail(tool, `\`mime_type\` must be an image/*, audio/*, or video/* type (got "${effectiveMime}")`);
|
|
8011
|
+
}
|
|
8012
|
+
const { base64: base642 } = normalizeMediaData(tool, "data_base64", rawData, MAX_ANNOTATION_BYTES);
|
|
8013
|
+
const entry = { mime_type: effectiveMime, data_base64: base642 };
|
|
8014
|
+
const filename = optionalString(tool, raw, "filename");
|
|
8015
|
+
if (filename !== void 0 && filename.trim() !== "") {
|
|
8016
|
+
entry.filename = filename.trim();
|
|
8017
|
+
}
|
|
8018
|
+
const caption = optionalString(tool, raw, "caption");
|
|
8019
|
+
if (caption !== void 0 && caption.trim() !== "") {
|
|
8020
|
+
entry.caption = caption.trim();
|
|
8021
|
+
}
|
|
8022
|
+
return entry;
|
|
8023
|
+
}
|
|
8024
|
+
function validateAnnotationInputs(args) {
|
|
8025
|
+
const tool = "answer";
|
|
8026
|
+
if (args === void 0 || args === null) {
|
|
8027
|
+
return [];
|
|
8028
|
+
}
|
|
8029
|
+
if (!Array.isArray(args)) {
|
|
8030
|
+
fail(tool, "`annotations` must be an array when present");
|
|
8031
|
+
}
|
|
8032
|
+
if (args.length > MAX_ANNOTATIONS) {
|
|
8033
|
+
fail(tool, `\`annotations\` may contain at most ${MAX_ANNOTATIONS} items (got ${args.length})`);
|
|
8034
|
+
}
|
|
8035
|
+
const out = [];
|
|
8036
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
8037
|
+
const item = args[index];
|
|
8038
|
+
const label = `annotations[${index}]`;
|
|
8039
|
+
if (!isRecord(item)) {
|
|
8040
|
+
fail(tool, `\`${label}\` must be an object`);
|
|
8041
|
+
}
|
|
8042
|
+
for (const key of Object.keys(item)) {
|
|
8043
|
+
if (!ANNOTATION_KEYS.includes(key)) {
|
|
8044
|
+
fail(
|
|
8045
|
+
tool,
|
|
8046
|
+
`unknown argument \`${label}.${key}\`. Allowed arguments: ${ANNOTATION_KEYS.join(", ")}`
|
|
8047
|
+
);
|
|
8048
|
+
}
|
|
8049
|
+
}
|
|
8050
|
+
const text = optionalString(tool, item, "text");
|
|
8051
|
+
const annotatedMediaId = optionalString(tool, item, "annotated_media_id");
|
|
8052
|
+
if ((text === void 0 || text.trim() === "") && (annotatedMediaId === void 0 || annotatedMediaId.trim() === "")) {
|
|
8053
|
+
fail(tool, `\`${label}\` must carry \`text\` or \`annotated_media_id\``);
|
|
8054
|
+
}
|
|
8055
|
+
const entry = {};
|
|
8056
|
+
if (text !== void 0 && text.trim() !== "") {
|
|
8057
|
+
entry.text = text.trim();
|
|
8058
|
+
}
|
|
8059
|
+
if (annotatedMediaId !== void 0 && annotatedMediaId.trim() !== "") {
|
|
8060
|
+
if (!isSafeMediaId(annotatedMediaId)) {
|
|
8061
|
+
fail(tool, `\`${label}.annotated_media_id\` is not a valid media id`);
|
|
8062
|
+
}
|
|
8063
|
+
entry.annotated_media_id = annotatedMediaId;
|
|
8064
|
+
}
|
|
8065
|
+
const mediaId = optionalString(tool, item, "media_id");
|
|
8066
|
+
if (mediaId !== void 0 && mediaId.trim() !== "") {
|
|
8067
|
+
if (!isSafeMediaId(mediaId)) {
|
|
8068
|
+
fail(tool, `\`${label}.media_id\` is not a valid media id`);
|
|
8069
|
+
}
|
|
8070
|
+
entry.media_id = mediaId;
|
|
8071
|
+
}
|
|
8072
|
+
const atSeconds = item["at_seconds"];
|
|
8073
|
+
if (atSeconds !== void 0 && atSeconds !== null) {
|
|
8074
|
+
if (typeof atSeconds !== "number" || !Number.isFinite(atSeconds) || atSeconds < 0) {
|
|
8075
|
+
fail(tool, `\`${label}.at_seconds\` must be a non-negative number of seconds`);
|
|
8076
|
+
}
|
|
8077
|
+
entry.at_seconds = atSeconds;
|
|
8078
|
+
}
|
|
8079
|
+
out.push(entry);
|
|
8080
|
+
}
|
|
8081
|
+
return out;
|
|
8082
|
+
}
|
|
7759
8083
|
function extraSentenceTerminators(question) {
|
|
7760
8084
|
const scrubbed = question.replace(/\b(e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St)\./gi, (match) => match.replace(/\./g, "")).replace(/\d+\.\d+/g, "");
|
|
7761
8085
|
const withoutTrailing = scrubbed.replace(/[.!?]+\s*$/, "");
|
|
@@ -7800,6 +8124,7 @@ function validateAskHuman(args) {
|
|
|
7800
8124
|
const context = optionalString(tool, raw, "context");
|
|
7801
8125
|
const default_answer = optionalString(tool, raw, "default_answer");
|
|
7802
8126
|
const stream = optionalString(tool, raw, "stream");
|
|
8127
|
+
const media = validateAskHumanMedia(tool, raw);
|
|
7803
8128
|
const result = { question };
|
|
7804
8129
|
if (options !== void 0) {
|
|
7805
8130
|
result.options = options;
|
|
@@ -7819,6 +8144,9 @@ function validateAskHuman(args) {
|
|
|
7819
8144
|
if (stream !== void 0) {
|
|
7820
8145
|
result.stream = stream;
|
|
7821
8146
|
}
|
|
8147
|
+
if (media !== void 0) {
|
|
8148
|
+
result.media = media;
|
|
8149
|
+
}
|
|
7822
8150
|
return result;
|
|
7823
8151
|
}
|
|
7824
8152
|
function validateWaitForAnswer(args) {
|
|
@@ -7853,7 +8181,9 @@ var ASK_HUMAN_DESCRIPTION = [
|
|
|
7853
8181
|
"",
|
|
7854
8182
|
"Protocol you must follow: on `pending`, continue with independent work and call `wait_for_answer` again later \u2014 do not poll in a tight loop.",
|
|
7855
8183
|
"",
|
|
7856
|
-
'Set `default_answer` only when it is safe for you to proceed without a human; it is used only if the ticket expires. Set `options` for 2-5 mutually exclusive choices. `timeout_seconds` defaults to 1800 and maxes at 86400. `urgency` defaults to "normal".'
|
|
8184
|
+
'Set `default_answer` only when it is safe for you to proceed without a human; it is used only if the ticket expires. Set `options` for 2-5 mutually exclusive choices. `timeout_seconds` defaults to 1800 and maxes at 86400. `urgency` defaults to "normal".',
|
|
8185
|
+
"",
|
|
8186
|
+
"Optionally attach `media` (screenshots, audio, or video) for the human to inspect \u2014 media is never required. Each item is { mime_type, data_base64 } with optional `filename` and `caption`; `data_base64` is standard base64 (a `data:<mime>;base64,` prefix is accepted). The human may answer with a decision, with annotations on your media, or both; `wait_for_answer` returns any annotations, and rendered markup comes back as image content."
|
|
7857
8187
|
].join("\n");
|
|
7858
8188
|
var WAIT_FOR_ANSWER_DESCRIPTION = [
|
|
7859
8189
|
"Wait for the human to answer a ticket created by ask_human, then return the result.",
|
|
@@ -7904,6 +8234,28 @@ var TOOL_DEFINITIONS = [
|
|
|
7904
8234
|
stream: {
|
|
7905
8235
|
type: "string",
|
|
7906
8236
|
description: "Optional. Overrides the configured default stream for this ticket."
|
|
8237
|
+
},
|
|
8238
|
+
media: {
|
|
8239
|
+
type: "array",
|
|
8240
|
+
maxItems: 8,
|
|
8241
|
+
description: "Optional. Media (screenshots, audio, video) for the human to inspect. Never required.",
|
|
8242
|
+
items: {
|
|
8243
|
+
type: "object",
|
|
8244
|
+
properties: {
|
|
8245
|
+
mime_type: {
|
|
8246
|
+
type: "string",
|
|
8247
|
+
description: 'An image/*, audio/*, or video/* type, e.g. "image/png".'
|
|
8248
|
+
},
|
|
8249
|
+
data_base64: {
|
|
8250
|
+
type: "string",
|
|
8251
|
+
description: "Standard base64 bytes. A `data:<mime>;base64,` prefix is accepted."
|
|
8252
|
+
},
|
|
8253
|
+
filename: { type: "string", description: 'Optional. A display name, e.g. "before.png".' },
|
|
8254
|
+
caption: { type: "string", description: "Optional. A caption shown with the attachment." }
|
|
8255
|
+
},
|
|
8256
|
+
required: ["data_base64"],
|
|
8257
|
+
additionalProperties: false
|
|
8258
|
+
}
|
|
7907
8259
|
}
|
|
7908
8260
|
},
|
|
7909
8261
|
required: ["question"],
|
|
@@ -7976,12 +8328,63 @@ function waitOutput(ticket) {
|
|
|
7976
8328
|
if (ticket.answered_at !== void 0) {
|
|
7977
8329
|
out.answered_at = ticket.answered_at;
|
|
7978
8330
|
}
|
|
8331
|
+
if (ticket.media !== void 0) {
|
|
8332
|
+
out.media = ticket.media;
|
|
8333
|
+
}
|
|
8334
|
+
if (ticket.annotations !== void 0) {
|
|
8335
|
+
out.annotations = ticket.annotations;
|
|
8336
|
+
}
|
|
7979
8337
|
return { ...out };
|
|
7980
8338
|
}
|
|
8339
|
+
function annotationSummary(ticket) {
|
|
8340
|
+
const annotations = ticket.annotations;
|
|
8341
|
+
if (annotations === void 0 || annotations.length === 0) {
|
|
8342
|
+
return void 0;
|
|
8343
|
+
}
|
|
8344
|
+
const notes = annotations.filter((annotation) => annotation.text !== void 0).length;
|
|
8345
|
+
const markup = annotations.filter((annotation) => annotation.annotated_media !== void 0).length;
|
|
8346
|
+
const parts = [];
|
|
8347
|
+
if (notes > 0) {
|
|
8348
|
+
parts.push(`${notes} note${notes === 1 ? "" : "s"}`);
|
|
8349
|
+
}
|
|
8350
|
+
if (markup > 0) {
|
|
8351
|
+
parts.push(`${markup} annotated image${markup === 1 ? "" : "s"}`);
|
|
8352
|
+
}
|
|
8353
|
+
return `The human also left ${annotations.length} annotation(s) (${parts.join(", ")}); see the attached content and the structured "annotations" field.`;
|
|
8354
|
+
}
|
|
8355
|
+
async function waitResult(ticket, waitedSeconds, store) {
|
|
8356
|
+
const content = [{ type: "text", text: waitText(ticket, waitedSeconds) }];
|
|
8357
|
+
if (ticket.status === "answered" && ticket.annotations !== void 0) {
|
|
8358
|
+
for (const annotation of ticket.annotations) {
|
|
8359
|
+
const ref = annotation.annotated_media;
|
|
8360
|
+
if (ref === void 0) {
|
|
8361
|
+
continue;
|
|
8362
|
+
}
|
|
8363
|
+
const blob = await store.getMedia(ref.id);
|
|
8364
|
+
if (blob === void 0) {
|
|
8365
|
+
continue;
|
|
8366
|
+
}
|
|
8367
|
+
if (ref.kind === "image") {
|
|
8368
|
+
content.push({ type: "image", data: blob.data_base64, mimeType: blob.mime_type });
|
|
8369
|
+
} else if (ref.kind === "audio") {
|
|
8370
|
+
content.push({ type: "audio", data: blob.data_base64, mimeType: blob.mime_type });
|
|
8371
|
+
}
|
|
8372
|
+
}
|
|
8373
|
+
}
|
|
8374
|
+
return { content, structuredContent: waitOutput(ticket) };
|
|
8375
|
+
}
|
|
7981
8376
|
function waitText(ticket, waitedSeconds) {
|
|
7982
8377
|
switch (ticket.status) {
|
|
7983
|
-
case "answered":
|
|
7984
|
-
|
|
8378
|
+
case "answered": {
|
|
8379
|
+
const lines = [
|
|
8380
|
+
ticket.answer !== void 0 && ticket.answer !== "" ? answerText(ticket.answer, ticket.answered_by) : "HUMAN ANSWER: (no text; the human responded with annotations only \u2014 see the attached content)"
|
|
8381
|
+
];
|
|
8382
|
+
const summary = annotationSummary(ticket);
|
|
8383
|
+
if (summary !== void 0) {
|
|
8384
|
+
lines.push(summary);
|
|
8385
|
+
}
|
|
8386
|
+
return lines.join("\n\n");
|
|
8387
|
+
}
|
|
7985
8388
|
case "expired":
|
|
7986
8389
|
return ticket.answer !== void 0 ? `DEFAULT ANSWER (applied on expiry; no human responded): ${ticket.answer}` : `Ticket ${ticket.id} expired with no answer and no default_answer was set. Do not assume an answer: decide for yourself or ask again with ask_human.`;
|
|
7987
8390
|
case "cancelled":
|
|
@@ -7997,14 +8400,20 @@ async function runAskHuman(args, deps) {
|
|
|
7997
8400
|
defaultStream: deps.defaultStream,
|
|
7998
8401
|
now: deps.now()
|
|
7999
8402
|
});
|
|
8000
|
-
const
|
|
8001
|
-
|
|
8403
|
+
const mediaCount = ticket.media?.length ?? 0;
|
|
8404
|
+
const mediaText = mediaCount > 0 ? `, ${mediaCount} media attachment(s)` : "";
|
|
8405
|
+
const text = `Ticket ${ticket.id} enqueued (stream "${ticket.stream}", urgency ${ticket.urgency}, status pending${mediaText}, expires_at ${ticket.expires_at}). Continue with independent work and call wait_for_answer with id "${ticket.id}" later. Do not poll in a tight loop.`;
|
|
8406
|
+
const structured = {
|
|
8002
8407
|
id: ticket.id,
|
|
8003
8408
|
status: "pending",
|
|
8004
8409
|
stream: ticket.stream,
|
|
8005
8410
|
urgency: ticket.urgency,
|
|
8006
8411
|
expires_at: ticket.expires_at
|
|
8007
|
-
}
|
|
8412
|
+
};
|
|
8413
|
+
if (ticket.media !== void 0) {
|
|
8414
|
+
structured.media = ticket.media;
|
|
8415
|
+
}
|
|
8416
|
+
return textResult(text, structured);
|
|
8008
8417
|
}
|
|
8009
8418
|
async function runWaitForAnswer(args, deps) {
|
|
8010
8419
|
const input = validateWaitForAnswer(args);
|
|
@@ -8018,10 +8427,10 @@ async function runWaitForAnswer(args, deps) {
|
|
|
8018
8427
|
throw notFound(input.id);
|
|
8019
8428
|
}
|
|
8020
8429
|
if (ticket.status !== "pending") {
|
|
8021
|
-
return
|
|
8430
|
+
return waitResult(ticket, effective, deps.store);
|
|
8022
8431
|
}
|
|
8023
8432
|
if (Date.now() >= deadline) {
|
|
8024
|
-
return
|
|
8433
|
+
return waitResult(ticket, effective, deps.store);
|
|
8025
8434
|
}
|
|
8026
8435
|
await sleep(POLL_INTERVAL_MS);
|
|
8027
8436
|
}
|
|
@@ -8272,7 +8681,6 @@ function buildAllowedOrigins(port, extra) {
|
|
|
8272
8681
|
// src/http.ts
|
|
8273
8682
|
var SWEEP_MS = 5e3;
|
|
8274
8683
|
var HEARTBEAT_MS = 25e3;
|
|
8275
|
-
var MAX_BODY_BYTES = 1e6;
|
|
8276
8684
|
var HttpError = class extends Error {
|
|
8277
8685
|
status;
|
|
8278
8686
|
constructor(status, message) {
|
|
@@ -8410,14 +8818,69 @@ function createHttpApp(options) {
|
|
|
8410
8818
|
void sweep();
|
|
8411
8819
|
async function handleAnswer(req, res) {
|
|
8412
8820
|
const body = await readJsonBody(req);
|
|
8413
|
-
if (!isRecord2(body) || typeof body["id"] !== "string"
|
|
8414
|
-
throw new HttpError(
|
|
8821
|
+
if (!isRecord2(body) || typeof body["id"] !== "string") {
|
|
8822
|
+
throw new HttpError(
|
|
8823
|
+
400,
|
|
8824
|
+
"body must be { id: string, answer?: string, answered_by?: string, annotations?: AnnotationInput[] }"
|
|
8825
|
+
);
|
|
8826
|
+
}
|
|
8827
|
+
const rawAnswer = body["answer"];
|
|
8828
|
+
if (rawAnswer !== void 0 && rawAnswer !== null && typeof rawAnswer !== "string") {
|
|
8829
|
+
throw new HttpError(400, "`answer` must be a string when present");
|
|
8415
8830
|
}
|
|
8416
8831
|
const answeredBy = body["answered_by"] === "default" ? "default" : "human";
|
|
8417
|
-
|
|
8832
|
+
let annotations;
|
|
8833
|
+
try {
|
|
8834
|
+
annotations = validateAnnotationInputs(body["annotations"]);
|
|
8835
|
+
} catch (error2) {
|
|
8836
|
+
if (error2 instanceof ValidationError) {
|
|
8837
|
+
throw new HttpError(400, error2.message);
|
|
8838
|
+
}
|
|
8839
|
+
throw error2;
|
|
8840
|
+
}
|
|
8841
|
+
const ticket = await answer(
|
|
8842
|
+
store,
|
|
8843
|
+
body["id"],
|
|
8844
|
+
typeof rawAnswer === "string" ? rawAnswer : "",
|
|
8845
|
+
answeredBy,
|
|
8846
|
+
now2(),
|
|
8847
|
+
annotations
|
|
8848
|
+
);
|
|
8418
8849
|
broadcast();
|
|
8419
8850
|
sendJson(res, 200, { ticket });
|
|
8420
8851
|
}
|
|
8852
|
+
async function handleMediaUpload(req, res) {
|
|
8853
|
+
const body = await readJsonBody(req);
|
|
8854
|
+
let input;
|
|
8855
|
+
try {
|
|
8856
|
+
input = validateMediaUpload(body);
|
|
8857
|
+
} catch (error2) {
|
|
8858
|
+
if (error2 instanceof ValidationError) {
|
|
8859
|
+
throw new HttpError(400, error2.message);
|
|
8860
|
+
}
|
|
8861
|
+
throw error2;
|
|
8862
|
+
}
|
|
8863
|
+
const media = await uploadMedia(store, input, now2());
|
|
8864
|
+
sendJson(res, 201, { media });
|
|
8865
|
+
}
|
|
8866
|
+
async function handleMediaGet(res, id) {
|
|
8867
|
+
if (!isSafeMediaId(id)) {
|
|
8868
|
+
sendText(res, 404, "Not Found");
|
|
8869
|
+
return;
|
|
8870
|
+
}
|
|
8871
|
+
const blob = await store.getMedia(id);
|
|
8872
|
+
if (blob === void 0) {
|
|
8873
|
+
sendText(res, 404, "Not Found");
|
|
8874
|
+
return;
|
|
8875
|
+
}
|
|
8876
|
+
const bytes = Buffer.from(base64ToBytes(blob.data_base64));
|
|
8877
|
+
res.writeHead(200, {
|
|
8878
|
+
"content-type": blob.mime_type,
|
|
8879
|
+
"content-length": bytes.length,
|
|
8880
|
+
"cache-control": "no-store"
|
|
8881
|
+
});
|
|
8882
|
+
res.end(bytes);
|
|
8883
|
+
}
|
|
8421
8884
|
async function handleUndo(req, res) {
|
|
8422
8885
|
const body = await readJsonBody(req);
|
|
8423
8886
|
if (!isRecord2(body) || typeof body["id"] !== "string") {
|
|
@@ -8456,6 +8919,38 @@ function createHttpApp(options) {
|
|
|
8456
8919
|
await mcpHandler(req, res);
|
|
8457
8920
|
return;
|
|
8458
8921
|
}
|
|
8922
|
+
if (path === "/api/media") {
|
|
8923
|
+
if (method !== "POST") {
|
|
8924
|
+
res.writeHead(405, { allow: "POST" });
|
|
8925
|
+
res.end("Method Not Allowed");
|
|
8926
|
+
return;
|
|
8927
|
+
}
|
|
8928
|
+
if (!requireBearer(req, token)) {
|
|
8929
|
+
sendText(res, 401, "Unauthorized");
|
|
8930
|
+
return;
|
|
8931
|
+
}
|
|
8932
|
+
await handleMediaUpload(req, res);
|
|
8933
|
+
return;
|
|
8934
|
+
}
|
|
8935
|
+
if (path.startsWith("/api/media/")) {
|
|
8936
|
+
if (method !== "GET") {
|
|
8937
|
+
res.writeHead(405, { allow: "GET" });
|
|
8938
|
+
res.end("Method Not Allowed");
|
|
8939
|
+
return;
|
|
8940
|
+
}
|
|
8941
|
+
if (!requireBearer(req, token)) {
|
|
8942
|
+
sendText(res, 401, "Unauthorized");
|
|
8943
|
+
return;
|
|
8944
|
+
}
|
|
8945
|
+
let id;
|
|
8946
|
+
try {
|
|
8947
|
+
id = decodeURIComponent(path.slice("/api/media/".length));
|
|
8948
|
+
} catch {
|
|
8949
|
+
id = path.slice("/api/media/".length);
|
|
8950
|
+
}
|
|
8951
|
+
await handleMediaGet(res, id);
|
|
8952
|
+
return;
|
|
8953
|
+
}
|
|
8459
8954
|
const allowed = ROUTE_METHODS[path];
|
|
8460
8955
|
if (allowed === void 0) {
|
|
8461
8956
|
sendText(res, 404, "Not Found");
|
|
@@ -20563,11 +21058,23 @@ function log2(message) {
|
|
|
20563
21058
|
process.stderr.write(`readyroom: ${message}
|
|
20564
21059
|
`);
|
|
20565
21060
|
}
|
|
21061
|
+
async function writeAtomic(path, bytes) {
|
|
21062
|
+
const tmp = `${path}.tmp`;
|
|
21063
|
+
const handle = await open2(tmp, "w");
|
|
21064
|
+
try {
|
|
21065
|
+
await handle.writeFile(bytes);
|
|
21066
|
+
await handle.sync();
|
|
21067
|
+
} finally {
|
|
21068
|
+
await handle.close();
|
|
21069
|
+
}
|
|
21070
|
+
await rename2(tmp, path);
|
|
21071
|
+
}
|
|
20566
21072
|
var FileTicketStore = class {
|
|
20567
21073
|
stateDir;
|
|
20568
21074
|
ticketsPath;
|
|
20569
21075
|
ticketsTmpPath;
|
|
20570
21076
|
auditPath;
|
|
21077
|
+
mediaDir;
|
|
20571
21078
|
/** In-memory mirror of `tickets.json`, keyed by ticket id. */
|
|
20572
21079
|
tickets = /* @__PURE__ */ new Map();
|
|
20573
21080
|
/** Tail of the write chain. Every mutation appends to it; nothing runs concurrently. */
|
|
@@ -20581,6 +21088,7 @@ var FileTicketStore = class {
|
|
|
20581
21088
|
this.ticketsPath = join2(stateDir, TICKETS_FILE);
|
|
20582
21089
|
this.ticketsTmpPath = join2(stateDir, TICKETS_TMP_FILE);
|
|
20583
21090
|
this.auditPath = join2(stateDir, AUDIT_FILE);
|
|
21091
|
+
this.mediaDir = join2(stateDir, MEDIA_DIR);
|
|
20584
21092
|
}
|
|
20585
21093
|
/**
|
|
20586
21094
|
* Serialize a task behind every previously queued write. The returned promise
|
|
@@ -20674,6 +21182,46 @@ var FileTicketStore = class {
|
|
|
20674
21182
|
}
|
|
20675
21183
|
});
|
|
20676
21184
|
}
|
|
21185
|
+
/**
|
|
21186
|
+
* Store a media blob as `<mediadir>/<id>` (raw bytes) with a sidecar
|
|
21187
|
+
* `<mediadir>/<id>.json` carrying the MIME type. The sidecar exists because a raw
|
|
21188
|
+
* file cannot say what it is, and `GET /api/media/{id}` needs the content type
|
|
21189
|
+
* without consulting a ticket.
|
|
21190
|
+
*
|
|
21191
|
+
* Writes go through the same tmp -> fsync -> rename path as `tickets.json`, so a
|
|
21192
|
+
* crash mid-write can never leave a truncated blob where a reader would find it.
|
|
21193
|
+
*/
|
|
21194
|
+
async putMedia(blob) {
|
|
21195
|
+
if (!isSafeMediaId(blob.id)) {
|
|
21196
|
+
throw new Error(`refusing to store media under an unsafe id: ${blob.id}`);
|
|
21197
|
+
}
|
|
21198
|
+
return this.serialize(async () => {
|
|
21199
|
+
await mkdir2(this.mediaDir, { recursive: true });
|
|
21200
|
+
const bytes = base64ToBytes(blob.data_base64);
|
|
21201
|
+
await writeAtomic(join2(this.mediaDir, blob.id), bytes);
|
|
21202
|
+
const meta2 = JSON.stringify({ mime_type: blob.mime_type, size_bytes: bytes.length });
|
|
21203
|
+
await writeAtomic(join2(this.mediaDir, `${blob.id}.json`), new TextEncoder().encode(meta2));
|
|
21204
|
+
});
|
|
21205
|
+
}
|
|
21206
|
+
/** The blob, or `undefined` for an unknown or unsafe id. Never throws on a bad id. */
|
|
21207
|
+
async getMedia(id) {
|
|
21208
|
+
if (!isSafeMediaId(id)) {
|
|
21209
|
+
return void 0;
|
|
21210
|
+
}
|
|
21211
|
+
let bytes;
|
|
21212
|
+
let metaRaw;
|
|
21213
|
+
try {
|
|
21214
|
+
bytes = await readFile2(join2(this.mediaDir, id));
|
|
21215
|
+
metaRaw = await readFile2(join2(this.mediaDir, `${id}.json`), "utf8");
|
|
21216
|
+
} catch (error2) {
|
|
21217
|
+
if (error2.code === "ENOENT") {
|
|
21218
|
+
return void 0;
|
|
21219
|
+
}
|
|
21220
|
+
throw error2;
|
|
21221
|
+
}
|
|
21222
|
+
const meta2 = JSON.parse(metaRaw);
|
|
21223
|
+
return { id, mime_type: meta2.mime_type, data_base64: bytesToBase64(bytes) };
|
|
21224
|
+
}
|
|
20677
21225
|
/**
|
|
20678
21226
|
* Atomic write: tmp file -> fsync -> rename over the real file. The rename is the
|
|
20679
21227
|
* atomic step; the fsync guarantees the bytes are on disk before the rename lands.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readyroom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "An MCP server that lets coding agents request a human decision without hijacking whichever window is focused. Agents enqueue a ticket; the human drains the queue from one page when they choose to.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -55,8 +55,11 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@cloudflare/workers-types": "^5.20261015.1",
|
|
58
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
59
|
+
"@semantic-release/git": "^11.0.1",
|
|
58
60
|
"@types/node": "^26.6.5",
|
|
59
61
|
"esbuild": "^0.28.2",
|
|
62
|
+
"semantic-release": "^25.0.9",
|
|
60
63
|
"tsx": "^4.23.15",
|
|
61
64
|
"typescript": "^7.0.2",
|
|
62
65
|
"wrangler": "^4.151.0"
|