Skip to content
Open
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
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ applies to every space of this project. Security problems go through
| `pnpm typecheck` | `tsc --noEmit` |
| `pnpm build` | Typecheck + build `dist/` (ESM, CJS, rolled-up `.d.ts`) |
| `pnpm build:site` | Build the docs site into `site/` (demo app + API reference) |
| `pnpm wiki:sync` | Push `docs/wiki/` to the GitHub wiki (maintainers only) |

The build has one entry per public import path: `lib/index.ts` becomes
`dist/ai-form-fill.*` (imported as `ai-form-fill`) and `lib/voice/index.ts`
Expand Down Expand Up @@ -74,6 +75,16 @@ radio inputs) rather than the Radix-based shadcn `Select`/`Checkbox`/
`RadioGroup`: the library writes to native form controls, and a Radix widget
with a hidden mirror input would not update visually.

## Wiki

The [wiki](https://github.com/JDeffner/ai-form-fill/wiki) is authored in this repository, under
`docs/wiki/`. Edit the page files there and open a pull request like any other change; do not edit
the pages in the GitHub wiki UI, because the next sync overwrites them. After the pull request is
merged, a maintainer runs `pnpm wiki:sync`, which clones the wiki repository, replaces its Markdown
with `docs/wiki/*.md` and pushes. File names become page titles, so `Getting-Started.md` is the page
"Getting Started" and links between pages are `[Getting Started](Getting-Started)`. `_Sidebar.md` and
`_Footer.md` are the wiki's navigation.

## Docs site

`pnpm build:site` builds what GitHub Pages serves at
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ OpenAI-compatible API. Zero runtime dependencies.
| You want | Use |
| -------------------------------- | ------------------------------------------------------------- |
| A finished panel, no build step | A script tag plus `<ai-form-fill>` |
| A finished panel inside your app | `import 'ai-form-fill/ui'` plus `<ai-form-fill>` |
| A finished panel inside your app | `defineFormFillElement()` plus `<ai-form-fill>` |
| Your own interface | `createFormFill()`, `useFormFill()` or the `AIFormFill` class |

The element is built on the controller, the controller is built on the class.
Expand Down
146 changes: 146 additions & 0 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Architecture

This page is the orientation for contributors: the data flow, the layers on top of it, the domains,
the invariants, and how to extend the library. It mirrors `docs/ARCHITECTURE.md` in the repository.

The library is small on purpose: zero runtime dependencies, one data flow, three domains.

## Data flow

```
HTMLFormElement
| lib/form/analyze.ts getFormFields()
v
FieldInfo[] stable keys (name -> id -> field_<n>), labels
| (label[for], wrapping label, aria-*, title),
| normalized options, checkbox/radio grouping
v
lib/prompt/build.ts buildExtractionPrompt() + buildFormSchema()
| prompt text JSON schema with enums
v
AIProvider.chat() lib/providers/* - standard wire formats
| (Ollama REST, OpenAI chat-completions)
v
raw model output
| lib/prompt/parse-response.ts parseModelResponse()
v
Record<string, unknown> throws ResponseParseError on garbage
| lib/core/ai-form-fill.ts applyExtraction() loop, by field.key
v
lib/form/apply.ts applyFieldValue(): per-type coercion, exact option
| matching, strict ISO date validation, native
v prototype setters + input/change events
FillResult { filled(+previous), skipped(+reason),
unmatchedKeys, missingRequired, raw }
```

`extract()` is the same flow stopped one step early, at `Record<string, unknown>`: everything above
the apply loop, nothing below it. The loop itself is `applyExtraction(data, fields)`, and
`fillForm()` is literally `extract()` followed by that call, so the two cannot drift. A review step
calls the two halves separately, with the user's edits in between. See
[Events, Results and Review](Events-Results-and-Review).

`fillField` is the single-field variant of the same flow: `analyzeField` -> `buildFieldPrompt` ->
`chat` -> `applyFieldValue`.

Every step reports itself as a bubbling `CustomEvent` on the form (`lib/core/events.ts`):
`aff:start` before the request, `aff:field-filled` per written field, `aff:done` with the
`FillResult`, `aff:error` when the extraction throws. Nothing in the library listens to them; they
exist so a UI does not have to wrap the calls.

`lib/form/revert.ts` runs the flow backwards: `revertFill(result)` writes each `filled` entry's
`previous` value back through the same setters and events.

`lib/core/controller.ts` sits on top of all of it. `createFormFill()` resolves a form, a text source
and a trigger, owns one `AIFormFill`, and turns the promise into an observable `idle` / `working` /
`done` / `error` snapshot with `cancel` and `undo`. It adds no DOM of its own, so UI layers build on
the snapshot instead of on the promise.

## Layers

```
<ai-form-fill> useFormFill() your own UI
lib/ui/element.ts lib/react/use-form-fill.ts
\ | /
\ | /
------------ createFormFill() --------
lib/core/controller.ts
|
new AIFormFill()
lib/core/ai-form-fill.ts
|
form engine + prompt/schema + providers
lib/form/ lib/prompt/ lib/providers/

createDictation() -> text -> any of the three layers above
lib/voice/dictation.ts
```

Each layer is a separate entry point, so nothing is paid for twice:

| Entry point | Builds on | Contains |
| -------------------- | -------------- | ----------------------------------------- |
| `ai-form-fill` | - | Class, controller, form engine, providers |
| `ai-form-fill/ui` | the controller | The custom element, its markup and CSS |
| `ai-form-fill/react` | the controller | One hook, `react` stays external |
| `ai-form-fill/voice` | nothing | The Web Speech wrapper |

The voice module is text-out only. It never calls the core, so the two are independent: dictation
produces text, and what happens with it is the caller's decision.

## Domains

| Directory | Responsibility |
| ---------------- | ----------------------------------------------------------------------------- |
| `lib/core/` | Public class, controller, events, types, typed errors, defaults |
| `lib/form/` | DOM work: reading (`analyze.ts`), writing (`apply.ts`), undo (`revert.ts`) |
| `lib/prompt/` | LLM-facing text and schema (`build.ts`), output parsing (`parse-response.ts`) |
| `lib/providers/` | Transport: base class, Ollama, OpenAI-compatible, shared HTTP helper |
| `lib/ui/` | The `<ai-form-fill>` element and its stylesheet |
| `lib/react/` | The `useFormFill` hook |
| `lib/voice/` | Dictation through the Web Speech API |

## Key invariants

- **`FieldInfo.key` is the single source of field identity.** The prompt, the schema and the fill
loop all use it. Keys are derived once in `getFormFields` and deduplicated; never re-derive
identity elsewhere.
- **Option matching is exact** (value, then label, then case and whitespace insensitive equality). No
substring matching: `"male"` must never select `Female`. The schema's `enum` values make exact
model answers likely in the first place.
- **Application never throws for value problems.** `applyFieldValue` returns
`{ applied: false, reason }`; only provider and parse failures throw (`ProviderError`,
`ResponseParseError`).
- **Values go through native prototype setters** and `input` plus `change` events, so controlled
React, Vue and Svelte components observe the change.
- **Configuration is per-instance.** `AFF_DEFAULTS` is frozen; there is no global mutable state.
- **Schemas avoid provider-hostile constructs**: no `required` (extraction is optional per field),
and date and time constraints use `[0-9]` regex patterns, because grammar-based enforcers (Ollama,
llama.cpp) reject `\d` and would turn `format: 'time'` into RFC 3339 full-time with a UTC offset,
which HTML inputs reject.

## Adding a provider

Extend `AIProvider` (`lib/providers/provider.ts`):

1. Implement `chat`, `listModels` and `isAvailable` using `requestJson`
(`lib/providers/http.ts`) for uniform timeout, abort and error handling. Failures must surface as
`ProviderError`.
2. Set `supportsStructured = true` if the service accepts a JSON schema, and translate
`ChatRequest.format` into the service's mechanism (`response_format` for OpenAI-style, top-level
`format` for Ollama).
3. Export it from `lib/index.ts`; add wire-format tests with a stubbed `fetch` (see
`tests/providers/`).

A worked example is in [Providers](Providers).

## Adding a field type

1. `lib/form/analyze.ts`: make `analyzeField` extract whatever metadata the type needs (options,
multiplicity, and so on).
2. `lib/prompt/build.ts`: give it a schema shape and, if needed, a format hint in the extraction
prompt.
3. `lib/form/apply.ts`: add an application branch returning `applied` or `skipped` outcomes.
4. Add unit tests for all three layers plus an end-to-end case in `tests/core/ai-form-fill.test.ts`.

See [Contributing](Contributing) for the workflow around these changes.
116 changes: 116 additions & 0 deletions docs/wiki/Contributing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Contributing

This page covers the local setup, the scripts, the test layout and the pull request rules. It
mirrors `CONTRIBUTING.md` in the repository.

Bug reports, feature requests and pull requests are all welcome. The
[code of conduct](https://github.com/JDeffner/ai-form-fill/blob/main/CODE_OF_CONDUCT.md) applies to
every space of this project. Security problems go through
[the security policy](https://github.com/JDeffner/ai-form-fill/security/advisories/new), not through
a public issue.

## Setup

1. Install [Node.js](https://nodejs.org) 20 or newer and [pnpm](https://pnpm.io/installation).
2. Clone the repository and run `pnpm install`.
3. Optional, only for the integration tests and the local demo: install [Ollama](https://ollama.com)
and pull the default model.

```bash
ollama pull gemma3:4b
```

4. Optional, only for the cloud provider demos: copy `.env.example` to `.env` and fill in the keys
you have. `.env` is gitignored.

## Scripts

| Script | What it does |
| ----------------------- | ------------------------------------------------------------- |
| `pnpm dev` | Vite dev server with the demo pages |
| `pnpm test` | Unit tests once, no network needed |
| `pnpm test:watch` | Unit tests in watch mode |
| `pnpm test:integration` | The Ollama integration suite, needs a running Ollama |
| `pnpm lint` | ESLint over the whole repository |
| `pnpm lint:fix` | ESLint with autofix |
| `pnpm format` | Prettier write |
| `pnpm format:check` | Prettier check, the CI mode |
| `pnpm typecheck` | `tsc --noEmit` |
| `pnpm build` | Typecheck and build `dist/` (ESM, CJS, rolled-up `.d.ts`) |
| `pnpm build:site` | Build the docs site into `site/` (demo app and API reference) |
| `pnpm wiki:sync` | Push `docs/wiki/` to the GitHub wiki (maintainers) |

The build has one entry per public import path: `lib/index.ts` becomes `dist/ai-form-fill.*` and
`lib/voice/index.ts` becomes `dist/voice.*`. Add an entry in `vite.config.js` and an `exports` key in
`package.json` for every new one.

## Tests

- Unit tests live in `tests/` and run in a jsdom environment, so no local setup is required.
`document` and the DOM element classes are globals; reset state with `document.body.innerHTML = ''`
in a `beforeEach` if your test touches the DOM.
- The files in `tests/requirements/` trace to thesis requirements (FR-01 to FR-11). Keep the `FR-XX`
prefix in the file names, and update the tests when the API changes, but do not delete them.
- `tests/integration/ollama.integration.test.ts` talks to a real Ollama at
`http://localhost:11434` and **fails**, rather than skipping, when Ollama is unreachable. Run it
explicitly with `pnpm test:integration`.

## Demo app

`examples/` is a small React app with hash-routed pages in `examples/pages/`, styled with shadcn/ui.
The pages are Element, Controller, Voice, React hook, Advanced and Script tag. The last one links to
`examples/vanilla.html`, which is static, loads the built script-tag bundle by relative path and
imports nothing from `lib/`, so run `pnpm build` once before opening it. `examples/snippets/` holds
the Vue and Svelte snippets.

The generated shadcn components live in `examples/components/ui/`, `components.json` configures the
shadcn CLI (`pnpm dlx shadcn@latest add <component>`), and the `@/` alias points at `examples/`. All
of this is dev-only: the library build has no Tailwind or shadcn dependency, and the only entry that
touches React is `lib/react/`, where `react` is an optional peer dependency left external.

Forms in the demos use native controls (`NativeSelect`, plain checkbox and radio inputs) rather than
the Radix-based shadcn `Select`, `Checkbox` and `RadioGroup`: the library writes to native form
controls, and a Radix widget with a hidden mirror input would not update visually.

## Documentation

- **The wiki** is authored in the repository, under `docs/wiki/`. Edit the page files there and open
a pull request like any other change; do not edit the pages in the GitHub wiki UI, because the next
sync overwrites them. After the pull request is merged, a maintainer runs `pnpm wiki:sync`, which
copies `docs/wiki/*.md` into the wiki repository and pushes it. File names become page titles, so
`Getting-Started.md` is the page "Getting Started" and links between pages are
`[Getting Started](Getting-Started)`. `_Sidebar.md` and `_Footer.md` are the wiki's navigation.
- **The API reference** is generated from the TSDoc comments by `pnpm build:site`. Public symbols
carry TSDoc, and the rolled-up `dist/*.d.ts` preserves it for consumers, so document every new
exported symbol.
- **The README** stays the short overview; the long guides belong in the wiki.

## Docs site

`pnpm build:site` builds what GitHub Pages serves at https://jdeffner.github.io/ai-form-fill/ into
`site/`, which is gitignored:

1. `vite build -c vite.site.config.js` builds the demo app with the base path `/ai-form-fill/`. The
app is hash-routed, so it needs no rewrite rules.
2. `node scripts/build-site.mjs` copies `examples/vanilla.html` and the built
`dist/ai-form-fill.browser.js` into `site/examples/`. The page loads the bundle by relative path,
so the pair works under any base path.
3. `typedoc` generates the API reference from the four entry points into `site/api`, which the "Docs"
link in the demo header points at. In `pnpm dev` that link 404s until you have run
`pnpm build:site` once.

`.github/workflows/pages.yml` runs this on every push to `main` and deploys the result.

## Pull requests

- Keep pull requests focused. Renames and moves go in their own commit so diffs stay reviewable.
- Before pushing, run
`pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build`. CI runs exactly
these, on Node 20 and 22.
- Add a line under `## Unreleased` in `CHANGELOG.md` for anything a user would notice.
- Public APIs carry TSDoc comments, so please document any new exported symbol.
- `dist/` is tracked in git, because Composer installs from the repository. Do not edit it by hand,
but do commit the rebuilt output when you change `lib/`: CI fails a pull request against `main`
whose `dist/` is stale.

The layout of the source and the invariants a change has to keep are in [Architecture](Architecture).
Loading
Loading