Draft
Conversation
Phase 1 of P059 (parent repo). Standalone TanStack Start app combining the SimplePDF editor iframe (left) with a chat sidebar (right, currently a placeholder), wired for a form switcher between IRS W-9 (default) and a Dutch form (TODO: swap URL once uploaded) to showcase multilingual support. Iframe points at headless.simplepdf.com/editor?open=<pdf>.
Switches the W-9 to cdn.simplepdf.com/simple-pdf/assets/demo/fw9.pdf and wires the Dutch form to the Belastingdienst Loonheffingen form at cdn.simplepdf.com/simple-pdf/assets/demo/loonheffingen.pdf. Both now live under a stable path we control (see client/assets/demo/ in the parent repo). Removes the TODO placeholder.
Adds src/lib/iframe_bridge.ts implementing the full 11-tool surface over raw window.postMessage with request_id correlation (load_document, go_to, select_tool, detect_fields, remove_fields, get_document_content, get_fields, set_field_value, focus_field, create_field, submit). Listens for EDITOR_READY + REQUEST_RESULT, rejects pending requests on dispose, 30s request timeout. EditorPane forwards the iframe ref; DebugPanel (?debug=1) exposes a button per tool with a last-50 call log so each tool can be exercised without the LLM. Switcher preserves debug across form changes. Phase 2 skips extending @simplepdf/react-embed-pdf — getFields, setFieldValue, focusField, createField are not yet in the public package; the Decision Log and plan risks are updated accordingly.
Adds the LLM-powered side of Form Copilot: - src/server/tools.ts: Zod schemas + system prompt; 6 client-side tools exposed (get_fields, get_document_content, set_field_value, focus_field, go_to_page, submit_download). - src/server/rate_limit.ts: in-memory token bucket (10/hr + 50/day per IP hash), getClientIp reads DO-Connecting-IP / CF-Connecting-IP / X-Forwarded-For / X-Real-IP in order. - src/routes/api/chat.ts: TanStack Start POST handler proxying to Claude Haiku 4.5 via @ai-sdk/anthropic, streams via toUIMessageStreamResponse. Structured 429 with retry-after on limit. ANTHROPIC_API_KEY read server-side only. - ChatPane (useChat + DefaultChatTransport): streaming assistant messages, stop button, markdown via react-markdown (no code highlighter), tool invocation cards, language-aware suggested prompts, onToolCall dispatches to the IframeBridge per tool. - InfoModal + "?" button next to the tagline explaining the human-in-the-loop framing and the BYOS privacy story (S3 / Azure Blob Storage / SharePoint).
- Forms expand from 2 to 4 use cases: Tax (W-9, default), Healthcare (CMS-1500), HR onboarding (Mutual NDA), State bureaucracy (Loonheffingen NL). Header's single switch-link becomes a "Use case" dropdown. - LanguagePicker: searchable select of 25 languages, keyboard navigable. Selection feeds into every /api/chat request as language_label and is spliced into the system prompt. Replaces the per-form bilingual suggested prompts (SuggestedPrompts removed). - InfoModal adds a "Saving time for everyone" section with per-use-case bullets and a PII warning: data typed to the assistant is sent to Anthropic for this demo.
- Language selection now persists as ?lang=<code>; the route's search schema validates it and the ChatPane becomes a controlled view over that URL state. Form switcher preserves lang; language picker preserves form and debug. - Prefilled prompt buttons are back (Help me fill this form / Which fields are still empty? / Explain each field in one sentence) inside SuggestedPrompts, replacing the prose "Try ..." hint in the empty state. - Languages: drop Russian, add Estonian.
Drops the generic TanStack scaffold favicon/logos and points the tab icon at https://simplepdf.com/favicon.ico so the app matches the brand. Removes unused public/{favicon.ico,logo192.png,logo512.png,manifest.json}.
Adds a small footer section to the info modal: - "Powered by the SimplePDF Pro plan" with a link to /pricing. - "Source code on GitHub" pointing at simplepdf-embed/tree/main/examples/pdf-form-copilot. - ghbtns.com GitHub star button for SimplePDF/simplepdf-embed.
Cuts token pressure against Anthropic's per-minute rate limit: - get_fields: drops 'name' when it duplicates field_id and strips empty values (W-9 saved ~50% of field metadata bytes). - get_document_content: caps each page to 900 chars, appends '… [truncated]'. - Server: maxOutputTokens=500 so the assistant can't produce walls of text per turn, maxRetries=2 for transient 429s. Also moves the Pro-plan + source-code banner to the very top of the info modal (was at the bottom), since discovery of paid plan features + OSS status is the first thing to communicate.
- /api/summarize: new TanStack Start POST handler that compresses extracted PDF text into a ~250-word summary using Claude Haiku 4.5. Shares the same in-memory IP rate limit as /api/chat. Respects the requested reply language. - Client: get_document_content tool dispatch now calls /api/summarize when total content exceeds 1500 chars, replacing raw pages with a single summary in the tool output. Falls back to per-page truncation (900 chars) if the summarize call fails. Info modal: - Move the "Heads up" amber warning above the marketing sections so the PII/tool-call disclosure is the first thing after the Pro-plan/GitHub banner. - Healthcare use case body: add "and LLM provider" to reinforce that submissions route straight to the provider's own storage AND chosen LLM, not through SimplePDF's servers.
- Info modal body becomes max-w-4xl and the "Saving time for everyone" section switches to a responsive grid (2 cols sm, 4 cols lg). Adds a fourth use case: Insurance (ACORD applications, claims, endorsements) sitting between Healthcare and State bureaucracy. - Switcher gains a fifth form: "State bureaucracy (scanned PDF)" pointing at a 130 DPI rasterized version of the Loonheffingen form (see parent repo: client/assets/demo/loonheffingen-scanned.pdf). Demonstrates field detection + OCR on forms with no native fields.
Converts /api/chat from the string-form \`system:\` param to two system messages at the head of the messages array, marking the static one with providerOptions.anthropic.cacheControl = ephemeral. Anthropic caches tools + system up to the breakpoint, so the tool schemas (~500 tokens) + base system prompt (~300 tokens) become a cache read from the second turn onwards — exactly the lever that reduces input token cost 50–90% per Anthropic's guidance. The dynamic \`Reply in <Language>\` line stays as a second, uncached system message so language changes don't invalidate the cache. Adds onFinish logging to surface input_tokens / cached_input_tokens in the server log so we can verify the cache is actually hitting.
…DAY) Local development keeps running into the 10/hr + 50/day cap while iterating. Exposes the limits via RATE_LIMIT_PER_HOUR and RATE_LIMIT_PER_DAY with safe parsing; falls back to the production defaults (10 / 50) when unset. .env.example documents the override. The limiter is still in-memory; a dev restart wipes the bucket either way.
Avoids the Ctrl+C + npm run dev dance when hammering the demo locally.
404s in production (NODE_ENV === 'production'). Usage:
curl -X POST http://localhost:3001/api/rate-limit-reset
Returns { cleared: <count> } with the number of IP buckets wiped.
…le use cases Reshapes the assistant's flow per the product spec: - System prompt now prescribes: always parallel get_fields + get_document_content first; if 0 fields, call detect_fields; if still 0, call select_tool(TEXT) and guide the user to add fields manually; if labels are opaque paths, infer meaning from get_document_content. - Re-exposes select_tool and detect_fields to the LLM (in addition to the existing 6 client-side tools) with Zod schemas. - New Toolbar below the chat header (cursor / TEXT / CHECKBOX / SIGNATURE / PICTURE). Clicking a tool drives bridge.selectTool; when the LLM calls select_tool, the toolbar mirrors it so the human and the copilot share the same state. - While a non-cursor tool is active, the chat polls bridge.getFields every 500ms; on an increase it auto-sends a "N new field(s) were just added" user message so the copilot keeps guiding the user without needing a manual trigger. - Info modal's use-case cards become clickable (Healthcare, State bureaucracy, HR onboarding each map to a form). Insurance stays as a non-clickable teaser. Footer copy updated to "Click on any of the use cases above to switch forms."
… default Two behaviour changes: 1. No technical language. The assistant never mentions tool names, field ids, paths, APIs, or "the editor". It speaks about "the form" and its fields only. Replies stay brief and polite, no filler. 2. Fill by default, ask as last resort. The assistant now calls focus_field + set_field_value on anything it can infer, and only asks the user when the field is a SIGNATURE / PICTURE (requires a human gesture) or when it genuinely lacks the data (personal details the user must provide). Flow rules (parallel get_fields + get_document_content, detect_fields fallback, manual-add via select_tool(TEXT), opaque ids resolved via get_document_content) are preserved but reworded in user-facing terms.
Two extra rules:
- No meta commentary. The assistant never narrates what it is about
to do ('I'll look at the form', 'Let me check...', 'Now I'll...').
It just does the work. It also never recaps the form layout or
lists sections up front — the user wants the form filled, not a
tour.
- One question at a time. When the assistant needs data from the
user, it asks for exactly one piece of information (tied to the
current field) and waits for the answer before moving on.
Explicit good vs. bad example in the prompt.
Latency diagnosis: - Skip the /api/summarize sub-agent by default (useSummarizer: false in dispatch). Truncate pages to 900 chars each instead. The summarize sub-agent was adding a full Anthropic round-trip (~1–2s) on every first turn; saving it buys back that latency. - Keep the summarizer wired (with in-memory cache keyed by language + document name + content length) so we can flip it back on later by flipping useSummarizer to true. Timing instrumentation: - Client: log per-tool dispatch duration, turn start, first-token latency (submitted → streaming), and total turn duration (submitted → ready). All prefixed with [copilot] so they're easy to grep. - Server: onFinish now includes elapsed_ms alongside token usage so we can compare client-perceived vs server-side time to separate network from model latency.
Two behaviour changes reported as broken:
1. The model was narrating every step ('Great! I detected 13 fields',
'Now I'll fill in your BSN'). The prompt now lists forbidden
openers explicitly ('Great!', 'Perfect!', 'Now I'll...', etc.) and
bans announcing field counts, progress, or form recap.
2. After a successful set_field_value the model was stopping instead
of moving on. The filling loop now states explicitly: IMMEDIATELY
focus_field + set_field_value (or ask one question) for the NEXT
field, no 'Done!' or 'Now I'll move on' messages in between.
These rules are strict and use clear forbidden phrases to push the
model past its default chatty prior.
…d scoping
Two fixes against observed behaviour:
1. Text between tool calls. Haiku kept narrating ('I'll check the
form for you', 'Let me pull that:'). The prompt now states: the
assistant emits text ONLY when (a) asking the user for data or
(b) confirming completion. Every other turn is tool calls only,
with NO accompanying text — including before the first tool call.
A worked example at the end shows the exact shape.
2. focus_field scoping. focus_field was being called before every
set_field_value, doubling round-trips per field. It is now
reserved for (a) SIGNATURE / PICTURE fields (user must act) and
(b) when the user explicitly says they want to type the value
themselves. For values the assistant knows, it goes straight to
set_field_value.
48s first-token observed in the wild. Root cause: maxRetries=2 means the AI SDK silently retries twice against Anthropic's 10k-TPM org cap, each retry honouring the retry-after header. The UI was frozen the whole time. - Drop maxRetries to 0 on /api/chat so a 429 surfaces immediately instead of burning 30-60s in backoff. - Add a "Thinking…" indicator in the chat while status is submitted|streaming so the user never sees a silent UI. - Render chat errors with a clearer 'Something went wrong' title and the full error message below. - useChat onError now logs the full error object to the console with a [copilot] prefix so we can grep real-world failures.
Observed: model called set_field_value({value:"true"}) on a checkbox
because the old system prompt explicitly said to. Editor rightly
rejected it, and the model carried on as if nothing happened.
Two fixes:
1. Correct checkbox protocol everywhere.
- System prompt: value="checked" ticks, value=null un-ticks. Never
"true"/"false"/"yes"/"no".
- Per-field-type rules added (TEXT, BOXED_TEXT, CHECKBOX,
SIGNATURE, PICTURE).
- Zod schema for SetFieldValueInput now permits null and its
description mirrors the same rule, so the model sees it in the
tool definition too.
2. Require the model to read tool errors and fix them.
- New "Handling tool errors" section: if a tool returns
success=false, read error.message and correct the next call. Do
not silently skip. If unrecoverable, tell the user briefly and
ask how to proceed.
Anthropic's 10k-TPM org cap was still triggering mid-conversation: the existing cache_control on the static system prompt (Breakpoint #1) only reuses the first ~1000 prefix tokens. Everything after (tool schemas dynamic language line message history) is a fresh input every turn — with 13 fields, get_fields alone carries ~400 tokens per turn and grows as tool results accumulate. Adds a second cache breakpoint on the LAST model message in the conversation (which is the current user turn in normal flow, or the most recent tool result during auto-continuation). Anthropic caches everything up to and including that block, so the next turn's prefix is a pure cache read (10% of input-token cost and, crucially, doesn't count against the non-cached TPM bucket). Two breakpoints total (system + rolling) — Anthropic allows up to four. Cache writes still happen on the incremental tail each turn (new user message + assistant reply), so expect a small cache-write overhead on each turn in exchange for a large cache-read saving thereafter.
First-turn get_document_content was blowing past Anthropic's 10k-TPM by itself when forms are 3-4+ pages. The truncation was 900 chars per page with no page cap, so worst case ~3600 chars (~900 tokens) per call plus it lives in message history every turn after. - Per-page cap drops from 900 to 400 chars. - New total-page cap of 4. If the doc has more pages, a single placeholder entry records how many were omitted. Combined with the rolling prompt cache just landed, this keeps get_document_content under ~400 tokens and that payload gets cached on turn 2+ so it stops counting against the TPM bucket.
Combined with loadingPlaceholder=true the iframe now boots in a clean state: any native AcroForm fields baked into the PDF are stripped, forcing every demo run through the same get_fields (0) → detect_fields path regardless of the form. Makes the scanned-PDF and native-PDF flows converge on the same UX.
Caps dropped from 4 pages x 400 chars to 1 page x 1200 chars. The first page is enough for the model to infer what the form is and what it asks for; extra pages mostly added tokens without changing the filling flow. A placeholder entry still records how many pages were omitted. Net effect: get_document_content's worst-case footprint drops from ~1600 chars to ~1200 chars, and more importantly it collapses into a single cached tool result on turn 2+.
Doubles the demo's per-IP quota. Rationale: - Haiku 4.5 with our prompt-cache setup lands around \$0.002-0.01 per turn, so 20/hr caps exposure at ~\$0.20/hr and 100/day at ~\$1/day per IP. - 10 unique users a day ~\$10/day ceiling, which is safe. - Existing RATE_LIMIT_PER_HOUR / RATE_LIMIT_PER_DAY env overrides still work if we need to dial further up (or down) without a code change.
Two coupled changes so the user instantly sees where input is expected: 1. System prompt now requires wrapping every user-facing question (and hand-off instructions for SIGNATURE/PICTURE fields) in Markdown bold. Updated GOOD/BAD examples use the new format. 2. ReactMarkdown in the chat pane maps <strong> to text-sky-700 + font-semibold for assistant messages (user messages keep their current white-on-blue styling).
Real cost per full form fill is ~4¢ but we were exhausting 20/hr in minutes because every tool-call auto-continuation (sendAutomaticallyWhen after addToolOutput) hits /api/chat and we counted each POST as one hit. One user turn triggers 5-10 auto-continuation POSTs. Fix: only charge the rate limit when the last UI message is a fresh user turn (role='user' with a text part). Auto-continuations (whose last message is an assistant message carrying the tool_result) bypass the bucket. Log now reports counted_against_limit so we can verify. Raised defaults to 30/hr + 150/day to reflect real user-turn volume; cost cap stays around ~\$1.50/IP/day.
Updates EDITOR_HOST, EDITOR_ORIGIN and the README reference from headless.simplepdf.com to pdf-form-copilot.simplepdf.com so the demo runs on its dedicated subdomain.
Was: simplepdf.com/android-chrome-512x512.png (the 512x512 PWA icon — colored on white). Looked muddy on the new sky-700 header. Now: cdn.simplepdf.com/simple-pdf/assets/common/logo-white.png (white mark, transparent background, optimized to 128KB). Dropped the .rounded class since the new asset is already shaped.
…he original 24px)
- Remove "pre-signed URLs" row from production architecture diagram and move the iframe -> SimplePDF arrow up to the "Telemetry + metadata" row. - Update architectureDescription across all 23 locales to drop the pre-signed URL example. - Revert chat aside to slate-200 border. - Apply sky-700 to chat footer (input + send) so it matches the header.
Reframes the input from interrogative ("Ask") to conversational ("Talk to")
across all 23 locales.
- Input is now rounded-full with px-4 padding for visual balance. - Send button is a circular sky-600 icon button (h-9 w-9, rounded-full) with an inline SVG arrow-up glyph; aria-label preserves the "Send" accessible name. - Drop the top border on the chat footer.
DO npm ci was failing with `Missing: @emnapi/core@1.10.0 / @emnapi/runtime@1.10.0 from lock file`. They live as transitive deps of @rolldown/binding-wasm32-wasi (pulled in by Vite 8). npm only records them in the lockfile when the wasm32-wasi binding is the resolved variant on the install host, so each `npm install` from a different OS/arch flips them in or out and the next `npm ci` on DO fails. Declaring them as optionalDependencies of form-copilot itself pins them as top-level entries in the lockfile permanently, regardless of install platform. Both packages are pure-JS (~2KB each).
…plicate locale keys Code-review finding (this session): the chat input placeholder ignored the no-model state, so a fork in the canonical first-run scenario (BYOK-locked + custom PDF picked) saw the header say "Bring your own provider to chat" and the input say "Please wait…" — two contradictory nudges. Final fix consolidates both surfaces: - Single `chatStatusMessage` useMemo covers all non-ready states (!hasActiveModel → subtitleNoModel, requiresUserUpload → subtitleNoDocument, else subtitleWaiting). Header `<p>` and input `placeholder` consume the same value. The previous subtitle IIFE is gone. - Placeholder = canSend ? Ready : chatStatusMessage. - One source of truth per state across all 23 locales. The duplicate `inputPlaceholderNoDocument` (introduced earlier this session) and the now-unused `inputPlaceholderWaiting` keys are removed everywhere. Only `inputPlaceholderReady` remains placeholder-specific (the header has no "ready" subtitle; it swaps to the model-name button instead). - `subtitleNoDocument` strengthened from "Load a document" to "Load a document first" to read naturally in both surfaces. Translator-supplied values for the 22 non-EN locales reused. - `forms.customSubtitle` updated to "Any PDF works - kept on your device" with matching translations across all 23 locales (separator: ` - `). `npx tsc --noEmit` clean, all 23 locale JSON files parse.
…ownload for demo)
Splits the single submit_download tool into separate submit and download
tools and gates exposure by VITE_SIMPLEPDF_COMPANY_IDENTIFIER.
- companyIdentifier === 'copilot' (the SimplePDF-hosted demo): only
`download` is registered; toolbar shows Download (Lucide Download icon);
click + LLM tool both route through the upsell-aware host handler that
ultimately fires bridge.download() (the existing demo flow, unchanged).
- companyIdentifier !== 'copilot' (any Pro fork): only `submit` is
registered; toolbar shows Submit (Lucide Send icon); click fires
bridge.submit({ downloadCopy: false }) directly (no upsell modal — a
Pro deployment is already past that point); the LLM-driven submit goes
through the same dispatcher path. The SimplePDF SUBMIT iframe event
carries the filled PDF to the host's BYOS storage + webhook stack.
Implementation:
- src/lib/mode.ts (new) exports MODE / IS_DEMO_MODE from the build-time
Vite env var. Both client and server import it; the constant is inlined
per build.
- schemas.ts: SubmitDownloadInput → SubmitInput + DownloadInput;
CLIENT_TOOL_NAMES gains 'submit' and 'download', drops 'submit_download'.
- dispatch.ts: separate cases. submit → bridge.submit({downloadCopy:false}),
download → bridge.download().
- server/tools.ts: SYSTEM_PROMPT const → buildSystemPrompt({ action })
function. The two references to submit_download in the prompt + the
"ready to submit" closing line are parameterised by the action verb /
tool name so the LLM never sees the wrong name.
- chat.ts (server route) and byok/transport.ts both pick the matching
tool entry + matching system prompt from IS_DEMO_MODE at module init.
- chat_pane.tsx: new fireSubmit callback; handleFinalisationRequested
resolves to fireSubmit (Pro) or handleDownloadRequested (demo).
createDemoDownloadMiddleware now matches `download` (was submit_download)
and is only composed in demo mode — Pro never registers the tool, so
the middleware never fires.
- toolbar.tsx: prop rename downloadPrimary/onDownload →
finalisationPrimary/onFinalisation; reads IS_DEMO_MODE to pick the
Lucide icon + label key. Demo: Download icon + toolbar.download. Pro:
Send icon + toolbar.submit.
- locales: toolbar.submit (which previously held the value "Download")
was renamed to toolbar.download in all 23 files, preserving every
existing translation. en.json gains a new toolbar.submit: "Submit"
(only EN; the 22 non-EN locales fall back to EN until the final
translator sweep, per the deferred-translator process rule).
tsc clean, vitest 50/50 passing.
…true while open
When the LanguagePicker disables on `isStreaming` (chat is streaming, including
during tool-call retries) and a tool fails, isStreaming stays true for the full
retry cycle. If the user had opened the picker before the failure, the trigger
button's HTML `disabled` attribute blocks its own onClick, leaving the panel
visually open with no way to close it via the trigger. Click-outside and
Escape technically still work but the UX reads as broken.
Auto-close matches the semantic of `disabled` ("no interactions"). When the
prop flips to true while open, the dropdown closes itself.
…isStreaming} on LanguagePicker Previous commit (8c0204c) patched the symptom — a useEffect inside the generic Dropdown that auto-closed the panel when `disabled` flipped true while open. Real cause: `disabled={isStreaming}` was passed to LanguagePicker with no product justification (defensive default since the Phase 3.36 scaffold rename). Removing it makes the dropdown always interactable and the bug disappears without touching the generic component. If the user changes language mid-stream, the URL flips, the editor reloads into the new locale, and the in-flight LLM response lands in the new thread under the new cacheKey. Slightly disorienting but recoverable, and the user explicitly initiated the change.
…sure even with a failed tool `isExpanded` was computed as `isManuallyExpanded || hasError`, which forced the disclosure open whenever any tool in the group had errored. The toggle flipped internal state but the OR clamped the visible state to expanded. Replaced the boolean-OR with a single nullable override: null until the user clicks (defaults to hasError, so errors still auto-expand on first appearance); once clicked, the user's choice wins. Trades auto-re-expand on subsequent errors for the ability to dismiss — one click to re-open if needed, and unable-to-collapse is the worse failure mode.
… 'pro' → 'simplepdf_customer' Addresses code-review findings on commit 305cb20: - New shared `finalisation.ts` exports `FINALISATION_TOOL`, `FINALISATION_ACTION`, `withFinalisationTool` and the discriminated `FinalisationToolMap` type. The helper takes `T & { submit?: never; download?: never }` so any static tool map declaring a `submit` or `download` key becomes a compile error rather than a silent overwrite from the spread. Replaces the duplicated `Record<string, ...>` typing in chat.ts + byok/transport.ts. - chat.ts and byok/transport.ts now import `FINALISATION_ACTION` from the shared module and call `withFinalisationTool({...})` instead of spreading. The static map definition is the single source of truth in each file; the finalisation tool is composed by the helper. - chat_pane.tsx imports `FINALISATION_ACTION` from the same shared module (drops the duplicate inline definition). `handleFinalisationRequested` is now memoised via `useMemo`, matching the file's pattern for handlers passed as props. Middleware composition is immutable: a `sharedMiddleware` array is the base; demo mode prepends the demo download middleware via spread instead of mutating with `unshift`. - `Mode` type literal `'pro'` renamed to `'simplepdf_customer'` to reflect what the mode actually means (any non-demo SimplePDF customer fork, not specifically the SimplePDF Pro tier). Comments updated to match. tsc clean, vitest 50/50 passing.
Splash modal shown on first visit, gated to lg+ viewports (the existing mobile fallback in Layout already takes over below 1024px). Persistent dismissal via localStorage key form-copilot:welcome-dismissed. - New WelcomeModal component overlays the brand artwork with two CTAs on the right half of the image. Get-started (Button size lg, ~2x default) dismisses the modal; "How Form Copilot works" dismisses + opens the existing info modal via ?show=info. Backdrop dismiss + Escape + close button (top-right) all trigger the same dismissal path. - Image hosted on the SimplePDF CDN via the assets-upload skill: https://cdn.simplepdf.com/simple-pdf/assets/meta/form-copilot-welcome.png Same image is now the og:image / twitter:image for the form-copilot app. og:title, og:description, og:type, og:image:width/height and twitter:card meta tags added in __root.tsx. - Button gained an lg size (rounded-lg px-6 py-4 text-base) — used by the Get-started CTA, available for any other hero-button surfaces. - localStorage gate: lazy initialiser checks dismissal + matchMedia(min-width 1024px) once on mount; SSR returns false to avoid hydration mismatch. The modal pops in on the client when both conditions are met. - Final translator sweep: 5 new keys (toolbar.submit, welcomeModal.{title, getStarted, howItWorks, close}) added across all 22 non-EN locales, plus audit confirmed the 12 mid-iteration EN keys are still aligned with their non-EN translations. Bonus fi.json typo fix (Tekoaly → Tekoäly). tsc clean. JSON parses across all 23 locale files.
…uage locale detection
Welcome modal redesigned and lifted into the SSR pass.
Visuals:
- New illustration (CDN: common/form-copilot-illustration.png) replaces the
text-baked artwork; the brand text is now translatable HTML/CSS on the
right pane.
- Two-column grid (50/50) on a #96cafc background. Image flush against the
bottom-left edge so it bleeds into the modal frame. Right pane has the
Form Copilot wordmark at text-7xl, the existing header.tagline at
text-[48px] with a max-w-[340px] wrap, Get-started button (Button size
lg, +2x default), and a How-it-works link below. White SimplePDF logo
watermarks the bottom-right of the panel at h-32 w-32. EN-only highlight
on the first three words of the tagline ("AI that helps") — non-EN
locales render the existing translated tagline without colour split.
SSR via cookie:
- Loader returns `{ demoGate, welcomeDismissed }`. New server fn
`readWelcomeDismissed` reads the `form-copilot-welcome-dismissed` cookie
via `getRequestHeader('cookie')`. Lightweight inline parser (no new dep).
- WelcomeModal rewritten without `Modal`/`createPortal` so the markup
ships in the SSR HTML; mobile gating moved to CSS (`hidden lg:flex`
on the backdrop). Inner Escape listener via useEffect.
- Dismiss writes `document.cookie = ...; max-age=31536000; SameSite=Lax;
Secure` (Secure conditional on https). No localStorage, no matchMedia.
Locale detection:
- New helpers in lib/i18n.ts: `matchSupportedLocale` (full-tag → primary
fallback), `matchLocaleFromAcceptLanguage` (parses comma-separated
Accept-Language). `readInitialLocale` now also tries `navigator.languages`
on the client when ?lang= is absent, so client init matches the server's
Accept-Language detection (no hydration mismatch).
- New server fn `readPreferredLocaleWhenLangAbsent` reads Accept-Language
via `getRequestHeader` and returns null if ?lang= was explicit (so the
user's choice always wins). beforeLoad awaits the detection and calls
`i18n.changeLanguage` if needed before render.
i18n init flash fix:
- `i18n.ts` exports `i18nReady` (the Promise from `init()`). Route's
beforeLoad `await i18nReady` before render so the very first render
doesn't run with raw key strings while init's microtask is pending.
tsc clean, vitest 50/50 passing.
… tagline alignment
- New `welcomeModal.tagline` key with `<accent>...</accent>` markup wraps
the locale-equivalent of "AI that helps". WelcomeModal renders via
`<Trans>` with `{ accent: <span className="text-blue-600" /> }`, so the
blue highlight applies in every language, not just EN.
- Translator pass added the key to all 22 non-EN locales with
locale-appropriate accent boundaries.
- Locale-aware font sizing: `COMPACT_TAGLINE_LOCALES = ['en', 'vi', 'zh']`
use 48px (their tagline visible char count is < 50). All other locales
drop to 42px so longer translations (fr/tr/fi/pt at 70-78 chars) keep
the same line count as EN. Tagline `max-w` 340 → 406 to give the larger
lines a touch more room.
- FR copy edits: "Apportez votre propre IA" → "Utilisez votre propre IA"
(4 places) + "Apportez votre propre stockage" → "Utilisez votre propre
stockage" for parallel-bullet consistency. FR header.tagline updated to
the new direct-address phrasing ("L'IA qui vous aide à remplir vos
formulaires PDF, étape par étape") matching the welcome modal.
- PL header.tagline: "Sztuczna inteligencja..." → "AI..." (was the only
locale where header.tagline used a different word for AI than the rest
of the file's UI strings).
- Tagline alignment sweep: all 23 locales now have `header.tagline`
identical to `welcomeModal.tagline` minus the `<accent>` tags. Seven
more locales (cs, de, hi, nl, tr, uk, zh) had word-order drift from
the translator's restructuring around the accent boundary; their
header.tagline values were updated to match the welcome version.
tsc clean, all 23 locale JSON files parse, accent tags balanced in every
locale.
ioredis defaults (10s connectTimeout, infinite exponential retry) caused ETIMEDOUT against an unreachable Valkey instance to pile up hung sockets, congesting the Node event loop. Once the loop got congested, DO App Platform's health check timed out, the load balancer marked the instance unhealthy, and the public URL started returning connection timeouts — even though the app process was still alive. Hardening: - connectTimeout: 3s (down from 10s default). Plenty for an in-VPC managed cache; cuts the worst-case socket pile-up by 3x. - retryStrategy: bounded at 3 attempts with quadratic backoff (200ms / 800ms / 1.8s) capped at 5s, then null (give up). After give-up the limiter stays unready, callers see system_failure, and the event loop isn't dragged by new socket attempts. ETIMEDOUT during firewall-change propagation is the canonical case this guards against. When `REDIS_URL` is unset, nothing changes (in-memory fallback path). tsc clean, vitest 50/50 passing.
…App side" gotcha Operator hit ETIMEDOUT after adding the App as a Trusted Source on the Valkey cluster. The actual fix is to open the App → Settings → App Spec → "Create or Attach Database" and pick the cluster from there. DO then auto-handles trusted sources, VPC routing, and connection-string injection. Same shape as adding a custom domain — has to be done from the App side, not the resource side. Adding a callout to the README so a forker doesn't hit the same trap on their first deploy.
…at banner When DO App Platform's load balancer can't reach the App container (Valkey timeout cascade, health-check failure, deploy in progress, etc.), it returns its own HTML 503 page directly to the browser. The AI SDK builds an Error from the response body, so error.message becomes raw HTML. classifyError didn't recognise the shape (not a JSON envelope, no status property), so the error fell through to GenericPanel which rendered the HTML payload inside a <pre><code> block — visible to every visitor as ~30 lines of HTML noise. Fix: - New classifier helper isUpstreamHtmlError(message) detects upstream HTML pages by signature (DOCTYPE / <html prefix, via_upstream text, "App Platform failed to forward" string). - New error kind 'service_unavailable' takes precedence over the JSON envelope and direct-status checks. - New ServiceUnavailablePanel renders just "Something went wrong" + a clean "The service is temporarily unavailable. Please try again in a few minutes." message — no <pre> block, no raw payload exposure. - GenericPanel: "Full error" disclosure label now inherits the parent's rose-700 text colour (matching the "Something went wrong" header) instead of the lighter rose-600. Three new test cases cover the DO 503 HTML body, bare HTML wrappers, and the via_upstream signature without HTML. Total 53 tests passing (was 50). en.json gains chat.errorServiceUnavailableBody. Non-EN locales fall back to EN until the next translator sweep — flagging because this surface was added outside the welcome-modal copy iteration window.
Code-review finding on commit 6d4202a: returning `null` from ioredis's retryStrategy after 3 failed attempts permanently disabled the client (`status: 'end'`). Once the firewall propagation / DO incident / cold start exceeded the 4-attempt window, the limiter stayed unready until an app redeploy — defeating the original intent of recovery. Replaced with a quadratic backoff capped at 30s, never returning null: retryStrategy: (times) => Math.min(times * times * 200, 30_000) Worst-case event-loop cost during a long outage is two 3s connectTimeout hung sockets per minute — predictable and negligible. Once the network heals, the limiter self-recovers without operator intervention.
…able banner When the demo's chat path is down (DO 503, etc.), the user has a working escape hatch: bring their own AI. BYOK requests bypass our server entirely (browser → provider direct), so a DO outage is recoverable without waiting for it to clear. Updated `chat.errorServiceUnavailableBody` to embed a `<switchModel>` slot — same Trans-component pattern AuthPanel uses. Clicking the link opens the model picker. Wired the `onSwitchModel` callback through to ServiceUnavailablePanel.
Operator confirmed that attaching DO Managed Caching via App Platform's
"Create or Attach Database" flow auto-injects the connection string as
DATABASE_URL (not REDIS_URL). The form-copilot server reads REDIS_URL,
so the post-attach step is to either rename the auto-injected var or
add a REDIS_URL=${cluster-name.DATABASE_URL} alias.
README's DO App Platform gotcha block now spells this out so a forker
doesn't repeat the dig.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
This PR introduces Form Copilot as a MIT-licensed forkable demo. The demo runs live at https://form-copilot.simplepdf.com.
Form Copilot allows filling PDF forms using LLMs by leveraging client-side tool calling over an iframe bridge (introduced as part of this PR).
Changes
form-copilot/, built with TanStack Start (React 19, Vite, Nitro), Tailwind CSS, and the Vercel AI SDKform-copilot/src/lib/embed-bridge/together with two adapters:embed-bridge-adapters/react(host-sideuseEmbedBridgehook) andembed-bridge-adapters/client-tools(LLM-tool input/output Zod schemas)Notes
We will most likely move the iframe bridge into the web-embed package since we've so far shipped poor-man's bridges per consumer (less capable, less durable, less typed than what a shared package would offer).