Reference web client for the OpenAgriNet Experience Layer. It talks to the Experience API, which in turn calls the Decision Support System (DSS). Its job is to make the DSS's capabilities visible — streamed answers, provenance, refusals, and the non-answered outcomes — in a deployment that carries no adopter branding.
Status: early. Forked 2026-09, under active refactoring. Nothing here is stable yet. See
docs/ADR/0000-fork-oan-ui.md§6 for what came across from upstream.
This repository is a hard fork of OpenAgriNet/OAN-UI,
taken from branch bh-main at commit
50aa452
(2026-08-12), MIT licensed. The complete upstream history is preserved here and
the fork point is tagged fork-point:
git log fork-point..main # everything this project changed
git diff fork-point..mainUpstream is a production tenant application. This repository exists because a
reference implementation needs to be configurable and unbranded, which that
application was never required to be. See
docs/ADR/0000-fork-oan-ui.md for why we forked
rather than built fresh, and what that decision costs.
It is a demonstration client: a neutral, configurable reference that shows what the Experience API and DSS can do, and a worked example for teams building their own client.
It is not a product, and it is not intended for adopters to deploy or fork as their branded farmer-facing app. It carries no tenant data, no production hardening, and no compatibility guarantees. Adopters should build their own client against the Experience API contract and treat this as a reference, not a starting point.
If you find yourself forking this to ship it, that is a signal the Experience API contract needs better documentation — please open an issue instead.
Requires Node 22 or newer.
npm install
npm run dev # http://localhost:3000No environment variables are required to boot. Where the app sends API calls
is api.baseUrl in config.json, an absolute path on the app's own origin.
The default, /experience/api, is the reference deployment's path (see
Serving from a sub-path).
The dev server proxies api.baseUrl to an Experience API on
http://localhost:8078; set DEV_API_URL for another address. Without one,
every question shows an error card.
To work with no API at all, set stubs.enabled to true in
src/config/app-config.json. Every endpoint then returns canned data, nothing
leaves the browser, and the chat works end to end — each reply is labelled as
stubbed so fabricated agricultural advice cannot be mistaken for real. The
shipped file is the deployed one, so do not commit the change. See
src/lib/api-stubs.ts.
The hooks live in .husky/ and are activated by a single local git setting,
core.hooksPath. Installing dependencies sets it, because package.json runs
husky from its prepare script:
npm installCheck that yours are actually active:
git config core.hooksPath # should print .husky/_If that prints nothing, the hooks are not running. Fix it with npx husky.
This is worth checking rather than assuming: core.hooksPath is local to your
clone and is not committed, so anyone who installed dependencies before a hook
existed silently has no hooks.
| Hook | Runs |
|---|---|
pre-commit |
lint, typecheck, knip, tests |
commit-msg |
checks the subject against the convention in CONVENTIONS.md |
Hooks are fast feedback, not the gate — they can be skipped with
--no-verify, so CI runs the same checks.
The image is a static build served by nginx. It expects to sit behind something else — a reverse proxy terminating TLS and handling access control — and does nothing about either itself.
docker compose up --build # http://localhost:8080or without compose:
docker build -t experience-ui .
docker run -p 8080:8080 experience-uiGET /healthz returns ok for whatever is in front.
The client is built for the origin root by default. To mount it under a path on a shared domain, build with that path and have the proxy strip the prefix:
docker build --build-arg VITE_BASE_PATH=/experience/ -t experience-ui .location /experience/ {
proxy_pass http://experience-ui:8080/; # the trailing slash strips the prefix
}
location = /experience {
return 301 /experience/; # otherwise a bare /experience 404s
}The Experience API sits behind the same proxy, one level down. api.baseUrl
in config.json defaults to /experience/api to match this layout, so
nothing is mounted over the shipped config. The proxy routes that path to the
API and strips the prefix:
location /experience/api/ {
proxy_pass http://experience-api:8000/; # your API's address; the slash strips the prefix
proxy_buffering off; # or the answer arrives all at once
proxy_read_timeout 120s; # the whole-turn timeout; nginx's default is 60s
client_max_body_size 2m; # above the DSS's 1 MB, so the DSS decides "too long"
}A deployment at another path changes api.baseUrl to match. Nothing derives
it from VITE_BASE_PATH: the two can differ, and an API call that quietly
went to the wrong place would be hard to trace.
Leading and trailing slashes on VITE_BASE_PATH both matter. The path is baked
into the bundle, so an image built for a sub-path serves only from that
path — a deployment that needs two paths builds two images. In CI it comes
from the VITE_BASE_PATH repository variable, defaulting to /.
Note that nginx resolves a proxy_pass hostname once at config load: restart
the app container and the proxy keeps the old address until it is reloaded too.
logo, favicon and assistantAvatar are used exactly as written — nothing
rewrites them. Two forms work:
"logo": "brand/logo.png", // a file served by this app
"logo": "https://cdn.example.org/logo.svg" // anywhere elseFor a file, put it under public/ and reference it without a leading
slash, so it resolves relative to wherever the app is served from. A leading
slash pins it to the domain root and breaks a sub-path deployment.
Everything a deployment changes — name, logo, favicon, colours, which features
are on, whether stubs are enabled — lives in config.json, read at boot and
applied without a rebuild. The image ships a default, so an unconfigured
container runs.
To override it, mount a directory containing config.json at
/etc/experience-ui:
docker run -p 8080:8080 -v "$PWD/my-config:/etc/experience-ui:ro" experience-uidocker-compose.yml has the same mount commented out — uncomment it once the
directory exists. It is left off by default because an empty or missing
directory would shadow the config the image ships with.
# Kubernetes
volumeMounts:
- name: config
mountPath: /etc/experience-ui
volumes:
- name: config
configMap:
name: experience-ui-config # with a config.json keyA directory rather than a single file, deliberately: single-file bind mounts
fail outright on some container runtimes, and a ConfigMap mounted with subPath
never sees later updates.
config.json is served with Cache-Control: no-store, so a changed
configuration takes effect on the next reload rather than whenever a cache
happens to expire. Content-hashed assets under /assets/ are cached for a year;
index.html is not cached, because it names those assets.
| Doc | Contents |
|---|---|
docs/ADR/ |
Accepted architecture decisions |
AGENTS.md |
What this repo is, its stack, layout, testing patterns and gotchas |
CONVENTIONS.md |
Naming, git workflow, commit format, linting |
MIT — see LICENSE. The upstream copyright notice is retained as
that license requires.