Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions examples/next/.env.example
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions examples/next/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.next
next-env.d.ts
.env
*.tsbuildinfo
50 changes: 50 additions & 0 deletions examples/next/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions examples/next/app/api/ai/form-assist/route.ts
Original file line number Diff line number Diff line change
@@ -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 }),
});
7 changes: 7 additions & 0 deletions examples/next/app/api/mode/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
83 changes: 83 additions & 0 deletions examples/next/app/globals.css
Original file line number Diff line number Diff line change
@@ -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); }
16 changes: 16 additions & 0 deletions examples/next/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
149 changes: 149 additions & 0 deletions examples/next/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="wrap">
<header>
<h1>ai-forms</h1>
<p className="lede">
Describe the job. Then keep talking to it. The second sentence <em>patches</em> the
form — it does not wipe it and start over.
</p>
{engine && (
<p className={`engine engine-${engine.mode}`}>
{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.'}
</p>
)}
</header>

<section className="bar">
<textarea
rows={2}
value={draft}
placeholder={form.isEmpty ? 'Describe the role…' : 'Now change something…'}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void send(draft);
}}
/>
<div className="bar-actions">
<button onClick={() => void send(draft)} disabled={form.busy || !draft.trim()}>
{form.busy ? 'Thinking…' : form.isEmpty ? 'Fill the form' : 'Apply change'}
</button>
<button className="ghost" onClick={form.undo} disabled={!form.canUndo}>Undo</button>
<button className="ghost" onClick={form.reset}>Reset</button>
<span className="intent">
intent: <code>{form.isEmpty ? 'fill' : 'refine'}</code> (inferred)
</span>
</div>
<div className="chips">
{(form.isEmpty ? [FILL_EXAMPLE] : REFINE_EXAMPLES).map(example => (
<button key={example} className="chip" onClick={() => void send(example)} disabled={form.busy}>
{example}
</button>
))}
</div>
{form.error && <p className="error">{form.error}</p>}
</section>

<section className="grid">
{FIELDS.filter(f => !f.aiExcluded).map(field => (
<label key={field.name} className={form.changed.includes(field.name) ? 'field just-changed' : 'field'}>
<span className="label">
{field.label}
{form.isAiTouched(field.name) && <em className="tag" title="Written by the assistant">ai</em>}
</span>
<FieldInput field={field} form={form} />
{field.hint && <small>{field.hint}</small>}
</label>
))}
</section>

{form.transcript.length > 0 && (
<section className="transcript">
<h2>Conversation</h2>
{form.transcript.map((turn, i) => (
<p key={i} className={turn.role}>
<strong>{turn.role === 'user' ? 'You' : 'Assistant'}</strong> {turn.text}
</p>
))}
</section>
)}

<details className="values">
<summary>Form values</summary>
<pre>{JSON.stringify(form.values, null, 2)}</pre>
</details>
</main>
);
}

function FieldInput({ field, form }: { field: FieldSpec; form: ReturnType<typeof useAiForm> }) {
const value = form.values[field.name];

switch (field.type) {
case 'textarea':
return <textarea rows={4} value={form.text(field.name)} onChange={e => form.setValue(field.name, e.target.value)} />;
case 'select':
return (
<select value={form.text(field.name)} onChange={e => form.setValue(field.name, e.target.value)}>
<option value="">—</option>
{(field.options ?? []).map(o => (
<option key={o.value} value={o.value}>{o.label ?? o.value}</option>
))}
</select>
);
case 'boolean':
return <input type="checkbox" checked={value === true} onChange={e => form.setValue(field.name, e.target.checked)} />;
case 'number':
return (
<input type="number" value={form.text(field.name)}
onChange={e => form.setValue(field.name, e.target.value === '' ? '' : Number(e.target.value))} />
);
case 'tags':
return (
<input value={Array.isArray(value) ? value.join(', ') : form.text(field.name)}
placeholder="comma separated"
onChange={e => form.setValue(field.name, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
);
case 'date':
return <input type="date" value={form.text(field.name)} onChange={e => form.setValue(field.name, e.target.value)} />;
default:
return (
<input type={field.type === 'email' ? 'email' : 'text'} placeholder={field.placeholder}
value={form.text(field.name)} onChange={e => form.setValue(field.name, e.target.value)} />
);
}
}
Loading
Loading