Skip to content

Feat/ai chat presentation mode#87

Open
Vijayabaskar56 wants to merge 17 commits into
mainfrom
feat/ai-chat-presentation-mode
Open

Feat/ai chat presentation mode#87
Vijayabaskar56 wants to merge 17 commits into
mainfrom
feat/ai-chat-presentation-mode

Conversation

@Vijayabaskar56

Copy link
Copy Markdown
Member

No description provided.

New plan-gated `aiChatPresentation` feature that renders a form as a
turn-by-turn chat with the AI parsing free-text replies into typed
Answers, with monthly per-Org Chat Session caps and per-Org-Day builder
preview budgets.

- /api/ai/chat-form: stateless POST per turn (start/advance/parse-and-
  advance/finish). Cap is checked before insert so a 429 doesn't burn a
  quota slot; resumes are detected via `isExistingChatSession` and skip
  the cap. Published-form load combines forms+formVersions in a single
  join.
- ai_chat_sessions / ai_chat_preview_counts / ai_chat_rate_limits
  tables (drizzle migration) for per-submission marker, per-org-day
  preview counts, and per-IP token-bucket rate limit.
- Client `useAiChatSession` hook owns transcript, terminal-failure
  trip, retry budget, parse retry. Reused `AiToolEmission` from the
  shared types module rather than redeclaring it.
- AiChatForm component: file-upload now sends the same
  `{draftId, filename, contentType, base64}` payload as FileUploadField
  (was passing a raw File and an `as any` cast — runtime-broken).
- Preview-mode reuses the AI chat renderer; `content` and
  `previewElements` are memoized so the validation schema is rebuilt
  only when the underlying doc content changes.
- CONTEXT.md: introduce "Chat Session" term + add the new gate to the
  Plan-gated feature list.
…esign composer

The published-form snapshot stored in `formVersions.content` is the raw
Plate editor doc, not the typed `TransformedElement[]` that
`answerableQuestions` expects — so the formHeader leaked in as the
"first question," the AI got an empty label and echoed the field id
verbatim, and `QuestionBubble` returned `null` for the unknown
fieldType, leaving the floating panel empty with no textarea (which is
why typing appeared broken).

- Server: `loadPublishedFormForChat` and `loadDraftFormForPreview` in
  `routes/api/ai/chat-form.ts` now run `transformPlateStateToFormElements`
  on the raw content before it reaches `answerableQuestions`.
- Client: `public-form-page.tsx` memoizes the same transform per
  `form.content` and passes it as `content` to `AiChatForm` (preview-mode
  was already doing this for the builder side).
- System prompt now instructs the AI to weave a brief greeting that
  name-checks the form title into the first askQuestion `prompt`, and
  forbids referencing Questions by `id`. Field summaries fall back to
  humanized `name` and include `placeholder` so the AI always has real
  text to phrase. Deterministic `start`-fallback prose now opens with
  "Welcome to {formTitle}!" instead of the generic continuation.
- Composer redesign: floating `bg-card` pill anchored at the bottom
  (max-w-2xl, shadow, focus ring), click-anywhere-to-focus, plain
  `<textarea>` with `bg-transparent` so the visible click target is the
  whole pill. Other phase footers (structured / file / submitting /
  closed / fallback) share a `<FloatingPanel>` / `<FloatingMessage>`
  wrapper to dedupe the six near-identical layout shells.
- Focus useEffect dropped `bubbles.length` from its dep list — was
  re-focusing on every appended bubble and could steal focus mid-typing
  on resume.
- Defensive client fallback: if the API returns a successful response
  whose `tool` isn't `askQuestion` on `start`, append a deterministic
  question bubble instead of leaving the transcript blank.
- `humanizeName` exported once from `chat-form-helpers.ts` (was
  duplicated in the session hook).

Share sidebar's Mode picker switches to a stacked `ConfigRow` variant so
the Card / Field-by-field / AI Chat tabs get full width. Adds the
@tanstack/router-core 1.169.2 patch referenced by the lockfile.
…r EDIT_FORM

EDIT_FORM and SUBMISSIONS_EXPORT both bound to Mod+E. The submissions
page's export registration uses conflictBehavior: 'replace' so it
shadowed EDIT_FORM in the registry — even when export was disabled
(no rows selected), the binding stayed claimed and Cmd+E silently
no-op'd on /submissions. Cmd+E now reliably opens the editor; export
moves to Mod+Shift+X (mnemonic, matches existing Mod+Shift+_ pattern).
The fullpage branch of PreviewMode only rendered <FormPreviewFromPlate>,
missing the previewIsAiChat switch that the embed and popup branches
already had. Now all three share modes (embed/popup/fullpage) honor
presentationMode === 'ai-chat' and show the AI Chat preview when
selected, instead of falling back to the card-style form preview.
1. Drop the redundant advance call after confirmParse. The server's
   confirmParse tool already returns 'the prose for the next Question'
   per its description — the client was rendering that bubble AND THEN
   making a second 'advance' API call that produced an identical prompt
   for the same question, hence two messages per turn ('Thanks for that!
   What's your phone?' + 'Hello! Welcome to the form. Could you provide
   your phone?'). Now we use confirmParse.prompt directly and only call
   advanceTo(null) for the terminal/finish case.

2. Defend against server 5xx and network failures in callApi. Wrap the
   fetch + json() chain in try/catch and synthesize a soft error so the
   existing failure-budget logic runs. Previously a 500 (or any non-JSON
   response) threw inside the await chain, bypassing setPhase('ready')
   and freezing the UI in the loading state forever.

3. Thread priorAnswers from client to server so the AI can personalize.
   Body schema accepts priorAnswers; runAiTurn forwards them into
   buildSystemPrompt; client sends current answers state on every turn.
   The system prompt builder already includes priorAnswers in the AI's
   context — we were just sending {} every turn. Now once the user
   provides their name, subsequent prompts can address them by it.
…essive extraction

Three follow-on issues from yesterday's AI-chat round:

1. Wrong next question after parse. Last commit dropped the advance call
   and relied on confirmParse.prompt as the next-Q bubble — but the
   server's system prompt only describes the *current* Q to the AI, so
   the AI's confirmParse prompt re-asked the just-answered Q instead of
   the next one. Restore the two-call pattern (parse-and-advance then
   advance) but DON'T render confirmParse.prompt — let advance's
   askQuestion.prompt be the single next-Q bubble. One message per turn,
   pointing at the right question.

2. Validation errors weren't reaching the AI. When client-side Zod
   rejected a parsedValue, we showed a static "Hmm, I didn't quite catch
   that" — the AI had no way to learn what went wrong, so its next
   attempt would emit the same broken value. Now the client retries
   parse-and-advance once with  in the body; the server
   threads it into the system prompt with explicit instructions to
   either re-extract more carefully OR write a specific re-ask
   explaining the expected format (e.g. "I need a URL like
   https://example.com" instead of generic). The AI's contextual prompt
   is rendered if the retry still can't parse.

3. Aggressive extraction rules in the system prompt. Added explicit
   guidance: extract ONLY the relevant value from verbose replies,
   convert magnitudes ("1 million" → 1000000, "5k" → 5000, "$100k"
   → 100000), pick the right number when multiple appear ("100k would
   be great as I already get 50k" → 100000 for desired salary),
   prepend https:// for bare domains, strip prefatory phrases like
   "my email is" / "sure here's my number", and never invent values
   when extraction is impossible. Plus a personalization rule: address
   the Respondent by their first name once they've given it (used
   sparingly to stay natural).
AI Chat conversation/UX:
- Gate the first-turn welcome to the start intent (fixes double-welcome)
- Thread the freshest answers into the next question (no one-step name lag)
- Acknowledge only the immediately-preceding question; record skips as null
- Optional fields: re-ask unparseable replies, honor explicit declines
  (skip immediately), prompt for content on bare affirmations
- Required-field declines re-ask instead of tripping the whole chat
- Disable the composer (not hide it) while the AI is composing
- key per question so structured selections don't leak across questions
- Render AI bubbles as Markdown (streamdown, inline-only allowlist)
- Full-width transcript scroll; resume in-progress answers from localStorage

File upload:
- Attach-a-file composer for FileUpload questions, inheriting the field's
  accepted types + max size; preview-safe; Skip disabled mid-upload
- Public upload server fn + /api/forms/upload route; shared file-upload-types

Public form + share:
- Full-height AI-chat layout; no resume banner (recap shown in-chat);
  AI-failure fallback switches to the card form (no cover background)
- FormShareCard (copy link + X/LinkedIn/WhatsApp) replaces "Submit another
  response" on completion across card/field-by-field/AI-chat

Fixes:
- Optional fields accept null (skipped answers carried from AI chat)
- /api/icons href drops the .svg suffix (static handler was 404ing it)
- Phone country search matches the calling code; preview cap bypassed in dev
- Sync Drizzle schema/relations with the ai_chat_* tables (db pull)
- Remove unused icon deps (@hugeicons, @phosphor-icons, @tabler)
@vercel

vercel Bot commented May 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
reform Ready Ready Preview, Comment May 28, 2026 8:09am

Request Review

The phone field defaulted its country from `navigator.language` (the
browser UI language, e.g. `en-US`), not the visitor's location — so a
US-English browser in India picked +1 and reformatted a national number
like an Indian one as US `(936) 099-2440`.

Resolve the initial country from Vercel's edge geo header
(`x-vercel-ip-country`, the same source analytics uses), fetched once via
a shared `visitor-country` query and applied across every PhoneInput
(public form, AI-chat bubble, builder preview). `navigator.language` is
kept only as a fallback once the geo query settles with no result (local
dev / non-Vercel), so we never flash the wrong locale country before geo
resolves.
The db:pull output landed in drizzle-kit's own formatting, which CI's
`oxfmt --check` rejects (the local pre-commit `oxfmt .` runs in fix-mode
and exits 0, so it never blocked the commit). Reformat to satisfy CI.
The placeholder was a static "+1 (555) ..." string baked into the field,
so it stayed US-formatted even after the country resolved to e.g. +91.
Generate the placeholder from an example number for the currently-selected
country (tracked via onCountryChange, seeded by the geo-IP default), in
national format — e.g. IN -> "081234 56789". Reuses react-phone-number-input's
loaded metadata, adding only libphonenumber-js's small example-numbers map.
Falls back to the provided placeholder for unknown countries.
Geo-IP is authoritative but only resolves after a client round-trip — the
public form HTML is shared/CDN-cached, so geo can't be baked into the first
paint. That left the first frame using the browser locale (en-US -> US) until
geo arrived, so the country/placeholder flashed US for non-US users.

Add a synchronous, location-based timezone guess (e.g. Asia/Kolkata -> IN) as
the first-paint default, ahead of the locale fallback; geo-IP still confirms/
overrides it once it loads. The locale fallback now only applies after the geo
query settles with no result.
…-presentation-mode

# Conflicts:
#	pnpm-lock.yaml
#	src/components/form-components/fields/FileUploadField.tsx
#	src/components/form-components/form-preview-from-plate.tsx
#	src/components/form-components/form-preview-rsc.tsx
#	src/components/form-components/server-form-icon.tsx
#	src/components/ui/phone-input.tsx
#	src/lib/server-fn/public-file-uploads.ts
#	src/routes/forms/-components/public-form-page.tsx
…-presentation-mode

# Conflicts:
#	pnpm-lock.yaml
#	src/db/schema.ts
#	src/lib/form-schema/generate-preview-schema.ts
#	src/lib/server-fn/public-file-uploads.ts
#	src/routes/forms/-components/public-form-page.tsx
#	src/styles/styles.css
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