Skip to content

Form Copilot#30

Draft
bendersej wants to merge 215 commits intomainfrom
P059-pdf-form-copilot
Draft

Form Copilot#30
bendersej wants to merge 215 commits intomainfrom
P059-pdf-form-copilot

Conversation

@bendersej
Copy link
Copy Markdown
Member

@bendersej bendersej commented Apr 22, 2026

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

  • Add the Form Copilot demo at form-copilot/, built with TanStack Start (React 19, Vite, Nitro), Tailwind CSS, and the Vercel AI SDK
  • Add the iframe bridge at form-copilot/src/lib/embed-bridge/ together with two adapters: embed-bridge-adapters/react (host-side useEmbedBridge hook) and embed-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).

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.
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant