| # M08 — UI (Gradio Dashboard + Mobile Client) |
|
|
| **Spec version:** v1.0 |
| **Depends on:** M03 (bus, the ONLY data source the UI talks to), X03 (observability, for trace display), X04 (config), M09 (emergency state subscribed), `gradio>=6.0.0` |
| **Depended on by:** M13 (onboarding extends the UI), M12 (CLI may launch UI) |
|
|
| The UI's strict rule: **it never imports a service module**. Every piece of data comes via `bus.call(...)` or via the bus's introspection APIs (`topology_snapshot`, `recent_traces`). This keeps the UI swappable. |
|
|
| --- |
|
|
| ## 1. Responsibility |
|
|
| Present a local-host web UI at `http://127.0.0.1:7860` showing: |
|
|
| - Live topology of the mesh |
| - An "ask" pane wired to `llm.chat` + `rag.query` |
| - A chat tab for direct messages |
| - A marketplace tab |
| - A files tab |
| - An emergency tab (visible only when offline) |
| - A settings tab |
| - A mobile web client served at `/mobile` |
|
|
| --- |
|
|
| ## 2. File layout |
|
|
| ``` |
| hearthnet/ui/ |
| ├── __init__.py |
| ├── app.py # build_ui(): assembles Gradio Blocks |
| ├── topology.py # Cytoscape.js-backed topology component |
| ├── theme.py # Colour tokens, fonts, CSS |
| ├── onboarding.py # M13 owns this; reachable from settings |
| ├── tabs/ |
| │ ├── __init__.py |
| │ ├── ask.py # LLM passthrough with optional RAG |
| │ ├── chat.py # direct messages |
| │ ├── marketplace.py |
| │ ├── files.py |
| │ ├── emergency.py # only mounted when offline state active |
| │ └── settings.py |
| └── mobile/ # served as static at /mobile |
| ├── index.html |
| ├── app.js |
| └── style.css |
| ``` |
|
|
| --- |
|
|
| ## 3. Public API |
|
|
| ### 3.1 `app.py` |
|
|
| ```python |
| # hearthnet/ui/app.py |
| import gradio as gr |
| |
| class UiApp: |
| def __init__( |
| self, |
| bus: CapabilityBus, |
| state_bus: StateBus, # M09 |
| config: UiConfig, |
| node_id_short: str, |
| community_name: str, |
| ): |
| ... |
| |
| def build(self) -> gr.Blocks: |
| """Assemble the full UI.""" |
| |
| async def launch_async(self) -> None: |
| """Non-blocking launch. Used by node.py.""" |
| |
| async def shutdown(self) -> None: ... |
| |
| def build_ui(bus, state_bus, config, **meta) -> UiApp: |
| """Convenience constructor used by node.py.""" |
| ``` |
|
|
| ### 3.2 `topology.py` |
|
|
| ```python |
| # hearthnet/ui/topology.py |
| class TopologyComponent: |
| """Wraps Cytoscape.js inside a Gradio HTML component. |
| Auto-refreshes from bus.topology_snapshot() every 2s. |
| Animates recent trace events (last 10s) along edges.""" |
| |
| def __init__(self, bus: CapabilityBus): ... |
| |
| def render(self) -> gr.HTML: ... |
| |
| def push_trace(self, event: CallTraceEvent) -> None: |
| """Trigger an edge animation. Color by capability prefix.""" |
| |
| def push_topology(self, snapshot: TopologySnapshot) -> None: ... |
| |
| # Cytoscape config: |
| # - Nodes: one per known peer + one for self |
| # - Edges: dynamic; appear on trace events; fade after 5s |
| # - Edge colour: llm.*=teal, rag.*=purple, file.*=amber, |
| # chat.*=blue, market.*=green, community.*=grey |
| # - Node colour: online=green, stale=amber, offline=red |
| # - Node label: display_name + capability badges |
| # - On node click: side panel shows full manifest |
| ``` |
|
|
| --- |
|
|
| ## 4. Composition |
|
|
| The Gradio Blocks tree: |
|
|
| ``` |
| gr.Blocks(theme=hearthnet_theme, title="HearthNet") |
| ├── header bar |
| │ ├── community name + node display name |
| │ ├── status pill (online/offline) → bound to state_bus |
| │ └── settings gear |
| ├── topology pane (always visible at top) |
| └── tabs: |
| ├── Ask (always) |
| ├── Chat (always; badge with unread count) |
| ├── Marketplace (always) |
| ├── Files (always) |
| ├── Notfall (visible only when state.mode != "online") |
| └── Settings (always; includes Onboarding entry point) |
| ``` |
|
|
| --- |
|
|
| ## 5. Tabs |
|
|
| ### 5.1 Ask tab — `tabs/ask.py` |
|
|
| A simple chat interface: |
|
|
| - Top: Corpus selector (dropdown, populated via `bus.call("rag.list_corpora", ...)`) |
| - Top right: Model selector (capabilities from `bus.topology_snapshot().capabilities_*` filtered by name=`llm.chat`) |
| - Centre: Chat history (Gradio Chatbot) |
| - Bottom: Input + Send |
|
|
| Behaviour on send: |
|
|
| ``` |
| 1. if corpus selected: |
| chunks = bus.call("rag.query", (1,0), {params:{corpus}, input:{query:msg, k:5}}) |
| build system prompt with chunks + sources |
| 2. messages = [system_with_chunks, ...history, user_msg] |
| 3. stream = bus.stream("llm.chat", (1,0), {params:{model}, input:{messages, stream:true}}) |
| 4. accumulate tokens into a streaming response in the Chatbot |
| 5. on done: append sources panel (clickable to open file) |
| ``` |
|
|
| ### 5.2 Settings tab — `tabs/settings.py` |
|
|
| - Node identity (read-only) |
| - Community membership (read-only; "leave community" with double-confirm) |
| - LLM backend list (read-only; edit via config.toml) |
| - Theme toggle (Hearth / Spark dark mode) |
| - Debug toggles (verbose logging, trace ring buffer dump) |
| - Onboarding entrypoints: "Create new community", "Join via invite" |
| - Privacy: "Erase all data" (triple-confirm, wipes keys + state) |
|
|
| ### 5.3 Chat tab — `tabs/chat.py` |
|
|
| - Left: peer list with last-message timestamps, unread badges |
| - Source: `bus.call("chat.history", (1,0), {input:{}})` → group by peer |
| - Right: message thread for selected peer |
| - Auto-refresh on local pubsub topic `chat.message.<our_short_id>` |
| - Bottom: input + send + attachment button (opens file picker → uploads as blob via `file.put` → attaches CID) |
| - "Encrypted" indicator placeholder (Phase 2) |
|
|
| ### 5.4 Marketplace tab — `tabs/marketplace.py` |
|
|
| - Top: Category filter, tag filter, search box (semantic) |
| - Centre: Cards (one per post) |
| - Bottom-right: "Neuer Beitrag" → modal for new post |
| - "Mark fulfilled" / "Withdraw" on each card if author == us |
|
|
| ### 5.5 Files tab — `tabs/files.py` |
|
|
| - Left: corpus / pinned / recent |
| - Centre: file grid |
| - Upload area at top |
| - Click: preview (image / PDF / text), download, advertise to peers |
|
|
| ### 5.6 Emergency tab — `tabs/emergency.py` |
|
|
| Visible only when `state_bus.current().mode != "online"`. Designed for big buttons, low-stress reading. Large amber banner at top. |
|
|
| Contents: |
|
|
| - Big "Was tun?" button → opens the most relevant corpus (default `niederrhein-emergency`) |
| - Neighbour list (last seen times prominent) |
| - Direct chat shortcut |
| - "Update" indicator: how far behind the event log we are vs. last sync |
| - Shared resources table (generator availability, water, light) — Phase 2 |
|
|
| ### 5.7 Banner |
|
|
| ``` |
| INTERNET OFFLINE — LOKAL AKTIV |
| seit 14:32 · 3 Nachbar*innen erreichbar |
| ``` |
|
|
| When degraded: |
| ``` |
| EINGESCHRÄNKTE VERBINDUNG · Lokale Dienste aktiv |
| ``` |
|
|
| --- |
|
|
| ## 6. Mobile client (`mobile/`) |
|
|
| Plain static HTML + JS, no framework. Served by [X01](../cross-cutting/X01-transport.md) at `/mobile/*`. Same bus API (signed requests, but credentials stored in `IndexedDB`). |
|
|
| Minimum features: |
| - Ask (LLM passthrough) |
| - Chat |
| - Marketplace browse |
| - Emergency mode banner |
| - No topology viz (too dense for small screen) |
|
|
| Auth on mobile: the user scans an invite QR with the camera → key derived in WebCrypto → stored in IndexedDB. |
|
|
| --- |
|
|
| ## 7. Theming (`theme.py`) |
|
|
| Two themes: |
|
|
| - **Hearth (default)** — warm, parchment background, dark walnut accents |
| - **Spark (high-contrast / dark)** — black bg, amber accents — also the emergency theme |
|
|
| CSS variables: |
|
|
| ```css |
| --hn-bg: #f4ead7; /* hearth */ |
| --hn-bg-dark: #1a1816; /* spark */ |
| --hn-accent: #b45309; /* amber */ |
| --hn-accent-2: #14b8a6; /* teal */ |
| --hn-accent-3: #6d28d9; /* purple, used for rag */ |
| --hn-text: #2c1810; |
| --hn-text-dark: #f4ead7; |
| --hn-error: #b91c1c; |
| --hn-warn: #d97706; |
| --hn-ok: #15803d; |
| ``` |
|
|
| When emergency mode is active, theme switches to Spark with amber accents and the banner. |
|
|
| --- |
|
|
| ## 8. Behaviour |
|
|
| ### 8.1 Topology refresh |
|
|
| Every 2 s the topology component calls `bus.topology_snapshot()`. Diff with previous; only changed nodes/edges trigger re-render. Trace ring is read via `bus.recent_traces(50)` every 1 s and pushed as animations. |
|
|
| ### 8.2 Live updates without polling |
|
|
| Where possible the UI subscribes: |
|
|
| - `state_bus.subscribe()` for emergency banner |
| - `bus.registry.subscribe()` for topology pane (additive) |
| - Pubsub `marketplace.post.created` for marketplace tab live refresh |
| - Pubsub `chat.message.<our_short_id>` for chat tab notifications |
|
|
| ### 8.3 Error display |
|
|
| - Capability call errors → toast at top with code and "details" expander |
| - Backend warm-up takes time → spinner with "Modell wird geladen ..." |
| - Network failures during a stream → frame "verbindung abgerissen" injected; user can retry |
|
|
| ### 8.4 Settings persistence |
|
|
| Settings tab edits go to `config.toml` via [X04 §3](../cross-cutting/X04-config.md). Some require restart; UI clearly indicates this. |
|
|
| ### 8.5 First-run handoff to M13 |
|
|
| If on startup `config.community.community_id is None`, the UI redirects to onboarding (see [M13](M13-onboarding.md)) instead of showing tabs. |
|
|
| --- |
|
|
| ## 9. Configuration |
|
|
| From [X04 §3](../cross-cutting/X04-config.md): |
|
|
| ```python |
| config.ui.host # 127.0.0.1 |
| config.ui.port # 7860 |
| config.ui.launch_browser # auto-open in browser on launch |
| ``` |
|
|
| --- |
|
|
| ## 10. Tests |
|
|
| ### Unit |
| - `test_theme_tokens_present` |
| - `test_emergency_tab_hidden_when_online` |
| - `test_emergency_tab_shown_when_offline` |
| - `test_topology_diff_avoids_unchanged_render` (mock bus) |
| - `test_settings_writes_to_config_file_atomically` |
|
|
| ### Integration |
| - `test_ask_tab_does_rag_then_llm_in_order` — mock bus, observe call sequence |
| - `test_marketplace_tab_refreshes_on_pubsub_event` |
| - `test_mobile_endpoint_serves_index_html` |
|
|
| ### Manual |
| - Demo dry-run script: open UI, type query, observe topology animation, unplug WAN, observe banner ≤ 5s. Document in `tests/demo_script.md`. |
|
|
| --- |
|
|
| ## 11. Cross-references |
|
|
| | What | Where | |
| |------|-------| |
| | Bus introspection APIs | [M03 §3.7](M03-bus.md) | |
| | Emergency state source | [M09 §3.1](M09-emergency.md) | |
| | Pubsub topics | [CONTRACT §8](../CAPABILITY_CONTRACT.md) | |
| | Onboarding flow | [M13](M13-onboarding.md) | |
| | Mobile served by | [X01 §3.2](../cross-cutting/X01-transport.md) | |
| | Trace event format | [M03 §3.6](M03-bus.md) | |
|
|
| --- |
|
|
| ## 12. Open questions |
|
|
| 1. **Gradio version compatibility** — Gradio 6.x evolves quickly. Pin a minor. |
| 2. **Native mobile** — Phase 2 (Flutter or React Native). Web works for hackathon. |
| 3. **Accessibility** — colour contrast meets WCAG AA in both themes; not yet audited. |
| 4. **Internationalisation** — UI strings in German + English. Switchable. Plattdeutsch as a stretch. |
| 5. **Cytoscape vs D3** — Cytoscape preferred (less code). Performance budget: 50 nodes, 500 edges. |
|
|