The web UI build ships in ONE artifact that runs in three environments. Every environment-specific decision funnels through two modules so pages stay small:
web_ui/src/lib/desktop-session.tsx—isElectron(), boot discovery (initDesktopSession), theDesktopSessionProvider/useDesktopSessioncontext, andmodelsAbsentForRealEngine(the first-run gate predicate).web_ui/src/types/desktop.d.ts— the ambientwindow.desktopApitype (the bridge is exposed bydesktop/preload/index.ts).
| Mode | When | Documents / ingest / settings | Chat |
|---|---|---|---|
| Electron-hosted | window.desktopApi present (packaged app:// renderer OR the Electron dev window) |
ApiClient against the Electron-hosted loopback backend (GET/DELETE /documents, POST /ingest/file), settings via GET/PUT /settings |
mode === 'api' branch posting to {backend}/ask/stream |
| Pure-browser (local) | plain browser, browser-local mode | IndexedDB + WASM pipeline (wllama/ONNX/EdgeVec/FlexSearch) | RAGOrchestrator in-page |
Both apps can also generate with an External model (Settings → Model & connection): an
OpenAI- or Anthropic-compatible endpoint, local, LAN or cloud, off by default. Retrieval
stays local; inside Electron the desktop backend makes the request (/status/models
reports engine external), in the plain browser the page does. See CONFIGURATION.md and
ADR-0011. The former browser "Remote Python
server" mode (user-entered server URL, {serverUrl}/ask/stream) was removed
(settings-wiring-honesty): mode === 'api' now exists only inside Electron, and a stored
browser api mode migrates to browser-local on load.
- Discovery —
desktopApi.getBackendInfo()returns{ mode, port, url }(frozen address-only shape). The backend binds127.0.0.1on a NEW random port every launch, and the launch token rotates too, so the renderer MUST rediscover both at every boot (DesktopBootGateinApp.tsx). - Auth — every request carries the per-launch token in the
X-Desktop-Tokenheader (RAW token value — noBearerprefix; the desktop loopback guard compares it directly). The Python surface'sAuthorization: Bearerconvention is untouched and remains what direct API clients ofapi_server.pyuse (the browser app has no server mode). - CORS — the guard's allowlist is
['app://*']; the dev window (ELECTRON_START_URL, e.g. thevite previeworigin used by the e2e suite) must be added viaTRAININGAPP_DESKTOP_DEV_ORIGINS. Browser preflights (OPTIONS) are answered without the token by design; every real request still requires it. Responses carryCross-Origin-Resource-Policy: cross-originbecause the renderer page sets COEPrequire-corp. - Inference mode — Electron boots seed
localStorage['inference-mode']withmode: 'api'and the fresh backend URL before the provider mounts. Browser-local engine boot (useServiceInitialization) is skipped. - First-run model gate — at boot the renderer reads
GET /status/models({ engine, profile, models: { quality, fast }, resident?: { state: 'idle'|'loading'|'ready', profile, loadStartedAt } };residentreports the model-load state the chat gate keys on). Chat send is blocked with an informative overlay ONLY when a REAL engine (engine !== 'stub') has NO model file for either profile; the CI/dev stub answers/askwithout weights and is never gated.
- No per-document delete in Electron mode. The frozen API contract only
exposes
DELETE /documents(clear all) — per-document delete does not exist on either backend. Electron mode hides the per-document button and offers "Clear all" behind an explicit two-step confirm. - No IndexedDB migration. Documents created in the pure-browser build stay in IndexedDB; Electron mode lists the server store. Switching back to the pure-browser build shows them again.
X-Profile-Idstays unwired — desktop profile scoping is by store path (ADR-0006); the status UI only DISPLAYS the profile name.
App.tsx—DesktopBootGate(discovery, model status, mode seeding) and theskipflag foruseServiceInitialization.DocumentsPage.tsx— load/upload/delete handlers, plus the C7 Knowledge Packs surface (issue #74): thePacksPanelmounts in both apps over the shared PackClient (ADR-0012, which superseded the ADR-0009 gate). In Electron mode a dropped.ziproutes toPOST /packs/installand pack operations never touch the browser-profile IndexedDB namespaces; in the browser app the same panel installs into the browser pack store.SettingsPage.tsx— settings load/save via the backend, "Desktop backend" status section; server-URL + browser-model sections hidden.ChatPage.tsx— SSE URL/token/header from the session; model gate. C7 (issue #74): the SSE done event's pack-attributedcitationsrender as<pack> v<version>provenance in the citation pills.useDocumentCount.ts— count fromGET /documents;recount()exposed for same-tab refresh after upload/clear.InferenceModeContext.tsx— connectivity probe carries the session header.