Feat/ai chat presentation mode#87
Open
Vijayabaskar56 wants to merge 17 commits into
Open
Conversation
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.
…-presentation-mode
…presentation-mode
…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).
…-presentation-mode
…presentation-mode
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)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
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.
No description provided.