From e463a3bad950f460be68a628b82e0b2b8f1d5633 Mon Sep 17 00:00:00 2001 From: ratchaw Date: Sun, 12 Jul 2026 10:18:53 +0100 Subject: [PATCH] =?UTF-8?q?Phase=203:=20playground=20=E2=80=94=20grounded?= =?UTF-8?q?=20extraction=20demo=20(Hono=20API=20+=20Vite/React)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/playground: drag-drop a PDF/image, watch fields stream in, hover a field to highlight its source region on the page. - Hono API (@hono/node-server): GET /api/config, POST /api/extract streamed as SSE via core's streamExtract; model registry injectable so routes test against a mock model with no API key. - Vite/React client: pdfjs for PDFs (lazy-loaded) + images, bbox overlays from core's normalized coords, live field reveal, per-field confidence, token usage and cost. - Preset invoice/receipt schemas defined server-side; models resolved from whichever provider keys are present. - 50 tests (SSE parser, geometry, field tree, upload, model/schema registries, full /api/extract SSE flow); dual client/server tsconfigs. Docs (README, ROADMAP, CLAUDE.md, apps/playground/README) updated. The demo GIF + first live extraction remain gated on a provider key. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- README.md | 4 +- ROADMAP.md | 6 +- apps/playground/.env.example | 8 + apps/playground/README.md | 62 ++ apps/playground/index.html | 12 + apps/playground/package.json | 43 ++ apps/playground/src/client/App.tsx | 174 ++++++ .../src/client/components/DocumentViewer.tsx | 185 ++++++ .../src/client/components/Dropzone.tsx | 68 +++ .../src/client/components/ResultPanel.tsx | 274 +++++++++ .../src/client/components/Toolbar.tsx | 95 ++++ apps/playground/src/client/lib/api.ts | 50 ++ apps/playground/src/client/lib/fields.ts | 82 +++ apps/playground/src/client/lib/geometry.ts | 36 ++ apps/playground/src/client/lib/sse.ts | 53 ++ apps/playground/src/client/lib/upload.ts | 33 ++ apps/playground/src/client/main.tsx | 13 + apps/playground/src/client/styles.css | 536 ++++++++++++++++++ apps/playground/src/server/app.ts | 101 ++++ apps/playground/src/server/index.ts | 35 ++ apps/playground/src/server/models.ts | 106 ++++ apps/playground/src/server/schemas.ts | 87 +++ apps/playground/src/shared/api.ts | 62 ++ apps/playground/test/app.test.ts | 134 +++++ apps/playground/test/fields.test.ts | 79 +++ apps/playground/test/geometry.test.ts | 43 ++ apps/playground/test/helpers.ts | 98 ++++ apps/playground/test/models.test.ts | 45 ++ apps/playground/test/schemas.test.ts | 45 ++ apps/playground/test/sse.test.ts | 63 ++ apps/playground/test/upload.test.ts | 54 ++ apps/playground/tsconfig.json | 9 + apps/playground/tsconfig.node.json | 7 + apps/playground/vite.config.ts | 23 + apps/playground/vitest.config.ts | 7 + pnpm-lock.yaml | 426 +++++++++++++- 37 files changed, 3142 insertions(+), 18 deletions(-) create mode 100644 apps/playground/.env.example create mode 100644 apps/playground/README.md create mode 100644 apps/playground/index.html create mode 100644 apps/playground/package.json create mode 100644 apps/playground/src/client/App.tsx create mode 100644 apps/playground/src/client/components/DocumentViewer.tsx create mode 100644 apps/playground/src/client/components/Dropzone.tsx create mode 100644 apps/playground/src/client/components/ResultPanel.tsx create mode 100644 apps/playground/src/client/components/Toolbar.tsx create mode 100644 apps/playground/src/client/lib/api.ts create mode 100644 apps/playground/src/client/lib/fields.ts create mode 100644 apps/playground/src/client/lib/geometry.ts create mode 100644 apps/playground/src/client/lib/sse.ts create mode 100644 apps/playground/src/client/lib/upload.ts create mode 100644 apps/playground/src/client/main.tsx create mode 100644 apps/playground/src/client/styles.css create mode 100644 apps/playground/src/server/app.ts create mode 100644 apps/playground/src/server/index.ts create mode 100644 apps/playground/src/server/models.ts create mode 100644 apps/playground/src/server/schemas.ts create mode 100644 apps/playground/src/shared/api.ts create mode 100644 apps/playground/test/app.test.ts create mode 100644 apps/playground/test/fields.test.ts create mode 100644 apps/playground/test/geometry.test.ts create mode 100644 apps/playground/test/helpers.ts create mode 100644 apps/playground/test/models.test.ts create mode 100644 apps/playground/test/schemas.test.ts create mode 100644 apps/playground/test/sse.test.ts create mode 100644 apps/playground/test/upload.test.ts create mode 100644 apps/playground/tsconfig.json create mode 100644 apps/playground/tsconfig.node.json create mode 100644 apps/playground/vite.config.ts create mode 100644 apps/playground/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index fd103e7..270b8e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ extractkit is an open-source TypeScript document-extraction engine: Zod schema + ## Current Phase -**Phase 2 (evals) harness shipped; live runs and the DocILE half are pending.** `packages/core` and the `packages/evals` harness are implemented and tested against mock models. The 25 CORD-v2 receipts are pinned in `packages/evals/data/manifest.json`; the 25 DocILE invoices are blocked on the dataset token (ROADMAP Phase 0, pending human action) — loader and curation script are ready. The eval lineup spans Anthropic, OpenAI, and Google Gemini (`packages/evals/src/models.ts`); a run includes every provider whose API key is set, or the subset named in `EVAL_PROVIDERS`. The first live eval run (needs at least one provider key — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY`) doubles as core's first live-provider validation and fills the benchmark page + README table via `pnpm report`. `apps/playground` does not exist yet. Keep README/ROADMAP/docs in sync with what actually ships. +**Phase 3 (playground) shipped against mock models; the first live provider run is the remaining gate across evals + playground.** `packages/core`, the `packages/evals` harness, and `apps/playground` are all implemented and tested against mock models. The 25 CORD-v2 receipts are pinned in `packages/evals/data/manifest.json`; the 25 DocILE invoices are blocked on the dataset token (ROADMAP Phase 0, pending human action) — loader and curation script are ready. The eval lineup spans Anthropic, OpenAI, and Google Gemini (`packages/evals/src/models.ts`); a run includes every provider whose API key is set, or the subset named in `EVAL_PROVIDERS`. The playground (`apps/playground`) is a Hono API + Vite/React client: `GET /api/config`, `POST /api/extract` streamed as SSE via core's `streamExtract`, preset invoice/receipt schemas defined server-side, and a model registry resolved from whichever provider keys are present. It needs the same provider keys to run live, and its model registry is injectable so the routes test against a mock model. The first live run (any one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`) doubles as core's first live-provider validation, fills the benchmark page + README table via `pnpm report`, and enables capturing the playground demo GIF. Keep README/ROADMAP/docs in sync with what actually ships. ## Planned Architecture diff --git a/README.md b/README.md index 1712404..d922760 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Extraction you can audit.** Define a Zod schema, feed it a PDF or image, get back schema-validated JSON where every field carries provenance — the page and bounding box it came from — plus a confidence score. -> **Status: in development.** The core library ([`packages/core`](./packages/core)) is implemented and tested against mock models; the eval benchmark and playground are next. See [ROADMAP.md](./ROADMAP.md). +> **Status: in development.** The core library ([`packages/core`](./packages/core)), the eval harness ([`packages/evals`](./packages/evals)), and the playground ([`apps/playground`](./apps/playground)) are implemented and tested against mock models. What's left is the first live provider run, which publishes the benchmark table and the demo GIF. See [ROADMAP.md](./ROADMAP.md). ## Why @@ -12,7 +12,7 @@ TypeScript has structured-output libraries (instructor-js, AI SDK `generateObjec - **Core library** (`packages/core`, shipped) — Zod schema + PDF/image → validated JSON with per-field `{ value, confidence, page, bbox }`. Provider-agnostic via the Vercel AI SDK. Document validation, typed failure handling, repair retries, streaming, and cost tracking built in. [Usage docs →](./packages/core/README.md) - **Eval harness** (`packages/evals`, harness shipped) — public benchmark on ~50 pinned real documents (CORD-v2 receipts + DocILE invoices): field accuracy per model, grounding accuracy, cost per 1k docs. Fully reproducible — documents pinned by checksum, reports generated only from recorded runs. [Reproduce it →](./packages/evals/README.md) -- **Playground** (planned) — drag-drop a document, watch fields extract; hover a JSON field to highlight its source region on the page. +- **Playground** (`apps/playground`, built) — drag-drop a document, watch fields stream in, hover a field to highlight its source region on the page. Hono API + Vite/React client, running `extractkit` against a live model. [Run it →](./apps/playground/README.md) See [ROADMAP.md](./ROADMAP.md) for the build plan. diff --git a/ROADMAP.md b/ROADMAP.md index 59fa914..f9df8c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,9 +24,9 @@ Tested against mock models only so far; first live-provider validation happens w ## Phase 3 — Playground (`apps/playground`) -- [ ] Hono API + Vite/React client -- [ ] Drag-drop extraction with hover-field → highlight-source-bbox interaction -- [ ] Demo GIF for the README +- [x] Hono API + Vite/React client — `GET /api/config`, `POST /api/extract` streamed as SSE; routes tested against a mock model +- [x] Drag-drop extraction with hover-field → highlight-source-bbox interaction (images + PDFs via pdfjs) +- [ ] Demo GIF for the README (needs one live run — same provider key as the Phase 2 first run) ## Phase 4 — Release diff --git a/apps/playground/.env.example b/apps/playground/.env.example new file mode 100644 index 0000000..c10d0b8 --- /dev/null +++ b/apps/playground/.env.example @@ -0,0 +1,8 @@ +# Set at least one provider key; the playground offers the models whose key is present. +# The server reads these from the environment (or a local .env file, git-ignored). +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GOOGLE_GENERATIVE_AI_API_KEY= + +# Port the Hono API server listens on (default 8787). The Vite dev server proxies /api here. +PORT=8787 diff --git a/apps/playground/README.md b/apps/playground/README.md new file mode 100644 index 0000000..fc21177 --- /dev/null +++ b/apps/playground/README.md @@ -0,0 +1,62 @@ +# extractkit playground + +The hosted demo for [extractkit](../../packages/core): drop in a PDF or image, watch fields +stream in as they extract, and **hover any field to highlight the exact region it came from** on the +page. A Vite + React client talks to a small Hono API that runs `extractkit` against a live model. + +## Run it + +From the repo root (the core library must be built once so the server can import it): + +```sh +pnpm install +pnpm --filter extractkit build +``` + +Set at least one provider key — copy `.env.example` to `.env` and fill one in, or export it: + +```sh +# .env (git-ignored) +ANTHROPIC_API_KEY=sk-ant-... +# or OPENAI_API_KEY=... / GOOGLE_GENERATIVE_AI_API_KEY=... +``` + +Then, from `apps/playground`: + +```sh +pnpm dev # Hono API on :8787 + Vite client on :5173 (open this) +``` + +The client offers whichever models have a key set. With no key, the UI loads but extraction is +disabled and tells you which variable to set. + +### Production preview + +```sh +pnpm build # bundles the client to dist/client +pnpm start # Hono serves the API and the built client on :8787 +``` + +## How it works + +- **`src/server`** — a Hono app (`app.ts`) with two routes: + - `GET /api/config` — the preset schemas and the models available in this environment. + - `POST /api/extract` — a multipart upload streamed back as Server-Sent Events. It calls + `streamExtract` from core and forwards each field as it completes, then a final `result` or a + typed `error`. The model registry is injected, so tests exercise the routes against a mock model + with no API key. +- **`src/client`** — the React app. `DocumentViewer` renders images directly and PDFs via `pdfjs`, + overlaying each field's bounding box (normalized 0–1 coordinates from core) on the page. + `ResultPanel` shows fields streaming in, then the validated tree with per-field confidence, token + usage, and cost. +- **`src/shared`** — the DTOs both sides share. + +Schemas are fixed presets (invoice, receipt) defined server-side — the playground does not accept +arbitrary schemas from the browser. + +## Test & typecheck + +```sh +pnpm test # server routes (mock model) + client logic (SSE, geometry, fields, upload) +pnpm typecheck # client (DOM) and server (node) tsconfigs +``` diff --git a/apps/playground/index.html b/apps/playground/index.html new file mode 100644 index 0000000..1f9d21f --- /dev/null +++ b/apps/playground/index.html @@ -0,0 +1,12 @@ + + + + + + extractkit · playground + + +
+ + + diff --git a/apps/playground/package.json b/apps/playground/package.json new file mode 100644 index 0000000..623ef81 --- /dev/null +++ b/apps/playground/package.json @@ -0,0 +1,43 @@ +{ + "name": "@extractkit/playground", + "private": true, + "version": "0.0.0", + "description": "Hosted demo for extractkit: drag-drop a document, watch fields extract, hover a field to highlight its source region on the page.", + "type": "module", + "engines": { + "node": ">=20.19" + }, + "scripts": { + "dev": "concurrently -k -n server,client -c blue,green \"pnpm dev:server\" \"pnpm dev:client\"", + "dev:server": "tsx watch src/server/index.ts", + "dev:client": "vite", + "build": "vite build", + "start": "NODE_ENV=production tsx src/server/index.ts", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit" + }, + "dependencies": { + "@ai-sdk/anthropic": "^4.0.10", + "@ai-sdk/google": "^4.0.12", + "@ai-sdk/openai": "^4.0.11", + "@hono/node-server": "^2.0.8", + "ai": "^7.0.16", + "extractkit": "workspace:*", + "hono": "^4.12.29", + "pdfjs-dist": "^6.1.200", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "concurrently": "^10.0.3", + "tsx": "^4.23.0", + "typescript": "^6.0.3", + "vite": "^8.1.4", + "vitest": "^4.1.10" + } +} diff --git a/apps/playground/src/client/App.tsx b/apps/playground/src/client/App.tsx new file mode 100644 index 0000000..e22e133 --- /dev/null +++ b/apps/playground/src/client/App.tsx @@ -0,0 +1,174 @@ +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ApiError, ConfigResponse, SerializedResult } from '../shared/api'; +import { fetchConfig, runExtract } from './lib/api'; +import type { FieldEntry } from './lib/fields'; +import { fieldEntries, pathKey } from './lib/fields'; +import { Dropzone } from './components/Dropzone'; +import { Toolbar } from './components/Toolbar'; +import { ResultPanel } from './components/ResultPanel'; + +// The viewer pulls in pdfjs; load it only once a document is opened. +const DocumentViewer = lazy(() => + import('./components/DocumentViewer').then((m) => ({ default: m.DocumentViewer })), +); + +export type Phase = 'idle' | 'running' | 'done' | 'error'; + +interface RunState { + phase: Phase; + live: FieldEntry[]; + liveKeys: Set; + result: SerializedResult | null; + error: ApiError | null; +} + +const IDLE_RUN: RunState = { phase: 'idle', live: [], liveKeys: new Set(), result: null, error: null }; + +export function App() { + const [config, setConfig] = useState(null); + const [configError, setConfigError] = useState(null); + const [schemaId, setSchemaId] = useState(''); + const [modelId, setModelId] = useState(''); + const [file, setFile] = useState(null); + const [docUrl, setDocUrl] = useState(null); + const [run, setRun] = useState(IDLE_RUN); + const [activeKey, setActiveKey] = useState(null); + const abortRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + fetchConfig(controller.signal) + .then((cfg) => { + setConfig(cfg); + setSchemaId(cfg.defaults.schema); + setModelId(cfg.defaults.model ?? ''); + }) + .catch((err: unknown) => { + if (!controller.signal.aborted) { + setConfigError(err instanceof Error ? err.message : 'Failed to load configuration.'); + } + }); + return () => controller.abort(); + }, []); + + useEffect(() => { + if (docUrl === null) return; + return () => URL.revokeObjectURL(docUrl); + }, [docUrl]); + + const selectFile = useCallback((next: File) => { + setFile(next); + setDocUrl(URL.createObjectURL(next)); + setActiveKey(null); + setRun(IDLE_RUN); + }, []); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + }, []); + + const extract = useCallback(async () => { + if (file === null || modelId === '') return; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setActiveKey(null); + setRun({ phase: 'running', live: [], liveKeys: new Set(), result: null, error: null }); + + try { + for await (const ev of runExtract({ file, schema: schemaId, model: modelId, signal: controller.signal })) { + if (ev.type === 'field') { + const key = pathKey(ev.path); + setRun((prev) => { + if (prev.liveKeys.has(key)) return prev; + const liveKeys = new Set(prev.liveKeys); + liveKeys.add(key); + return { ...prev, live: [...prev.live, { key, path: ev.path, field: ev.field }], liveKeys }; + }); + } else if (ev.type === 'result') { + setRun((prev) => ({ ...prev, phase: 'done', result: ev.result })); + } else { + setRun((prev) => ({ ...prev, phase: 'error', error: ev.error })); + } + } + } catch (err) { + if (controller.signal.aborted) { + setRun(IDLE_RUN); + } else { + setRun((prev) => ({ + ...prev, + phase: 'error', + error: { name: 'NetworkError', code: null, message: err instanceof Error ? err.message : 'Extraction failed.' }, + })); + } + } + }, [file, modelId, schemaId]); + + const entries = useMemo( + () => (run.result !== null ? fieldEntries(run.result.fields) : run.live), + [run.result, run.live], + ); + const boxes = useMemo( + () => entries.filter((e) => e.field.bbox !== null && e.field.page !== null), + [entries], + ); + + return ( +
+
+
+ extractkit + playground +
+

Drag in a document, watch fields extract, hover a field to see where it came from.

+ + GitHub ↗ + +
+ + void extract()} + onCancel={cancel} + /> + +
+
+ {file !== null && docUrl !== null ? ( + Loading viewer…
}> + + + ) : ( + + )} + +
+ +
+ + + ); +} diff --git a/apps/playground/src/client/components/DocumentViewer.tsx b/apps/playground/src/client/components/DocumentViewer.tsx new file mode 100644 index 0000000..3e4f399 --- /dev/null +++ b/apps/playground/src/client/components/DocumentViewer.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from 'react'; +import type { DragEvent } from 'react'; +import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'; +import type { PDFDocumentLoadingTask, PDFPageProxy } from 'pdfjs-dist'; +import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +import type { FieldEntry } from '../lib/fields'; +import { formatPath, formatValue } from '../lib/fields'; +import { bboxToStyle } from '../lib/geometry'; +import { isPdf, pickFile } from '../lib/upload'; + +GlobalWorkerOptions.workerSrc = workerSrc; + +interface ViewerProps { + file: File; + docUrl: string; + boxes: FieldEntry[]; + activeKey: string | null; + onActivate: (key: string | null) => void; + onFile: (file: File) => void; +} + +export function DocumentViewer(props: ViewerProps) { + const { file, docUrl, boxes, activeKey, onActivate, onFile } = props; + const [dragging, setDragging] = useState(false); + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragging(false); + const result = pickFile(e.dataTransfer.files); + if ('file' in result) onFile(result.file); + }; + + return ( +
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + > + {isPdf(file) ? ( + + ) : ( +
+ +
+ )} +
+ ); +} + +interface PageBoxProps { + boxes: FieldEntry[]; + pageIndex: number; + activeKey: string | null; + onActivate: (key: string | null) => void; +} + +function ImagePage({ src, ...page }: { src: string } & PageBoxProps) { + return ( +
+ Uploaded document + +
+ ); +} + +function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit) { + const [pages, setPages] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let loadingTask: PDFDocumentLoadingTask | null = null; + setPages([]); + setError(null); + void (async () => { + try { + const buffer = await file.arrayBuffer(); + if (cancelled) return; + loadingTask = getDocument({ data: new Uint8Array(buffer) }); + const doc = await loadingTask.promise; + const proxies = await Promise.all( + Array.from({ length: doc.numPages }, (_, i) => doc.getPage(i + 1)), + ); + if (cancelled) return; + setPages(proxies); + } catch { + if (!cancelled) setError('This PDF could not be rendered in the browser.'); + } + })(); + return () => { + cancelled = true; + if (loadingTask !== null) void loadingTask.destroy(); + }; + }, [file]); + + if (error !== null) return
{error}
; + + return ( +
+ {pages.map((page, index) => ( +
+ + +
+ ))} +
+ ); +} + +function PdfCanvas({ page }: { page: PDFPageProxy }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + const context = canvas.getContext('2d'); + if (context === null) return; + + const base = page.getViewport({ scale: 1 }); + const scale = Math.min(2, 1400 / base.width); + const viewport = page.getViewport({ scale }); + canvas.width = Math.ceil(viewport.width); + canvas.height = Math.ceil(viewport.height); + + const task = page.render({ canvas, canvasContext: context, viewport }); + task.promise.catch(() => { + // Cancelled on unmount or superseded render; ignore. + }); + return () => task.cancel(); + }, [page]); + + return ; +} + +function BoxLayer({ boxes, pageIndex, activeKey, onActivate }: PageBoxProps) { + const onPage = boxes.filter((box) => (box.field.page ?? 0) === pageIndex); + return ( +
+ {onPage.map((box) => ( + + ))} +
+ ); +} + +function BBox({ + entry, + active, + onActivate, +}: { + entry: FieldEntry; + active: boolean; + onActivate: (key: string | null) => void; +}) { + const ref = useRef(null); + const { bbox } = entry.field; + + useEffect(() => { + if (active) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [active]); + + if (bbox === null) return null; + return ( +
onActivate(entry.key)} + onMouseLeave={() => onActivate(null)} + /> + ); +} diff --git a/apps/playground/src/client/components/Dropzone.tsx b/apps/playground/src/client/components/Dropzone.tsx new file mode 100644 index 0000000..43d4167 --- /dev/null +++ b/apps/playground/src/client/components/Dropzone.tsx @@ -0,0 +1,68 @@ +import { useCallback, useRef, useState } from 'react'; +import type { DragEvent } from 'react'; +import { ACCEPT_ATTR, pickFile } from '../lib/upload'; + +interface DropzoneProps { + onFile: (file: File) => void; +} + +export function Dropzone({ onFile }: DropzoneProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + + const accept = useCallback( + (files: FileList | null) => { + const result = pickFile(files); + if ('file' in result) { + setError(null); + onFile(result.file); + } else { + setError(result.error); + } + }, + [onFile], + ); + + const onDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + setDragging(false); + accept(e.dataTransfer.files); + }, + [accept], + ); + + return ( +
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click(); + }} + > + accept(e.target.files)} + /> +
+ ⤓ +
+

Drop a document here

+

or click to browse — PDF, PNG, JPEG, or WebP, up to 15 MB

+ {error !== null &&

{error}

} +
+ ); +} diff --git a/apps/playground/src/client/components/ResultPanel.tsx b/apps/playground/src/client/components/ResultPanel.tsx new file mode 100644 index 0000000..d0ea37e --- /dev/null +++ b/apps/playground/src/client/components/ResultPanel.tsx @@ -0,0 +1,274 @@ +import type { ApiError, SerializedResult } from '../../shared/api'; +import type { Phase } from '../App'; +import type { AnyField, FieldEntry } from '../lib/fields'; +import { formatPath, formatValue, isField, leafLabel, pathKey } from '../lib/fields'; +import type { FieldPath } from 'extractkit'; + +interface ResultPanelProps { + phase: Phase; + live: FieldEntry[]; + result: SerializedResult | null; + error: ApiError | null; + hasFile: boolean; + activeKey: string | null; + onActivate: (key: string | null) => void; +} + +export function ResultPanel(props: ResultPanelProps) { + const { phase, live, result, error, hasFile, activeKey, onActivate } = props; + + return ( +
+
+

Extraction

+ +
+ +
+ {error !== null && } + {error === null && result !== null && ( +
+ +
+ )} + {error === null && result === null && phase === 'running' && ( + + )} + {error === null && result === null && phase !== 'running' && } +
+ + {error === null && result !== null && } +
+ ); +} + +function indentStyle(level: number) { + return { paddingLeft: `${12 + Math.max(0, level) * 14}px` }; +} + +interface NodeProps { + node: unknown; + path: FieldPath; + activeKey: string | null; + onActivate: (key: string | null) => void; +} + +function FieldNode({ node, path, activeKey, onActivate }: NodeProps) { + if (node === null) { + return ( +
+ {leafLabel(path)} + +
+ ); + } + + if (isField(node)) { + const key = pathKey(path); + return ( + + ); + } + + if (Array.isArray(node)) { + return ( +
+
+ {leafLabel(path)} {node.length} +
+ {node.map((child, i) => ( + + ))} +
+ ); + } + + const entries = Object.entries(node as Record); + return ( +
+ {path.length > 0 && ( +
+ {leafLabel(path)} +
+ )} + {entries.map(([key, child]) => ( + + ))} +
+ ); +} + +function LiveList({ + entries, + activeKey, + onActivate, +}: { + entries: FieldEntry[]; + activeKey: string | null; + onActivate: (key: string | null) => void; +}) { + if (entries.length === 0) { + return ( +
+ Reading the document… +
+ ); + } + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} + +function FieldRow({ + label, + entry, + level, + active, + onActivate, +}: { + label: string; + entry: FieldEntry; + level: number; + active: boolean; + onActivate: (key: string | null) => void; +}) { + const { field } = entry; + return ( +
onActivate(entry.key)} + onMouseLeave={() => onActivate(null)} + > + {label} + {formatValue(field.value)} + +
+ ); +} + +function Confidence({ field }: { field: AnyField }) { + const pct = Math.round(field.confidence * 100); + const tier = field.confidence >= 0.8 ? 'high' : field.confidence >= 0.5 ? 'mid' : 'low'; + const located = field.bbox !== null; + return ( + + + + + {pct}% + {!located && ( + + ⚠ + + )} + + ); +} + +function StatusPill({ phase, count }: { phase: Phase; count: number | undefined }) { + if (phase === 'running') { + return {count !== undefined && count > 0 ? `Extracting · ${count}` : 'Extracting…'}; + } + if (phase === 'done') return Done; + if (phase === 'error') return Error; + return Idle; +} + +function EmptyHint({ hasFile }: { hasFile: boolean }) { + return ( +
+ {hasFile ? 'Press Extract to pull fields from this document.' : 'Upload a document to get started.'} +
+ ); +} + +function ResultFooter({ result }: { result: SerializedResult }) { + const { usage } = result; + return ( +
+
+ + + + + +
+ {result.issues.length > 0 && ( +
+ + {result.issues.length} provenance {result.issues.length === 1 ? 'note' : 'notes'} + +
    + {result.issues.map((issue, i) => ( +
  • {issue}
  • + ))} +
+
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ {value} + {label} +
+ ); +} + +const ERROR_TITLES: Record = { + DOCUMENT_UNREADABLE: 'Document unreadable', + MISSING_REQUIRED_FIELDS: "Some required fields weren't found", + EXTRACTION_FAILED: 'Extraction failed', + UNSUPPORTED_MEDIA_TYPE: 'Unsupported file type', + MEDIA_TYPE_MISMATCH: 'File type mismatch', + ENCRYPTED_DOCUMENT: 'Document is encrypted', + INVALID_DOCUMENT: 'Invalid document', + SCHEMA_UNSUPPORTED: 'Unsupported schema', +}; + +function ErrorBanner({ error }: { error: ApiError }) { + const title = (error.code !== null && ERROR_TITLES[error.code]) || error.name; + return ( +
+
{title}
+
{error.message}
+ {error.missingPaths !== undefined && error.missingPaths.length > 0 && ( +
    + {error.missingPaths.map((path) => ( +
  • + {path} +
  • + ))} +
+ )} +
+ ); +} + +function formatUSD(amount: number): string { + if (amount === 0) return '$0'; + if (amount < 0.01) return `$${amount.toFixed(4)}`; + return `$${amount.toFixed(2)}`; +} diff --git a/apps/playground/src/client/components/Toolbar.tsx b/apps/playground/src/client/components/Toolbar.tsx new file mode 100644 index 0000000..64a9876 --- /dev/null +++ b/apps/playground/src/client/components/Toolbar.tsx @@ -0,0 +1,95 @@ +import { useRef } from 'react'; +import type { ConfigResponse } from '../../shared/api'; +import type { Phase } from '../App'; +import { ACCEPT_ATTR, pickFile } from '../lib/upload'; + +interface ToolbarProps { + config: ConfigResponse | null; + configError: string | null; + schemaId: string; + modelId: string; + hasFile: boolean; + phase: Phase; + onSchema: (id: string) => void; + onModel: (id: string) => void; + onFile: (file: File) => void; + onExtract: () => void; + onCancel: () => void; +} + +export function Toolbar(props: ToolbarProps) { + const { config, configError, schemaId, modelId, hasFile, phase } = props; + const inputRef = useRef(null); + const running = phase === 'running'; + const noModels = config !== null && config.models.length === 0; + const canExtract = hasFile && modelId !== '' && !running; + + return ( +
+ + + + +
+ { + const result = pickFile(e.target.files); + if ('file' in result) props.onFile(result.file); + e.target.value = ''; + }} + /> + + {running ? ( + + ) : ( + + )} +
+ + {configError !== null &&

{configError}

} + {noModels && configError === null && ( +

+ Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or{' '} + GOOGLE_GENERATIVE_AI_API_KEY on the server to enable extraction. +

+ )} +
+ ); +} diff --git a/apps/playground/src/client/lib/api.ts b/apps/playground/src/client/lib/api.ts new file mode 100644 index 0000000..5c44442 --- /dev/null +++ b/apps/playground/src/client/lib/api.ts @@ -0,0 +1,50 @@ +import type { ConfigResponse, ExtractEvent } from '../../shared/api'; +import { readSSE } from './sse'; + +/** Fetches the available schemas and models. */ +export async function fetchConfig(signal?: AbortSignal): Promise { + const res = await fetch('/api/config', signal ? { signal } : undefined); + if (!res.ok) throw new Error(`Failed to load config (${res.status}).`); + return (await res.json()) as ConfigResponse; +} + +export interface ExtractInput { + file: File; + schema: string; + model: string; + signal?: AbortSignal; +} + +/** + * Runs an extraction and yields each streamed event. Request-level failures + * (bad input, too large) come back as a single synthetic `error` event so + * callers only handle one shape. + */ +export async function* runExtract(input: ExtractInput): AsyncGenerator { + const form = new FormData(); + form.set('file', input.file); + form.set('schema', input.schema); + form.set('model', input.model); + + const res = await fetch('/api/extract', { + method: 'POST', + body: form, + ...(input.signal ? { signal: input.signal } : {}), + }); + + if (!res.ok || res.body === null) { + let message = `Extraction request failed (${res.status}).`; + try { + const body = (await res.json()) as { error?: string }; + if (typeof body.error === 'string') message = body.error; + } catch { + // Non-JSON error body; keep the status-based message. + } + yield { type: 'error', error: { name: 'RequestError', code: null, message } }; + return; + } + + for await (const message of readSSE(res.body)) { + yield JSON.parse(message.data) as ExtractEvent; + } +} diff --git a/apps/playground/src/client/lib/fields.ts b/apps/playground/src/client/lib/fields.ts new file mode 100644 index 0000000..b2afc14 --- /dev/null +++ b/apps/playground/src/client/lib/fields.ts @@ -0,0 +1,82 @@ +import type { ExtractedField, FieldPath } from 'extractkit'; + +export type AnyField = ExtractedField; + +export interface FlatField { + path: FieldPath; + field: AnyField; +} + +/** A flat field plus its stable key, ready for overlays and list rendering. */ +export interface FieldEntry extends FlatField { + key: string; +} + +/** Stable string key for a field path, for map lookups and React keys. */ +export function pathKey(path: FieldPath): string { + return JSON.stringify(path); +} + +/** True when `node` is a provenance leaf rather than a nested branch. */ +export function isField(node: unknown): node is AnyField { + if (typeof node !== 'object' || node === null) return false; + const record = node as Record; + return ( + 'value' in record && + 'confidence' in record && + 'page' in record && + 'bbox' in record && + typeof record['confidence'] === 'number' + ); +} + +/** Depth-first list of every provenance leaf under a FieldMap, with its path. */ +export function flattenFields(node: unknown, base: FieldPath = []): FlatField[] { + if (node === null || node === undefined) return []; + if (isField(node)) return [{ path: base, field: node }]; + if (Array.isArray(node)) return node.flatMap((child, i) => flattenFields(child, [...base, i])); + if (typeof node === 'object') { + return Object.entries(node).flatMap(([key, child]) => flattenFields(child, [...base, key])); + } + return []; +} + +/** Every provenance leaf under a FieldMap, each with its stable key. */ +export function fieldEntries(node: unknown): FieldEntry[] { + return flattenFields(node).map(({ path, field }) => ({ key: pathKey(path), path, field })); +} + +/** Reads the plain value at `path` from an extraction's `data`. */ +export function valueAtPath(data: unknown, path: FieldPath): unknown { + let current: unknown = data; + for (const key of path) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current; +} + +/** The last path segment, as a display label (e.g. `description`, `[0]`). */ +export function leafLabel(path: FieldPath): string { + const last = path.at(-1); + if (last === undefined) return '(root)'; + return typeof last === 'number' ? `[${last}]` : last; +} + +/** Dotted, index-aware rendering of a whole path (e.g. `lineItems[0].amount`). */ +export function formatPath(path: FieldPath): string { + let out = ''; + for (const segment of path) { + if (typeof segment === 'number') out += `[${segment}]`; + else out += out === '' ? segment : `.${segment}`; + } + return out === '' ? '(root)' : out; +} + +/** Human-readable rendering of a leaf value for the field list. */ +export function formatValue(value: unknown): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'string') return value === '' ? '(empty)' : value; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value); +} diff --git a/apps/playground/src/client/lib/geometry.ts b/apps/playground/src/client/lib/geometry.ts new file mode 100644 index 0000000..524b203 --- /dev/null +++ b/apps/playground/src/client/lib/geometry.ts @@ -0,0 +1,36 @@ +import type { BBox } from 'extractkit'; + +export interface BoxStyle { + left: string; + top: string; + width: string; + height: string; +} + +function clamp01(n: number): number { + if (Number.isNaN(n)) return 0; + return Math.min(1, Math.max(0, n)); +} + +function pct(n: number): string { + return `${(n * 100).toFixed(3)}%`; +} + +/** + * Converts a normalized bbox (0–1, origin top-left) into percentage CSS for an + * overlay positioned inside a page container. Corners are ordered and clamped so + * a mildly malformed box still renders as a valid rectangle. + */ +export function bboxToStyle(bbox: BBox): BoxStyle { + const x0 = clamp01(Math.min(bbox.x0, bbox.x1)); + const y0 = clamp01(Math.min(bbox.y0, bbox.y1)); + const x1 = clamp01(Math.max(bbox.x0, bbox.x1)); + const y1 = clamp01(Math.max(bbox.y0, bbox.y1)); + return { left: pct(x0), top: pct(y0), width: pct(x1 - x0), height: pct(y1 - y0) }; +} + +/** Fit `intrinsicWidth` into `containerWidth` without upscaling past 1×. */ +export function fitScale(intrinsicWidth: number, containerWidth: number): number { + if (intrinsicWidth <= 0 || containerWidth <= 0) return 1; + return Math.min(1, containerWidth / intrinsicWidth); +} diff --git a/apps/playground/src/client/lib/sse.ts b/apps/playground/src/client/lib/sse.ts new file mode 100644 index 0000000..86bcdb9 --- /dev/null +++ b/apps/playground/src/client/lib/sse.ts @@ -0,0 +1,53 @@ +export interface SSEMessage { + event: string; + data: string; +} + +function parseRecord(raw: string): SSEMessage | null { + let event = 'message'; + const dataLines: string[] = []; + for (const line of raw.replace(/\r/g, '').split('\n')) { + if (line === '' || line.startsWith(':')) continue; + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).replace(/^ /, '')); + } + } + if (dataLines.length === 0) return null; + return { event, data: dataLines.join('\n') }; +} + +/** + * Parses a `fetch` response body as a Server-Sent Events stream, yielding one + * message per `\n\n`-delimited record. Enough of the SSE grammar for the + * playground's own server: `event:` and `data:` fields, comment lines ignored. + */ +export async function* readSSE(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + // Fold CRLF to LF so records split on \n\n regardless of line endings. A + // trailing lone \r is left for the next chunk's \n to complete the pair. + buffer = buffer.replace(/\r\n/g, '\n'); + let boundary = buffer.indexOf('\n\n'); + while (boundary !== -1) { + const record = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const message = parseRecord(record); + if (message !== null) yield message; + boundary = buffer.indexOf('\n\n'); + } + } + buffer += decoder.decode(); + const tail = parseRecord(buffer); + if (tail !== null) yield tail; + } finally { + reader.releaseLock(); + } +} diff --git a/apps/playground/src/client/lib/upload.ts b/apps/playground/src/client/lib/upload.ts new file mode 100644 index 0000000..c0715f8 --- /dev/null +++ b/apps/playground/src/client/lib/upload.ts @@ -0,0 +1,33 @@ +/** Mirrors the server's upload ceiling so the client can reject early. */ +export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; + +const ACCEPTED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.webp']; +const ACCEPTED_TYPE = /^(application\/pdf|image\/(png|jpe?g|webp))$/; + +/** The `accept` attribute for the file input. */ +export const ACCEPT_ATTR = '.pdf,.png,.jpg,.jpeg,.webp,application/pdf,image/png,image/jpeg,image/webp'; + +export function isPdf(file: File): boolean { + return file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); +} + +/** A client-side pre-check; the server validates authoritatively. Returns an error message, or null when acceptable. */ +export function validateFile(file: File): string | null { + const name = file.name.toLowerCase(); + const okExtension = ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext)); + const okType = file.type === '' ? okExtension : ACCEPTED_TYPE.test(file.type); + if (!okExtension && !okType) return 'Unsupported file type — upload a PDF, PNG, JPEG, or WebP.'; + if (file.size > MAX_UPLOAD_BYTES) return 'File is larger than the 15 MB limit.'; + return null; +} + +/** + * First acceptable file from a drop or picker, or an error message. Typed as + * ArrayLike so it stays DOM-free (a browser FileList is assignable). + */ +export function pickFile(files: ArrayLike | null): { file: File } | { error: string } { + const file = files?.[0]; + if (file === undefined) return { error: 'No file selected.' }; + const error = validateFile(file); + return error === null ? { file } : { error }; +} diff --git a/apps/playground/src/client/main.tsx b/apps/playground/src/client/main.tsx new file mode 100644 index 0000000..17f65c0 --- /dev/null +++ b/apps/playground/src/client/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +const container = document.getElementById('root'); +if (container === null) throw new Error('Missing #root element.'); + +createRoot(container).render( + + + , +); diff --git a/apps/playground/src/client/styles.css b/apps/playground/src/client/styles.css new file mode 100644 index 0000000..a8ffa8b --- /dev/null +++ b/apps/playground/src/client/styles.css @@ -0,0 +1,536 @@ +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --border: #e2e5ea; + --text: #1a1d24; + --text-muted: #6b7280; + --accent: #2563eb; + --accent-soft: #eff4ff; + --highlight: #f59e0b; + --high: #16a34a; + --mid: #d97706; + --low: #dc2626; + --radius: 10px; + --shadow: 0 1px 2px rgba(20, 24, 33, 0.05), 0 4px 16px rgba(20, 24, 33, 0.04); + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +code { + font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + font-size: 0.85em; + background: var(--surface-2); + padding: 1px 5px; + border-radius: 5px; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header */ +.app-header { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 22px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.brand { + display: flex; + align-items: baseline; + gap: 8px; +} +.brand-mark { + font-weight: 700; + font-size: 18px; + letter-spacing: -0.01em; +} +.brand-sub { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.tagline { + margin: 0; + color: var(--text-muted); + font-size: 13.5px; + flex: 1; +} +.repo-link { + color: var(--accent); + text-decoration: none; + font-size: 13px; + font-weight: 500; +} +.repo-link:hover { + text-decoration: underline; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: flex-end; + gap: 14px; + padding: 12px 22px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.field { + display: flex; + flex-direction: column; + gap: 5px; +} +.field-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +select { + appearance: none; + padding: 7px 30px 7px 11px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface) + url("data:image/svg+xml,%3Csvg width='10' height='6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%236b7280' stroke-width='1.5' fill='none' fill-rule='evenodd'/%3E%3C/svg%3E") + no-repeat right 11px center; + font-size: 14px; + color: var(--text); + min-width: 170px; + cursor: pointer; +} +select:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.toolbar-actions { + display: flex; + gap: 8px; + margin-left: auto; +} +.btn { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.btn:hover:not(:disabled) { + background: var(--surface-2); +} +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.btn.primary:hover:not(:disabled) { + background: #1d4ed8; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.toolbar-note { + flex-basis: 100%; + margin: 2px 0 0; + font-size: 12.5px; + color: var(--text-muted); +} +.toolbar-note.error { + color: var(--low); +} + +/* Workspace */ +.workspace { + flex: 1; + display: grid; + grid-template-columns: 1fr 420px; + min-height: 0; +} +.viewer-pane { + overflow: auto; + padding: 24px; + background: var(--surface-2); +} +.result-pane { + border-left: 1px solid var(--border); + background: var(--surface); + display: flex; + flex-direction: column; + min-height: 0; +} + +/* Dropzone */ +.dropzone { + height: 100%; + min-height: 320px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + border: 2px dashed var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text-muted); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.dropzone:hover, +.dropzone[data-dragging='true'] { + border-color: var(--accent); + background: var(--accent-soft); +} +.dropzone-icon { + font-size: 34px; + color: var(--accent); +} +.dropzone-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text); +} +.dropzone-hint { + margin: 0; + font-size: 13px; +} +.dropzone-error { + margin: 6px 0 0; + color: var(--low); + font-size: 13px; +} + +/* Document viewer */ +.viewer { + min-height: 100%; + border-radius: var(--radius); + outline: 2px solid transparent; + transition: outline-color 0.15s; +} +.viewer[data-dragging='true'] { + outline: 2px dashed var(--accent); + outline-offset: 6px; +} +.page-stack { + max-width: 820px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 18px; +} +.page { + position: relative; + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--shadow); + overflow: hidden; + line-height: 0; +} +.page-image, +.page-canvas { + display: block; + width: 100%; + height: auto; +} +.box-layer { + position: absolute; + inset: 0; +} +.bbox { + position: absolute; + border: 1.5px solid rgba(37, 99, 235, 0.4); + background: rgba(37, 99, 235, 0.08); + border-radius: 2px; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, box-shadow 0.1s; +} +.bbox:hover, +.bbox[data-active='true'] { + border-color: var(--highlight); + background: rgba(245, 158, 11, 0.22); + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.35); + z-index: 2; +} +.viewer-message { + padding: 40px; + text-align: center; + color: var(--text-muted); +} +.viewer-message.error { + color: var(--low); +} + +/* Result panel */ +.result-panel { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} +.result-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} +.result-head h2 { + margin: 0; + font-size: 15px; +} +.result-body { + flex: 1; + overflow: auto; + padding: 8px 0; +} + +.pill { + font-size: 11.5px; + font-weight: 600; + padding: 3px 9px; + border-radius: 999px; + letter-spacing: 0.02em; +} +.pill.idle { + background: var(--surface-2); + color: var(--text-muted); +} +.pill.running { + background: var(--accent-soft); + color: var(--accent); +} +.pill.done { + background: #e7f6ec; + color: var(--high); +} +.pill.error { + background: #fdeaea; + color: var(--low); +} + +/* Field tree */ +.tree { + display: flex; + flex-direction: column; +} +.tree-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 18px 6px 12px; + cursor: default; + border-left: 2px solid transparent; +} +.tree-row:hover, +.tree-row[data-active='true'] { + background: var(--accent-soft); + border-left-color: var(--highlight); +} +.tree-key { + font-size: 13px; + color: var(--text-muted); + min-width: 92px; + flex-shrink: 0; +} +.tree-value { + font-size: 13.5px; + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tree-row.muted .tree-value { + color: var(--text-muted); + font-weight: 400; +} +.tree-group-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: 10px 18px 4px 12px; + display: flex; + align-items: center; + gap: 6px; +} +.badge { + background: var(--surface-2); + border-radius: 999px; + padding: 1px 7px; + font-size: 10px; + letter-spacing: 0; +} + +/* Confidence */ +.confidence { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.confidence-bar { + width: 46px; + height: 5px; + border-radius: 3px; + background: var(--surface-2); + overflow: hidden; +} +.confidence-fill { + display: block; + height: 100%; + border-radius: 3px; +} +.confidence-fill[data-tier='high'] { + background: var(--high); +} +.confidence-fill[data-tier='mid'] { + background: var(--mid); +} +.confidence-fill[data-tier='low'] { + background: var(--low); +} +.confidence-pct { + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + width: 30px; + text-align: right; +} +.no-source { + color: var(--mid); + font-size: 12px; +} + +/* Live + empty */ +.live-empty, +.result-empty { + display: flex; + align-items: center; + gap: 10px; + padding: 24px 18px; + color: var(--text-muted); + font-size: 13.5px; +} +.result-empty { + justify-content: center; + text-align: center; +} +.spinner { + width: 15px; + height: 15px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Footer stats */ +.result-footer { + border-top: 1px solid var(--border); + padding: 12px 18px; + background: var(--surface); +} +.stats { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} +.stat { + display: flex; + flex-direction: column; + gap: 2px; +} +.stat-value { + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.stat-label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.issues { + margin-top: 12px; + font-size: 12.5px; + color: var(--text-muted); +} +.issues summary { + cursor: pointer; + font-weight: 600; +} +.issues ul { + margin: 8px 0 0; + padding-left: 18px; +} +.issues li { + margin-bottom: 4px; +} + +/* Error */ +.error-banner { + margin: 12px 18px; + padding: 14px 16px; + border: 1px solid #f3c9c9; + background: #fdf3f3; + border-radius: var(--radius); +} +.error-title { + font-weight: 600; + color: var(--low); + margin-bottom: 4px; +} +.error-message { + font-size: 13px; + color: var(--text); +} +.error-paths { + margin: 8px 0 0; + padding-left: 18px; + font-size: 12.5px; +} + +@media (max-width: 860px) { + .workspace { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } + .result-pane { + border-left: none; + border-top: 1px solid var(--border); + } +} diff --git a/apps/playground/src/server/app.ts b/apps/playground/src/server/app.ts new file mode 100644 index 0000000..79c1b8c --- /dev/null +++ b/apps/playground/src/server/app.ts @@ -0,0 +1,101 @@ +import { ExtractKitError, MissingRequiredFieldsError, streamExtract } from 'extractkit'; +import type { ExtractResult } from 'extractkit'; +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { streamSSE } from 'hono/streaming'; +import type { z } from 'zod'; +import type { ApiError, ConfigResponse, ExtractEvent, SerializedResult } from '../shared/api'; +import type { PlaygroundModel } from './models'; +import { defaultModelId, modelInfos } from './models'; +import { DEFAULT_SCHEMA_ID, getPreset, schemaInfos } from './schemas'; + +export interface AppOptions { + /** Models this instance can run. Injected so tests can pass a mock model. */ + models: PlaygroundModel[]; +} + +/** Upload ceiling for the public demo; documents above this are rejected. */ +const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; + +function toSerialized(result: ExtractResult): SerializedResult { + return { + data: result.data, + fields: result.fields, + issues: result.issues, + usage: result.usage, + pages: result.pages, + }; +} + +function toApiError(err: unknown): ApiError { + if (err instanceof ExtractKitError) { + const mapped: ApiError = { name: err.name, code: err.code, message: err.message }; + if (err instanceof MissingRequiredFieldsError) mapped.missingPaths = err.missingPaths; + return mapped; + } + if (err instanceof Error) return { name: err.name, code: null, message: err.message }; + return { name: 'Error', code: null, message: String(err) }; +} + +export function createApp(options: AppOptions) { + const modelById = new Map(options.models.map((m) => [m.id, m])); + const app = new Hono(); + + app.get('/api/config', (c) => { + const body: ConfigResponse = { + schemas: schemaInfos(), + models: modelInfos(options.models), + defaults: { schema: DEFAULT_SCHEMA_ID, model: defaultModelId(options.models) }, + }; + return c.json(body); + }); + + app.post( + '/api/extract', + bodyLimit({ + maxSize: MAX_UPLOAD_BYTES, + onError: (c) => c.json({ error: 'Document exceeds the 15 MB upload limit.' }, 413), + }), + async (c) => { + const form = await c.req.parseBody(); + const file = form['file']; + const schemaId = typeof form['schema'] === 'string' ? form['schema'] : ''; + const modelId = typeof form['model'] === 'string' ? form['model'] : ''; + + if (!(file instanceof File)) { + return c.json({ error: 'Upload a document in the "file" field.' }, 400); + } + const preset = getPreset(schemaId); + if (preset === undefined) { + return c.json({ error: `Unknown schema "${schemaId}".` }, 400); + } + const model = modelById.get(modelId); + if (model === undefined) { + return c.json({ error: `Unknown or unavailable model "${modelId}".` }, 400); + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + + return streamSSE(c, async (sse) => { + const send = (event: ExtractEvent) => sse.writeSSE({ event: event.type, data: JSON.stringify(event) }); + const stream = streamExtract({ + schema: preset.schema, + document: { data: bytes, filename: file.name }, + model: model.create(), + pricing: model.pricing, + }); + try { + for await (const ev of stream) { + await send({ type: 'field', path: ev.path, field: ev.field }); + } + const result = await stream.result; + await send({ type: 'result', result: toSerialized(result) }); + } catch (err) { + await send({ type: 'error', error: toApiError(err) }); + } + }); + }, + ); + + return app; +} diff --git a/apps/playground/src/server/index.ts b/apps/playground/src/server/index.ts new file mode 100644 index 0000000..db849b1 --- /dev/null +++ b/apps/playground/src/server/index.ts @@ -0,0 +1,35 @@ +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { createApp } from './app'; +import { resolveModels } from './models'; + +// Load a local .env if present; in deployment the vars come from the real env. +try { + process.loadEnvFile('.env'); +} catch { + // No .env file — fall back to the ambient environment. +} + +const models = resolveModels(); +const app = createApp({ models }); + +// In production the Hono server also serves the built client (same origin as +// the API). In dev the Vite server serves the client and proxies /api here. +if (process.env['NODE_ENV'] === 'production') { + app.use('/*', serveStatic({ root: './dist/client' })); + app.get('*', serveStatic({ path: './dist/client/index.html' })); +} + +const port = Number(process.env['PORT'] ?? 8787); + +serve({ fetch: app.fetch, port }, (info) => { + console.log(`extractkit playground → http://localhost:${info.port}`); + if (models.length === 0) { + console.warn( + 'No provider API key found. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or ' + + 'GOOGLE_GENERATIVE_AI_API_KEY to enable extraction.', + ); + } else { + console.log(`Models available: ${models.map((m) => m.id).join(', ')}`); + } +}); diff --git a/apps/playground/src/server/models.ts b/apps/playground/src/server/models.ts new file mode 100644 index 0000000..ff5e02d --- /dev/null +++ b/apps/playground/src/server/models.ts @@ -0,0 +1,106 @@ +import { anthropic } from '@ai-sdk/anthropic'; +import { google } from '@ai-sdk/google'; +import { openai } from '@ai-sdk/openai'; +import type { LanguageModel } from 'ai'; +import type { Pricing } from 'extractkit'; +import type { ModelInfo } from '../shared/api'; + +/** A model the playground can run, plus how to build it and price it. */ +export interface PlaygroundModel { + id: string; + label: string; + provider: string; + pricing: Pricing; + create: () => LanguageModel; +} + +interface CatalogEntry { + /** Provider model id, also used as the client-facing id. */ + id: string; + label: string; + provider: string; + /** Env var the AI SDK provider reads for its key; gates availability. */ + apiKeyEnv: string; + create: (id: string) => LanguageModel; + pricing: Pricing; +} + +/** + * A demo-sized lineup: a strong and a cost-efficient model per provider, all + * vision-capable. Pricing is the public per-MTok list price (2026-07), matching + * the eval harness catalog, so the cost the playground shows is real. + */ +const CATALOG: CatalogEntry[] = [ + { + id: 'claude-sonnet-5', + label: 'Claude Sonnet 5', + provider: 'anthropic', + apiKeyEnv: 'ANTHROPIC_API_KEY', + create: (id) => anthropic(id), + pricing: { inputPerMTokUSD: 3, outputPerMTokUSD: 15 }, + }, + { + id: 'claude-haiku-4-5', + label: 'Claude Haiku 4.5', + provider: 'anthropic', + apiKeyEnv: 'ANTHROPIC_API_KEY', + create: (id) => anthropic(id), + pricing: { inputPerMTokUSD: 1, outputPerMTokUSD: 5 }, + }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6 Luna', + provider: 'openai', + apiKeyEnv: 'OPENAI_API_KEY', + create: (id) => openai(id), + pricing: { inputPerMTokUSD: 1, outputPerMTokUSD: 6 }, + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + provider: 'openai', + apiKeyEnv: 'OPENAI_API_KEY', + create: (id) => openai(id), + pricing: { inputPerMTokUSD: 0.75, outputPerMTokUSD: 4.5 }, + }, + { + id: 'gemini-2.5-flash', + label: 'Gemini 2.5 Flash', + provider: 'google', + apiKeyEnv: 'GOOGLE_GENERATIVE_AI_API_KEY', + create: (id) => google(id), + pricing: { inputPerMTokUSD: 0.3, outputPerMTokUSD: 2.5 }, + }, + { + id: 'gemini-2.5-flash-lite', + label: 'Gemini 2.5 Flash-Lite', + provider: 'google', + apiKeyEnv: 'GOOGLE_GENERATIVE_AI_API_KEY', + create: (id) => google(id), + pricing: { inputPerMTokUSD: 0.1, outputPerMTokUSD: 0.4 }, + }, +]; + +/** The models runnable in this environment: those whose provider key is set. */ +export function resolveModels(env: NodeJS.ProcessEnv = process.env): PlaygroundModel[] { + return CATALOG.filter((entry) => { + const key = env[entry.apiKeyEnv]; + return key !== undefined && key !== ''; + }).map((entry) => ({ + id: entry.id, + label: entry.label, + provider: entry.provider, + pricing: entry.pricing, + create: () => entry.create(entry.id), + })); +} + +export function modelInfos(models: PlaygroundModel[]): ModelInfo[] { + return models.map((m) => ({ id: m.id, label: m.label, provider: m.provider })); +} + +/** The model to preselect: prefer Anthropic Sonnet, else the first available. */ +export function defaultModelId(models: PlaygroundModel[]): string | null { + const preferred = models.find((m) => m.id === 'claude-sonnet-5'); + return preferred?.id ?? models[0]?.id ?? null; +} diff --git a/apps/playground/src/server/schemas.ts b/apps/playground/src/server/schemas.ts new file mode 100644 index 0000000..ffbc301 --- /dev/null +++ b/apps/playground/src/server/schemas.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import type { SchemaInfo } from '../shared/api'; + +/** + * Amounts are extracted as strings exactly as printed. Business documents mix + * "." and "," as decimal/thousands separators, so parsing them to numbers would + * test locale guessing rather than extraction — and a parsed number no longer + * maps to a region of the page, which breaks the provenance guarantee. Callers + * parse after extraction. + */ +const invoiceSchema = z.object({ + vendorName: z.string().nullable().describe('Legal name of the party issuing the invoice'), + invoiceNumber: z.string().nullable().describe('Invoice identifier assigned by the vendor'), + issueDate: z.string().nullable().describe('Date the invoice was issued, as printed'), + dueDate: z.string().nullable().describe('Payment due date, as printed'), + currency: z.string().nullable().describe('Currency code or symbol used for the amounts, e.g. "USD" or "€"'), + subtotal: z.string().nullable().describe('Total before tax, as printed'), + tax: z.string().nullable().describe('Total tax amount, as printed'), + total: z.string().describe('Total amount due, as printed'), + lineItems: z + .array( + z.object({ + description: z.string().describe('Line item description as printed'), + quantity: z.string().nullable().describe('Quantity as printed'), + unitPrice: z.string().nullable().describe('Per-unit price as printed'), + amount: z.string().nullable().describe('Line total as printed'), + }), + ) + .describe('Every line item in printed order'), +}); + +const receiptSchema = z.object({ + merchant: z.string().nullable().describe('Store or merchant name as printed'), + date: z.string().nullable().describe('Transaction date, as printed'), + lineItems: z + .array( + z.object({ + description: z.string().describe('Item name as printed, e.g. "ICE BLACKCOFFE"'), + quantity: z.string().nullable().describe('Quantity as printed, e.g. "2" or "1X"'), + unitPrice: z.string().nullable().describe('Per-unit price as printed, e.g. "@11000"'), + amount: z.string().nullable().describe('Line total as printed, e.g. "24,000"'), + }), + ) + .describe('Every purchased item in printed order, including sub-items listed under another item'), + subtotal: z.string().nullable().describe('Subtotal before tax/discount/service, as printed'), + tax: z.string().nullable().describe('Tax amount as printed'), + total: z.string().describe('Final charged total as printed'), +}); + +interface Preset { + schema: z.ZodObject; + label: string; + description: string; +} + +/** + * The playground offers a fixed set of business-document schemas rather than + * accepting arbitrary Zod from the browser — the schema is the library's public + * API surface, and running caller-supplied code server-side is out of scope. + */ +const PRESETS: Record = { + invoice: { + schema: invoiceSchema, + label: 'Invoice', + description: 'Vendor, dates, currency, totals, and line items from an invoice.', + }, + receipt: { + schema: receiptSchema, + label: 'Receipt', + description: 'Merchant, date, line items, and totals from a store receipt.', + }, +}; + +export const DEFAULT_SCHEMA_ID = 'invoice'; + +export function getPreset(id: string): Preset | undefined { + return PRESETS[id]; +} + +export function schemaInfos(): SchemaInfo[] { + return Object.entries(PRESETS).map(([id, preset]) => ({ + id, + label: preset.label, + description: preset.description, + topLevelFields: Object.keys(preset.schema.shape), + })); +} diff --git a/apps/playground/src/shared/api.ts b/apps/playground/src/shared/api.ts new file mode 100644 index 0000000..2e3d4c6 --- /dev/null +++ b/apps/playground/src/shared/api.ts @@ -0,0 +1,62 @@ +import type { ExtractedField, ExtractUsage, FieldPath } from 'extractkit'; + +/** A single extracted leaf with its provenance, as sent over the wire. */ +export type WireField = ExtractedField; + +/** A preset extraction schema the playground offers. */ +export interface SchemaInfo { + id: string; + label: string; + description: string; + /** Top-level field names — a quick preview of what the schema pulls out. */ + topLevelFields: string[]; +} + +/** A model the server can run, resolved from the API keys present in its env. */ +export interface ModelInfo { + id: string; + label: string; + provider: string; +} + +/** Everything the client needs to render its controls before any extraction. */ +export interface ConfigResponse { + schemas: SchemaInfo[]; + models: ModelInfo[]; + /** Ids the client should select initially; `model` is null when no key is set. */ + defaults: { schema: string; model: string | null }; +} + +/** + * An extract result with the Zod generic erased for JSON transport. `data` + * mirrors the schema; `fields` mirrors it with every leaf replaced by a + * WireField (extractkit's FieldMap). + */ +export interface SerializedResult { + data: unknown; + fields: unknown; + issues: string[]; + usage: ExtractUsage; + pages: number; +} + +/** A failed extraction, mapped from an extractkit or provider error. */ +export interface ApiError { + /** Error class name, e.g. "DocumentError" or "MissingRequiredFieldsError". */ + name: string; + /** extractkit error code when present; null for provider/transport errors. */ + code: string | null; + message: string; + /** Set on MissingRequiredFieldsError. */ + missingPaths?: string[]; +} + +/** + * SSE payloads streamed from POST /api/extract. Each event's `type` is also its + * SSE `event:` name. Fields arrive as they complete; then exactly one terminal + * `result` or `error`. + */ +export type ExtractEvent = + | { type: 'field'; path: FieldPath; field: WireField } + | { type: 'result'; result: SerializedResult } + | { type: 'error'; error: ApiError }; diff --git a/apps/playground/test/app.test.ts b/apps/playground/test/app.test.ts new file mode 100644 index 0000000..0330d4f --- /dev/null +++ b/apps/playground/test/app.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import type { ConfigResponse, ExtractEvent } from '../src/shared/api'; +import { createApp } from '../src/server/app'; +import { readSSE } from '../src/client/lib/sse'; +import { invoiceEnvelope, mockPlaygroundModel, tinyPng, unreadableEnvelope } from './helpers'; + +function appWith(envelope: string) { + return createApp({ models: [mockPlaygroundModel('mock-model', envelope)] }); +} + +function extractForm(opts: { file?: Blob; schema?: string; model?: string }): FormData { + const form = new FormData(); + if (opts.file !== undefined) form.set('file', opts.file, 'doc.png'); + if (opts.schema !== undefined) form.set('schema', opts.schema); + if (opts.model !== undefined) form.set('model', opts.model); + return form; +} + +function pngFile(): File { + return new File([tinyPng()], 'doc.png', { type: 'image/png' }); +} + +async function collectEvents(body: ReadableStream): Promise { + const events: ExtractEvent[] = []; + for await (const msg of readSSE(body)) { + events.push(JSON.parse(msg.data) as ExtractEvent); + } + return events; +} + +describe('GET /api/config', () => { + it('returns preset schemas, injected models, and defaults', async () => { + const app = appWith(invoiceEnvelope()); + const res = await app.request('/api/config'); + expect(res.status).toBe(200); + const body = (await res.json()) as ConfigResponse; + + expect(body.schemas.map((s) => s.id)).toEqual(['invoice', 'receipt']); + const invoice = body.schemas.find((s) => s.id === 'invoice'); + expect(invoice?.topLevelFields).toContain('total'); + expect(body.models).toEqual([{ id: 'mock-model', label: 'mock-model', provider: 'mock' }]); + expect(body.defaults).toEqual({ schema: 'invoice', model: 'mock-model' }); + }); + + it('reports a null default model when no keys are set', async () => { + const app = createApp({ models: [] }); + const body = (await (await app.request('/api/config')).json()) as ConfigResponse; + expect(body.models).toEqual([]); + expect(body.defaults.model).toBeNull(); + }); +}); + +describe('POST /api/extract', () => { + it('streams field events then a validated result', async () => { + const app = appWith(invoiceEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + + const events = await collectEvents(res.body as ReadableStream); + const fields = events.filter((e) => e.type === 'field'); + const terminal = events.at(-1); + + expect(fields.length).toBeGreaterThan(0); + expect(terminal?.type).toBe('result'); + if (terminal?.type !== 'result') throw new Error('expected a result event'); + + const data = terminal.result.data as { total: string; vendorName: string; lineItems: unknown[] }; + expect(data.total).toBe('1,250.50'); + expect(data.vendorName).toBe('Acme Corp'); + expect(data.lineItems).toHaveLength(1); + expect(terminal.result.pages).toBe(1); + // pricing was supplied, so cost is computed from the mock's token usage. + expect(terminal.result.usage.costUSD).toBeCloseTo((800 * 3 + 200 * 15) / 1_000_000, 10); + }); + + it('carries per-field provenance through to the result', async () => { + const app = appWith(invoiceEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }), + }); + const events = await collectEvents(res.body as ReadableStream); + const terminal = events.at(-1); + if (terminal?.type !== 'result') throw new Error('expected a result event'); + + const fields = terminal.result.fields as { total: { page: number; bbox: { x0: number } } }; + expect(fields.total.page).toBe(0); + expect(fields.total.bbox.x0).toBeCloseTo(0.7, 10); + }); + + it('emits an error event when the model reports the document unreadable', async () => { + const app = appWith(unreadableEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ file: pngFile(), schema: 'invoice', model: 'mock-model' }), + }); + expect(res.status).toBe(200); + const events = await collectEvents(res.body as ReadableStream); + const terminal = events.at(-1); + if (terminal?.type !== 'error') throw new Error('expected an error event'); + expect(terminal.error.code).toBe('DOCUMENT_UNREADABLE'); + }); + + it('rejects an unknown schema id with 400', async () => { + const app = appWith(invoiceEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ file: pngFile(), schema: 'nope', model: 'mock-model' }), + }); + expect(res.status).toBe(400); + }); + + it('rejects an unknown model id with 400', async () => { + const app = appWith(invoiceEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ file: pngFile(), schema: 'invoice', model: 'ghost' }), + }); + expect(res.status).toBe(400); + }); + + it('rejects a request with no file with 400', async () => { + const app = appWith(invoiceEnvelope()); + const res = await app.request('/api/extract', { + method: 'POST', + body: extractForm({ schema: 'invoice', model: 'mock-model' }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/playground/test/fields.test.ts b/apps/playground/test/fields.test.ts new file mode 100644 index 0000000..9e4f531 --- /dev/null +++ b/apps/playground/test/fields.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + flattenFields, + formatPath, + formatValue, + isField, + leafLabel, + pathKey, + valueAtPath, +} from '../src/client/lib/fields'; + +const leaf = (value: unknown) => ({ value, confidence: 0.9, page: 0, bbox: null }); + +describe('isField', () => { + it('recognizes a provenance leaf', () => { + expect(isField(leaf('x'))).toBe(true); + expect(isField(leaf(null))).toBe(true); + }); + + it('rejects branches and primitives', () => { + expect(isField({ total: leaf('x') })).toBe(false); + expect(isField(null)).toBe(false); + expect(isField('x')).toBe(false); + expect(isField({ value: 1, confidence: 'high', page: 0, bbox: null })).toBe(false); + }); +}); + +describe('flattenFields', () => { + it('walks objects and arrays into leaf paths', () => { + const fields = { + total: leaf('10'), + lineItems: [{ description: leaf('a') }, { description: leaf('b') }], + }; + const flat = flattenFields(fields); + expect(flat.map((f) => f.path)).toEqual([ + ['total'], + ['lineItems', 0, 'description'], + ['lineItems', 1, 'description'], + ]); + expect(flat[0]?.field.value).toBe('10'); + }); + + it('skips null branches', () => { + expect(flattenFields({ a: null, b: leaf('x') })).toHaveLength(1); + }); +}); + +describe('valueAtPath', () => { + const data = { total: '10', lineItems: [{ description: 'a' }] }; + it('reads nested values', () => { + expect(valueAtPath(data, ['total'])).toBe('10'); + expect(valueAtPath(data, ['lineItems', 0, 'description'])).toBe('a'); + }); + it('returns undefined off the end of the tree', () => { + expect(valueAtPath(data, ['lineItems', 5, 'description'])).toBeUndefined(); + expect(valueAtPath(null, ['total'])).toBeUndefined(); + }); +}); + +describe('path and value formatting', () => { + it('formats paths with dotted keys and bracketed indices', () => { + expect(formatPath(['lineItems', 0, 'amount'])).toBe('lineItems[0].amount'); + expect(formatPath(['total'])).toBe('total'); + expect(formatPath([])).toBe('(root)'); + }); + it('labels a leaf by its last segment', () => { + expect(leafLabel(['lineItems', 2, 'amount'])).toBe('amount'); + expect(leafLabel(['lineItems', 2])).toBe('[2]'); + }); + it('renders values, with a dash for null', () => { + expect(formatValue('USD')).toBe('USD'); + expect(formatValue(null)).toBe('—'); + expect(formatValue('')).toBe('(empty)'); + expect(formatValue(false)).toBe('false'); + }); + it('keys a path stably', () => { + expect(pathKey(['lineItems', 0])).toBe('["lineItems",0]'); + }); +}); diff --git a/apps/playground/test/geometry.test.ts b/apps/playground/test/geometry.test.ts new file mode 100644 index 0000000..3982784 --- /dev/null +++ b/apps/playground/test/geometry.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { bboxToStyle, fitScale } from '../src/client/lib/geometry'; + +describe('bboxToStyle', () => { + it('maps a normalized box to percentage offsets and size', () => { + expect(bboxToStyle({ x0: 0.1, y0: 0.2, x1: 0.5, y1: 0.35 })).toEqual({ + left: '10.000%', + top: '20.000%', + width: '40.000%', + height: '15.000%', + }); + }); + + it('orders swapped corners into a valid rectangle', () => { + expect(bboxToStyle({ x0: 0.5, y0: 0.35, x1: 0.1, y1: 0.2 })).toEqual({ + left: '10.000%', + top: '20.000%', + width: '40.000%', + height: '15.000%', + }); + }); + + it('clamps out-of-range coordinates to [0, 1]', () => { + expect(bboxToStyle({ x0: -0.2, y0: 0, x1: 1.4, y1: 1 })).toEqual({ + left: '0.000%', + top: '0.000%', + width: '100.000%', + height: '100.000%', + }); + }); +}); + +describe('fitScale', () => { + it('scales down to fit and never upscales past 1×', () => { + expect(fitScale(1000, 500)).toBe(0.5); + expect(fitScale(400, 800)).toBe(1); + }); + + it('is safe for degenerate inputs', () => { + expect(fitScale(0, 500)).toBe(1); + expect(fitScale(500, 0)).toBe(1); + }); +}); diff --git a/apps/playground/test/helpers.ts b/apps/playground/test/helpers.ts new file mode 100644 index 0000000..b891355 --- /dev/null +++ b/apps/playground/test/helpers.ts @@ -0,0 +1,98 @@ +import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; +import type { LanguageModel } from 'ai'; +import type { Pricing } from 'extractkit'; +import type { PlaygroundModel } from '../src/server/models'; + +/** A 12-byte buffer with a valid PNG signature — enough for normalizeDocument. */ +export function tinyPng(): Uint8Array { + return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]); +} + +function splitEvery(text: string, size: number): string[] { + const parts: string[] = []; + for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size)); + return parts; +} + +/** A mock model that streams `text` back in small chunks as one response. */ +export function streamingModel(text: string): LanguageModel { + return new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'text-start' as const, id: '1' }, + ...splitEvery(text, 24).map((delta) => ({ type: 'text-delta' as const, id: '1', delta })), + { type: 'text-end' as const, id: '1' }, + { + type: 'finish' as const, + finishReason: { unified: 'stop' as const, raw: undefined }, + usage: { + inputTokens: { total: 800, noCache: 800, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 200, text: 200, reasoning: undefined }, + }, + }, + ], + }), + }), + }); +} + +/** Wire-format leaf with plausible provenance defaults (bbox is a 4-number array). */ +export function wireLeaf(value: unknown, over: Record = {}) { + const missing = value === null || value === undefined; + return { + value, + page: missing ? null : 0, + bbox: missing ? null : [0.1, 0.2, 0.4, 0.26], + confidence: missing ? 0 : 0.94, + ...over, + }; +} + +/** Valid wire `fields` for the invoice preset schema (every leaf present). */ +function invoiceFields() { + return { + vendorName: wireLeaf('Acme Corp', { bbox: [0.08, 0.05, 0.4, 0.09] }), + invoiceNumber: wireLeaf('INV-2026-014', { bbox: [0.62, 0.05, 0.92, 0.09] }), + issueDate: wireLeaf('2026-07-01'), + dueDate: wireLeaf('2026-07-31'), + currency: wireLeaf('USD'), + subtotal: wireLeaf('1,200.00'), + tax: wireLeaf('50.50'), + total: wireLeaf('1,250.50', { bbox: [0.7, 0.8, 0.92, 0.84], confidence: 0.99 }), + lineItems: [ + { + description: wireLeaf('Consulting services', { bbox: [0.08, 0.4, 0.5, 0.44] }), + quantity: wireLeaf('10'), + unitPrice: wireLeaf('120.00'), + amount: wireLeaf('1,200.00'), + }, + ], + }; +} + +/** A complete, valid wire envelope for the invoice preset schema. */ +export function invoiceEnvelope(): string { + return JSON.stringify({ readable: true, issues: [], fields: invoiceFields() }); +} + +/** + * An "unreadable document" envelope: the wire schema still requires `fields`, so + * they are present and valid — only the `readable` flag drives the error. + */ +export function unreadableEnvelope(): string { + return JSON.stringify({ readable: false, issues: ['Page is blank.'], fields: invoiceFields() }); +} + +const DEMO_PRICING: Pricing = { inputPerMTokUSD: 3, outputPerMTokUSD: 15 }; + +/** A PlaygroundModel backed by a mock that streams `envelope`. */ +export function mockPlaygroundModel(id: string, envelope: string): PlaygroundModel { + return { + id, + label: id, + provider: 'mock', + pricing: DEMO_PRICING, + create: () => streamingModel(envelope), + }; +} diff --git a/apps/playground/test/models.test.ts b/apps/playground/test/models.test.ts new file mode 100644 index 0000000..f01e434 --- /dev/null +++ b/apps/playground/test/models.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { defaultModelId, modelInfos, resolveModels } from '../src/server/models'; + +describe('resolveModels', () => { + it('offers only models whose provider key is set', () => { + const models = resolveModels({ OPENAI_API_KEY: 'sk-test' }); + expect(models.length).toBeGreaterThan(0); + expect(models.every((m) => m.provider === 'openai')).toBe(true); + }); + + it('treats an empty-string key as unset', () => { + expect(resolveModels({ ANTHROPIC_API_KEY: '' })).toEqual([]); + }); + + it('combines providers when several keys are present', () => { + const providers = new Set( + resolveModels({ ANTHROPIC_API_KEY: 'a', GOOGLE_GENERATIVE_AI_API_KEY: 'g' }).map((m) => m.provider), + ); + expect(providers).toEqual(new Set(['anthropic', 'google'])); + }); + + it('returns nothing when no key is present', () => { + expect(resolveModels({})).toEqual([]); + }); +}); + +describe('defaultModelId', () => { + it('prefers Claude Sonnet when available', () => { + expect(defaultModelId(resolveModels({ ANTHROPIC_API_KEY: 'a' }))).toBe('claude-sonnet-5'); + }); + it('falls back to the first available model', () => { + expect(defaultModelId(resolveModels({ OPENAI_API_KEY: 'o' }))).toBe('gpt-5.6-luna'); + }); + it('is null when there are no models', () => { + expect(defaultModelId([])).toBeNull(); + }); +}); + +describe('modelInfos', () => { + it('exposes only id, label, and provider', () => { + const infos = modelInfos(resolveModels({ ANTHROPIC_API_KEY: 'a' })); + expect(infos[0]).toEqual({ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'anthropic' }); + expect(Object.keys(infos[0] ?? {})).toEqual(['id', 'label', 'provider']); + }); +}); diff --git a/apps/playground/test/schemas.test.ts b/apps/playground/test/schemas.test.ts new file mode 100644 index 0000000..9204342 --- /dev/null +++ b/apps/playground/test/schemas.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCHEMA_ID, getPreset, schemaInfos } from '../src/server/schemas'; + +describe('schemaInfos', () => { + it('lists the invoice and receipt presets with their top-level fields', () => { + const infos = schemaInfos(); + expect(infos.map((s) => s.id)).toEqual(['invoice', 'receipt']); + const invoice = infos.find((s) => s.id === 'invoice'); + expect(invoice?.topLevelFields).toEqual([ + 'vendorName', + 'invoiceNumber', + 'issueDate', + 'dueDate', + 'currency', + 'subtotal', + 'tax', + 'total', + 'lineItems', + ]); + }); +}); + +describe('getPreset', () => { + it('returns a parseable Zod schema for a known id', () => { + const preset = getPreset('receipt'); + expect(preset).toBeDefined(); + const parsed = preset?.schema.safeParse({ + merchant: 'Cafe', + date: null, + lineItems: [{ description: 'Coffee', quantity: '1', unitPrice: '3.00', amount: '3.00' }], + subtotal: '3.00', + tax: '0.00', + total: '3.00', + }); + expect(parsed?.success).toBe(true); + }); + + it('is undefined for an unknown id', () => { + expect(getPreset('contract')).toBeUndefined(); + }); + + it('names a valid default schema', () => { + expect(getPreset(DEFAULT_SCHEMA_ID)).toBeDefined(); + }); +}); diff --git a/apps/playground/test/sse.test.ts b/apps/playground/test/sse.test.ts new file mode 100644 index 0000000..b33a7a3 --- /dev/null +++ b/apps/playground/test/sse.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { readSSE } from '../src/client/lib/sse'; + +function streamFrom(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); +} + +async function collect(chunks: string[]) { + const out: Array<{ event: string; data: string }> = []; + for await (const message of readSSE(streamFrom(chunks))) out.push(message); + return out; +} + +describe('readSSE', () => { + it('parses event and data fields', async () => { + const out = await collect(['event: field\ndata: {"a":1}\n\n', 'event: result\ndata: {"b":2}\n\n']); + expect(out).toEqual([ + { event: 'field', data: '{"a":1}' }, + { event: 'result', data: '{"b":2}' }, + ]); + }); + + it('reassembles records split across chunk boundaries', async () => { + const out = await collect(['event: fie', 'ld\ndata: {"a"', ':1}\n\nevent: result\ndata:{"b":2}\n\n']); + expect(out.map((m) => m.event)).toEqual(['field', 'result']); + expect(out[0]?.data).toBe('{"a":1}'); + expect(out[1]?.data).toBe('{"b":2}'); + }); + + it('defaults the event name and ignores comments and blank lines', async () => { + const out = await collect([': keep-alive\ndata: hello\n\n']); + expect(out).toEqual([{ event: 'message', data: 'hello' }]); + }); + + it('joins multi-line data with newlines', async () => { + const out = await collect(['data: line1\ndata: line2\n\n']); + expect(out[0]?.data).toBe('line1\nline2'); + }); + + it('handles CRLF line endings across multiple records', async () => { + const out = await collect(['event: field\r\ndata: {"a":1}\r\n\r\nevent: result\r\ndata: {"b":2}\r\n\r\n']); + expect(out).toEqual([ + { event: 'field', data: '{"a":1}' }, + { event: 'result', data: '{"b":2}' }, + ]); + }); + + it('handles a CRLF pair split across chunk boundaries', async () => { + const out = await collect(['event: field\r\ndata: {"a":1}\r', '\n\r\nevent: result\r\ndata: {"b":2}\r\n\r\n']); + expect(out.map((m) => m.event)).toEqual(['field', 'result']); + }); + + it('emits a trailing record with no final blank line', async () => { + const out = await collect(['event: result\ndata: done']); + expect(out).toEqual([{ event: 'result', data: 'done' }]); + }); +}); diff --git a/apps/playground/test/upload.test.ts b/apps/playground/test/upload.test.ts new file mode 100644 index 0000000..af98654 --- /dev/null +++ b/apps/playground/test/upload.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_UPLOAD_BYTES, isPdf, pickFile, validateFile } from '../src/client/lib/upload'; + +function file(name: string, type: string, size = 100): File { + const f = new File([new Uint8Array(4)], name, { type }); + Object.defineProperty(f, 'size', { value: size }); + return f; +} + +describe('validateFile', () => { + it('accepts supported documents', () => { + expect(validateFile(file('invoice.pdf', 'application/pdf'))).toBeNull(); + expect(validateFile(file('receipt.PNG', 'image/png'))).toBeNull(); + expect(validateFile(file('scan.jpg', 'image/jpeg'))).toBeNull(); + }); + + it('accepts by extension when the browser reports no type', () => { + expect(validateFile(file('receipt.webp', ''))).toBeNull(); + }); + + it('rejects unsupported types', () => { + expect(validateFile(file('notes.txt', 'text/plain'))).toMatch(/unsupported/i); + expect(validateFile(file('archive.zip', ''))).toMatch(/unsupported/i); + }); + + it('rejects files over the size limit', () => { + expect(validateFile(file('big.pdf', 'application/pdf', MAX_UPLOAD_BYTES + 1))).toMatch(/15 MB/); + }); +}); + +describe('isPdf', () => { + it('detects PDFs by type or extension', () => { + expect(isPdf(file('a.pdf', 'application/pdf'))).toBe(true); + expect(isPdf(file('a.PDF', ''))).toBe(true); + expect(isPdf(file('a.png', 'image/png'))).toBe(false); + }); +}); + +describe('pickFile', () => { + it('returns the first acceptable file', () => { + const result = pickFile([file('a.pdf', 'application/pdf')]); + expect('file' in result).toBe(true); + }); + + it('reports an error for an empty selection', () => { + expect(pickFile(null)).toEqual({ error: 'No file selected.' }); + expect(pickFile([])).toEqual({ error: 'No file selected.' }); + }); + + it('reports the validation error for an unsupported file', () => { + const result = pickFile([file('a.txt', 'text/plain')]); + expect('error' in result).toBe(true); + }); +}); diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json new file mode 100644 index 0000000..32808f2 --- /dev/null +++ b/apps/playground/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src/client", "src/shared"] +} diff --git a/apps/playground/tsconfig.node.json b/apps/playground/tsconfig.node.json new file mode 100644 index 0000000..a3e22db --- /dev/null +++ b/apps/playground/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/server", "src/shared", "test", "vite.config.ts", "vitest.config.ts"] +} diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts new file mode 100644 index 0000000..d9e996e --- /dev/null +++ b/apps/playground/vite.config.ts @@ -0,0 +1,23 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig, loadEnv } from 'vite'; + +// The client is a static SPA served by Vite in dev and by the Hono server in +// prod. In dev, /api is proxied to the Hono server so the browser sees one +// origin. PORT is read from the same .env the server loads, so they stay in sync. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const apiPort = env['PORT'] ?? '8787'; + return { + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': `http://localhost:${apiPort}`, + }, + }, + build: { + outDir: 'dist/client', + emptyOutDir: true, + }, + }; +}); diff --git a/apps/playground/vitest.config.ts b/apps/playground/vitest.config.ts new file mode 100644 index 0000000..43e56f4 --- /dev/null +++ b/apps/playground/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10c6be..7000eab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,70 @@ importers: .: {} + apps/playground: + dependencies: + '@ai-sdk/anthropic': + specifier: ^4.0.10 + version: 4.0.10(zod@4.4.3) + '@ai-sdk/google': + specifier: ^4.0.12 + version: 4.0.12(zod@4.4.3) + '@ai-sdk/openai': + specifier: ^4.0.11 + version: 4.0.11(zod@4.4.3) + '@hono/node-server': + specifier: ^2.0.8 + version: 2.0.8(hono@4.12.29) + ai: + specifier: ^7.0.16 + version: 7.0.16(zod@4.4.3) + extractkit: + specifier: workspace:* + version: link:../../packages/core + hono: + specifier: ^4.12.29 + version: 4.12.29 + pdfjs-dist: + specifier: ^6.1.200 + version: 6.1.200 + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) + concurrently: + specifier: ^10.0.3 + version: 10.0.3 + tsx: + specifier: ^4.23.0 + version: 4.23.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) + packages/core: dependencies: pdf-lib: @@ -28,7 +92,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) + version: 4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -68,7 +132,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) + version: 4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) packages: @@ -308,6 +372,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@2.0.8': + resolution: {integrity: sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -321,6 +391,76 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/canvas-android-arm64@1.0.2': + resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.2': + resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.2': + resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.2': + resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -452,10 +592,31 @@ packages: '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@vercel/oidc@3.2.0': resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -494,6 +655,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -517,9 +686,25 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + concurrently@10.0.3: + resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==} + engines: {node: '>=22'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -536,6 +721,9 @@ packages: oxc-resolver: optional: true + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -548,6 +736,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -573,10 +765,22 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + hono@4.12.29: + resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -686,6 +890,10 @@ packages: pdf-lib@1.17.1: resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pdfjs-dist@6.1.200: + resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -700,6 +908,15 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -727,11 +944,21 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -745,6 +972,18 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -820,8 +1059,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -909,6 +1148,22 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -1087,6 +1342,10 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hono/node-server@2.0.8(hono@4.12.29)': + dependencies: + hono: 4.12.29 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1101,6 +1360,54 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/canvas-android-arm64@1.0.2': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.2': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + optional: true + + '@napi-rs/canvas@1.0.2': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.2 + '@napi-rs/canvas-darwin-arm64': 1.0.2 + '@napi-rs/canvas-darwin-x64': 1.0.2 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.2 + '@napi-rs/canvas-linux-arm64-musl': 1.0.2 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-musl': 1.0.2 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.2 + '@napi-rs/canvas-win32-x64-msvc': 1.0.2 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -1195,8 +1502,21 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -1206,13 +1526,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))': + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1247,6 +1567,10 @@ snapshots: '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) zod: 4.4.3 + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + ansis@4.3.1: {} assertion-error@2.0.1: {} @@ -1263,14 +1587,35 @@ snapshots: chai@6.2.2: {} + chalk@5.6.2: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + concurrently@10.0.3: + dependencies: + chalk: 5.6.2 + rxjs: 7.8.2 + shell-quote: 1.8.4 + supports-color: 10.2.2 + tree-kill: 1.2.2 + yargs: 18.0.0 + convert-source-map@2.0.0: {} + csstype@3.2.3: {} + defu@6.1.7: {} detect-libc@2.1.2: {} dts-resolver@3.0.0: {} + emoji-regex@10.6.0: {} + empathic@2.0.1: {} es-module-lexer@2.3.0: {} @@ -1304,6 +1649,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1319,10 +1666,16 @@ snapshots: fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 + hono@4.12.29: {} + hookable@6.1.1: {} hyparquet@1.26.2: {} @@ -1401,6 +1754,10 @@ snapshots: pako: 1.0.11 tslib: 1.14.1 + pdfjs-dist@6.1.200: + optionalDependencies: + '@napi-rs/canvas': 1.0.2 + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -1413,6 +1770,13 @@ snapshots: quansync@1.0.0: {} + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.26.0(rolldown@1.1.4)(typescript@6.0.3): @@ -1452,8 +1816,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.4 '@rolldown/binding-win32-x64-msvc': 1.1.4 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + scheduler@0.27.0: {} + semver@7.8.5: {} + shell-quote@1.8.4: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1462,6 +1834,18 @@ snapshots: std-env@4.1.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + supports-color@10.2.2: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -1503,8 +1887,7 @@ snapshots: tslib@1.14.1: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.23.0: dependencies: @@ -1521,7 +1904,7 @@ snapshots: undici-types@8.3.0: {} - vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0): + vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -1534,10 +1917,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.0 - vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)): + vitest@4.1.10(@types/node@26.1.0)(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -1554,7 +1937,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.0)(esbuild@0.28.1)(tsx@4.23.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 @@ -1566,4 +1949,23 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + y18n@5.0.8: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + zod@4.4.3: {}