Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/viz-llm-tabs-design.md

viz LLM tabs — 🤖 Claude + 🧠 Models — design

Class: design · Function axis: f0 both tabs as viz-embedded Elm Facets over the job/seam pattern · f1 the 🤖 Claude tab + direct Messages-API client (feature claude) · f2 the 🧠 Models tab + downloads-as-jobs + GPU probe. Strict: only what is built. Both are native-viz tabs (not in the wasm deck — see viz-web-design.md); neither adds a CLI verb. Anthropic-client reasoning lives in local-llm-setup.md and generator-backends.md.

f0 — two LLM tabs, one pattern

Both new tabs are facett-style Elm panes whose entire rendered surface is a state_json (LAW #6), and both put their real work behind an injectable seam so the robot-UI test drives a full cycle with no network and no disk pull in CI. The Models tab additionally runs each download as a nornir job on the same nornir-jobs JobSink seam the Build-tab test-boot uses.

f1 — 🤖 Claude tab + direct Messages-API client (feature claude)

The client (src/viz/claude.rs) — CONSUMES the shared nornir-claude (ln) leaf (LAW #5). There is no official Anthropic Rust SDK, so the Messages API transport/wire/error/consts + the offline stub live ONCE in edda/crates/nornir-claude — the SAME leaf facett-claude and dwarves consume. nornir was the lone holdout hand-rolling a byte-parallel copy (ClaudeMessage/ ClaudeRequest/ClaudeError/HttpTransport/FakeTransport + MESSAGES_ENDPOINT/ ANTHROPIC_VERSION/DEFAULT_MODEL/MODELS/MAX_TOKENS, ~250 LOC); that twin is deleted and src/viz/claude.rs now RE-EXPORTS the leaf's types under nornir's historical names (WireMsg→ClaudeMessage, OfflineTransport→FakeTransport, the consts) so every crate::viz::claude::* path resolves unchanged. POST to MESSAGES_ENDPOINT (https://api.anthropic.com/v1/messages) with x-api-key + anthropic-version: 2023-06-01, default model claude-opus-4-8 (DEFAULT_MODEL; selector MODELS; MAX_TOKENS = 4096), non-streaming (assembled via the leaf's assemble_text), is all leaf-side now — nornir's claude feature just forwards to nornir-claude/http.

What STAYS in nornir is DOMAIN (L5a — conductor, not leaf): the Anthropic-key file source <home>/.nornir/anthropic-key and the env → file → session resolution ladder (resolve_api_key / api_key_present) the headless funnel executor, funnel decompose, and funnel prompt --llm rely on — the leaf's own ladder is env→ANTHROPIC_AUTH_TOKEN→session and knows nothing of nornir's home dir. The runtime HttpTransport (feature claude) resolves via THIS ladder and hands the resolved key to ln::send_request (the leaf's POST primitive, ADDED this wave so a consumer with its own credential resolution never re-hand-rolls the reqwest POST), so the funnel's Claude calls make the byte-identical request they did before.

impl role
HttpTransport nornir domain key-ladder → ln::send_request; feature-gated claude (forwards to nornir-claude/http)
DisabledTransport returns ClaudeError::FeatureDisabled when the feature is absent — never a panic
FakeTransport (= ln::OfflineTransport) records requests + replies a canned answer; the CI test path (replying / failing / echoing)

default_transport() picks the real or disabled transport; the re-exported leaf types (ClaudeRequest/ClaudeMessage/ClaudeError) + the trait + the offline stub compile whenever viz is on, so only the real transport needs the claude feature.

The tab (src/viz/claude_tab.rs). ClaudeModel (claude_tab.rs:52) is the pure observable state (the user↔assistant Turns, the prompt, the selected model, the sending flag); ClaudeMsg (claude_tab.rs:74) is every input; ClaudeModel::update (claude_tab.rs:94) is the only transition and returns ClaudeEffects (claude_tab.rs:87) the live ClaudeTab (claude_tab.rs:145) runs — spawn the send off the paint thread, poll (claude_tab.rs:204) it back. state_json (claude_tab.rs:236) exposes turn count + last-assistant text + the sending flag so a headless robot asserts a send landed without a pixel (set_transport_for_test :175, send_for_test :224 drive it through the fake).

f2 — 🧠 Models tab + downloads-as-jobs + GPU probe (src/viz/models_tab.rs)

Browse a model CATALOG (models_tab.rs:71, ModelSpec :63), pick a backend (Backend :36 — candle / ollama / ort) and a device (Device :86 — Cpu / Cuda / Rocm), and download one as a nornir job: ModelsTab::download (models_tab.rs:448) opens a running job on the JobSink, runs the pull through the ModelDownloader seam, and poll (models_tab.rs:509) / cancel (models_tab.rs:528) finish/fail/cancel the job as the download does, with a live progress row (DlStatus, models_tab.rs:114).

seam at role
ModelDownloader (trait) models_tab.rs:156 pluggable pull; DownloadProc (:145) is the live handle
OllamaPullDownloader models_tab.rs:162 runtime impl — shells ollama pull, lands models under a /home models dir (never the source disk)
FakeDownloader models_tab.rs:259 CI seam — no network/disk, drives a real progress→done (or failing) cycle

GPU / backend probe. GpuProbe (models_tab.rs:328), built by gpu_probe() (models_tab.rs:344), reports what accelerator runtime is actually present: the embed-ort dlopen check for libonnxruntime.so (onnxruntime), an NVIDIA driver (cuda_driver via vector::cuda::driver_present), a ROCm runtime (rocm via vector::rocm::available), the pinned --cuda/--rocm runtimes at /opt/nornir/{cuda,rocm}, and which gen backends are compiled in (gen_candle, gen_ollama, embed_ort via cfg!). The tab surfaces the active backend, the selected device, and whether the GPU runtime lib is present. HuggingFace / candle (hf-hub) pulls are a documented follow-up.