From 6f1a97c0d0f2c066dbf9b3b3d519f0268535b60e Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:03:41 +0200 Subject: [PATCH] feat(examples): a runnable Next.js example that needs no API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had nothing a visitor could run. Evaluating the library meant wiring a provider into your own app first, which is the wrong order — the behaviour has to be visible before anyone invests that. `examples/next` is a real job-posting form: prose fills it, follow-up instructions patch it. It runs with zero configuration because `complete` is caller-supplied, so the example ships a deterministic offline pattern matcher for the no-key case. That matcher is NOT a language model and the UI says so in plain words — it exists so the library's own contribution (merge intent, sanitizing, field discipline) is visible without a provider in the way. Set GROQ_API_KEY or OPENAI_API_KEY for real model behaviour. Verified end to end against the running example: - fill from one sentence -> 9 fields set, salary parsed as 130000-160000 - "make it principal" -> changed == ["seniority"], everything else survives - "set the posting id" -> postingId unchanged; it is aiExcluded - unknown target -> rejected before any model call Root package unaffected: 14/14 tests green. Co-Authored-By: Claude Opus 5 --- README.md | 15 + examples/next/.env.example | 5 + examples/next/.gitignore | 5 + examples/next/README.md | 50 + examples/next/app/api/ai/form-assist/route.ts | 16 + examples/next/app/api/mode/route.ts | 7 + examples/next/app/globals.css | 83 ++ examples/next/app/layout.tsx | 16 + examples/next/app/page.tsx | 149 +++ examples/next/lib/complete.ts | 118 ++ examples/next/lib/form.ts | 45 + examples/next/next.config.mjs | 8 + examples/next/package-lock.json | 1048 +++++++++++++++++ examples/next/package.json | 25 + examples/next/tsconfig.json | 21 + 15 files changed, 1611 insertions(+) create mode 100644 examples/next/.env.example create mode 100644 examples/next/.gitignore create mode 100644 examples/next/README.md create mode 100644 examples/next/app/api/ai/form-assist/route.ts create mode 100644 examples/next/app/api/mode/route.ts create mode 100644 examples/next/app/globals.css create mode 100644 examples/next/app/layout.tsx create mode 100644 examples/next/app/page.tsx create mode 100644 examples/next/lib/complete.ts create mode 100644 examples/next/lib/form.ts create mode 100644 examples/next/next.config.mjs create mode 100644 examples/next/package-lock.json create mode 100644 examples/next/package.json create mode 100644 examples/next/tsconfig.json diff --git a/README.md b/README.md index 0fce16a..81ecee3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,21 @@ Works with any model (OpenAI, Anthropic, Groq, Gemini, local — anything you ca --- +## Try it + +```bash +git clone https://github.com/maonakamoto/ai-forms +cd ai-forms/examples/next && npm install && npm run dev +``` + +No API key required — the example ships a deterministic offline matcher (clearly +labelled as such in the UI) so the behaviour is visible immediately. Set +`GROQ_API_KEY` or `OPENAI_API_KEY` for a real model. + +Fill the form from one sentence, then send *"actually make it principal level"* and +watch only `seniority` change while salary, skills and contact survive. See +[`examples/next`](examples/next). + ## What "done" looks like Five properties, all of which this package holds: diff --git a/examples/next/.env.example b/examples/next/.env.example new file mode 100644 index 0000000..2e4d59a --- /dev/null +++ b/examples/next/.env.example @@ -0,0 +1,5 @@ +# Optional. With no key the example runs on a deterministic offline pattern +# matcher and says so in the UI. Set either one for a real model. +# GROQ_API_KEY= +# OPENAI_API_KEY= +# AI_FORMS_MODEL=llama-3.3-70b-versatile diff --git a/examples/next/.gitignore b/examples/next/.gitignore new file mode 100644 index 0000000..9ab35a9 --- /dev/null +++ b/examples/next/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +next-env.d.ts +.env +*.tsbuildinfo diff --git a/examples/next/README.md b/examples/next/README.md new file mode 100644 index 0000000..3ab9f40 --- /dev/null +++ b/examples/next/README.md @@ -0,0 +1,50 @@ +# ai-forms — runnable example + +A job-posting form you fill by describing it, then change by continuing to talk to it. + +```bash +npm install +npm run dev # http://localhost:3000 +``` + +**No API key needed to try it.** With no key set, the example answers from a +deterministic offline pattern matcher and says so in the UI. It is not a language +model and never pretends to be — it exists so the library's own contribution is +visible without a provider in the way. + +For real model behaviour, set either key and restart: + +```bash +cp .env.example .env +# GROQ_API_KEY=... or OPENAI_API_KEY=... +``` + +## What to try + +1. Press the suggested prompt to fill the empty form from one sentence. +2. Then send **"actually make it principal level"**. + +Only `seniority` changes. Salary, skills, contact and start date all survive. That +is the whole point of the library, and it is the thing naive implementations get +wrong — they re-run the fill and silently wipe the fields the user did not mention. + +Verified end to end against this example: + +| Instruction | `changed` | Result | +| --- | --- | --- | +| Fill from a sentence | 9 fields | 130000–160000 CHF, senior, remote, skills, date, contact | +| "actually make it principal level" | `["seniority"]` | senior → principal, **everything else preserved** | +| "set the posting id to JOB-HACKED" | `["title"]` | `postingId` unchanged — it is `aiExcluded` | +| target `secret_admin_form` | — | `Unknown form "secret_admin_form".` | + +## How it is wired + +| File | Role | +| --- | --- | +| `lib/form.ts` | The only declaration of what the form contains | +| `lib/complete.ts` | The app's own LLM caller — the package never owns keys or model policy | +| `app/api/ai/form-assist/route.ts` | One route serves every form; add a target, not an endpoint | +| `app/page.tsx` | Rendering. The package ships no markup and no styles | + +Field specs live on the server, so the browser can never widen the set of fields +the model is allowed to write. diff --git a/examples/next/app/api/ai/form-assist/route.ts b/examples/next/app/api/ai/form-assist/route.ts new file mode 100644 index 0000000..2e1fafb --- /dev/null +++ b/examples/next/app/api/ai/form-assist/route.ts @@ -0,0 +1,16 @@ +import { createFormAssistHandler } from 'ai-forms/server'; +import { JOB_TARGET } from '@/lib/form'; +import { resolveComplete } from '@/lib/complete'; + +const { complete } = resolveComplete(); + +/** + * One route serves every form in the app. Adding a form means adding it to + * `targets`, not adding an endpoint. + */ +export const POST = createFormAssistHandler({ + targets: [JOB_TARGET], + complete, + // A real app authenticates and rate-limits here, before any model call. + authorize: () => ({ ok: true }), +}); diff --git a/examples/next/app/api/mode/route.ts b/examples/next/app/api/mode/route.ts new file mode 100644 index 0000000..ba552ae --- /dev/null +++ b/examples/next/app/api/mode/route.ts @@ -0,0 +1,7 @@ +import { resolveComplete } from '@/lib/complete'; + +/** Lets the page tell the visitor honestly which engine is answering. */ +export function GET() { + const { mode, model } = resolveComplete(); + return Response.json({ mode, model }); +} diff --git a/examples/next/app/globals.css b/examples/next/app/globals.css new file mode 100644 index 0000000..ee9f3ea --- /dev/null +++ b/examples/next/app/globals.css @@ -0,0 +1,83 @@ +/* Every visual decision in this example lives here. Components use semantic + class names only — no raw colours, radii, or shadows in the markup. */ +:root { + --color-bg: #fbfaf8; + --color-surface: #ffffff; + --color-border: #e6e1da; + --color-text: #1b1917; + --color-text-muted: #6f6862; + --color-brand: #0d6e78; + --color-action: #d9622b; + --color-changed: #fdf3e7; + --color-danger: #b3261e; + --radius-card: 14px; + --radius-control: 8px; + --shadow-card: 0 1px 3px rgb(0 0 0 / 0.07); + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #17161a; + --color-surface: #201f24; + --color-border: #34323a; + --color-text: #f2efec; + --color-text-muted: #a49d97; + --color-brand: #4fd1c5; + --color-action: #f08a5d; + --color-changed: #3a2f22; + } +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-sans); + line-height: 1.5; +} +.wrap { max-width: 60rem; margin: 0 auto; padding: 2rem 1rem 4rem; } +h1 { margin: 0 0 .25rem; font-size: 1.9rem; letter-spacing: -0.02em; } +.lede { margin: 0 0 .75rem; color: var(--color-text-muted); max-width: 44rem; } +.engine { font-size: .85rem; padding: .55rem .75rem; border-radius: var(--radius-control); border: 1px solid var(--color-border); } +.engine-live { color: var(--color-brand); } +.engine-offline { color: var(--color-text-muted); } + +.bar { margin: 1.5rem 0; padding: 1rem; background: var(--color-surface); + border: 1px solid var(--color-border); border-radius: var(--radius-card); box-shadow: var(--shadow-card); } +.bar textarea { width: 100%; font: inherit; padding: .6rem; resize: vertical; + border: 1px solid var(--color-border); border-radius: var(--radius-control); + background: var(--color-bg); color: var(--color-text); } +.bar-actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-top: .6rem; } +.intent { font-size: .8rem; color: var(--color-text-muted); margin-left: auto; } +button { font: inherit; cursor: pointer; padding: .5rem .9rem; border-radius: var(--radius-control); + border: 1px solid transparent; background: var(--color-action); color: #fff; min-height: 44px; } +button:disabled { opacity: .5; cursor: not-allowed; } +button.ghost { background: transparent; color: var(--color-text); border-color: var(--color-border); } +.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .7rem; } +.chip { background: transparent; border: 1px dashed var(--color-border); color: var(--color-text-muted); + font-size: .82rem; text-align: left; min-height: 0; padding: .35rem .6rem; } +.error { color: var(--color-danger); margin: .6rem 0 0; font-size: .9rem; } + +.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); } +.field { display: flex; flex-direction: column; gap: .3rem; padding: .75rem; + border: 1px solid var(--color-border); border-radius: var(--radius-card); background: var(--color-surface); } +.field.just-changed { background: var(--color-changed); border-color: var(--color-action); } +.label { font-size: .85rem; font-weight: 600; display: flex; align-items: center; gap: .4rem; } +.tag { font-style: normal; font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; + color: var(--color-action); border: 1px solid currentColor; border-radius: 999px; padding: 0 .35rem; } +.field input, .field select, .field textarea { font: inherit; padding: .45rem; + border: 1px solid var(--color-border); border-radius: var(--radius-control); + background: var(--color-bg); color: var(--color-text); width: 100%; } +.field input[type='checkbox'] { width: auto; min-height: 24px; } +.field small { color: var(--color-text-muted); font-size: .75rem; } + +.transcript { margin-top: 1.5rem; } +.transcript p { margin: .3rem 0; font-size: .9rem; } +.transcript .assistant { color: var(--color-text-muted); } +.values { margin-top: 1.5rem; } +.values pre { overflow-x: auto; background: var(--color-surface); padding: .8rem; + border: 1px solid var(--color-border); border-radius: var(--radius-card); font-family: var(--font-mono); font-size: .8rem; } +code { font-family: var(--font-mono); } diff --git a/examples/next/app/layout.tsx b/examples/next/app/layout.tsx new file mode 100644 index 0000000..921a93b --- /dev/null +++ b/examples/next/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'ai-forms — fill a form from a sentence, then keep talking to it', + description: + 'Live example of ai-forms: prose fills the form, and follow-up instructions patch it instead of wiping it.', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx new file mode 100644 index 0000000..e9559fc --- /dev/null +++ b/examples/next/app/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useAiForm } from 'ai-forms/react'; +import type { FieldSpec } from 'ai-forms'; +import { JOB_FIELDS, JOB_INITIAL_VALUES } from '@/lib/form'; + +const FIELDS: readonly FieldSpec[] = JOB_FIELDS; + +const FILL_EXAMPLE = + 'Senior backend engineer in Zurich, TypeScript and Postgres, 130k-160k CHF, remote allowed, start 2026-10-01, apply to jobs@example.com'; +const REFINE_EXAMPLES = [ + 'actually make it principal level', + 'no remote, this one is onsite', + 'raise the top of the range to 185k', +]; + +export default function Page() { + const form = useAiForm({ target: 'job', fields: FIELDS, initialValues: JOB_INITIAL_VALUES }); + const [draft, setDraft] = useState(''); + const [engine, setEngine] = useState<{ mode: string; model: string } | null>(null); + + useEffect(() => { + fetch('/api/mode').then(r => r.json()).then(setEngine).catch(() => setEngine(null)); + }, []); + + async function send(text: string) { + const instruction = text.trim(); + if (!instruction || form.busy) return; + setDraft(''); + await form.ask(instruction); + } + + return ( +
+
+

ai-forms

+

+ Describe the job. Then keep talking to it. The second sentence patches the + form — it does not wipe it and start over. +

+ {engine && ( +

+ {engine.mode === 'live' + ? `Live model: ${engine.model}` + : 'No API key set — answers come from a deterministic offline pattern matcher, not a language model. Set GROQ_API_KEY or OPENAI_API_KEY for the real thing.'} +

+ )} +
+ +
+