{"slug": "build-a-reviewable-gemini-form-assistant-in-react", "title": "Build a Reviewable Gemini Form Assistant in React", "summary": "A developer built Trip Workbench, a React and TypeScript demo that uses Gemini to turn a free-text travel brief into proposed form fields, treating each AI response as a proposal tied to a specific draft state rather than a direct mutation. The backend calls Gemini via @google/genai and validates responses against a Zod schema that constrains airports and rejects unknown keys, while a separate PATCH endpoint applies only user-accepted patches after checking authorization, revision, and domain rules.", "body_md": "A person filling out a travel request often already has the information in a message:\n\nPrepare a round trip from SFO to JFK, departing November 12, 2026 and returning November 15, 2026, for one traveler in economy. Purpose: customer workshop.\n\nAn AI assistant can turn that brief into form fields. The more interesting problem starts when its response arrives. The person might have changed the dates, corrected the purpose, or opened another request.\n\nI built and maintain [Trip Workbench](https://github.com/rajudandigam/trip-workbench), a React and TypeScript demo with synthetic travel data, to explore that interaction. Its assistance flow lets a person request suggestions, inspect proposed fields and clarification questions, and explicitly apply a change.\n\nThe useful unit of work is a **proposal tied to a particular draft state**. That gives the application something it can validate, display, reject when outdated, and save through its existing API.\n\nThe feature uses two application operations:\n\n| Operation | Responsibility | \n|---|---|\n| `POST /api/trips/:id/proposal` | Read the authorized draft, ask Gemini for suggested fields, and validate the response | \n| `PATCH /api/trips/:id` | Apply a user-accepted patch after checking authorization, revision, and domain rules | \n\nGenerating a proposal leaves the saved trip unchanged. Clicking **Apply proposed changes** sends a separate mutation. In this demo, applying saves the draft; reviewing and submitting the trip request happens afterward.\n\nThat distinction should be visible in the interface. A generic “Accept” button is less clear than a label that explains whether the action fills a local form or saves to the server.\n\nThe Gemini call runs in the Node backend using `@google/genai`. The React client sends its brief and draft context to the application API. Google’s [API-key guidance](https://ai.google.dev/gemini-api/docs/api-key) recommends keeping production keys out of browser code.\n\nThis article follows that embedded assistance flow. Trip Workbench’s separate WebMCP tools provide another entry point for external agents; they are not involved in the Suggest button.\n\nA proposal contains two things: a patch and clarification questions.\n\nFor the opening brief, this is an illustrative response:\n\n```\n{\n  \"patch\": {\n    \"origin\": \"SFO\",\n    \"destination\": \"JFK\",\n    \"departureDate\": \"2026-11-12\",\n    \"returnDate\": \"2026-11-15\",\n    \"tripType\": \"round_trip\",\n    \"passengerCount\": 1,\n    \"cabin\": \"economy\",\n    \"purpose\": \"customer workshop\"\n  },\n  \"clarifications\": []\n}\n```\n\nThe patch contains editable trip fields. It has no place for an owner ID, tenant ID, approval decision, or submitted status.\n\nPartial patches are useful. If the brief says “change the destination to JFK,” the assistant should preserve unrelated fields. If the destination is ambiguous, it can return an empty patch and a question.\n\nFor example, “Springfield next Friday” needs more context. This demo supports a small airport list. The assistant should explain that limit and ask for a supported destination instead of silently substituting one.\n\nHere is a stricter proposal schema I would use when extending the adapter. It constrains airports at the schema boundary and rejects unknown keys throughout:\n\n``` js\nimport { z } from \"zod\";\nimport { AIRPORTS } from \"../../domain/types.js\";\n\nconst Field = z.enum([\n  \"origin\", \"destination\", \"departureDate\", \"returnDate\",\n  \"tripType\", \"passengerCount\", \"cabin\", \"purpose\",\n]);\n\nconst ProposedTripSchema = z.object({\n  patch: z.object({\n    origin: z.enum(AIRPORTS).nullable().optional(),\n    destination: z.enum(AIRPORTS).nullable().optional(),\n    departureDate: z.string().nullable().optional(),\n    returnDate: z.string().nullable().optional(),\n    tripType: z.enum([\"one_way\", \"round_trip\"]).optional(),\n    passengerCount: z.number().int().min(1).max(9).optional(),\n    cabin: z.enum([\"economy\", \"premium_economy\", \"business\"]).optional(),\n    purpose: z.string().max(500).optional(),\n  }).strict(),\n  clarifications: z.array(z.object({\n    field: Field.nullable(),\n    kind: z.enum([\"missing\", \"ambiguous\", \"unsupported\"]),\n    question: z.string().min(1).max(240),\n  }).strict()).max(8),\n}).strict();\n```\n\nThe date strings still need calendar validation. A string-shaped value does not establish that February 30 exists.\n\nDefine patch semantics explicitly, too: omitting a field preserves its value; `null` clears a nullable field. The model should omit uncertain information and ask a question. It should clear a field only when the requested change calls for that.\n\nThe server loads the current saved draft after checking access. It also supplies a reference date, application timezone, and allowed values.\n\nThese details affect interpretation. “Next Friday” depends on a reference date and may still require clarification. An airport mentioned in ordinary language might be outside the application’s supported set.\n\nThe prompt should describe those limits and ask for only the requested changes. Treat the brief as input data; instructions embedded in it do not grant additional application capabilities.\n\nAt [commit `4b3f370`](https://github.com/rajudandigam/trip-workbench/tree/4b3f37034fc291ad5221969e0507d349878dc150), the repository pins `@google/genai` 1.30.0. Its adapter requests JSON using `responseMimeType`, then parses and validates the result locally. It does not yet pass an output schema to Gemini.\n\nA focused improvement is to supply the schema during generation as well:\n\n``` js\nconst { GoogleGenAI } = await import(\"@google/genai\");\nconst ai = new GoogleGenAI({ apiKey: config.geminiApiKey! });\n\n// config and prompt are constructed by the server adapter.\n// ProposedTripSchema is the schema defined above.\nconst response = await ai.models.generateContent({\n  model: config.geminiModel,\n  contents: prompt,\n  config: {\n    responseMimeType: \"application/json\",\n    responseJsonSchema: z.toJSONSchema(ProposedTripSchema),\n  },\n});\n\nif (!response.text) {\n  throw new Error(\"AI_INVALID_OUTPUT\");\n}\n\nconst proposal = ProposedTripSchema.parse(\n  JSON.parse(response.text),\n);\n```\n\nThis excerpt belongs inside the existing adapter, after its API-key check. Its route must translate parsing and validation failures into an application error.\n\nThe [SDK reference](https://googleapis.github.io/js-genai/release_docs/interfaces/types.GenerateContentConfig.html) documents `responseJsonSchema` for this API. It supports a subset of JSON Schema, so keep runtime checks such as string limits and calendar rules even when the schema is supplied to the model. Current Google examples also show the newer `responseFormat` shape; do not copy fields between SDK generations without checking the version you actually ship.\n\nThese examples follow the repository’s `models.generateContent` path. Google now documents that path under [Generate Content API Legacy](https://ai.google.dev/gemini-api/docs/generate-content/structured-output); its newer [Interactions API](https://ai.google.dev/gemini-api/docs/interactions-overview) uses a different request shape. Keep the SDK version and API surface consistent when adapting the code.\n\nThe next check is application-specific. Merge the proposed patch with the saved fields and run the existing domain validator. A return date can be a real date and still precede departure. A valid cabin can still require policy review.\n\nGoogle’s [structured-output documentation](https://ai.google.dev/gemini-api/docs/structured-output) also calls for application validation. Constraining the response shape does not establish that its values match the person’s intent.\n\nConsider this sequence:\n\nChecking only the server revision misses the problem. The person’s newer text may still be unsaved.\n\nTrip Workbench tracks three versions for this reason:\n\n| Version | What it tracks | \n|---|---|\n| Saved revision | Changes persisted by the backend | \n| Client edit version | Local form edits | \n| Brief version | Changes to the text being interpreted | \n\nThe server echoes those bindings outside the model-generated proposal. They are application metadata; Gemini does not choose them.\n\nThe coordinator’s freshness check includes:\n\n```\nreturn (\n  bound.boundRevision !== state.savedDraft.revision ||\n  bound.boundClientEditVersion !== state.editVersion ||\n  bound.boundBriefVersion !== state.briefVersion\n);\n```\n\nThe surrounding implementation also checks the draft identity, session, and editor generation. That prevents a response for one editor context from being treated as a suggestion for another.\n\nCheck freshness when the response arrives and again when the person clicks Apply. The second check matters because a proposal can become outdated while someone is reading it.\n\nThe simplest policy is to mark the proposal stale and ask for a new suggestion. For eight fields, that is easier to explain than automatically merging an old proposal with new intent. A larger editor might justify more granular conflict handling.\n\nThere is another practical detail: this endpoint uses **saved fields** as model context. Unsaved form edits are not sent to Gemini. The coordinator blocks an agent-style patch while local edits remain unsaved. A clearer UI would ask the person to save or discard those edits before requesting a suggestion.\n\nVersion checks protect this particular handoff. They do not, by themselves, prove that every refresh or recovery path in a shared editor preserves local changes.\n\nThe current React panel lists proposed values and clarification questions. It disables Apply when the proposal is stale or its patch is empty.\n\nFor an existing populated form, I would add a current-value column:\n\n| Field | Current | Proposed | \n|---|---|---|\n| Destination | BOS | JFK | \n| Departure | November 10, 2026 | November 12, 2026 | \n| Purpose | Team meeting | Customer workshop | \n\nThat table is a suggested review improvement, not a screenshot of the current demo. It makes the consequences of applying easier to inspect.\n\nKeep clarification questions visible beside the fields. A partial proposal can be useful without being ready for submission. In the current design, the person can refine the brief and request another suggestion.\n\nThe Apply handler checks freshness, then delegates to the existing coordinator:\n\n```\nif (coordinator.isProposalStale(proposal)) {\n  // Keep the form unchanged and ask for another suggestion.\n  return;\n}\n\nconst result = await coordinator.applyAgentPatch(\n  proposal.patch,\n  proposal.boundRevision,\n  newCommandId(),\n);\n```\n\nThe backend checks the user’s authority, validates the patch against the current draft, and enforces the expected revision. If another writer changed the draft, the application should require a fresh review before applying a different change.\n\nThis second validation is necessary even though the proposal was already checked. The draft can change between generation and application. Client-side review is also not an authorization boundary.\n\nA business-cabin request illustrates the distinction: the patch can save successfully while the later validation step returns a review requirement. Saving the draft does not approve a policy exception or purchase a ticket.\n\nThe manual form should remain usable when assistance fails.\n\nA rejected model response should leave the fields unchanged and explain that no suggestion could be prepared. A stale proposal should explain that the brief or draft changed. A revision conflict should lead to reviewing current data before trying again.\n\nFor a production feature, also release the “Suggesting” state in a `finally` block and distinguish provider failures from invalid output. The current adapter maps otherwise unclassified provider exceptions to `AI_TIMEOUT`; that label alone does not establish that a timeout occurred.\n\nGeneration failures and save failures need different treatment. A lost proposal response has not saved a trip. A lost PATCH response may have followed a successful save. The latter requires reconciling the original command’s outcome before creating another mutation.\n\nTrip Workbench has disabled, fixture, and live modes. Fixture mode returns canned examples so the interface can be exercised repeatedly. Its SFO–JFK example uses fixed dates, so it does not measure Gemini’s extraction ability.\n\nUse deterministic checks for the application behavior:\n\n| Check | Expected behavior | \n|---|---|\n| Unknown patch key or invalid calendar date | Reject the proposal without changing saved fields | \n| Form edit while generation is pending | Mark the returned proposal stale | \n| Brief change after generation | Prevent applying the old suggestion | \n| Navigation to another draft | Discard the old context’s response | \n| Server revision changes before Apply | Reject the stale mutation | \n| Network failure during generation | Restore usable controls and preserve the form | \n\nThen evaluate live Gemini with a complete brief, an ambiguous brief, and a partial update to an existing draft. Run each several times and record the model, prompt, proposed fields, clarification questions, human corrections, and final saved state.\n\nFor the partial update, explicitly check what stayed unchanged. A correct destination is not enough if the assistant also overwrote a purpose that the user never asked to change.\n\nThe repository’s [results document](https://github.com/rajudandigam/trip-workbench/blob/4b3f37034fc291ad5221969e0507d349878dc150/docs/RESULTS.md) records fixture evidence and leaves live Gemini unverified. These exercises define the next evaluation; they are not a reported success rate.\n\nFinally, compare the complete task with manual entry. Include the time spent writing the brief, waiting, reviewing, and correcting. For a short form, manual entry may be faster. Assistance becomes more interesting when the user already has a longer brief containing the required information.\n\nThe question I want feedback on is concrete: **can a person understand and correct the proposed change with less effort than entering it themselves?**\n\nThat is the outcome this design lets us evaluate. The model provides a candidate change, the interface makes it reviewable, and the application decides whether it can be saved.", "url": "https://wpnews.pro/news/build-a-reviewable-gemini-form-assistant-in-react", "canonical_source": "https://dev.to/raju_dandigam/build-a-reviewable-gemini-form-assistant-in-react-1o9c", "published_at": "2026-10-17 23:56:41+00:00", "updated_at": "2026-10-18 00:07:29.468941+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "large-language-models", "developer-tools"], "entities": ["Trip Workbench", "Gemini", "React", "TypeScript", "Google", "@google/genai", "Zod"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-reviewable-gemini-form-assistant-in-react", "markdown": "https://wpnews.pro/news/build-a-reviewable-gemini-form-assistant-in-react.md", "text": "https://wpnews.pro/news/build-a-reviewable-gemini-form-assistant-in-react.txt", "jsonld": "https://wpnews.pro/news/build-a-reviewable-gemini-form-assistant-in-react.jsonld"}}