Framework-agnostic library for AI-powered form filling. Extract structured data from unstructured text and automatically fill forms using Ollama or any OpenAI-compatible provider (OpenAI, Perplexity, OpenRouter, or your own).
- Uses LLMs to understand and extract data from natural language
- Automatically matches data to form fields (text, email, number, textarea, select, radio, checkbox, date/time,
<select multiple>, checkbox groups) - Structured output: a JSON schema (with exact option values as enums) is generated from your form
- Works with Ollama and any OpenAI-compatible API — standard wire format, no custom backend contract
- Framework-agnostic: values are applied through native setters +
input/changeevents, so vanilla JS, React (controlled components), Vue and Svelte all pick them up - Real results and errors:
fillFormreturns aFillResult; failures reject with typed errors - Field hints (
data-aff-hint) and field targeting - Zero runtime dependencies
npm install ai-form-fillThe fastest path runs fully local — no API keys, no backend. Install Ollama and pull a model:
ollama pull gemma3:4b<form id="aff-form" data-aff-provider="ollama">
<input type="text" name="name" placeholder="Name" />
<input type="email" name="email" placeholder="Email" />
<input type="tel" name="phone" placeholder="Phone" />
</form>
<textarea id="aff-text" placeholder="Paste your text here..."></textarea>
<button id="aff-text-button">Fill Form</button>import { autoInit } from 'ai-form-fill';
autoInit(); // that's itautoInit returns the created AIFormFill instance (or null with a console
warning when a required element is missing — it never throws).
| Element id | Purpose |
|---|---|
aff-form |
The form to fill (configurable via autoInit({ formId })) |
aff-text |
Textarea holding the source text |
aff-text-button |
Button that triggers the fill |
Attributes on the form: data-aff-provider (ollama, openai, perplexity, openrouter — case-insensitive, defaults to ollama) and optional data-aff-model.
import { AIFormFill } from 'ai-form-fill';
const aiForm = new AIFormFill('ollama', { model: 'gemma3:4b', debug: true });
const form = document.getElementById('myForm') as HTMLFormElement;
const text = 'My name is John Doe, email john@example.com, phone 555-1234';
const result = await aiForm.fillForm(form, text);
console.log(result.filled); // [{ key: 'name', element, value: 'John Doe' }, ...]
console.log(result.skipped); // [{ key: 'birthDate', reason: 'invalid-date-format' }, ...]
console.log(result.unmatchedKeys); // keys the model answered that match no field
console.log(result.raw); // raw model output, for debuggingProvider and parsing failures reject with typed errors instead of failing silently:
import { ProviderError, ResponseParseError } from 'ai-form-fill';
try {
await aiForm.fillForm(form, text);
} catch (error) {
if (error instanceof ProviderError) {
// network / HTTP / timeout — error.provider, error.status
} else if (error instanceof ResponseParseError) {
// the model returned something unusable — error.raw has the output
}
}Per-field problems (an option that doesn't exist, a malformed date) never
throw — they land in result.skipped with a reason.
fillForm writes straight to the form. When you want the user to confirm
first, call extract instead: same request, same parsing, but nothing is
written. Apply what they accept with the exported applyFieldValue.
import { AIFormFill, applyFieldValue } from 'ai-form-fill';
const { data, fields } = await aiForm.extract(form, text);
// data is keyed by field key: { firstName: 'John', email: 'john@example.com' }
showReviewUI(data);
// ...then apply only what the user accepted
for (const field of fields) {
if (accepted.has(field.key)) applyFieldValue(field.element, data[field.key]);
}fillForm is exactly extract followed by applying every value, so the two
never drift apart.
const bio = document.querySelector('#bio') as HTMLElement;
const outcome = await aiForm.fillField(bio); // { value } or nullconst controller = new AbortController();
aiForm.fillForm(form, text, { signal: controller.signal });
controller.abort(); // rejects with the abort reasonOpenAICompatibleProvider speaks the standard OpenAI wire format
(POST {baseUrl}/chat/completions, GET {baseUrl}/models), so it works with
any OpenAI-compatible endpoint. The recommended production setup is
pointing baseUrl at a small server-side passthrough that injects your API
key — see examples/server for a ~40-line, zero-dependency
Node proxy (LiteLLM or an API gateway work just as well):
import { AIFormFill, OpenAICompatibleProvider } from 'ai-form-fill';
// Via your proxy (recommended): the key stays server-side.
const provider = new OpenAICompatibleProvider('openai', {
baseUrl: 'https://my-app.com/ai', // your passthrough
});
const aiForm = new AIFormFill(provider);For quick local prototyping you can talk to the API directly — this requires an explicit opt-in because anyone can read the key from a shipped page:
const provider = new OpenAICompatibleProvider('openrouter', {
apiKey: 'sk-or-...',
allowApiKeyInBrowser: true, // prototyping only, never production
model: 'openai/gpt-4o-mini',
});Presets supply default base URLs and models: openai → https://api.openai.com/v1, openrouter → https://openrouter.ai/api/v1, perplexity → https://api.perplexity.ai. Any other OpenAI-compatible service works by name + baseUrl:
const lmstudio = new OpenAICompatibleProvider('lmstudio', {
baseUrl: 'http://localhost:1234/v1',
model: 'qwen2.5-7b-instruct',
});Note: Perplexity has no
GET /modelsendpoint, solistModels()/isAvailable()fail against it directly — set the model explicitly or let your proxy answer/models(the dev mocks in this repo do exactly that).
new AIFormFill('ollama', {
model: 'gemma3:4b',
baseUrl: 'http://localhost:11434', // optional
timeout: 40000, // optional, ms
targetFields: ['firstName', 'email'], // optional whitelist of field keys
debug: true, // per-instance logging
});Defaults live in the frozen AFF_DEFAULTS export; all configuration is
per-instance (there is no global mutable state).
aiForm.setFields(['name', 'phone']); // only fill these
aiForm.setFields(undefined); // back to all fieldsFields are identified by a stable key: name attribute → id →
field_<n>, deduplicated on collision. Targeting by name therefore works
naturally.
Give the model per-field guidance:
<input type="date" name="startDate" data-aff-hint="Use the earliest date mentioned in the text" />
<textarea name="bio" data-aff-hint="Professional summary, max 2 sentences"></textarea>await aiForm.getAvailableModels(); // string[] (throws ProviderError when unreachable)
await aiForm.setSelectedModel('mistral'); // validated against the list; false if not offered
await aiForm.setSelectedModel('sonar', { validate: false }); // set unvalidatedVoice stays out of the core: speech becomes text (Web Speech API), text goes
into the same fillForm call. See examples/pages/voice.tsx for a
complete page — the essence is:
const recognition = new (window.SpeechRecognition ?? window.webkitSpeechRecognition)();
recognition.onresult = (event) => {
textarea.value = event.results[0][0].transcript;
};
recognition.start(); // then: aiForm.fillForm(form, textarea.value)Best supported in Chrome; other browsers fall back to typing.
- API keys never belong in shipped frontend code. The library refuses
apiKeyin the browser unless you explicitly passallowApiKeyInBrowser: true(for local prototyping). Production setups pointbaseUrlat a server-side proxy that injects the key —examples/serveris a complete reference. - The source text is sent to the configured provider. Form-filling input is frequently personal data (names, addresses, resumes), which matters under GDPR: with the local Ollama provider the text never leaves the machine; with cloud providers it goes to that vendor under your agreements with them.
- Treat the text as untrusted (prompt injection). The text pasted or
dictated by a user is model input and can contain instructions. The blast
radius is limited by design — extracted values only land in form fields the
user can review, constrained by the generated schema — but do not
auto-submit forms after filling, and validate server-side as you would for
any user input. When the form carries anything consequential, prefer
extractand an explicit confirmation step overfillForm.
new AIFormFill(provider: BuiltInProviderName | AIProvider, options?: AIFormFillOptions)| Method | Description |
|---|---|
fillForm(form, text, opts?) |
Parse text, fill matching fields → Promise<FillResult> |
extract(form, text, opts?) |
Parse text only, form untouched → Promise<ExtractResult> |
fillField(element, opts?) |
Generate + apply content for one field → { value } | null |
setProvider(provider) / getProvider() |
Swap / read the active provider |
setFields(keys) / getFields() |
Restrict which fields are filled |
getAvailableModels() |
Models offered by the provider |
setSelectedModel(model, opts?) / getSelectedModel() |
Choose the model |
isProviderAvailable() |
Reachability check (never throws) |
One-line setup for the quick-start layout. { formId?, provider?, model?, debug? } → AIFormFill | null.
OllamaProvider, OpenAICompatibleProvider, and the AIProvider base class
for custom providers (implement chat, listModels, isAvailable). See
docs/ARCHITECTURE.md for the data flow and extension
points.
Evergreen browsers (Chrome, Edge, Firefox, Safari). The library uses standard
DOM APIs, fetch and AbortController only. The optional voice example
additionally needs the Web Speech API (Chrome).
Run pnpm dev and open the landing page. The demos are one small React app
(examples/) styled with shadcn/ui; React, Tailwind
and shadcn are dev dependencies only, the library itself ships nothing but
the bundle.
| Page | Description |
|---|---|
pages/basic.tsx |
One-line autoInit() setup |
pages/advanced.tsx |
Provider/model switching, single-field fill, FillResult log |
pages/voice.tsx |
Web Speech API transcript into fillForm |
pages/controlled.tsx |
Controlled React components receive AI-filled values |
server/ |
Zero-dependency passthrough proxy for cloud providers |
The dev server ships passthrough proxies (mock/*.mock.ts) for OpenAI,
OpenRouter and Perplexity: copy .env.example to .env, add a key, pick the
provider in the advanced demo.
pnpm install
pnpm dev # demo pages
pnpm test # unit tests (no network needed)
pnpm test:integration # requires Ollama + gemma3:4b
pnpm lint && pnpm typecheck && pnpm buildSee CONTRIBUTING.md for the full setup, and docs/ARCHITECTURE.md for how the pieces fit together.
pnpm build emits to dist/:
ai-form-fill.js/ai-form-fill.umd.cjs— the bundled library (ESM and UMD)ai-form-fill.d.ts— rolled-up type declarations
The .d.ts keeps all TSDoc comments from the source, so consumers get hover
documentation and IntelliSense. Keep doc comments on exported APIs and leave
declaration: true plus the vite-plugin-dts plugin enabled.
The package is installable via Composer (jdeffner/ai-form-fill) for use in
PHP asset pipelines: dist/ is tracked in the repository, so include
dist/ai-form-fill.umd.cjs (script tag / asset pipeline) or the ESM build
from your bundler. There are no PHP classes — it is a JavaScript asset
package.