Modelforge is an Electron desktop app split into three independently-buildable packages, plus a standalone Python project used offline to train a small model:
frontend/ React 19 + Vite renderer — everything the user sees
app/ Electron main process — window/IPC/OS integration, provider adapters, persistence
lib/ Rust native addon (napi-rs) — GGUF downloads, plus atomic JSON store I/O and audit-log hashing
ml/ Standalone Python project — trains the hardware/model-fit recommender offline
frontend and app are separate npm workspaces (separate package.json, separate
node_modules) glued together only by the IPC bridge described below and by the build step that
copies frontend/dist into the packaged app as a static asset. lib is a separate Cargo crate
compiled to a native Node addon and copied into app/native. ml's training pipeline is not
built by npm run build:all or run in CI — it's a separate, manual, offline step — but its
output (a small trained .pt file) is bundled as a resource (see extraResources /
python in app/package.json) and consumed at runtime by a managed Python worker; see
ml/hardware-recommender/ below for exactly how.
Electron's two-process split is used exactly as intended, with nothing loosened for convenience:
- Main process (
app/src/main.ts) — a normal Node.js process with full OS access. Owns theBrowserWindow, registers everyipcMain.handlechannel, and is the only place that talks to Ollama, cloud provider APIs, the filesystem, child processes, and the OS keychain. - Renderer process (
frontend/) — the UI, running withcontextIsolation: trueandnodeIntegration: false. It has no direct access to Node or Electron APIs. - Preload script (
app/src/preload.ts) — the only bridge between them. It usescontextBridge.exposeInMainWorldto expose a fixed, typedwindow.electronAPIsurface (seefrontend/src/types/electron.d.tsfor the renderer-side type). The renderer can only ever call the specific functions this file exposes — there is no generic "run arbitrary IPC channel" escape hatch.
This means every capability the UI has — reading a file, starting Ollama, calling a cloud
provider, writing a setting — exists because a corresponding ipcMain.handle("namespace:action", …)
was deliberately added in main.ts and mirrored in preload.ts. Grep for ipcMain.handle in
app/src/main.ts for the authoritative, current list of every capability the renderer has; the
channel names are namespaced by feature (ollama:*, sessions:*, agent:*, downloads:*,
data:*, and so on).
Streaming responses (chat tokens, download progress, terminal output) use the complementary
event.sender.send(...) / ipcRenderer.on(...) push channel rather than invoke, since invoke
is request/response only.
- Routing / pages (
src/pages/):Chat.tsx(the main conversation view and Agent mode UI),Settings.tsx(by far the largest file in the app — every settings tab lives here),Compare.tsx(side-by-side multi-model prompting),DownloadCenter.tsx,RuntimeManager.tsx,UsageDashboard.tsx. - Shared components (
src/components/): layout chrome, the command palette, Markdown/Mermaid rendering, the onboarding wizard, the embedded terminal panel, andui/— generated shadcn-style primitives on top of Base UI. src/lib/— logic that isn't a component: i18n (i18n.tsx,translations.ts), the model catalog and pricing tables (model-catalog.ts,pricing.ts), provider capability metadata (providers.ts), context-window compaction (context-compaction.ts), keybindings, tool-approval policy (tool-approval.ts— see Agent mode), andsessions-context.tsx, the React context that owns in-memory chat/session state and talks to the main process throughwindow.electronAPI.
The renderer never talks to Ollama, OpenAI, Anthropic, etc. directly — every provider call is proxied through the main process so API keys never need to reach the renderer's JS context, and so CSP can block the renderer from making arbitrary network requests at all.
Production builds inline everything (JS, CSS, even encoded assets) into a single index.html via
vite-plugin-singlefile, so the packaged app can load the UI directly from file:// without a
local HTTP server — matching how it's actually loaded in production, so npm run dev and a
packaged build exercise the same loading path in spirit.
Roughly grouped by responsibility:
| Area | Files |
|---|---|
| Bootstrap | main.ts (window creation, all IPC registration), menu.ts, preload.ts |
| Provider adapters | providers/openai.ts, providers/anthropic.ts, providers/gemini.ts, providers/openai-compatible.ts (shared implementation for every OpenAI-compatible custom endpoint), providers/sse.ts (shared streaming parser), providers/types.ts |
| Local model runtimes | ollama-manager.ts (start/stop/pull/list against a local or remote Ollama daemon), llamacpp-manager.ts (GGUF inference via node-llama-cpp, GPU backend detection), local-server-manager.ts (custom OpenAI-compatible local backends: vLLM, LocalAI, TGI, etc.) |
| Agent mode | agent-tools.ts (every tool implementation, workspace sandboxing, the destructive-command blocklist), command-sandbox.ts (OS-level sandboxing via bubblewrap/sandbox-exec where available), terminal-manager.ts (the embedded PTY-backed terminal panel), mcp-client.ts (external MCP server connections) |
| Persistence | json-store.ts (shared atomic-write/corruption-recovery helper), settings-store.ts, sessions-store.ts, projects-store.ts, secrets-store.ts (OS-keychain-backed via Electron safeStorage), scheduled-tasks-store.ts, download-jobs-store.ts, energy-usage-store.ts, patient-cases-store.ts, audit-log-store.ts, evidence-store.ts (see Clinical workspace) |
| Clinical safety | medical-safety.ts (emergency detection, conflict warnings, redaction, citation checks), case-encryption.ts (AES-256-GCM encryption at rest for patient case data), mcp-oauth.ts (OAuth 2.1 + PKCE for HTTP MCP servers) — see Clinical workspace |
| Retrieval | rag.ts / rag-db.ts — chunking, Ollama-embedding, and similarity search for large folder/file attachments |
| Models & downloads | huggingface.ts (Hub search API), download-queue.ts / download-worker.ts / native-downloader.ts (resumable parallel downloads, using the Rust addon when available), download-verification.ts (checksum/size verification) |
| System | system-specs.ts (RAM/VRAM detection across all GPUs), resource-monitor.ts, power-monitor.ts, process-tree.ts, benchmark-runner.ts |
| Integrations | figma.ts, ocr.ts (tesseract.js), accounts.ts (linked GitHub account), scheduler.ts (scheduled chat-completion tasks) |
| Misc | logger.ts (rotating file logs surfaced in Settings → Diagnostics), updater.ts (GitHub Releases auto-update), data-transfer.ts (export/import), keybindings.ts, python-runtime-manager.ts (manages the bundled Python workers under app/python/) |
All app state (settings, sessions, projects, scheduled tasks, download jobs, energy history) is
plain JSON files under Electron's userData directory, going through the shared helpers in
json-store.ts:
- Atomic writes — write to a temp file, then rename over the real one. A rename is atomic on both Windows and POSIX filesystems, so a crash or power loss mid-write can never leave a reader looking at a half-written file.
- Corruption recovery — if an existing file fails to parse as JSON, it's copied aside as
<file>.corrupted-<timestamp>and logged, rather than silently discarded. This means the next write can't accidentally overwrite salvageable data with a blank slate.
Secrets (provider API keys) go through secrets-store.ts instead, which encrypts values at rest
via Electron's safeStorage (backed by the OS credential store — Keychain, DPAPI, or
libsecret/kwallet) rather than writing them out as plain JSON. On a system with no OS credential
store available (safeStorage.isEncryptionAvailable() returns false — some keyring-less Linux
setups), the store falls back to writing the value unencrypted rather than silently dropping the
key; this fallback is logged at warn level, and secrets:isEncryptionAvailable lets the renderer
check the same condition to show a plaintext-storage warning in Settings instead of the normal
"encrypted at rest" note.
A small Rust crate (modelforge-native), compiled with napi-rs into a
platform-specific .node addon that app/src loads like any other Node module (see
app/native/index.js). It currently backs two independent areas, each with its own
TypeScript bridge module and its own pure-Node fallback:
- Downloads (
native-downloader.ts,lib/src/download/,lib/src/manager.rs):download_gguf_fileresumes an interrupted Hugging Face GGUF download using parallel HTTP Range-request connections when the server and file size support it, falling back to a single stream otherwise;DownloadManagerhandles job queuing, global concurrency, and bandwidth limiting. Mirrors the signature and observable behavior of the TypeScript downloader it can replace, sodownload-queue.tscan use either implementation interchangeably. - Data store I/O (
native-datastore.ts,lib/src/datastore.rs): atomic JSON file read/write (temp-file-then-rename, private file mode on unix) backingjson-store.ts— and therefore every*-store.tsin the app — plus SHA-256 hashing and an O(1) JSON-array append used byaudit-log-store.ts's tamper-evident hash chain. See docs/RUST_DATASTORE_TEST_REPORT.md for what moving the audit log's append off a full read-modify-write actually fixed (an O(n²) growth pattern), and docs/RUST_MIGRATION_ASSESSMENT.md for what else in the TypeScript layer is and isn't a good candidate to move here next.
Every datastore function falls back to an equivalent pure-TypeScript implementation when the
addon isn't loaded (dev/test/E2E environments don't build it by default) — getNativeCapabilityReport()
in native-datastore.ts/native-downloader.ts distinguishes why the addon didn't load (missing
build, wrong ABI/Node version, unsupported platform, or an error during the addon's own
initialization) purely for diagnostics; the fallback behavior is identical regardless of which
reason applies.
Build it with npm run build:native --prefix app (invokes napi build in lib/, which requires
a Rust toolchain); npm run build:all --prefix app does this as part of a full production build.
The prebuilt .node binaries checked into app/native/ mean a plain npm run dev does not
require Rust installed — only rebuilding the native layer itself does. Known gap: CI's rust
job (cargo fmt/clippy/test) and every native-addon verification in this repository's history
have run on Linux x86_64 only — nothing currently builds or loads the .node addon on Windows or
macOS in CI, despite installers shipping for both (see the migration assessment's platform-matrix
section).
A standalone Python training project — see its own README
for details — that trains a small PyTorch multi-task model predicting hardware/model fit from the
public Open LLM Leaderboard catalog plus transparent memory/throughput heuristics (there's no
measured-compatibility dataset publicly available). Its trained output, artifacts/hardware_recommender.pt, is consumed at runtime — but through a
committed copy, not a live link to this directory: the checkpoint is duplicated to
app/python/artifacts/hardware_recommender.pt, and app/python/recommender_worker.py is a
trimmed, inference-only, self-contained reimplementation of this project's model architecture and
feature encoding (deliberately not importing ml/hardware-recommender/recommend.py, so the
packaged app/python/ directory doesn't need this project's pandas/pyarrow training dependencies
just to run inference). app/src/system-specs.ts spawns that worker as a long-lived JSON-line
process via app/src/python-runtime-manager.ts's ManagedPythonWorker, and calls
recommendModelsWithML(...) to enhance the plain-heuristic recommendation for each catalog model
with the trained model's prediction. If the worker isn't available (Python not installed, or the
worker is briefly unhealthy), recommendModelsWithML falls back to the pure heuristic per model
rather than blocking the recommendation — the ML path is a strict enhancement, never a dependency.
Retraining here and re-copying the updated .pt/worker logic into app/python/ is a manual,
offline step — it is not part of npm run build:all or CI, so the two copies can drift if a
model change here isn't manually ported over.
- Renderer (
Chat.tsxviasessions-context.tsx) callswindow.electronAPI.chat.send(...). - Preload forwards this to the
chat:sendIPC channel. main.tsresolves the active provider (ollama/llamacpp/openai/anthropic/gemini/ a custom OpenAI-compatible endpoint), decrypts its API key fromsecrets-storeif needed, and calls the matching adapter inproviders/.- The adapter opens a streaming HTTP request to the provider and, for each chunk, calls back into
main.ts, which forwards it to the renderer over a per-request push channel (chat:chunk:${requestId}). sessions-context.tsxappends chunks to the in-progress assistant message as they arrive and persists the finished message tosessions-store.tsonce the stream ends.- If Agent mode is active and the model requests a tool call, the same request/response cycle
happens per tool call via
agent:executeTool(gated behind the Allow/Deny UI — see Agent mode) before the conversation continues.
Cancellation works the same way in reverse: chat:cancel looks up the request's AbortController
in main.ts's activeChatRequests map and aborts the in-flight HTTP request.
- Agent mode — full tool list, sandboxing model, and approval flow
- Clinical workspace — Patient Cases, Evidence Library, Knowledge Graph, Audit & Privacy, encryption at rest, session locking, and the medical MCP integrations (Graphify/BioMCP/DICOM MCP)
- Development — setup, running, testing, building, and releasing