Compare commits
71
Commits
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,92 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
|
||||
|
||||
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
|
||||
|
||||
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
|
||||
|
||||
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
|
||||
|
||||
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
|
||||
|
||||
- **Per-route reachability verdicts in the Routes card.** Every route row now shows the result of its last health probe — "Reachable", or "Unreachable" with the actual reason ("TLS failed — server may be http://, not https://", "Connection refused", "No answer (timed out)", "HTTP 404 from /health") — and "Re-check" shows a live checking state instead of doing invisible background work. Verdicts persist between probes so you can see what the network last said.
|
||||
|
||||
- **Manage parity with the hermes-desktop dashboard.** The Manage tab can now do what the desktop dashboard can: **Models** — change the main model from the full provider/model catalog (`/api/model/options` → `/api/model/set`), including the expensive-model confirmation round-trip; new **Keys** tab — view, set (write-only, masked), reveal (server rate-limited), and clear provider keys / env secrets; **Profiles** — create profiles (clone-from-default), edit descriptions, set per-profile models, and **edit SOUL.md** in a full-file editor; **Skills** — browse the multi-source skills hub with search, SKILL.md preview-before-install, install/uninstall (async server-side), and update-all.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Standard (no-plugin) voice now rides the Hermes dashboard surface.** STT/TTS for the standard route uses the dashboard's `/api/audio/transcribe` + `/api/audio/speak` (the hermes-desktop voice contract) with the same cookie session Manage signs in with — a vanilla hermes-agent install needs no Relay plugin for voice. Previously the client targeted the API server, which has no audio routes, so standard-only voice always failed.
|
||||
|
||||
- **Auto STT/TTS route prefers Relay when paired.** Paired Relay voice is profile-aware and needs no dashboard sign-in; the standard dashboard route is the zero-plugin fallback. Voice Settings now shows live per-route status (ready / sign-in required / unreachable / unsupported build) with a "Sign in via Manage" shortcut, and the Realtime Agent engine is clearly marked as requiring a paired Relay.
|
||||
|
||||
- **Softened the active connection card.** The full-card Electric blue fill on the active connection was overpowering against body text; it now uses a muted indigo wash while small accents keep the vivid brand blue.
|
||||
|
||||
- **Connection wizard capability card now includes Voice.** Finishing setup shows Chat / Manage / Voice / Relay readiness in one card — voice availability (ready / unlocks with dashboard sign-in / build too old) is probed in the same pass, so the result is accurate the moment you connect.
|
||||
|
||||
- **No more relay warnings on standard-only connections.** Voice Settings no longer fetches Relay voice configs (and no longer shows "unavailable" rows or error snackbars) when no Relay is configured — relay-backed sections are replaced by a quiet note that speech uses the server's configured TTS/STT, with Relay pairing called out as the way to pick providers from the phone.
|
||||
|
||||
- **Skills hub opens with featured content.** The browse dialog lists the configured hub sources and the index's featured skills before the first search instead of starting blank.
|
||||
|
||||
- **Onboarding feature pages got real content.** Chat / Manage / Power tools pages now show three concrete feature rows each (streaming + profiles + voice; control + skills hub + one sign-in; terminal + bridge + realtime) instead of a single sentence.
|
||||
|
||||
- **Floating status pill.** The bottom status strip is now an inset rounded capsule floating above the gesture area instead of an edge-to-edge bordered bar that clashed with rounded display corners.
|
||||
|
||||
- **Ambient mode is now a gesture.** The top-bar sphere toggle is gone; long-press the conversation background to enter the fullscreen sphere, tap anywhere to return (a transient "tap to return to chat" pill teaches the exit on entry). Message long-press (copy) is unaffected.
|
||||
|
||||
- **Media settings labeled Relay-only.** The Media screen now states that its inbound-attachment controls apply to Relay-delivered files only, not to standard connections or images you attach in chat.
|
||||
|
||||
- **Quote in reply.** Long-pressing a message now offers Copy and "Quote in reply" — quoting drops the message into the input as a Markdown blockquote.
|
||||
|
||||
- **Share conversation.** A share icon in the chat top bar exports the visible conversation as Markdown through the system share sheet.
|
||||
|
||||
- **Manage cards declutter.** Cards with five or more actions (profiles) keep the three most-used buttons inline and fold the rest behind "More".
|
||||
|
||||
- **Ambient gesture is documented in Appearance.** Settings → Appearance now explains the long-press-to-enter / tap-to-return gesture, keeping it discoverable (including for screen-reader users) without a visible control.
|
||||
|
||||
- **User docs: Quick Start.** New two-minute Quick Start page leads the guide; the dashboard page documents the full phone Manage surface (skills hub, models, keys, profile + SOUL editing); voice docs lead with the standard no-Relay route.
|
||||
|
||||
- **Routes are now editable in Settings → Connections.** The Routes card gains "Add route" plus per-route Edit/Remove (the primary route mirrors the connection's API URL and stays protected) — the standard path's manual equivalent of the Relay QR's multi-endpoint provisioning. Add your server's Tailscale or public URL after the fact and the phone roams to it automatically; the wizard's optional Tailscale field remains the setup-time shortcut.
|
||||
|
||||
- **URL fields accept bare hosts and explain their ports.** Typing `100.71.8.56` (or any bare host/IP) into the API URL, wizard Tailscale, or route-editor fields now saves `http://100.71.8.56:8642` — scheme and API port defaulted, and the route editor previews exactly what will be saved ("Will save: http://100.71.8.56:8642") before you commit. Field copy now states which port is which (API `8642`, dashboard `9119`) and that `https://` should only be used when the server actually has TLS. Route rows display the full URL including the scheme, since an invisible `https` was the classic cause of a route that never won a probe.
|
||||
|
||||
- **Manage remembers its data and pre-warms it.** Dashboard payloads now live in a process-lifetime cache instead of screen state, so leaving and re-entering Manage shows the last data instantly (entries older than 30 s refresh quietly in the background — content stays put, only a thin progress bar shows). When a connection's saved dashboard status says it was reachable and signed in, the app pre-warms all Manage sections at startup (and again after a LAN↔Tailscale route handoff), so even the first open lands on real data. Signing in or out still clears the cache.
|
||||
|
||||
- **Manage loading and overview polish.** The cold-load skeleton is now one progress bar plus quiet content-shaped ghost cards — previously four stacked progress bars with fake narrative labels ("Checking dashboard session"…) that read like three different failures. The cryptic KPI glyphs (`ok / … / !`) are replaced by three cards: section count, a tone-colored dashboard state word (ready / sign-in / offline / error), and the server version (handy for confirming which host answered after a route handoff). The dashboard status banner is now two lines — state + identity with Sign out, then URL · route · checked time — so nothing truncates, and its duplicate "Connection" button is gone (the Connections tile sits directly below).
|
||||
|
||||
- **Manage names its dashboard target and explains per-route sign-in.** The Manage tab now shows exactly which dashboard URL it's talking to ("Dashboard: http://… · Tailscale route") above the content, and "Dashboard unavailable" errors name the URL that failed — the dashboard (`:9119`) is a separate server from the API (`:8642`), so "chat works" never proved Manage's target was reachable. When the resolver has moved Manage onto a different host (e.g. roamed to Tailscale), the sign-in card now explains that dashboard sign-ins are per host and a one-time sign-in on this route keeps both sessions — the same hint voice already had.
|
||||
|
||||
- **Remote access is discoverable, not an easter egg.** The standard setup form now shows a "Remote access — Tailscale URL (optional)" field in the main flow (previously buried under Advanced), with a hint when Tailscale is detected on the phone; the setup result card gains a "Remote" readiness line that calls out LAN-only connections; the "Hermes API unreachable" status now diagnoses the likely cause ("Away from the server's network? Add a Tailscale or public route") instead of just reporting; and the Connections card offers an "Add Tailscale route" shortcut when the phone is on Tailscale but the connection has no Tailscale route.
|
||||
|
||||
- **README + Play listing refresh.** Both rewritten around the standard-first story. The README quick start now mirrors the app's capability card (Chat / Manage / Voice / Remote / Relay), voice is no longer described as relay-only, Manage and remote access become headline features, the desktop CLI section is trimmed and clearly marked alpha (with its planned refocus into a remote "hands" connector), and the stale CI badge, broken in-page anchors, and version-pinned "What's new in v0.6.0" section are gone. The Play listing (`docs/play-store-listing.md`) gets an end-user-first short description, a quick-start beat, Manage/remote-access feature blocks, a corrected no-plugin voice story, and v0.8.1 release notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **App-start UI freeze (frozen sphere) from Keystore lock contention.** Cold starts could freeze the UI for many seconds (logcat: `Skipped 1386 frames`, `Davey! duration=11596ms`): every `EncryptedDashboardCookieStore` eagerly built its Keystore-backed prefs in its constructor — a 1–4 s operation on StrongBox devices that serializes through a process-global Tink lock — and several code paths (Manage section loads, connection validation, the Manage pre-warm) each constructed their own instance, stacking multi-second lock holds that main-thread keystore users then queued behind. The store now builds lazily on first cookie access (always an I/O thread), all dashboard-surface consumers share one cached instance per connection, and the pre-warm uses a single client plus the shared store for its whole sweep instead of one of each per section.
|
||||
|
||||
- **"Re-check" / "Use now" no longer fail silently.** When every saved route failed its probe, the user-triggered re-probe early-returned without publishing anything: the Routes card sat on "Current: Resolving" forever (showing the internal relay URL underneath, which read as "stuck on the internal route") with zero feedback. The probe now always publishes its outcome, the card states "No route reachable — using saved URL …" explicitly, and per-route rows show why each candidate failed. The old 100 ms post-probe delay — always shorter than a real resolve, leaving the follow-up health checks pointed at the stale route — is replaced by actually awaiting the resolve.
|
||||
|
||||
- **Standard (no-Relay) connections now follow LAN ↔ Tailscale network changes.** The ADR 24 network-aware route switching only activated when a Relay socket was open: the connectivity callback registered inside `connect()` and bailed without a socket URL, so a standard connection that left home Wi-Fi kept probing the dead LAN route until the app was backgrounded and reopened. The callback now registers at construction and re-resolves routes (debounced) even with no socket — chat, Manage, and standard voice follow the resolved endpoint automatically.
|
||||
|
||||
- **Standard voice follows the resolved route.** The standard voice client and its availability probe targeted the connection's persisted dashboard URL instead of the resolver's active route, so voice stayed pinned to the LAN host (and gated off) while away from home even after chat had switched to Tailscale. Both now ride `effectiveDashboardUrl`.
|
||||
|
||||
- **Stale probe cache can't pin a dead route.** App-resume and network-change revalidation now clear the endpoint resolver's probe cache, so a route that died moments ago can't win re-resolution for the remainder of its 60-second positive cache window. The periodic health check also escalates two consecutive unreachable probes into a full cache-cleared re-resolve — the safety net for handoffs Android never surfaces as connectivity changes (always-on VPN keeps "internet available" true throughout).
|
||||
|
||||
- **Editing URLs no longer wipes fallback routes.** Saving an API or Relay URL rebuilt the connection's route-candidate list from just the edited URL, silently dropping the setup wizard's Tailscale route (or extra endpoints from a pairing payload). Edits now merge: the touched route is rebuilt, stored extras are preserved verbatim.
|
||||
|
||||
- **Per-route sign-in is explained.** Dashboard sessions are cookie-based and per-host, so a Manage sign-in at home doesn't carry to the Tailscale host. When voice is gated on sign-in because the route moved, Voice Settings and the chat mic toast now say so ("sign in once in Manage on this route") instead of showing a bare sign-in nag that looks broken.
|
||||
|
||||
- **A network change can no longer resurrect a deliberately disconnected relay socket.** The route-switch path force-reconnected whenever the resolved winner differed from the last URL, even after an explicit Disconnect; socket actions are now gated on reconnect intent while route publication for HTTP surfaces continues.
|
||||
|
||||
## [0.8.1] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
|
||||
|
||||
## [0.8.0] - 2026-05-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -29,45 +29,53 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
|
||||
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
|
||||
1. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
|
||||
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
|
||||
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — loopback-only):**
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
|
||||
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
|
||||
- **Bootstrap maintenance:** Retire `hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -107,7 +115,7 @@ hermes-android/
|
||||
│ ├── tools/ # android_navigate.py, android_notifications.py
|
||||
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla upstream; removable after PR #8556
|
||||
├── hermes_relay_bootstrap/ ← Runtime compatibility patch; retire per surface as upstream replaces it
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
@@ -197,7 +205,7 @@ hermes-android/
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | MediaPlayer + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
@@ -230,7 +238,7 @@ hermes-android/
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla upstream; no-op on fork/upstream-merged; remove after PR #8556 |
|
||||
| `hermes_relay_bootstrap/` | Runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
@@ -374,10 +382,10 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; preferred |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Non-standard; bootstrap or fork |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
@@ -390,7 +398,7 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
|
||||
@@ -1,5 +1,137 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-06-11 — Standard-route network auto-switch (LAN ↔ Tailscale roaming without Relay)
|
||||
|
||||
**Context.** Bailey reported that away from home, a configured + signed-in standard connection never switched over to its Tailscale route — chat stayed dead and voice stayed gated, while the old Relay-paired path used to hand off fine (chat catch-up, voice, realtime). Audit traced it to the rechrome: every downstream piece (route candidates stored by the wizard, `effectiveApiServerUrl`/`effectiveDashboardUrl` flows, client-rebuild collectors, chat session refresh on client swap) was sound; nothing upstream ever *triggered* re-resolution on the standard path, and standard voice ignored the resolved route entirely.
|
||||
|
||||
**Root causes (4).** (1) `ConnectionManager`'s ADR 24 NetworkCallback registered only inside `connect()` — never on socketless standard connections — and its handlers early-returned without a socket URL. (2) The fallback trigger (`networkStatus` StateFlow → `revalidate()`) is value-deduped over 3 coarse states; with Tailscale's always-on VPN keeping "internet available" true through any handoff, the value never leaves `Available`, so the collector never fires — the exact user this feature targets is the one it can't see. (3) `activeDashboardUrl()` (standard voice client + availability probe) read the **persisted** dashboard URL, not `effectiveDashboardUrl`. (4) The resolver's 60s positive probe cache wasn't cleared on resume/network-change re-resolution, so a just-died LAN route kept winning.
|
||||
|
||||
**What changed (branch `fix/standard-route-network-switchover`, merged --no-ff; follow-ups direct on `feature/standard-voice-dashboard-surface`).**
|
||||
|
||||
- **ConnectionManager** — NetworkCallback registers at construction (no-op without context); `onAvailable`/`onLost` unify into a debounced (300ms) `scheduleNetworkReResolve()` that publishes `activeEndpoint` even with no socket, preserving the socket-swap/reconnect semantics when one exists. `refreshActiveEndpoint(clearProbeCache)` clears the resolver cache; `revalidate()` passes true.
|
||||
- **Standard voice** — `activeDashboardUrl()` now delegates to `effectiveDashboardUrl`, so the voice client and `probeStandardVoice()` follow the resolved route; `rebuildApiClient()` → `probeStandardVoice()` re-gates the mic automatically after a route swap.
|
||||
- **Escalation safety net** — the 30s API health loop turns two consecutive Unreachable probes into a cache-cleared re-resolve, covering handoffs Android never surfaces as connectivity events. Client rebuild stays reactive via the `effectiveApiServerUrl` collector (single rebuild path for all triggers).
|
||||
- **Route-candidate preservation** — new `Connection.mergeRouteCandidates(rebuilt, existing)`: URL edits (`updateApiServerUrl`, `updateRelayUrl`, `connectRelay`, `testRelayReachable`, `saveApiAndProbeVoice`, `saveStandardApiConnection` fallback) rebuild only the touched route and keep stored priority>0 extras verbatim — previously any URL save collapsed the list to one candidate and silently killed roaming. The wizard doesn't pre-fill its Tailscale field, so blank-on-rerun means "unchanged", not "remove".
|
||||
- **Per-route sign-in UX** — dashboard cookies are host-scoped, so a LAN sign-in doesn't authenticate the Tailscale host (the store holds both; it's a one-time sign-in per host). New `standardVoiceSignInRouteHint` flow + route-aware copy in Voice Settings' SignInRequired block + a Diagnostics entry from the probe.
|
||||
- **Cleanup** — removed the duplicate `networkStatus → revalidate()` collector left by the rechrome.
|
||||
|
||||
**Tests.** New `ConnectionManagerRouteTest` (Robolectric + MockWebServer): callback registration/unregistration at construction, socketless `onAvailable` publishing `activeEndpoint`, stale-cache vs `clearProbeCache` resolve. New `ConnectionRouteCandidateMergeTest`: extras preserved, payload relay URLs verbatim, host:port collision defers to rebuilt, primary replacement, no-extras passthrough. `:app:lint` + targeted unit suites green locally.
|
||||
|
||||
**On-device verification needed (Bailey, via Studio):** standard connection with a Tailscale route → leave Wi-Fi → chat should re-route within ~30–60s worst case (network callback usually immediate); Voice Settings should show the per-route sign-in hint until Manage sign-in on the Tailscale host; return home → routes flip back to LAN (priority 0). Also worth re-checking the Relay-paired handoff path for regressions since onAvailable/onLost were unified.
|
||||
|
||||
**Pre-release polish (same day).** Review pass before handing to Studio: (1) gated network-change socket actions on `shouldReconnect` — the swap path force-set it true, so a network event could resurrect a socket the user explicitly disconnected (pre-existing hole the refactor preserved; routes still publish for HTTP surfaces); (2) `refreshActiveEndpoint` keeps the live route on a transient probe miss while the WSS is Connected (mirrors the callback's guard — a resume-time probe blip no longer downgrades every HTTP surface to the saved LAN URL); (3) sign-in route hint upgraded to `displayLabel()` ("Tailscale") and the chat mic toast made route-aware; (4) escalation counter resets while no API client exists.
|
||||
|
||||
**Remote-access discoverability (same day, Bailey's ask).** UX audit of the onboarding → remote journey found the mechanics worked but nothing *led* users to them: the wizard's happy path (scan LAN → connect) produced a LAN-only connection silently (the Tailscale field hid inside the collapsed Advanced expander), the setup result card had no remote line, "Hermes API unreachable" didn't distinguish "server down" from "you're remote with no fallback route", and `TailscaleDetector` powered only an informational chip. Shipped four nudges, each at a moment of real user attention: (1) the Tailscale field lifted into the main setup form as "Remote access — Tailscale URL (optional)" with a detected-on-this-phone hint; (2) a "Remote" readiness line on the setup result card (`StandardApiSetupResult.remoteRouteConfigured`); (3) the unreachable status pill diagnoses by route count — single-route gets "add a Tailscale or public route" (sharpened when the phone is on Tailscale), multi-route gets "none of the N routes responded, fallbacks retried automatically"; (4) an "Add Tailscale route" shortcut on the Connections card when the phone is on Tailscale but the connection lacks a tailscale route (route-editor state hoisted out of the expander so it opens with the list collapsed). Deliberately skipped: auto-deriving the server's MagicDNS URL (needs server-side support vanilla hermes-agent doesn't have) and QR-for-standard (no upstream payload generator). user-docs `remote-access.md` gains an "Add or Edit Routes on the Phone" section + per-route sign-in tip.
|
||||
|
||||
**Routes editor (same day, Bailey's ask).** Two gaps confirmed: the standard path's only multi-route provisioning was the wizard's buried optional Tailscale field (single route, setup-time only, not pre-filled on re-runs — and no QR equivalent exists because vanilla hermes-agent has no payload generator; the QR comes from the Relay plugin), and the Routes card was read-only. Closed both: `EndpointsCard` gains **Add route** + per-row **Edit/Remove** (fallback rows only — the priority-0 primary mirrors the connection's API URL and is edited there; remove confirms first), backed by a `RouteEditorDialog` (Tailscale/Public/Custom role chips, URL field, inline validation errors from the save callback). `ConnectionViewModel.saveExtraRoute`/`removeExtraRoute` persist to `Connection.routeCandidates`, seeding from the same fallback chain `observeDeviceEndpoints` displays (per-device PairingPreferences → synthesized primary) so an edit never hides QR-provisioned routes; host:port collisions are rejected with a pointed message; removing a route clears a preferred-route override that pointed at it; both finish with a cache-cleared `refreshActiveEndpoint` so the change takes effect immediately. Relay URLs for manual routes are derived (`:8767` convention) — QR remains the path for custom relay URLs. Wizard helper text now points at Settings → Connections → Routes.
|
||||
|
||||
**Route probe visibility + URL forgiveness (same day, Bailey remote-debugging).** Field report from the road: Tailscale route added (`100.71.8.56`, port auto-appended), phone on the tailnet, but "Probe now"/"Use now" left the card on "Current: Resolving" showing the internal URL, with no probing indicator anywhere. Diagnosis found one real bug plus a UX black hole:
|
||||
|
||||
- **The bug** — `probeAndReconnect()` early-returned (`resolved?.relay?.url ?: current ?: return@launch`) when every probe failed on the standard (no-socket) path: nothing was published, nothing was shown, and the only record went to DiagnosticsLog. Replaced by `probeAndReconnectNow(): EndpointCandidate?` (awaitable; `probeAndReconnect()` is now a launch wrapper) which **always publishes the outcome** — with the Connected-socket transient-miss guard preserved. `probeNow()`'s 100ms-delay-then-health-check hack (a real resolve takes 4s+ when LAN must time out) now awaits the resolve, rebuilds the API client on route change, then health-probes.
|
||||
- **The black hole** — no probe feedback at any layer. New `RouteProbeOutcome` map on `EndpointResolver` (per-candidate verdict + human reason; survives `clearCache()` — caching ≠ verdict history) with the TLS case spelled out ("TLS failed — server may be http://, not https://"), `RouteProbeStatus` (Idle/Probing/Done(winner)) on ConnectionViewModel, and UI: Re-check buttons show "Checking…", route rows show Reachable/Unreachable-with-reason, the Current line states "No route reachable — using saved URL <api url>" in error color instead of eternal "Resolving" over the **relay** URL fallback (the "internal url" Bailey was seeing — ConnectionsSettingsScreen printed `connection.relayUrl` under the resolving label).
|
||||
- **Likely root cause of the field failure** — the route row only showed `host:port`; the scheme (the `tls` flag) was invisible, and the editor's placeholder even suggested `https://`. An https route against the plain-HTTP API server TLS-fails every probe. Rows now show the **full URL including scheme**; the editor previews "Will save: http://100.71.8.56:8642" live.
|
||||
- **URL forgiveness** — new `Connection.normalizeApiUrlInput(raw, defaultPort=8642)`: bare hosts/IPs get `http://` + the surface's default port (dashboard field uses 9119); explicitly-schemed URLs pass **verbatim** (an `https://host` may be a reverse proxy on 443 — never force-append 8642). Applied in `saveExtraRoute`, `saveStandardApiConnection` (API + Tailscale + dashboard fields), and `updateApiServerUrl` (save-time only — its single call site is `applyManualPair`, not per-keystroke). Wizard validators soften to accept bare hosts; field copy now names the ports (API 8642 vs dashboard 9119, relay 8767 derived). `saveExtraRoute`/`removeExtraRoute` end in a full `probeNow()` so a just-saved route shows its verdict immediately.
|
||||
|
||||
Tests: +4 resolver outcome tests, +2 ConnectionManager `probeAndReconnectNow` publish tests (winner published socketless; null published when all routes die — the old early-return regression case), +10 `normalizeApiUrlInput` cases incl. the bare-Tailscale-IP end-to-end journey. All green; lint green. user-docs `remote-access.md` gains "Which URL Do I Enter?" (raw `100.x` IP → `http://` + API server must listen beyond loopback; `*.ts.net` behind `tailscale serve` → `https://`, cert is name-only). **On-device verification (Bailey):** with the Tailscale route's scheme visible, check whether it was saved as https — edit to http if so; Re-check should now show per-route verdicts either way.
|
||||
|
||||
**README + Play listing audit (same day, Bailey's ask).** Audit found both documents lagging the standard-first pivot by two release cycles: README said "What's new in v0.6.0" (app at 0.8.1), the CI badge pointed at deleted `ci.yml`, two in-page anchors were broken (renamed headings), voice was described as relay-only in three places (surfaces table, features bullet, How It Works diagram) despite the dashboard-surface change, and neither document mentioned Manage parity or remote access at all. Rewrote both: README restructured around the setup card's Chat/Manage/Voice/Remote/Relay framing (one Quick Start instead of three overlapping sections — Quick Start / What It Does / Getting Started), operational detail (sideload steps, update-banner mechanics, paste-workflow demo, uninstall flags) compressed to one-liners with docs-site links, desktop section trimmed to install + 4 commands behind an explicit alpha banner stating the planned refocus into a remote "hands" connector now that hermes-desktop owns desktop chat/management (Bailey's direction). Play listing rewritten end-user-first: new short description ("Your self-hosted Hermes AI agent, in your pocket — chat, voice, and control.", 76/80 chars), a 3-step QUICK START block, Manage + Works Away From Home feature sections, voice corrected to the no-plugin story, "TUI" jargon dropped, v0.8.1 release notes drafted (464/500 chars). Compliance-sensitive GOOGLE PLAY BUILD / SECURITY & PRIVACY sections kept verbatim.
|
||||
|
||||
**Field follow-up: http fixed chat; Manage stayed dark over Tailscale (same day).** Confirmed on-device that flipping the route to `http://` made chat roam. Manage not working over the same route has three candidate causes, all by-design rather than app bugs: (1) the dashboard (`:9119`, `hermes_cli/web_server.py`) is a separate server from the API (`:8642`) — tailnet reachability of one proves nothing about the other (bind/port-mapping/ACL; note `hermes-relay-tailscale enable` fronts only 8767 + 8642, never 9119); (2) dashboard sessions are host-scoped cookies, so the home sign-in doesn't authenticate `100.x.y.z:9119` — one sign-in per route, the app keeps both; (3) an explicit dashboard URL override pins Manage to that host (only auto-managed URLs roam — deliberate, since overrides usually mean a reverse proxy; the wizard's LAN scan can store one silently when detected ≠ derived). Audit confirmed the app already targets `effectiveDashboardUrl` everywhere (client factory, sign-in dialog, payload cache keys) — what was missing was *visibility*. Manage now: shows a persistent "Dashboard: <url> · <route> route" target line under the mode strip; names the failing URL in the "Dashboard unavailable" card; and explains per-host sign-in in the sign-in card when the route has moved. Plumbing: new `ConnectionViewModel.dashboardRouteMovedHint` (route label when effective ≠ persisted dashboard URL); `standardVoiceSignInRouteHint` refactored to reuse it (semantics unchanged).
|
||||
|
||||
**Server-side root cause + fix (same evening, via SSH per Bailey).** `ss -tlnp` on docker-server showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 172.16.24.250` — LAN interface only, so `100.71.8.56:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" Bailey saw was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.71.8.56:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
|
||||
|
||||
**Manage loading/overview pass (same day, Bailey's ask).** Three complaints: the cold-load skeleton stacked four progress bars with fake narrative labels; every re-entry to Manage was a cold load; the KPI glyphs (`ok/…/!`) and the one-line status banner (truncated by two trailing buttons, one a duplicate "Connection" link) were weak. Shipped: (1) **process-lifetime payload cache** — `DashboardPayloadCache` singleton replaces the `remember{}` maps, keyed `connection|dashboardUrl|section` so connection switches and route handoffs stay partitioned; `Loaded.fetchedAtMillis` drives a 30s stale-while-revalidate window (fresh → no fetch; stale → cached content + thin refresh bar); sign-in/out clears as before. (2) **App-start pre-warm** — section fetch core extracted to `fetchDashboardSectionState()`; `prewarmDashboardManage()` (internal, same file) fills cold keys only, aborts the sweep on first unreachable/auth failure, never marks Loading so it can't fight the open screen; RelayApp fires it (1.5s debounce) when the persisted snapshot says reachable + signed-in/auth-free, re-firing on route handoff. (3) **Skeleton** — one LinearProgressIndicator + three pulsing content-shaped ghost cards. (4) **KPI strip** — count / tone-colored dashboard state word (ready/sign-in/offline/error) / server version (`RelayMetricCard` gains optional `valueColor`). (5) **Status banner** — two-line layout (state+identity+Sign out / URL·route·checked), duplicate "Connection" button removed (Connections tile is directly below).
|
||||
|
||||
**Frozen-sphere incident: Keystore/Tink global-lock contention (same day, found via adb after Bailey reported ~3s startup freeze).** Cold-start logcat showed `Skipped 45 frames` at first draw (pre-existing VM-init cost), then `Long monitor contention … AndroidKeysetManager$Builder.build() … owner DefaultDispatcher-worker-5 … for 4.095s` **on the main thread**, ending in `Skipped 1386 frames` / `Davey! duration=11596ms`. Mechanics: `EncryptedDashboardCookieStore` built its Keystore-backed prefs **eagerly in its constructor** — 1–4s per build on Samsung StrongBox, serialized through Tink's process-global `AndroidKeysetManager.Builder.build()` lock — and the new Manage pre-warm constructed one per section (8×, worker-5 = the lock owner in every contention event), while other paths (connection validation probe, Manage's per-fetch client factory, session clear) constructed yet more instances, one of them on the main thread. The pre-warm didn't create the main-thread keystore work, but it multiplied the stall by keeping the lock hot. Fix (three layers): (1) `EncryptedDashboardCookieStore.store` is now `by lazy` — construction free on any thread, the build lands on first cookie access, which is always an OkHttp/IO thread; (2) new `ConnectionViewModel.dashboardCookieStoreFor(connectionId)` — ONE cached instance per connection, now used by Manage's client factory, the validation probe, session clear, standard voice, and the pre-warm (previously each had private instances = N keyset builds for the same prefs file); (3) `prewarmDashboardManage` takes the shared store + builds ONE `DashboardApiClient` for the whole sweep (`fetchDashboardSectionStateWith(client, …)` core extracted; per-section client/store construction removed; `NonCancellable` shutdown in finally). `DashboardOAuthSignInDialog.cookieStoreFactory` widened to the `DashboardCookieStore` interface. Net keyset builds at cold start: was ~10+ serialized seconds-long holds; now ≤3 (two AuthManagers + one cookie store), all off-main. Honest correction recorded: my earlier "pre-warm can't delay the UI" claim missed the keystore-lock dimension — network was off-main, but lock contention is transitive.
|
||||
|
||||
## 2026-06-10 — Standard voice retargeted at the dashboard surface + Manage parity (model/keys/profiles) + softened brand blue
|
||||
|
||||
**Context.** Release verification found the just-landed `StandardHermesVoiceClient` implemented the right upstream contract (`/api/audio/transcribe` + `/api/audio/speak`, base64 data-url — hermes-desktop's voice path) but aimed it at the **API server** (:8642) with a bearer header. Verified against upstream/main (tip `d1383a6b1`, fetched 2026-06-10 into `hermes-agent-pr-prep`): `api_server.py` has **no audio routes** (`/v1/capabilities` says `audio_api: false`; PR #8199 unmerged) — the routes live on the **dashboard web server** (`hermes_cli/web_server.py:1877/2012`) behind its cookie-session auth gate. Net effect: standard-only users got an enabled mic and a guaranteed 404 per turn; relay users silently paid a full base64 upload to a 404 before each fallback.
|
||||
|
||||
**What changed (branch `feature/standard-voice-dashboard-surface`).**
|
||||
|
||||
- **Voice retarget.** `StandardHermesVoiceClient` now takes a `dashboardUrlProvider` (`Connection.resolvedDashboardUrl`, :9119 derived) and an OkHttpClient carrying the **same per-connection encrypted cookie jar Manage signs in with** (new `DynamicDashboardCookieJar` resolves the store per-request so connection switches stay correct). Bearer header dropped — meaningless on this surface. 401/404 error copy now points at Manage sign-in / server update.
|
||||
- **Availability model.** New `StandardVoiceAvailability` (Unknown/Ready/SignInRequired/Unreachable/Unsupported) in ConnectionViewModel, fed by `probeStandardVoice()`: `GET /api/status` (public) → `GET /api/auth/me` when gated → HEAD existence check on the audio route (405 = present, 404 = old build). Replaces `HermesApiClient.probeAudioApi()` (deleted). Probe runs in the health cycle + `rebuildApiClient()`, refreshes the persisted dashboard snapshot only on material change, and re-runs immediately after Manage sign-in/sign-out (`refreshStandardVoice()`).
|
||||
- **Route preference.** `AutoVoiceAudioClient` Auto order is now **Relay first, then Standard**: paired Relay is profile-aware and needs no dashboard sign-in; Standard is the zero-plugin path for vanilla installs. Power users can force either in Voice Settings.
|
||||
- **Voice Settings UX.** Stable STT/TTS Route section shows live per-route status (Standard: Ready / sign-in required / unreachable / unsupported; Relay: ready / not configured; Auto: which route it would use) with a "Sign in via Manage" CTA (navigates to the Manage tab) and an "update hermes-agent or pair Relay" hint. Realtime Agent engine now states "Requires a paired Relay" and shows an inline error + guidance when selected without one. Chat mic toast is availability-aware.
|
||||
- **Manage parity with hermes-desktop.** New `DashboardApiClient` methods + UI: **Models** tab gets "Change main model" (`/api/model/options` picker → `POST /api/model/set`, with the upstream expensive-model `confirm_required` round-trip); new **Keys** tab (`GET /api/env` inventory → Set (write-only, password-masked) / Reveal (`POST /api/env/reveal`, server rate-limited) / Clear (`DELETE /api/env` with JSON body)); **Profiles** tab gets New profile (`POST /api/profiles`, clone-from-default), Describe (`PUT .../description`), and per-profile Model (`PUT .../model`, shared picker). Overview gains Models + Keys tiles. Channel-managed env vars stay visible (tagged `channel`) since the app has no Channels page to defer to.
|
||||
- **Theme.** `RelayRefresh.Electric` softened `#111DFF` → `#4F5BD5` (user feedback: connections card too saturated/"blue" vs text + palette). Drives `relaySelectedPanel`, dark `primaryContainer`, light `primary`.
|
||||
- **Docs.** CLAUDE.md dashboard-surface paragraph now lists the audio/model/env/profile routes and the standard-voice auth model.
|
||||
|
||||
**Verified.** `:app:compileSideloadDebugKotlin`, `:app:lint`, and `:app:testGooglePlayDebugUnitTest` all green locally. On-device verification needed: Manage sign-in → standard voice turn on an API-only connection; Auto fallback with relay paired; model picker payload shape against the live dashboard (`parseModelOptions` is tolerant but unverified against real `build_models_payload` output).
|
||||
|
||||
**Follow-up (same day).** Closed the deferred parity items + re-scoped the blue:
|
||||
|
||||
- **Skills hub** — "Browse hub" on the Skills tab: multi-source search (`GET /api/skills/hub/search`, results marked installed via the lock-file map), SKILL.md **preview before install** (`/api/skills/hub/preview` → detail dialog), install (`POST .../install {identifier}`) / uninstall (`POST .../uninstall {name}`) / "Update installed" (`POST .../update`). All three mutations are **async spawns server-side** (`{ok, pid}`) — UI messages say "started — refresh Skills shortly" and install rows stay disabled to prevent double-fires. Dashboard client read timeout raised 30s→45s so the server's 30s search fan-out can't die client-side at the edge.
|
||||
- **SOUL editor** — "Edit SOUL" profile action fetches the **full** file (`GET /api/profiles/{name}/soul` is untruncated upstream, unlike the relay Inspector's 200KB cap), opens a monospace full-file editor dialog, `PUT {content}` on save; creates the file when absent.
|
||||
- **Blue re-scoped** — Bailey liked the original Electric elsewhere; reverted `Electric` to `#111DFF` and added `ElectricMuted` (`#4F5BD5`), applied only to the **active connection card** (was a full-opacity `primaryContainer` fill — the actual complaint) as a 0.42-alpha wash. Cockpit selected panels, pills, and light-theme primary keep the vivid brand blue.
|
||||
- **CHANGELOG** — `[Unreleased]` entries added (missed in the first commit batch; the dev-branch convention expects per-PR appends).
|
||||
|
||||
**Follow-up 2 (same day) — capability card, quiet standard-path UX, hub featured, onboarding copy.**
|
||||
|
||||
- **Capability card** — discovered the wizard's `StandardSetupResultCard` already renders Chat/Manage/Relay readiness lines; completed it with a **Voice** line instead of inventing a new surface. `StandardApiSetupResult` gains `voiceAvailability`, settled in the same setup probe (dashboard status → auth → audio-route HEAD) so the card and the mic gate are correct the moment setup finishes. Wording: Ready → "Speech ready via your Hermes server"; SignInRequired → "Unlocks with dashboard sign-in" (the existing Manage CTA covers it); Unsupported → "update or pair Relay".
|
||||
- **No spurious relay warnings on the standard path** (audit per Bailey). Verified `runVoiceRelayPreflight` only fires on the Realtime engine (correctly relay-gated). The real offender was **Voice Settings**: it fetched three relay configs on open and snackbar'd every failure — a standard-only user got two "Relay unreachable" snackbars for a route they don't use. Now gated on `relayVoiceReady`: fetches skipped entirely, relay-backed sections (Fallback TTS / Voice Output / Realtime config) replaced by one quiet "Voice Providers" card ("speaks through your Hermes server's configured TTS/STT — pair Relay to pick providers from the phone"), STT section shows a quiet line instead of permanent "loading...", and Test Current Engine labels the route honestly.
|
||||
- **Hub featured view** — `GET /api/skills/hub/sources` on dialog open: "Sources: Official (Nous), skills.sh, ..." line + featured skills (from the centralized index) listed before the first search, marked installed via the same lock map. Best-effort: silent on failure.
|
||||
- **Onboarding** — the rechrome (55a4227) reworked Welcome (sphere hero + Standard/Advanced paths) and the Connect step (shared ConnectionWizard), but Chat/Manage/Power remained icon + one sentence. They now carry three concrete feature rows each (reusing the Welcome page's row style): Chat = streaming/profiles/voice-no-install; Manage = control/skills-hub/one-sign-in; Power = terminal/bridge/realtime. Copy emphasizes the standard-first story ("no extra install", "signing in once also unlocks voice").
|
||||
|
||||
**Follow-up 3 (same day) — release polish: floating status pill, gesture ambient mode, media-settings scoping, voice.md standard route.**
|
||||
|
||||
- `RelayStatusStrip` is now a floating capsule (insets → 14dp side / 8dp bottom margins → pill clip) instead of a zero-radius bordered bar — the full-width rectangle clashed with rounded display corners. Gate caught a real overload error: Compose `padding()` can't mix `horizontal` with `top`/`bottom` — use start/end/top/bottom.
|
||||
- Ambient (fullscreen sphere) lost its top-bar toggle: long-press the conversation background to enter (bubbles keep their copy long-press — they consume first), tap/long-press anywhere to exit, transient "tap to return to chat" pill on every entry. Note: inside a Box nested in a Column, bare `AnimatedVisibility` resolves to the ColumnScope extension and fails — fully qualify `androidx.compose.animation.AnimatedVisibility`.
|
||||
- Media settings confirmed **Relay-only** (they govern `MEDIA:hermes-relay://<token>` fetches via MediaSettingsRepository) and now say so on-screen.
|
||||
- `user-docs/features/voice.md` intro rewritten: standard (no-Relay) voice via the dashboard audio routes is now the lead story, with a two-route tip block (Auto prefers Relay when paired) and Realtime marked relay-required. Remaining docs debt logged below.
|
||||
|
||||
**Follow-up 4 (same day) — chat enhancements + release docs.** Audit found most of the chat wishlist already shipped (scroll-to-bottom FAB with `userScrolledAway` auto-follow, session drawer search/pin/archive, not-connected empty state with Connect CTA, stop-during-streaming, tappable suggestion chips). Added the genuinely missing pieces: **Quote in reply** (bubble long-press now opens Copy/Quote menu when a quote handler is wired; copy-only call sites keep direct copy), **Share conversation** (top-bar share → Markdown via ACTION_SEND), Manage card **"More" overflow** for 5+ action rows, and the ambient-gesture tip in Appearance (a11y-discoverable). user-docs: new `guide/quick-start.md` (2-minute standard path, capability-card table, power-tools in a collapsed details block) registered first in the sidebar; `features/dashboard.md` Android Manage section rewritten per-section including hub/Keys/SOUL-editing (and the stale "SOUL editing requires the paired inspector" claim fixed); `features/voice.md` Requirements split standard-route vs relay-route. Release coordination: PR #64 (docs baseline refs → dev) overlaps us on CLAUDE.md/DEVLOG/upstream-contributions/dashboard.md — land #64 first, resolve in our PR. Dependabot PRs target `main` directly (off-policy); add `target-branch: dev` to `.github/dependabot.yml` as a follow-up.
|
||||
|
||||
**Docs debt (user-docs) for the release:** `features/dashboard.md` lacks the new Manage surfaces (Keys tab, model picker, profile create/describe/SOUL editor, skills-hub browse/featured); `guide/getting-started.md` (17.5KB) should split into a short Quick Start page + separate Install-options/Advanced pages; `features/voice.md` body still describes relay-era requirements beyond the new intro.
|
||||
|
||||
**Next.** When upstream PR #8199 lands `/v1/audio/*` on the API server, add it as the preferred standard route (capabilities already advertise `audio_api`) and demote the dashboard path to fallback. Remaining deferred parity: MCP manual add-server form (catalog install covers onboarding), per-profile cron/skill scoping in Manage.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-05 — Prefer upstream `/v1/skills` with legacy fallback
|
||||
|
||||
**Context.** Upstream Hermes Agent now has baseline skill/session API surface area, while Axiom's fork still preserves richer Relay-specific `/api/*` compatibility routes. The Android client should begin consuming upstream-compatible skill listings when present without breaking older fork/bootstrap installs.
|
||||
|
||||
**What changed.** `HermesApiClient.getSkills()` now tries `/v1/skills` first, then falls back to `/api/skills`. Skill parsing accepts upstream OpenAI-style list envelopes (`{"object":"list","data":[...]}`), legacy fork envelopes (`{"skills":[...]}` / `{"items":[...]}`), and direct arrays.
|
||||
|
||||
**Verification.** Added pure Kotlin unit coverage for endpoint order and `/v1/skills` `data` parsing. Verified with `ANDROID_HOME=$HOME/Android/Sdk ./gradlew :app:testGooglePlayDebugUnitTest --tests 'com.hermesandroid.relay.network.HermesApiClientTest'` → BUILD SUCCESSFUL. `git diff --check` passes.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-26 — Fix voice-mode crash: ExoPlayer audio session id read off-main (barge-in + legacy TTS)
|
||||
|
||||
**Report.** Discord user, sideload latest: voice chat crashes the instant Hermes starts answering — "I hear just 2 letters and it crashes." Stack: `IllegalStateException: Player is accessed on the wrong thread. Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'` with the Media3 `player-accessed-on-wrong-thread` doc link and a `Suppressed: ... Dispatchers.IO`.
|
||||
|
||||
**Root cause.** `Dispatchers.IO` threads are named `DefaultDispatcher-worker-N` (IO and Default share one scheduler pool), so the crash is on an IO coroutine. `BargeInListener` runs its mic reader on `Dispatchers.IO` and, to attach `AcousticEchoCanceler`, polls an `audioSessionIdProvider` lambda. On the **legacy `/voice/synthesize` (Media3) playback path**, `VoiceViewModel` wires that provider to `{ player.audioSessionId }` → `exoPlayer.audioSessionId`. ExoPlayer is thread-confined; its `getAudioSessionId()` getter calls `verifyApplicationThread()` and throws when read off-main. The realtime PCM path is unaffected because it wires the provider to an `AudioTrack` session id (thread-safe), which is why the bug only hit legacy/fallback setups. Sequence: first sentence starts → `runPlayWorker.onFileReady` → `startBargeInListenerIfEnabled()` → IO reader → `awaitNonZeroSessionId()` → off-main getter → crash ~2 syllables in.
|
||||
|
||||
**Fix.** `VoicePlayer.audioSessionId` now serves a `@Volatile cachedAudioSessionId` instead of the raw thread-confined getter. The cache is populated from main-thread Media3 callbacks: an `AnalyticsListener.onAudioSessionIdChanged` hook (authoritative, fires when Media3 allocates/reallocates the AudioTrack) plus a belt-and-braces read inside the existing `onIsPlayingChanged`. Reads from any thread are now safe.
|
||||
|
||||
**Tests.** Added `VoicePlayerTest` coverage: getter reflects the analytics-listener-cached id, never re-invokes `exoPlayer.audioSessionId` (the off-main call), and defaults to 0 before allocation. Captured the `AnalyticsListener` in the MockK harness. Verified locally: `:app:lintGooglePlayDebug` + `:app:testGooglePlayDebugUnitTest --tests VoicePlayerTest` both green (BUILD SUCCESSFUL).
|
||||
|
||||
**Next.** Landed on `dev` via PR #60, then shipped as the focused patch release `android-v0.8.1` (cherry-picked off the `android-v0.8.0` tag, PR #62); `main` merged back to `dev`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 — Background Hermes runs in Realtime Agent voice (ADR 33)
|
||||
|
||||
**Context.** Realtime Agent ran each Hermes turn synchronously *inside* the provider event pump (`_run_brokered_tool` did `return await task`), so a long research/multi-tool/desktop run froze the whole realtime session until it finished. ADR 33 + `docs/plans/2026-05-24-realtime-background-hermes-runs.md` define a three-tier model (foreground / promoted / durable) with the relay as an explicit audio-floor owner. Branch `feature/realtime-background-hermes-runs`.
|
||||
|
||||
**What shipped (phased, per the plan):**
|
||||
|
||||
- **Phase 0 — idle-tolerance probe + verdict.** `scripts/realtime-provider-idle-probe.py` + an "Idle tolerance" section in `docs/realtime-voice-poc.md`. **Ran live against OpenAI** (`VOICE_TOOLS_OPENAI_KEY` in `~/.hermes/.env`): the session survived 10s/20s/30s quiescent windows and returned clean audio on every post-idle turn → verdict **`hold-floor-ok`**. xAI has no creds on the dev box, so its verdict is recorded analytically as `hold-floor-ok` (same `turn_detection:None` multi-turn model; the implementation closes the pending call rather than holding an open response) — confirm on the relay host. Incidental finding logged: OpenAI now wants `session.audio.output.format.rate` at `session.update` (minor `_session_update` follow-up; session still worked).
|
||||
- **Phase 1 — floor owner.** New `plugin/relay/realtime_agent/floor.py`: pure, single-owner audio floor (`provider` / `relay_tts` / `android_filler` mouths; `idle/provider_speaking/hermes_filler/result_pending` labels). Wired behavior-preservingly into the broker (acquire/release on AUDIO_DELTA/AUDIO_DONE/RESPONSE_DONE; relay-TTS render holds the floor; filler gated by `can_speak`). Invariants in `test_realtime_floor.py`.
|
||||
- **Phase 2 — Tier B promotion (was default off).** `_run_brokered_tool` shields the run and waits `promote_after_ms`; if still running it detaches to the background, closes the pending provider call with an interim ack, optionally speaks a handoff, and `_deliver_background_result` speaks the answer once the floor is idle. New events `hermes.run.promoted` / `hermes.run.background_completed` + `tier`/`floor` on progress; 8 new settings. `test_realtime_promotion.py` (promote+pump-responsive, short=no-promote, cancel, detach-resume-replays).
|
||||
- **Phase 3 — default-on + Tier C + Android + docs.** Flipped `promotion_enabled` default **true** (safe: the path closes the pending call rather than holding an open response, so the socket only sees the normal between-turns idle gap). `hermes_run_task(mode="background")` detaches immediately (`tier:"durable"`). Settings exposed on `GET/PATCH /voice/realtime-agent/config`. Android: parse new events → "working on it" chip; Voice Settings → Realtime Agent → Background tasks (promote toggle, spoken-handoff toggle, result-delivery segmented control) → `RelayVoiceClient.updateRealtimeAgentPromotion()`.
|
||||
|
||||
**Why default-on despite the Phase 0 gate.** The implementation closes the pending function call with an interim background ack instead of parking an open provider response, so the worst-case "idle open-response" the gate worried about doesn't occur — the socket sits in the same idle state it does between any two user turns. The probe is retained to confirm per-provider survival; documented in config + ADR.
|
||||
|
||||
**Verified.** Python realtime suite **58 tests green** (`test_realtime_floor`, `test_realtime_promotion`, both provider suites, routes, profile-voice-config). `./gradlew lint` — Kotlin compiles clean; the only 2 lint errors are in the gitignored `local.properties` (absent in CI). Pre-existing unrelated `test_reads_hermes_xai_oauth_credential_pool` failure confirmed on `origin/dev` baseline.
|
||||
|
||||
**Next.** Confirm the xAI idle verdict on the relay host (where xAI creds live); fix the OpenAI `_session_update` rate field; run the lab smoke on a paired device. Open the PR to `dev`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-23 — Un-defer the voice/audio test suite (issue #32) + barge-in resume bug
|
||||
|
||||
**Context.** GitHub issue #32 tracked 5 voice/audio unit tests `@Ignore`'d during the v0.5.1 release because the full `:app:testGooglePlayDebugUnitTest` task "hung indefinitely." Scope had quietly grown to **8** ignored classes (3 of the "pure-logic, should-work" ones got swept in defensively). Branch `fix/voice-test-suite`.
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>One Hermes agent. Two ways to use it.</strong><br>
|
||||
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
|
||||
use a server-deployed Hermes from your laptop as if it were running locally.
|
||||
<strong>Your self-hosted Hermes agent, native on your phone.</strong><br>
|
||||
Chat, voice, and full agent management over your own infrastructure —<br>
|
||||
plus an experimental desktop CLI that gives the agent hands on your computer.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI%20%28alpha%29-orange.svg" alt="Desktop CLI (alpha)"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
|
||||
@@ -35,107 +35,136 @@
|
||||
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
|
||||
| **[Android app](#quick-start-android)** | Native phone client — streaming chat, hands-free voice, full agent management (models, keys, skills, profiles), and on sideload builds the agent can read your screen and act on it. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop CLI](#desktop-cli-alpha)** | The agent reaching back to **your machine** — local tool routing (files, terminal, screenshots, clipboard) plus a remote shell to the host. | **Alpha** — `desktop-v*` releases, expect heavy changes |
|
||||
|
||||
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
|
||||
Both share the same WSS relay and credentials store. **Pair once from either, both work.**
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Quick Start (Android)
|
||||
|
||||
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
|
||||
Install → connect → talk, in about two minutes. A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**.
|
||||
|
||||
### 1a. Android app
|
||||
|
||||
<!-- TODO: Uncomment when Play Store listing is live
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
|
||||
-->
|
||||
### 1. Install the app
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and choose the newest Android release (`android-v*`; historical Android releases used bare `v*`)
|
||||
- **APK** — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Full walkthrough — integrity verification, signing fingerprint, what's in each build — in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
#### Sideload APK (GitHub Releases)
|
||||
Sideload builds check GitHub for new releases and show a one-tap update banner when you're behind; Play builds update through the Play Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
### 2. Have Hermes running
|
||||
|
||||
1. Download the file ending in **`-sideload-release.apk`** from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
|
||||
3. Open the APK from your downloads and tap **Install**.
|
||||
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
|
||||
Run upstream Hermes with its API server and dashboard enabled:
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)"
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
|
||||
### 1b. Desktop app + CLI (experimental)
|
||||
echo "Android API URL: http://<this-computer-ip>:8642"
|
||||
echo "Android API key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
|
||||
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
|
||||
### 3. Connect and talk
|
||||
|
||||
**Install tray app** (Windows PowerShell):
|
||||
Open the app, choose **Standard Hermes**, and enter your server's address and API key. The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|---|---|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session also unlocks voice. That's the whole standard setup.
|
||||
|
||||
**Going places?** Put your server's Tailscale URL in the setup form's "Remote access" field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
|
||||
### 4. Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen; if you can't scan, use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**. (`/hermes-relay-pair` and the dashed `hermes-pair` shim remain for chat-surface and older builds.)
|
||||
|
||||
- **Updating:** `hermes-relay-update` — idempotent; or re-run the install one-liner.
|
||||
- **Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a "Relay" tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server · macOS / Linux / Windows for the desktop CLI.
|
||||
|
||||
## Desktop CLI (alpha)
|
||||
|
||||
> **Alpha — expect heavy changes.** With [hermes-desktop](https://hermes-agent.nousresearch.com) now covering chat and management on the desktop, this surface is being refocused into a pure remote **"hands" connector**: the agent reaching back through the relay to run tools on your machine (files, terminal, screenshots, clipboard, editor). The chat and shell features that overlap hermes-desktop will be removed in a future release. Binaries are unsigned during the experimental phase — SmartScreen/Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, and more **on your machine** over the same WSS relay — with a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch. No Node required; installs are self-contained native binaries.
|
||||
|
||||
**Install** (Windows PowerShell / macOS / Linux):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI only** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay # interactive Hermes TUI in tmux
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
hermes-relay daemon # headless tool router (agent reaches you anytime)
|
||||
hermes-relay daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay # interactive Hermes TUI in tmux (legacy, being refocused)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
|
||||
- **Docs:** [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track:** tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
|
||||
## Features
|
||||
|
||||
- **Docs**: [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
### Android
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
- **Streaming chat** — direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage provider keys (write-only, masked, server-rate-limited reveal), create and edit agent profiles including `SOUL.md`, and browse, install, and update skills from the hub. One dashboard sign-in covers it all
|
||||
- **Voice mode** — talk hands-free on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice providers and an opt-in provider-native Realtime Agent with background task handoff
|
||||
- **Works away from home** — add your server's Tailscale or public URL and the app roams automatically: LAN at home, fallback elsewhere. Routes are editable per connection, and an unreachable server gets a diagnosis ("away from the server's network? add a route"), not just a red dot
|
||||
- **Multi-Connection + profiles** — pair with multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay an agent profile's model + `SOUL.md` per chat
|
||||
- **Phone control (bridge)** — with the Relay plugin paired, the agent reads the screen and acts on it: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros, and event-driven waits. Guarded by safety rails: per-app blocklist (banking/payments/2FA default-blocked), destructive-verb confirmation, idle auto-disable, full activity log
|
||||
- **Notification companion** — opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
### Desktop CLI
|
||||
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
|
||||
- **Local tool routing** — `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` run on your machine; agent-proposed patches render as colored diffs with interactive approval
|
||||
- **Daemon mode** — headless tool router; the agent can reach you with no shell open
|
||||
- **Multi-endpoint pairing, reconnect-on-drop, TOFU cert pinning** — same model as the Android app
|
||||
- **Self-update** — `hermes-relay update` verifies SHA256 and atomic-swaps the binary
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
|
||||
## Install with an AI agent
|
||||
|
||||
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
|
||||
|
||||
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
|
||||
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
|
||||
|
||||
### For AI Agents
|
||||
|
||||
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
|
||||
If an AI assistant (Claude, GPT, etc.) manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
@@ -146,109 +175,38 @@ Read the canonical setup recipe before acting:
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via the plugin-provided `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
|
||||
|
||||
| Surface | Channel | What | Status |
|
||||
|---------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
|
||||
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
|
||||
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
|
||||
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
|
||||
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
|
||||
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
|
||||
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
|
||||
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for the full list.
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
|
||||
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
|
||||
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
|
||||
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
|
||||
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
|
||||
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
|
||||
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Bridge safety rails** — Per-app blocklist (banking, payments, 2FA default-blocked), destructive-verb confirmation modal (send, pay, delete, transfer…), idle auto-disable timer, optional persistent-status overlay, full activity log
|
||||
- **Security & pairing** — QR-code pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
|
||||
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
|
||||
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
|
||||
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
|
||||
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
|
||||
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
|
||||
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
|
||||
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
|
||||
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Android:**
|
||||
|
||||
1. **Install the app** from the [link above](#1a-android-app)
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
**Desktop CLI:**
|
||||
|
||||
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
|
||||
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Server (:8767) [voice routes — API key or relay session]
|
||||
Phone (WSS/HTTP) --> Server (:8767) [terminal, bridge, media, sessions]
|
||||
Desktop CLI (WSS) --> Server (:8767) [tui, terminal, desktop tools]
|
||||
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
Desktop CLI (WSS) --> Relay (:8767) [desktop tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
|
||||
Chat connects directly to the Hermes API Server with the API key — the same pattern used by Open WebUI and other Hermes frontends. The Manage tab and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, desktop tools, and relay-side voice providers (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, and Desktop `desktop-v*`) |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, Desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,7 +265,7 @@ hermes-relay/
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
### Server (optional — bridge, terminal, TUI, media, and voice routes)
|
||||
### Server (optional — bridge, terminal, TUI, media, and relay voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
@@ -325,7 +283,7 @@ See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setu
|
||||
|
||||
### Hermes Plugin (for contributors)
|
||||
|
||||
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
|
||||
End users should install via the [one-liner](#4-optional-install-relay-for-power-tools) above. For local development from a clone:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
@@ -333,7 +291,7 @@ cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
Then restart hermes and run the plugin-provided `hermes pair` to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
|
||||
+25
-66
@@ -1,85 +1,44 @@
|
||||
# Hermes-Relay-Android v0.8.0
|
||||
# Unreleased
|
||||
|
||||
**Release Date:** May 23, 2026
|
||||
**Since v0.7.0:** Google Play-safe Bridge Core hardening, provider-native Realtime Agent voice with reliable low-latency playback, a text + mic Voice Lab, connection diagnostics, and clearer Voice Settings.
|
||||
## Changed
|
||||
|
||||
v0.8.0 is the Android release-prep build for resubmitting the enhanced Google Play track. The Play artifact keeps chat, profiles, voice, terminal/TUI relay, media, notification companion, relay sessions, QR pairing, and diagnostics, while leaving AccessibilityService-backed Device Control only in the sideload flavor.
|
||||
- Android now defaults to a standard Hermes layout with **Chat**, **Manage**, and **Settings** in bottom navigation. Terminal and Bridge remain available under **Settings → Power tools** and through existing routes.
|
||||
- Added a native **Manage** surface backed by the Hermes dashboard/admin API for Skills, Cron, MCP servers/catalog, Profiles, Models, and Config. It supports dashboard sign-in, common management actions, cron run details, and read-only profile SOUL details without requiring relay pairing.
|
||||
- Relay-only features now show a consistent **Requires pairing** / **Pair to unlock** gate when the active connection is not paired.
|
||||
- Connections now model API auth, dashboard auth, and relay pairing separately. Dashboard URLs derive from the API host on port `9119` by default.
|
||||
|
||||
---
|
||||
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v0.8.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-0.8.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.0-sideload-release.apk` | Direct-install APK for full Device Control testing. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
## Fixed
|
||||
|
||||
### Google Play Bridge Core
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
|
||||
- Google Play now ships Bridge Core without AccessibilityService-backed Device Control.
|
||||
- The Play artifact keeps pairing, profiles, chat, voice, terminal/TUI relay, media, notification companion, relay sessions, connection status, and diagnostics.
|
||||
- Sideload remains the only track for screen reading, gestures, screenshots, SMS/call/contact/location helpers, overlays, wake locks, and unattended control.
|
||||
- User docs, feature matrix, privacy/security copy, and Play listing notes now match that artifact boundary.
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
|
||||
### Provider-native Realtime Agent
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
|
||||
- Realtime Agent is now a true provider-native voice engine rather than a render-after-Hermes fallback.
|
||||
- Android streams mic PCM to the relay; the relay opens a server-side xAI or OpenAI realtime session.
|
||||
- Hermes remains the only path for tools, memory, research/current-data checks, confirmations, side effects, profile context, and durable transcript state.
|
||||
- The provider receives compact Hermes function results and speaks natural post-tool summaries instead of reading raw tool output aloud.
|
||||
|
||||
### Reliable, low-latency realtime playback
|
||||
|
||||
- Fixed silent / choppy first-turn realtime audio: the AudioTrack deep-buffer cold-start was parking the playback head at zero. The streaming buffer was shrunk (4000ms → 700ms), the low-latency prebuffer retuned, and a preroll force-start removed for reliable playback from the first frame.
|
||||
- Added playback diagnostics — time-to-first-audio, requested-vs-actual buffer logging, a first-frame watchdog, and a drain cross-check — so cold-start and underrun issues surface in the Diagnostics log.
|
||||
|
||||
### Voice Lab (text + mic demos)
|
||||
|
||||
- The realtime voice test screen offers a **Text demo** (raw provider TTS) and a **Mic demo** (full agent path: real speech recognition, Hermes brokering, spoken reply) with tap-to-record / tap-to-stop capture.
|
||||
- The Voice Lab waveform now follows the playback cursor (driven by the player's amplitude at the playback position) instead of socket-arrival time, so the visual matches what is heard.
|
||||
|
||||
### Voice Settings and diagnostics
|
||||
|
||||
- Voice Settings now separates **Voice Engine** from global controls.
|
||||
- The active engine controls the visible card: **Hermes Chat + Voice Output** or **Realtime Agent**.
|
||||
- The fallback TTS card is global and remains visible for both engines.
|
||||
- **Test Current Engine** plays the stable voice-output sample in stable mode and opens a provider-native Realtime Agent test session in Realtime Agent mode.
|
||||
- Settings now has app-level Diagnostics, and API / Relay / Session detail drawers include sanitized recent activity tails.
|
||||
- Voice turns preflight relay health and use shorter timeouts so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
|
||||
---
|
||||
|
||||
## Google Play Resubmission Notes
|
||||
|
||||
- Upload `hermes-relay-0.8.0-googlePlay-release.aab`.
|
||||
- Play Console release notes can use the `docs/play-store-listing.md` **Release Notes** section.
|
||||
- The merged Play manifest should contain no `AccessibilityService`, `BIND_ACCESSIBILITY_SERVICE`, `SYSTEM_ALERT_WINDOW`, `FOREGROUND_SERVICE_SPECIAL_USE`, `WAKE_LOCK`, `SEND_SMS`, `CALL_PHONE`, contacts, or location permissions.
|
||||
- The Play listing should not claim screen reading, screenshots, phone control, or accessibility automation.
|
||||
|
||||
## Verification
|
||||
|
||||
- Android version metadata: `0.8.0` / `versionCode 10`.
|
||||
- Google Play release Kotlin compile passed.
|
||||
- Google Play release AAB build passed.
|
||||
- Google Play merged manifest has no forbidden Device Control entries.
|
||||
- Google Play AAB is release-signed with `CN=Bailey Dixon, OU=Hermes-Relay, O=Codename-11`.
|
||||
- VitePress user-docs build passed.
|
||||
- Focused voice engine, realtime playback (buffer policy / amplitude / watchdog), and diagnostics unit tests passed.
|
||||
|
||||
## Post-install Smoke
|
||||
|
||||
- Install/update the sideload APK over the existing sideload app with `adb install -r`.
|
||||
- Existing pairing should survive a same-flavor update. Re-pair only if you uninstall app data, switch flavor/applicationId, revoke the device, or intentionally clear the server session store.
|
||||
- Confirm Settings -> Connections shows API, Relay, Session, and Diagnostics activity.
|
||||
- Confirm Settings -> Voice shows the selected engine card and Test Current Engine follows the selected path.
|
||||
- In Voice mode, test Hermes Chat + Voice Output first, then opt into Realtime Agent and ask a current-data question to confirm Hermes is queried instead of the provider guessing.
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
|
||||
-1
@@ -4,7 +4,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PowerFeatureGateUiTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun requiresPairingCard_showsPairToUnlock() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredPairingCard_showsPairAgain() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardSignInCard_usesDashboardLanguage() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Manage",
|
||||
summary = "Open dashboard-backed management features.",
|
||||
status = PowerFeatureGateStatus.DashboardSignInRequired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
-1
@@ -6,7 +6,6 @@ import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
+64
-162
@@ -1,22 +1,19 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@@ -24,270 +21,175 @@ class OnboardingFlowTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay")
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip")
|
||||
.onNodeWithText("Standard")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.onNodeWithText("Chat")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1: Hermes-Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 5: Connect to Hermes
|
||||
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 6: Relay Server (last page)
|
||||
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Back button ---
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// On page 1, Back should not exist
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Go to page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiKeyField() {
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.onNodeWithText("Standard Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
fun standardSetup_showsApiFields() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog is showing
|
||||
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
|
||||
|
||||
// Dismiss it
|
||||
composeTestRule.onNodeWithText("Got it").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should be gone
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- Page 6: Relay page ---
|
||||
|
||||
@Test
|
||||
fun relayPage_showsOptionalMessaging() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5) // Last page
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("This is optional", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
fun connectPage_keepsPairingOptional() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,52 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
fun terminalScreen_showsCurrentTopBar() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val terminalViewModel = TerminalViewModel(app)
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPhase2Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 2")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Secure shell access", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Full ANSI terminal emulator", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("tmux session management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Bridge Screen ---
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTitle() {
|
||||
fun bridgeScreen_showsCurrentTopBar() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Device Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPhase3Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Agent-controlled device interaction", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Permission management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
v0.8.0 - Play-safe Bridge Core, Realtime Agent, and diagnostics
|
||||
|
||||
Google Play
|
||||
* Google Play keeps chat, profiles, voice, terminal/TUI relay, media,
|
||||
notification companion, relay sessions, QR pairing, and diagnostics.
|
||||
* AccessibilityService-backed screen reading, phone control, screenshots,
|
||||
SMS/calls, contacts/location, overlays, wake locks, and unattended control
|
||||
remain sideload-only.
|
||||
v0.8.1 - Voice mode crash fix
|
||||
|
||||
Voice
|
||||
* Voice Settings now separates Voice Engine from global controls.
|
||||
* Hermes Chat + Voice Output and Realtime Agent show their own focused cards.
|
||||
* Fallback TTS stays visible as a global safety-net card.
|
||||
* Test Current Engine now plays saved stable voice or a provider-native Realtime Agent sample.
|
||||
* Realtime Agent uses provider-native xAI/OpenAI speech while Hermes remains
|
||||
the tool, memory, profile, confirmation, and current-data authority.
|
||||
* Realtime voice now plays reliably from the first frame with low latency —
|
||||
fixed silent/choppy first-turn audio and added playback diagnostics.
|
||||
* The Voice Lab adds a Text demo (raw provider TTS) and a Mic demo (full agent
|
||||
path with tap-to-record/stop); the waveform follows the playback cursor.
|
||||
|
||||
Diagnostics
|
||||
* Settings now has app-level diagnostics.
|
||||
* Connection detail sheets show recent API, relay, session, endpoint, and voice
|
||||
activity so relay hangs surface quickly.
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -83,9 +84,33 @@ class VoicePlayer(
|
||||
private var visualizer: Visualizer? = null
|
||||
private var visualizerAttached = false
|
||||
|
||||
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
|
||||
// thread-confined — every accessor (the audioSessionId getter included)
|
||||
// calls verifyApplicationThread() and throws "Player is accessed on the
|
||||
// wrong thread" if touched off the player's construction thread. The
|
||||
// barge-in pipeline reads [audioSessionId] from BargeInListener's
|
||||
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
|
||||
// can't expose the raw getter. Instead we cache the id from the
|
||||
// main-thread Media3 callbacks below and serve the getter from this
|
||||
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
|
||||
// first sentence played for ~2 syllables before the IO read threw.)
|
||||
@Volatile private var cachedAudioSessionId: Int = 0
|
||||
|
||||
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
|
||||
|
||||
init {
|
||||
// AnalyticsListener callbacks are delivered on the player's
|
||||
// application (main) thread, so caching the id here is the
|
||||
// authoritative, thread-correct way to track it as Media3 allocates
|
||||
// and reallocates the underlying AudioTrack.
|
||||
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioSessionIdChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioSessionId: Int,
|
||||
) {
|
||||
cachedAudioSessionId = audioSessionId
|
||||
}
|
||||
})
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
@@ -94,8 +119,16 @@ class VoicePlayer(
|
||||
// actually begins — the audio session id is stable from
|
||||
// player construction on Media3 1.x but some OEM pipelines
|
||||
// don't allocate the track until playback starts.
|
||||
if (isPlaying && !visualizerAttached) {
|
||||
attachVisualizer(exoPlayer.audioSessionId)
|
||||
if (isPlaying) {
|
||||
// Belt-and-braces with the analytics listener above: this
|
||||
// runs on the main thread too, so reading the getter here
|
||||
// is safe and guarantees the cache is warm by the time
|
||||
// playback is audible (and thus by the time barge-in
|
||||
// starts its IO reader).
|
||||
cachedAudioSessionId = exoPlayer.audioSessionId
|
||||
if (!visualizerAttached) {
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +244,20 @@ class VoicePlayer(
|
||||
* poll this property briefly rather than assume it's hot-ready at
|
||||
* [VoicePlayer] construction time.
|
||||
*
|
||||
* Exposed read-only. Internally the same id drives the Visualizer
|
||||
* attach logic in [attachVisualizer]; B4 reads it via a provider
|
||||
* lambda so the listener can re-check across the 1 s poll window
|
||||
* without holding a stale reference.
|
||||
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
|
||||
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
|
||||
* thread-confined and [BargeInListener] reads this from its
|
||||
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
|
||||
* throws `IllegalStateException: Player is accessed on the wrong thread`.
|
||||
* The cache is populated from main-thread Media3 callbacks (the
|
||||
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
|
||||
*
|
||||
* Exposed read-only. B4 reads it via a provider lambda so the listener
|
||||
* can re-check across the 1 s poll window without holding a stale
|
||||
* reference.
|
||||
*/
|
||||
val audioSessionId: Int
|
||||
get() = exoPlayer.audioSessionId
|
||||
get() = cachedAudioSessionId
|
||||
|
||||
/**
|
||||
* Set the playback volume of the underlying ExoPlayer.
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -40,6 +41,15 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val pairedSessionMetaJson: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Orchestrates pairing + session token lifecycle for the relay channel.
|
||||
*
|
||||
@@ -134,6 +144,56 @@ class AuthManager(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
ConnectionAuthSecrets(
|
||||
sessionToken = store.getString(KEY_SESSION_TOKEN),
|
||||
refreshToken = store.getString(KEY_REFRESH_TOKEN),
|
||||
deviceId = store.getString(KEY_DEVICE_ID),
|
||||
apiKey = store.getString(KEY_API_KEY),
|
||||
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun importStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
secrets: ConnectionAuthSecrets,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
|
||||
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenStoreForBackup(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): SessionTokenStore {
|
||||
val appContext = context.applicationContext
|
||||
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
private fun writeOrRemove(
|
||||
store: SessionTokenStore,
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) {
|
||||
store.remove(key)
|
||||
} else {
|
||||
store.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `profiles` array from an `auth.ok` payload into a list of
|
||||
* [Profile] entries. Extracted out of [handleAuthOk] so it's
|
||||
|
||||
@@ -3,6 +3,18 @@ package com.hermesandroid.relay.data
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
@Serializable
|
||||
data class DashboardConnectionStatus(
|
||||
val checkedAtMillis: Long? = null,
|
||||
val reachable: Boolean = false,
|
||||
val authRequired: Boolean? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authenticated: Boolean? = null,
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
@@ -30,9 +42,9 @@ import java.net.URI
|
||||
*
|
||||
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
|
||||
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
|
||||
* free to mean what Hermes's server config means by it (agent profiles —
|
||||
* name + model + description defined under `agent.profiles` in config.yaml).
|
||||
* A follow-up pass will introduce the new `Profile` concept on top.
|
||||
* free to mean upstream Hermes profiles: separate host-side Hermes homes
|
||||
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
|
||||
* sessions, skills, cron, and provider state.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
@@ -41,6 +53,24 @@ data class Connection(
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/**
|
||||
* Hermes dashboard/admin URL. Dashboard management features use this
|
||||
* separately from the relay pairing channel; a blank/null value means
|
||||
* "derive from [apiServerUrl] using the conventional same-host :9119".
|
||||
*/
|
||||
val dashboardUrl: String? = null,
|
||||
val dashboardAuthRequired: Boolean? = null,
|
||||
val dashboardAuthProviders: List<String> = emptyList(),
|
||||
val dashboardLastStatus: DashboardConnectionStatus? = null,
|
||||
/**
|
||||
* Candidate host routes for this saved Hermes server. Standard setup
|
||||
* stores at least one candidate here so API, dashboard, voice, and Relay
|
||||
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
|
||||
* Older installs and legacy serialized records default to an empty list.
|
||||
*/
|
||||
val routeCandidates: List<EndpointCandidate> = emptyList(),
|
||||
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
@@ -48,6 +78,12 @@ data class Connection(
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -57,6 +93,8 @@ data class Connection(
|
||||
*/
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
* from a connection UUID. Trimmed to the first 8 characters of the
|
||||
@@ -80,5 +118,213 @@ data class Connection(
|
||||
apiServerUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
apiServerUrl: String,
|
||||
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun buildRouteCandidates(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
role = inferRouteRole(apiServerUrl),
|
||||
priority = 0,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
.map { it.first.trim() to it.second.trim() }
|
||||
.filter { (_, url) -> url.isNotBlank() }
|
||||
.forEachIndexed { index, (role, url) ->
|
||||
endpointCandidateFromApiUrl(
|
||||
role = role.ifBlank { inferRouteRole(url) },
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a freshly-rebuilt candidate list onto an existing stored
|
||||
* one, preserving the stored extras (priority > 0) that the rebuild
|
||||
* doesn't already cover. URL edits rebuild only the route(s) the
|
||||
* user actually touched — without this merge, saving an API or
|
||||
* Relay URL collapsed the stored list to a single candidate,
|
||||
* silently dropping the setup wizard's Tailscale route (or a
|
||||
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
|
||||
*
|
||||
* Stored extras are preserved **verbatim** (role, priority, relay
|
||||
* URL) rather than re-derived, so payload-specified relay URLs
|
||||
* survive. Host:port collisions defer to the rebuilt entry.
|
||||
*/
|
||||
fun mergeRouteCandidates(
|
||||
rebuilt: List<EndpointCandidate>,
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
|
||||
* default a missing scheme to `http://`, and default a missing port
|
||||
* to [defaultPort] — most Hermes API servers speak plain HTTP on
|
||||
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
|
||||
* the most common thing users type.
|
||||
*
|
||||
* URLs that already carry a scheme are preserved **verbatim**
|
||||
* (including a wrong one like `ws://`, so downstream validators can
|
||||
* complain precisely): an explicit `https://hermes.example.com` may
|
||||
* be a reverse proxy on 443, and force-appending :8642 would break
|
||||
* it. Port-defaulting applies only to scheme-less input, where the
|
||||
* user is visibly relying on our defaults.
|
||||
*/
|
||||
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
|
||||
fun endpointCandidateFromApiUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = if (uri.port > 0) uri.port else 8642
|
||||
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl)
|
||||
?: return null
|
||||
val transportHint = when {
|
||||
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
?.lowercase()
|
||||
?: return "custom"
|
||||
return when {
|
||||
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
|
||||
host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
isPrivateLanIpv4(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTailscaleIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return parts[0] == 100 && parts[1] in 64..127
|
||||
}
|
||||
|
||||
private fun isPrivateLanIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,8 @@ class ConnectionStore private constructor(
|
||||
// callers shouldn't rely on insertion order of a duplicate
|
||||
// add, and the alternative (throwing) makes migration code
|
||||
// more brittle than it needs to be.
|
||||
val next = current.filterNot { it.id == connection.id } + connection
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.filterNot { it.id == connection.id } + normalized
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -177,7 +178,8 @@ class ConnectionStore private constructor(
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val next = current.map { if (it.id == connection.id) connection else it }
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.map { if (it.id == connection.id) normalized else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -212,27 +214,83 @@ class ConnectionStore private constructor(
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { connection ->
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-reset helper: clear the persisted connection list, active
|
||||
* pointer, legacy profile aliases, and every known per-connection auth
|
||||
* store. Unlike removing one connection, this intentionally does not pick
|
||||
* a successor; callers are resetting the app back to "no connection".
|
||||
*/
|
||||
suspend fun clearAllConnections() {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
val normalizedConnections = connections.map { it.withDashboardDefaults() }
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (normalizedConnections.isEmpty()) {
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
} else {
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
|
||||
}
|
||||
if (normalizedActiveId == null) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTokenStoresFor(connection: Connection) {
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +352,8 @@ class ConnectionStore private constructor(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
@@ -340,6 +400,8 @@ class ConnectionStore private constructor(
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
|
||||
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
@@ -355,6 +417,33 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDashboardStatus(
|
||||
connectionId: String,
|
||||
status: DashboardConnectionStatus,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
dashboardAuthRequired = status.authRequired,
|
||||
dashboardAuthProviders = status.authProviders,
|
||||
dashboardLastStatus = status,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---------------------------------------------------
|
||||
|
||||
private fun encodeConnections(list: List<Connection>): String =
|
||||
@@ -364,12 +453,36 @@ class ConnectionStore private constructor(
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(connectionListSerializer, raw)
|
||||
.map { it.withDashboardDefaults() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import android.util.Log
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
|
||||
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.StoredDashboardCookie
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -20,8 +24,8 @@ import java.io.File
|
||||
/**
|
||||
* Manages app data: backup, restore, and reset.
|
||||
*
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
* Backup format is a JSON file containing full connection metadata and
|
||||
* credentials. Treat exported files as sensitive secrets.
|
||||
*/
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
@@ -51,8 +55,7 @@ class DataManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
* Backup data model.
|
||||
*
|
||||
* **Schema history:**
|
||||
* - v1: `serverUrl` only (single endpoint, pre-API-split).
|
||||
@@ -66,22 +69,50 @@ class DataManager(
|
||||
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
|
||||
* get `connections = emptyList()` since the old string list was not
|
||||
* structurally compatible.
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 4,
|
||||
val version: Int = 5,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
val activeConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionSecretBackup(
|
||||
val connectionId: String,
|
||||
val tokenStoreKey: String,
|
||||
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
|
||||
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardCookieBackup(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
* Includes connection credentials. The export UI must warn the user that
|
||||
* the resulting JSON file is sensitive.
|
||||
*
|
||||
* The `sessionLabels` parameter is a legacy dead parameter — it was
|
||||
* previously sourced from `AuthManager.sessionLabels`, a field removed
|
||||
@@ -100,7 +131,7 @@ class DataManager(
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value
|
||||
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
@@ -108,19 +139,56 @@ class DataManager(
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val connectionSecrets = connectionsSnapshot.map { connection ->
|
||||
ConnectionSecretBackup(
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
|
||||
dashboardCookies = EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).load().map { it.toBackup() },
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 4,
|
||||
version = 5,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot ?: emptyList(),
|
||||
exportedAt = System.currentTimeMillis()
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
}
|
||||
|
||||
suspend fun restoreConnectionBackup(backup: AppBackup) {
|
||||
val store = connectionStore ?: return
|
||||
deleteSensitivePreferenceFiles()
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
backup.connectionSecrets.forEach { secret ->
|
||||
val connection = connectionsById[secret.connectionId] ?: return@forEach
|
||||
AuthManager.importStoredSecrets(
|
||||
context = context,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
secrets = secret.auth,
|
||||
)
|
||||
EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).save(secret.dashboardCookies.map { it.toStoredCookie() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from a JSON string.
|
||||
* Returns the parsed backup, or null if invalid.
|
||||
@@ -221,6 +289,11 @@ class DataManager(
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
|
||||
// Multi-connection reset: clear the hot ConnectionStore state and
|
||||
// delete every per-connection token store before the global
|
||||
// DataStore is wiped.
|
||||
connectionStore?.clearAllConnections()
|
||||
|
||||
// Clear all DataStore preferences
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
|
||||
@@ -231,15 +304,7 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the EncryptedSharedPreferences file for auth tokens
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
|
||||
if (authFile.exists()) {
|
||||
authFile.delete()
|
||||
Log.d(TAG, "Deleted auth preferences file")
|
||||
}
|
||||
}
|
||||
deleteSensitivePreferenceFiles()
|
||||
|
||||
// Clear cache directory
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -254,6 +319,61 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val stores = buildSet {
|
||||
add(AUTH_PREFS_NAME)
|
||||
add(Connection.LEGACY_TOKEN_STORE_KEY)
|
||||
prefsDir.listFiles()?.forEach { file ->
|
||||
if (file.extension == "xml") {
|
||||
val name = file.nameWithoutExtension
|
||||
if (
|
||||
name.startsWith("hermes_auth_") ||
|
||||
name.startsWith("hermes_dashboard_")
|
||||
) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stores.forEach { storeName ->
|
||||
try {
|
||||
context.deleteSharedPreferences(storeName)
|
||||
Log.d(TAG, "Deleted auth preferences file: $storeName")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
|
||||
DashboardCookieBackup(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
|
||||
@@ -12,8 +12,10 @@ import kotlinx.serialization.Serializable
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
|
||||
* changes the active agent identity for the Android chat surface:
|
||||
* A Profile is an upstream Hermes profile context within a Connection.
|
||||
* Upstream stores named profiles as separate Hermes homes under
|
||||
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
|
||||
* identity for the Android chat surface:
|
||||
* - which profile API server the phone routes chat/session calls to when
|
||||
* the relay advertises [apiServerUrl];
|
||||
* - which profile name the phone sends to the server for new sessions and
|
||||
|
||||
@@ -87,6 +87,12 @@ class ProfileSelectionStore(
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,12 @@ class ProfileSessionStore(
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileSessionsDataStore: DataStore<Preferences>
|
||||
|
||||
@@ -24,11 +24,19 @@ import kotlinx.coroutines.flow.map
|
||||
*/
|
||||
data class VoiceSettings(
|
||||
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 3000L,
|
||||
val autoTts: Boolean = false,
|
||||
val language: String = "",
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
* across turns (persistent conversation). When false, falls back to the
|
||||
* legacy one-session-per-utterance path. See
|
||||
* docs/plans/2026-05-24-realtime-persistent-session.md.
|
||||
*/
|
||||
val realtimePersistentSession: Boolean = true,
|
||||
)
|
||||
|
||||
enum class VoiceEngineMode(val storageValue: String) {
|
||||
@@ -41,24 +49,40 @@ enum class VoiceEngineMode(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoiceAudioRoute(val storageValue: String) {
|
||||
Auto("auto"),
|
||||
Standard("standard"),
|
||||
Relay("relay");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceAudioRoute =
|
||||
values().firstOrNull { it.storageValue == value } ?: Auto
|
||||
}
|
||||
}
|
||||
|
||||
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
|
||||
private val KEY_AUDIO_ROUTE = stringPreferencesKey("voice_audio_route")
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
|
||||
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
|
||||
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
|
||||
const val DEFAULT_AUDIO_ROUTE = "auto"
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
|
||||
const val DEFAULT_AUTO_TTS = false
|
||||
const val DEFAULT_LANGUAGE = ""
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
}
|
||||
|
||||
val settings: Flow<VoiceSettings> = dataStore.data
|
||||
@@ -67,12 +91,17 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
engineMode = VoiceEngineMode.fromStorage(
|
||||
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
|
||||
).storageValue,
|
||||
audioRoute = VoiceAudioRoute.fromStorage(
|
||||
prefs[KEY_AUDIO_ROUTE] ?: DEFAULT_AUDIO_ROUTE,
|
||||
).storageValue,
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
@@ -81,6 +110,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setAudioRoute(route: VoiceAudioRoute) {
|
||||
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setInteractionMode(mode: String) {
|
||||
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
}
|
||||
@@ -100,4 +133,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setRealtimePersistentSession(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,21 @@ class ConnectionManager(
|
||||
private val context: Context? = null,
|
||||
/**
|
||||
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
|
||||
* a non-null [deviceIdProvider], every call to [connect] first consults
|
||||
* the resolver before opening the WSS; on network changes the resolver
|
||||
* is re-run and we hot-swap to the new winner. When null the manager
|
||||
* uses the caller-supplied URL verbatim (pre-ADR-24 behavior).
|
||||
* either [endpointCandidatesProvider] or a non-null [deviceIdProvider],
|
||||
* every call to [connect] first consults the resolver before opening the
|
||||
* WSS; on network changes the resolver is re-run and we hot-swap to the
|
||||
* new winner. When null the manager uses the caller-supplied URL verbatim
|
||||
* (pre-ADR-24 behavior).
|
||||
*/
|
||||
private val endpointResolver: EndpointResolver? = null,
|
||||
/**
|
||||
* Candidate supplier for the active saved connection. This is the
|
||||
* standard-Hermes route source: it works before Relay pairing, so API,
|
||||
* dashboard, voice, and future Relay calls can hand off between LAN and
|
||||
* Tailscale using the same resolver. If it returns an empty list, we fall
|
||||
* back to the legacy per-device PairingPreferences source below.
|
||||
*/
|
||||
private val endpointCandidatesProvider: (suspend () -> List<EndpointCandidate>)? = null,
|
||||
/**
|
||||
* Suspending supplier for the active device id. Used to key into
|
||||
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
|
||||
@@ -172,10 +181,34 @@ class ConnectionManager(
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/**
|
||||
* Debounce job for network-change re-resolution. Android fires one
|
||||
* onAvailable per satisfying network (Wi-Fi + cell + VPN can land within
|
||||
* milliseconds of each other, and registration itself replays every
|
||||
* current network), so each event cancels the previous pending resolve
|
||||
* and the last one wins after a short settle window.
|
||||
*/
|
||||
@Volatile
|
||||
private var networkResolveJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
// Register at construction, not on first connect(). Standard
|
||||
// (no-Relay) connections never open the WSS socket, but their HTTP
|
||||
// surfaces (chat, dashboard, voice) still need [activeEndpoint] to
|
||||
// follow LAN/Tailscale handoffs — leaving registration inside
|
||||
// connect() left the whole ADR 24 network-aware path dormant for
|
||||
// exactly those users. No-op when [context] is null (tests).
|
||||
ensureNetworkCallbackRegistered()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// Settle window before re-resolving after a network event. Long
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
// enough that a route swap still feels immediate.
|
||||
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
|
||||
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
|
||||
// on the WSS upgrade, we're IP-banned server-side — retrying at
|
||||
// our normal 1-30s cadence re-fills the ban bucket and keeps us
|
||||
@@ -331,21 +364,30 @@ class ConnectionManager(
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val endpoints: List<EndpointCandidate> = try {
|
||||
val endpoints = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
endpointCandidatesProvider?.invoke()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
} ?: run {
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
if (endpoints.isEmpty()) return null
|
||||
|
||||
@@ -371,35 +413,58 @@ class ConnectionManager(
|
||||
/**
|
||||
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
|
||||
* of cache state. Backs the "Probe now" row action in the Endpoints card.
|
||||
* Fire-and-forget wrapper around [probeAndReconnectNow] for callers that
|
||||
* don't need the outcome.
|
||||
*/
|
||||
fun probeAndReconnect() {
|
||||
scope.launch { probeAndReconnectNow() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaitable body of [probeAndReconnect]. Returns the resolved winner —
|
||||
* or null when no candidate answered — so callers (probe-status UI) can
|
||||
* report the outcome instead of guessing with a fixed delay.
|
||||
*
|
||||
* Unlike the pre-2026-06 version this ALWAYS publishes the resolve
|
||||
* outcome to [activeEndpoint]: a standard (no relay socket) connection
|
||||
* whose probes all failed used to early-return before publishing,
|
||||
* leaving the Routes card stuck on "Resolving" with no feedback. The
|
||||
* only exception is the live-socket transient-miss guard shared with
|
||||
* [refreshActiveEndpoint].
|
||||
*/
|
||||
suspend fun probeAndReconnectNow(): EndpointCandidate? {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// — keep the live route published rather than downgrading every
|
||||
// HTTP surface to the saved URL. Mirrors refreshActiveEndpoint.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,9 +472,22 @@ class ConnectionManager(
|
||||
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
|
||||
* so they can follow LAN/Tailscale/VPN route changes even when the relay
|
||||
* socket is currently disconnected or intentionally not paired.
|
||||
*
|
||||
* @param clearProbeCache wipe the resolver's probe cache first. Pass
|
||||
* `true` from "the world may have changed" triggers (app resume,
|
||||
* network change) — otherwise a route that died within the positive
|
||||
* cache TTL (60s) can still be returned as the winner.
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
|
||||
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
|
||||
if (clearProbeCache) endpointResolver?.clearCache()
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// (slow resume, mid-handoff blip) — keep publishing the live
|
||||
// route instead of downgrading every HTTP surface to the saved
|
||||
// URL. Mirrors scheduleNetworkReResolve's guard.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
return resolved
|
||||
}
|
||||
@@ -432,26 +510,48 @@ class ConnectionManager(
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
private fun resolveAndSwitchIfNeeded(closeReason: String) {
|
||||
/**
|
||||
* Debounced network-change re-resolution, shared by both NetworkCallback
|
||||
* events. Re-runs the resolver and publishes the winner to
|
||||
* [activeEndpoint] so HTTP-only surfaces (chat, dashboard, standard
|
||||
* voice) follow the route change even when no relay socket exists. When
|
||||
* a socket IS up, additionally swaps it to a differing winner, or
|
||||
* reconnects a disconnected socket on the same winner — preserving the
|
||||
* pre-refactor relay-path behavior.
|
||||
*/
|
||||
private fun scheduleNetworkReResolve(closeReason: String) {
|
||||
if (endpointResolver == null) return
|
||||
val current = serverUrl ?: return
|
||||
scope.launch {
|
||||
networkResolveJob?.cancel()
|
||||
networkResolveJob = scope.launch {
|
||||
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
_activeEndpoint.value = null
|
||||
// Don't clear a live socket's endpoint on a transient probe
|
||||
// miss — only drop the published route when nothing is
|
||||
// actually connected.
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val newUrl = resolved.relay.url
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
if (current == null) return@launch
|
||||
// After an explicit disconnect() the route still publishes above
|
||||
// (HTTP surfaces keep roaming), but no socket action: without
|
||||
// this gate a network event whose winner differs from the last
|
||||
// URL would resurrect a socket the user deliberately closed.
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(newUrl, closeReason)
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "endpoint fallback: same winner is disconnected — reconnecting $current")
|
||||
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
@@ -464,36 +564,15 @@ class ConnectionManager(
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
if (endpointResolver == null) return
|
||||
val url = serverUrl ?: return
|
||||
endpointResolver.clearCache()
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val newUrl = resolved?.relay?.url
|
||||
if (newUrl == null) {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Only swap if the winner actually differs from the
|
||||
// currently-connected URL. Avoids dropping a healthy
|
||||
// socket on a no-op network flap (Wi-Fi scan, cell
|
||||
// handover that ends up on the same route, etc.).
|
||||
if (normalizedNew != url) {
|
||||
Log.i(TAG, "network change: swapping $url → $normalizedNew")
|
||||
connectToUrlOnMainPath(newUrl, "Network change — switching endpoint")
|
||||
}
|
||||
}
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
resolveAndSwitchIfNeeded("Network lost — switching endpoint")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.KeystoreTokenStore
|
||||
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardAuthProvider(
|
||||
val name: String,
|
||||
val displayName: String? = null,
|
||||
val supportsPassword: Boolean = false,
|
||||
) {
|
||||
val isRedirectProvider: Boolean
|
||||
get() = !supportsPassword
|
||||
}
|
||||
|
||||
data class DashboardLoginResponse(
|
||||
val ok: Boolean,
|
||||
val next: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardAuthSession(
|
||||
val authenticated: Boolean,
|
||||
val username: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardWsTicket(
|
||||
val ticket: String,
|
||||
val ttlSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Native client for the Hermes dashboard/admin server (:9119).
|
||||
*
|
||||
* This is deliberately separate from [HermesApiClient] and all relay pairing
|
||||
* clients. Dashboard cookies authenticate standard admin surfaces such as
|
||||
* skills/cron/MCP/profile config; relay pairing remains the auth path for
|
||||
* terminal, bridge, media relay, and profile memory file editing.
|
||||
*/
|
||||
class DashboardApiClient(
|
||||
baseUrl: String,
|
||||
private val okHttpClient: OkHttpClient = defaultClient(),
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) {
|
||||
private val baseUrl: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
suspend fun getStatus(): Result<DashboardStatus> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/status").mapCatching { parseStatus(it) }
|
||||
}
|
||||
|
||||
suspend fun getAuthProviders(): Result<List<DashboardAuthProvider>> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/auth/providers").mapCatching { root ->
|
||||
parseProviders(root["providers"])
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
getJson(normalized)
|
||||
}
|
||||
|
||||
suspend fun getJsonElement(path: String): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.get()
|
||||
.build()
|
||||
executeJsonElement(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun postJsonObject(
|
||||
path: String,
|
||||
payload: JsonObject = JsonObject(emptyMap()),
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun putJsonObject(
|
||||
path: String,
|
||||
payload: JsonObject,
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.put(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
suspend fun deleteJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.delete()
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
/** DELETE with a JSON body — upstream's `DELETE /api/env` reads the key from the body. */
|
||||
suspend fun deleteJsonObjectWithBody(
|
||||
path: String,
|
||||
payload: JsonObject,
|
||||
): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val normalized = if (path.startsWith("/")) path else "/$path"
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$normalized")
|
||||
.delete(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
executeJson(request, normalized)
|
||||
}
|
||||
|
||||
// --- Models (dashboard parity with hermes-desktop Settings → Model) ---
|
||||
|
||||
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
|
||||
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
|
||||
|
||||
/**
|
||||
* Assign the main model in `~/.hermes/config.yaml` (new sessions only).
|
||||
* Upstream may answer `{ok: false, confirm_required: true, warning: ...}`
|
||||
* for expensive models — re-call with [confirmExpensive] after the user
|
||||
* accepts the warning.
|
||||
*/
|
||||
suspend fun setMainModel(
|
||||
provider: String,
|
||||
model: String,
|
||||
confirmExpensive: Boolean = false,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/model/set",
|
||||
payload = buildJsonObject {
|
||||
put("scope", "main")
|
||||
put("provider", provider)
|
||||
put("model", model)
|
||||
if (confirmExpensive) put("confirm_expensive_model", true)
|
||||
},
|
||||
)
|
||||
|
||||
// --- Env / keys (dashboard parity with hermes-desktop Settings → Keys) ---
|
||||
|
||||
/** Curated env-var inventory: name → {is_set, redacted_value, description, category, ...}. */
|
||||
suspend fun getEnvVars(): Result<JsonObject> = getJsonObject("/api/env")
|
||||
|
||||
suspend fun setEnvVar(key: String, value: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/env",
|
||||
payload = buildJsonObject {
|
||||
put("key", key)
|
||||
put("value", value)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun deleteEnvVar(key: String): Result<JsonObject> =
|
||||
deleteJsonObjectWithBody(
|
||||
path = "/api/env",
|
||||
payload = buildJsonObject { put("key", key) },
|
||||
)
|
||||
|
||||
/** Server rate-limits reveals (5 per 30s) and audit-logs each one. */
|
||||
suspend fun revealEnvVar(key: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/env/reveal",
|
||||
payload = buildJsonObject { put("key", key) },
|
||||
)
|
||||
|
||||
// --- Skills hub (dashboard parity with hermes-desktop Browse-hub tab) ---
|
||||
|
||||
/**
|
||||
* Parallel multi-source hub search. Response carries `results` (name /
|
||||
* description / source / identifier / trust_level / repo / tags),
|
||||
* `source_counts`, `timed_out`, and `installed` (identifier → lock entry)
|
||||
* so already-installed results can be marked. Server caps limit at 50 and
|
||||
* fans out with a 30s overall timeout — keep client read timeouts above that.
|
||||
*/
|
||||
suspend fun searchSkillsHub(query: String, limit: Int = 20): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/search?q=${queryValue(query)}&limit=${limit.coerceIn(1, 50)}")
|
||||
|
||||
/** SKILL.md + manifest for an identifier WITHOUT installing — read before you trust. */
|
||||
suspend fun previewSkillsHub(identifier: String): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/preview?identifier=${queryValue(identifier)}")
|
||||
|
||||
/**
|
||||
* Configured hub sources + featured skills (`{sources, index_available,
|
||||
* featured, installed}`) — content for the browse dialog before the first
|
||||
* search. Featured entries share the search-result payload shape.
|
||||
*/
|
||||
suspend fun getSkillsHubSources(): Result<JsonObject> =
|
||||
getJsonObject("/api/skills/hub/sources")
|
||||
|
||||
/**
|
||||
* Spawns `hermes skills install <identifier>` server-side and returns
|
||||
* `{ok, pid}` immediately — the install completes in the background, so
|
||||
* callers should message "started" and refresh the skills list later.
|
||||
*/
|
||||
suspend fun installSkillsHub(identifier: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/skills/hub/install",
|
||||
payload = buildJsonObject { put("identifier", identifier) },
|
||||
)
|
||||
|
||||
/** Async spawn like install; takes the installed skill *name*, not the hub identifier. */
|
||||
suspend fun uninstallSkillsHub(name: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/skills/hub/uninstall",
|
||||
payload = buildJsonObject { put("name", name) },
|
||||
)
|
||||
|
||||
/** Async spawn of `hermes skills update` for all hub-installed skills. */
|
||||
suspend fun updateSkillsHub(): Result<JsonObject> =
|
||||
postJsonObject("/api/skills/hub/update")
|
||||
|
||||
// --- Profiles (write surface) ---
|
||||
|
||||
/** Full SOUL.md text — upstream returns the complete file, safe for round-trip editing. */
|
||||
suspend fun putProfileSoul(name: String, content: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/soul",
|
||||
payload = buildJsonObject { put("content", content) },
|
||||
)
|
||||
|
||||
suspend fun createProfile(
|
||||
name: String,
|
||||
cloneFromDefault: Boolean = true,
|
||||
description: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put("clone_from_default", cloneFromDefault)
|
||||
if (!description.isNullOrBlank()) put("description", description)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun setProfileDescription(name: String, description: String): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/description",
|
||||
payload = buildJsonObject { put("description", description) },
|
||||
)
|
||||
|
||||
suspend fun setProfileModel(
|
||||
name: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/profiles/${pathSegment(name)}/model",
|
||||
payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("model", model)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun toggleSkill(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/skills/toggle",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put("enabled", enabled)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun pauseCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/pause${profileQuery(profile)}")
|
||||
|
||||
suspend fun resumeCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/resume${profileQuery(profile)}")
|
||||
|
||||
suspend fun triggerCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/trigger${profileQuery(profile)}")
|
||||
|
||||
suspend fun getCronJobRuns(
|
||||
jobId: String,
|
||||
profile: String? = null,
|
||||
limit: Int = 20,
|
||||
): Result<JsonObject> =
|
||||
getJsonObject("/api/cron/jobs/${pathSegment(jobId)}/runs${profileLimitQuery(profile, limit)}")
|
||||
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
"env",
|
||||
buildJsonObject {
|
||||
env.forEach { (key, value) -> put(key, value) }
|
||||
},
|
||||
)
|
||||
put("enable", enable)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun setActiveProfile(name: String): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/profiles/active",
|
||||
payload = buildJsonObject { put("name", name) },
|
||||
)
|
||||
|
||||
suspend fun getProfileSoul(name: String): Result<JsonObject> =
|
||||
getJsonObject("/api/profiles/${pathSegment(name)}/soul")
|
||||
|
||||
suspend fun deleteProfile(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/profiles/${pathSegment(name)}")
|
||||
|
||||
suspend fun loginPassword(
|
||||
provider: String = "basic",
|
||||
username: String,
|
||||
password: String,
|
||||
next: String = "/",
|
||||
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
|
||||
val payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("next", next)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/auth/password-login")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
executeJson(request, "Dashboard sign-in").mapCatching { root ->
|
||||
DashboardLoginResponse(
|
||||
ok = root.booleanField("ok") ?: true,
|
||||
next = root.stringField("next"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentSession(): Result<DashboardAuthSession> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/auth/me")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext Result.success(DashboardAuthSession(authenticated = false))
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
|
||||
}
|
||||
val root = response.readJsonObject(json)
|
||||
Result.success(parseAuthSession(root))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this dashboard build exposes the hermes-desktop voice routes
|
||||
* (`/api/audio/transcribe` + `/api/audio/speak`). HEAD on a POST-only
|
||||
* FastAPI route returns 405 when the path exists and 404 when it doesn't;
|
||||
* an auth-gated 401/403 also proves the route is registered.
|
||||
*/
|
||||
suspend fun audioRoutesPresent(): Boolean = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.head()
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { it.code != 404 }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestWsTicket(): Result<DashboardWsTicket> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/auth/ws-ticket")
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
|
||||
executeJson(request, "Dashboard websocket ticket").mapCatching { root ->
|
||||
val ticket = root.stringField("ticket")
|
||||
?: root.stringField("ws_ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
DashboardWsTicket(
|
||||
ticket = ticket,
|
||||
ttlSeconds = root.intField("ttl_seconds") ?: root.intField("ttl"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun authLoginUrl(provider: String, next: String = "/"): String =
|
||||
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
|
||||
|
||||
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
|
||||
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
|
||||
|
||||
fun shutdown() {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
okHttpClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private suspend fun getJson(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$path")
|
||||
.get()
|
||||
.build()
|
||||
executeJson(request, path)
|
||||
}
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
Result.success(response.readJsonObject(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeJsonElement(request: Request, operation: String): Result<JsonElement> {
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
Result.success(response.readJsonElement(json))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
fun pathSegment(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
private fun queryValue(value: String): String =
|
||||
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
|
||||
fun authLoginUrl(baseUrl: String, provider: String, next: String = "/"): String {
|
||||
val root = baseUrl.trim().trimEnd('/')
|
||||
return "$root/auth/login?provider=${queryValue(provider)}&next=${queryValue(next)}"
|
||||
}
|
||||
|
||||
fun authLandingPath(baseUrl: String): String {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return "/"
|
||||
val basePath = httpUrl.encodedPath.trimEnd('/')
|
||||
return when {
|
||||
basePath.isBlank() || basePath == "/" -> "/"
|
||||
else -> "$basePath/"
|
||||
}
|
||||
}
|
||||
|
||||
fun gatewayWebSocketUrl(baseUrl: String, ticket: String, path: String = "/api/ws"): String? {
|
||||
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return null
|
||||
val websocketPrefix = when (httpUrl.scheme) {
|
||||
"https" -> "wss://"
|
||||
"http" -> "ws://"
|
||||
else -> return null
|
||||
}
|
||||
val normalizedPath = if (path.startsWith("/")) path else "/$path"
|
||||
val basePath = httpUrl.encodedPath.trimEnd('/')
|
||||
val encodedPath = when {
|
||||
basePath.isBlank() || basePath == "/" -> normalizedPath
|
||||
else -> "$basePath$normalizedPath"
|
||||
}
|
||||
val url = httpUrl.newBuilder()
|
||||
.encodedPath(encodedPath)
|
||||
.addQueryParameter("ticket", ticket)
|
||||
.build()
|
||||
.toString()
|
||||
return websocketPrefix + url.substringAfter("://")
|
||||
}
|
||||
|
||||
private fun profileQuery(profile: String?): String {
|
||||
val trimmed = profile?.trim().orEmpty()
|
||||
return if (trimmed.isBlank()) "" else "?profile=${pathSegment(trimmed)}"
|
||||
}
|
||||
|
||||
private fun profileLimitQuery(profile: String?, limit: Int): String {
|
||||
val params = buildList {
|
||||
val trimmed = profile?.trim().orEmpty()
|
||||
if (trimmed.isNotBlank()) add("profile=${pathSegment(trimmed)}")
|
||||
add("limit=${limit.coerceIn(1, 100)}")
|
||||
}
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
val providersElement = root["auth_providers"]
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAuthSession(root: JsonObject): DashboardAuthSession {
|
||||
val user = root["user"] as? JsonObject
|
||||
val session = root["session"] as? JsonObject
|
||||
val explicitAuthenticated = root.booleanField("authenticated")
|
||||
?: root.booleanField("ok")
|
||||
val flatIdentityPresent =
|
||||
root.stringField("user_id") != null ||
|
||||
root.stringField("email") != null ||
|
||||
root.stringField("display_name") != null ||
|
||||
root.stringField("provider") != null ||
|
||||
root["expires_at"] != null
|
||||
val authenticated = explicitAuthenticated
|
||||
?: (user != null || session != null || flatIdentityPresent)
|
||||
return DashboardAuthSession(
|
||||
authenticated = authenticated,
|
||||
username = root.stringField("username")
|
||||
?: root.stringField("display_name")
|
||||
?: root.stringField("email")
|
||||
?: root.stringField("user_id")
|
||||
?: user.stringField("username")
|
||||
?: user.stringField("name")
|
||||
?: session.stringField("username"),
|
||||
provider = root.stringField("provider")
|
||||
?: session.stringField("provider")
|
||||
?: user.stringField("provider"),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseProviders(element: JsonElement?): List<DashboardAuthProvider> {
|
||||
return when (element) {
|
||||
is JsonArray -> element.mapNotNull { provider(it) }
|
||||
is JsonObject -> element.entries.mapNotNull { (key, value) ->
|
||||
val name = key.trim().takeIf { it.isNotBlank() }
|
||||
if (name != null && value is JsonObject) {
|
||||
provider(name, value)
|
||||
} else {
|
||||
provider(value) ?: name?.let {
|
||||
DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}.distinctBy { it.name }
|
||||
}
|
||||
|
||||
private fun provider(element: JsonElement?): DashboardAuthProvider? {
|
||||
return when (element) {
|
||||
is JsonPrimitive -> element.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it)) }
|
||||
is JsonObject -> {
|
||||
val name = element.stringField("id")
|
||||
?: element.stringField("name")
|
||||
?: element.stringField("provider")
|
||||
?: element.stringField("type")
|
||||
name?.let { provider(it, element) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun provider(name: String, element: JsonObject): DashboardAuthProvider =
|
||||
DashboardAuthProvider(
|
||||
name = name,
|
||||
displayName = element.stringField("display_name")
|
||||
?: element.stringField("label")
|
||||
?: element.stringField("title"),
|
||||
supportsPassword = element.booleanField("supports_password")
|
||||
?: isPasswordProvider(name),
|
||||
)
|
||||
|
||||
private fun isPasswordProvider(name: String): Boolean =
|
||||
name.equals("basic", ignoreCase = true) ||
|
||||
name.equals("password", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
class InMemoryDashboardCookieStore : DashboardCookieStore {
|
||||
private val lock = Any()
|
||||
private var cookies: List<StoredDashboardCookie> = emptyList()
|
||||
|
||||
override fun load(): List<StoredDashboardCookie> = synchronized(lock) { cookies }
|
||||
|
||||
override fun save(cookies: List<StoredDashboardCookie>) {
|
||||
synchronized(lock) {
|
||||
this.cookies = cookies
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(lock) {
|
||||
cookies = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EncryptedDashboardCookieStore(
|
||||
context: Context,
|
||||
connectionId: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : DashboardCookieStore {
|
||||
private val serializer = ListSerializer(StoredDashboardCookie.serializer())
|
||||
private val appContext = context.applicationContext
|
||||
private val prefsName = prefsName(connectionId)
|
||||
|
||||
// DEFERRED on purpose. Building the Keystore-backed prefs takes 1-4s
|
||||
// on StrongBox devices and serializes through a process-GLOBAL Tink
|
||||
// lock (AndroidKeysetManager.Builder.build) — eager construction here
|
||||
// froze the main thread for ~11s at app start when several stores were
|
||||
// built concurrently (frozen-sphere incident, 2026-06-11). Construction
|
||||
// is now free on any thread; the expensive build happens on the first
|
||||
// actual cookie access, which is always an OkHttp/IO thread.
|
||||
private val store: SessionTokenStore by lazy {
|
||||
KeystoreTokenStore.tryCreate(appContext, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, prefsName)
|
||||
}
|
||||
|
||||
override fun load(): List<StoredDashboardCookie> {
|
||||
val raw = store.getString(KEY_COOKIES) ?: return emptyList()
|
||||
return runCatching { json.decodeFromString(serializer, raw) }
|
||||
.getOrElse { emptyList() }
|
||||
}
|
||||
|
||||
override fun save(cookies: List<StoredDashboardCookie>) {
|
||||
store.putString(KEY_COOKIES, json.encodeToString(serializer, cookies))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(KEY_COOKIES)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_COOKIES = "dashboard_cookies_json"
|
||||
|
||||
fun prefsName(connectionId: String): String =
|
||||
"hermes_dashboard_${connectionId.take(8)}"
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardCookieJar(
|
||||
private val store: DashboardCookieStore,
|
||||
private val clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
) : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val now = clockMillis()
|
||||
val incoming = cookies.map { StoredDashboardCookie.fromCookie(it) }
|
||||
.filterNot { it.isExpired(now) }
|
||||
val retained = store.load()
|
||||
.filterNot { it.isExpired(now) }
|
||||
.filterNot { old -> incoming.any { it.key == old.key } }
|
||||
store.save(retained + incoming)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val now = clockMillis()
|
||||
val stored = store.load().filterNot { it.isExpired(now) }
|
||||
if (stored.size != store.load().size) {
|
||||
store.save(stored)
|
||||
}
|
||||
return stored.mapNotNull { it.toCookie() }
|
||||
.filter { it.matches(url) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
* Long-lived OkHttpClients (e.g. the standard voice client, remembered once
|
||||
* per process in RelayApp) can't bind a fixed [DashboardCookieStore] because
|
||||
* the active Connection — and therefore the encrypted cookie file — changes
|
||||
* when the user switches connections. A null store (no active connection)
|
||||
* degrades to an empty jar rather than failing the request.
|
||||
*/
|
||||
class DynamicDashboardCookieJar(
|
||||
private val storeProvider: () -> DashboardCookieStore?,
|
||||
) : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val store = storeProvider() ?: return
|
||||
DashboardCookieJar(store).saveFromResponse(url, cookies)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val store = storeProvider() ?: return emptyList()
|
||||
return DashboardCookieJar(store).loadForRequest(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun importDashboardCookieHeader(
|
||||
store: DashboardCookieStore,
|
||||
url: String,
|
||||
cookieHeader: String?,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return 0
|
||||
val raw = cookieHeader?.trim().orEmpty()
|
||||
if (raw.isBlank()) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
// CookieManager.getCookie(url) returns only "name=value" pairs; it does
|
||||
// not expose the original Set-Cookie Path attribute. Store imported
|
||||
// WebView auth cookies at root so a cookie observed on /auth/callback is
|
||||
// still sent to /api/auth/me during native session verification.
|
||||
val cookiePath = "/"
|
||||
val imported = raw.split(";")
|
||||
.mapNotNull { part ->
|
||||
val index = part.indexOf('=')
|
||||
if (index <= 0) return@mapNotNull null
|
||||
val name = part.substring(0, index).trim()
|
||||
val value = part.substring(index + 1).trim()
|
||||
if (name.isBlank()) return@mapNotNull null
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = Long.MAX_VALUE,
|
||||
domain = httpUrl.host,
|
||||
path = cookiePath,
|
||||
secure = httpUrl.isHttps,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
persistent = false,
|
||||
)
|
||||
}
|
||||
.filterNot { it.isExpired(now) }
|
||||
if (imported.isEmpty()) return 0
|
||||
|
||||
val retained = store.load()
|
||||
.filterNot { it.isExpired(now) }
|
||||
.filterNot { old -> imported.any { it.key == old.key } }
|
||||
store.save(retained + imported)
|
||||
return imported.size
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoredDashboardCookie(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
) {
|
||||
val key: String
|
||||
get() = "${name.lowercase()}|${domain.lowercase()}|$path"
|
||||
|
||||
fun isExpired(nowMillis: Long): Boolean =
|
||||
persistent && expiresAt <= nowMillis
|
||||
|
||||
fun toCookie(): Cookie? {
|
||||
return runCatching {
|
||||
val builder = Cookie.Builder()
|
||||
.name(name)
|
||||
.value(value)
|
||||
.path(path)
|
||||
if (hostOnly) {
|
||||
builder.hostOnlyDomain(domain)
|
||||
} else {
|
||||
builder.domain(domain)
|
||||
}
|
||||
if (persistent) {
|
||||
builder.expiresAt(expiresAt)
|
||||
}
|
||||
if (secure) builder.secure()
|
||||
if (httpOnly) builder.httpOnly()
|
||||
builder.build()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCookie(cookie: Cookie): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = cookie.name,
|
||||
value = cookie.value,
|
||||
expiresAt = cookie.expiresAt,
|
||||
domain = cookie.domain,
|
||||
path = cookie.path,
|
||||
secure = cookie.secure,
|
||||
httpOnly = cookie.httpOnly,
|
||||
hostOnly = cookie.hostOnly,
|
||||
persistent = cookie.persistent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.readJsonObject(json: Json): JsonObject {
|
||||
val raw = body.string()
|
||||
if (raw.isBlank()) return JsonObject(emptyMap())
|
||||
return json.parseToJsonElement(raw).jsonObject
|
||||
}
|
||||
|
||||
private fun Response.readJsonElement(json: Json): JsonElement {
|
||||
val raw = body.string()
|
||||
if (raw.isBlank()) return JsonObject(emptyMap())
|
||||
return json.parseToJsonElement(raw)
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val bodyDetail = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = bodyDetail.take(240).ifBlank { response.message }
|
||||
return IOException("$operation failed - HTTP ${response.code}: $detail")
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(name: String): String? =
|
||||
((this?.get(name) as? JsonPrimitive)?.contentOrNull)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
(this?.get(name) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
@@ -10,13 +10,38 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Last observed probe result for a single [EndpointCandidate], keyed by
|
||||
* [EndpointResolver.cacheKey] in [EndpointResolver.probeOutcomes]. Unlike the
|
||||
* probe *cache* (a short-TTL "don't re-ask the network" optimization), this is
|
||||
* a UI-facing record of what actually happened — it survives [EndpointResolver
|
||||
* .clearCache] so the Routes card can keep showing the most recent
|
||||
* reachability verdict between probes.
|
||||
*/
|
||||
data class RouteProbeOutcome(
|
||||
val reachable: Boolean,
|
||||
/** Short human-readable failure reason; null when [reachable]. */
|
||||
val detail: String? = null,
|
||||
/** Resolver-clock timestamp of when the probe finished. */
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the highest-priority **reachable** [EndpointCandidate] from a
|
||||
@@ -70,6 +95,26 @@ class EndpointResolver(
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||
|
||||
/**
|
||||
* Last probe verdict per candidate, keyed by [cacheKey]. Drives the
|
||||
* per-row reachability line in the Routes card. Deliberately NOT wiped by
|
||||
* [clearCache] — the cache controls when we re-ask the network; this
|
||||
* records what the network last said.
|
||||
*/
|
||||
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
|
||||
|
||||
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
|
||||
_probeOutcomes.update { outcomes ->
|
||||
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
|
||||
reachable = reachable,
|
||||
detail = detail,
|
||||
atMillis = clock(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EndpointResolver"
|
||||
/**
|
||||
@@ -99,6 +144,9 @@ class EndpointResolver(
|
||||
*/
|
||||
const val NEGATIVE_CACHE_TTL_MS = 2_000L
|
||||
|
||||
/** Shared timeout wording so HEAD-timeout and socket-timeout read the same. */
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
@@ -245,6 +293,7 @@ class EndpointResolver(
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -272,6 +321,11 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
||||
)
|
||||
ok
|
||||
}
|
||||
} ?: run {
|
||||
@@ -284,6 +338,7 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
@@ -296,6 +351,7 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
@@ -309,11 +365,27 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a probe exception to a short, actionable string for the Routes
|
||||
* card. The TLS case is the headline: a route saved with `https://`
|
||||
* against a plain-HTTP Hermes API server fails its handshake on every
|
||||
* probe and previously surfaced as a silent "never switches" mystery.
|
||||
*/
|
||||
private fun humanProbeFailure(e: Exception): String = when (e) {
|
||||
is SSLException -> "TLS failed — server may be http://, not https://"
|
||||
is ConnectException -> "Connection refused"
|
||||
is UnknownHostException -> "Host not found"
|
||||
is SocketTimeoutException -> PROBE_TIMEOUT_DETAIL
|
||||
is NoRouteToHostException -> "No route to host"
|
||||
else -> e.javaClass.simpleName
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
@@ -329,9 +401,16 @@ class EndpointResolver(
|
||||
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
|
||||
}
|
||||
|
||||
/** Test-only: wipe the probe cache so a fresh run starts clean. */
|
||||
/**
|
||||
* Wipe the probe cache so the next resolve runs fresh probes. Called on
|
||||
* "the world changed" triggers — NetworkCallback events, manual "Probe
|
||||
* now", and [refreshActiveEndpoint][ConnectionManager.refreshActiveEndpoint]
|
||||
* with `clearProbeCache = true` — where a positive entry for a
|
||||
* just-died route must not outlive the handoff.
|
||||
*/
|
||||
internal fun clearCache() {
|
||||
probeCache.clear()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -62,9 +63,9 @@ enum class ChatMode {
|
||||
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
|
||||
*/
|
||||
data class ServerCapabilities(
|
||||
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
|
||||
/** `/api/sessions` (CRUD) — true on native upstream, fork, OR bootstrap-injected older builds. */
|
||||
val sessionsApi: Boolean,
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true on native upstream or legacy fork builds. */
|
||||
val sessionsChatStream: Boolean,
|
||||
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
|
||||
val runs: Boolean,
|
||||
@@ -99,6 +100,62 @@ data class ServerCapabilities(
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.childObject(key: String): JsonObject? = this[key] as? JsonObject
|
||||
|
||||
private fun JsonObject.booleanFlag(key: String): Boolean =
|
||||
(this[key] as? JsonPrimitive)?.booleanOrNull == true
|
||||
|
||||
private fun JsonObject.hasEndpoint(key: String): Boolean {
|
||||
val path = ((this[key] as? JsonObject)?.get("path") as? JsonPrimitive)?.contentOrNull
|
||||
return !path.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities? {
|
||||
val root = try {
|
||||
json.decodeFromString<JsonObject>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val features = root.childObject("features")
|
||||
val endpoints = root.childObject("endpoints")
|
||||
if (features == null && endpoints == null) return null
|
||||
|
||||
fun feature(name: String): Boolean = features?.booleanFlag(name) == true
|
||||
fun endpoint(name: String): Boolean = endpoints?.hasEndpoint(name) == true
|
||||
|
||||
return ServerCapabilities(
|
||||
sessionsApi = feature("session_resources") ||
|
||||
endpoint("sessions") ||
|
||||
endpoint("session_create"),
|
||||
sessionsChatStream = feature("session_chat_streaming") ||
|
||||
endpoint("session_chat_stream"),
|
||||
runs = feature("run_events_sse") || endpoint("run_events"),
|
||||
portable = feature("chat_completions_streaming") ||
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
|
||||
|
||||
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items ?: parsed.data
|
||||
if (skills != null) return skills
|
||||
} catch (_: Exception) {
|
||||
// Fall through to direct-array compatibility below.
|
||||
}
|
||||
|
||||
try {
|
||||
return json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -242,7 +299,7 @@ class HermesApiClient(
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.items ?: parsed.sessions ?: emptyList())
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
@@ -331,7 +388,7 @@ class HermesApiClient(
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<MessageListResponse>(body)
|
||||
parsed.items ?: parsed.messages ?: emptyList()
|
||||
parsed.data ?: parsed.items ?: parsed.messages ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get messages: ${e.message}")
|
||||
@@ -342,27 +399,21 @@ class HermesApiClient(
|
||||
// --- Skills ---
|
||||
|
||||
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/skills").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
// Try structured response: { "skills": [...] } or { "items": [...] }
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items
|
||||
for (endpoint in HERMES_SKILL_ENDPOINTS) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl$endpoint").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
val body = response.body?.string() ?: return@use
|
||||
val skills = parseSkillListBody(json, body)
|
||||
if (skills != null) return@withContext skills
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
// Try direct array: [...]
|
||||
try {
|
||||
return@withContext json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
@@ -1129,14 +1180,15 @@ class HermesApiClient(
|
||||
*
|
||||
* Probe order:
|
||||
* 1. `/health` — if this fails, everything else is moot.
|
||||
* 2. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork OR
|
||||
* bootstrap-injected upstream).
|
||||
* 3. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* 2. `GET /v1/capabilities` — native upstream feature + endpoint map.
|
||||
* 3. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork,
|
||||
* native upstream, OR bootstrap-injected older upstream).
|
||||
* 4. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* presence. The handler only accepts POST, so HEAD returns 405
|
||||
* (Method Not Allowed) when the route is registered. 404 means
|
||||
* the route doesn't exist at all.
|
||||
* 4. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 5. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* 5. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 6. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* when the response explicitly advertises event-stream compatibility.
|
||||
*
|
||||
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
|
||||
@@ -1168,6 +1220,20 @@ class HermesApiClient(
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
null
|
||||
} else {
|
||||
parseCapabilitiesBody(json, response.body.string())
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
// Dispatchers.IO context from the outer withContext, so the
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkAddress
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Inet4Address
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesLanDiscoveryResult(
|
||||
val host: String,
|
||||
val apiUrl: String,
|
||||
val dashboardUrl: String?,
|
||||
val apiReachable: Boolean,
|
||||
val dashboardReachable: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-triggered local-network discovery for standard Hermes setup.
|
||||
*
|
||||
* This deliberately scans only the active RFC1918/link-local LAN around the
|
||||
* phone, never broad public or Tailscale ranges. Tailscale/public routes still
|
||||
* belong in the explicit advanced fields where the user controls the URL.
|
||||
*/
|
||||
object HermesLanDiscovery {
|
||||
private const val TAG = "HermesLanDiscovery"
|
||||
private const val MAX_HOSTS = 254
|
||||
private const val MAX_CONCURRENT_PROBES = 32
|
||||
private const val PROBE_TIMEOUT_MS = 650L
|
||||
private const val IPV4_MASK = 0xFFFF_FFFFL
|
||||
|
||||
suspend fun scan(
|
||||
context: Context,
|
||||
apiPort: Int = 8642,
|
||||
dashboardPort: Int = 9119,
|
||||
): List<HermesLanDiscoveryResult> = withContext(Dispatchers.IO) {
|
||||
val hosts = localLanHosts(context.applicationContext)
|
||||
if (hosts.isEmpty()) return@withContext emptyList()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
coroutineScope {
|
||||
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
|
||||
hosts.map { host ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
probeHost(client, host, apiPort, dashboardPort)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
.filterNotNull()
|
||||
.sortedWith(
|
||||
compareByDescending<HermesLanDiscoveryResult> { it.dashboardReachable }
|
||||
.thenByDescending { it.apiReachable }
|
||||
.thenBy { it.host },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeHost(
|
||||
client: OkHttpClient,
|
||||
host: String,
|
||||
apiPort: Int,
|
||||
dashboardPort: Int,
|
||||
): HermesLanDiscoveryResult? {
|
||||
val apiUrl = "http://$host:$apiPort"
|
||||
val dashboardUrl = "http://$host:$dashboardPort"
|
||||
val dashboardReachable = probe(
|
||||
client = client,
|
||||
url = "$dashboardUrl/api/status",
|
||||
expectedBody = ::looksLikeDashboardStatus,
|
||||
)
|
||||
val apiReachable = probe(
|
||||
client = client,
|
||||
url = "$apiUrl/health",
|
||||
expectedBody = ::looksLikeApiHealth,
|
||||
)
|
||||
if (!dashboardReachable && !apiReachable) return null
|
||||
return HermesLanDiscoveryResult(
|
||||
host = host,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl.takeIf { dashboardReachable },
|
||||
apiReachable = apiReachable,
|
||||
dashboardReachable = dashboardReachable,
|
||||
)
|
||||
}
|
||||
|
||||
private fun probe(
|
||||
client: OkHttpClient,
|
||||
url: String,
|
||||
expectedBody: (String, String) -> Boolean,
|
||||
): Boolean {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return false
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
.get()
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return true
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
}
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
val body = response.body.string().take(2_048)
|
||||
expectedBody(body, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return contentType.contains("json", ignoreCase = true) && (
|
||||
lower.contains("auth_required") ||
|
||||
lower.contains("auth_providers") ||
|
||||
lower.contains("authenticated") ||
|
||||
lower.contains("hermes")
|
||||
)
|
||||
}
|
||||
|
||||
private fun looksLikeApiHealth(body: String, contentType: String): Boolean {
|
||||
if (contentType.contains("json", ignoreCase = true)) return true
|
||||
if (contentType.contains("text/plain", ignoreCase = true)) return true
|
||||
return body.isBlank() || body.trimStart().startsWith("{")
|
||||
}
|
||||
|
||||
private fun localLanHosts(context: Context): List<String> {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
?: return emptyList()
|
||||
val networks = buildList {
|
||||
connectivityManager.activeNetwork?.let(::add)
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
if (!contains(network)) add(network)
|
||||
}
|
||||
}
|
||||
|
||||
val hosts = linkedSetOf<String>()
|
||||
for (network in networks) {
|
||||
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
|
||||
for (linkAddress in linkProperties.linkAddresses) {
|
||||
addHostsForLink(linkAddress, hosts)
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
return hosts.take(MAX_HOSTS)
|
||||
}
|
||||
|
||||
private fun addHostsForLink(linkAddress: LinkAddress, hosts: MutableSet<String>) {
|
||||
val address = linkAddress.address as? Inet4Address ?: return
|
||||
if (address.isLoopbackAddress || address.isMulticastAddress) return
|
||||
|
||||
val local = ipv4ToLong(address)
|
||||
if (!isScannableLanAddress(local)) return
|
||||
|
||||
val scanPrefix = when (linkAddress.prefixLength) {
|
||||
in 24..30 -> linkAddress.prefixLength
|
||||
else -> 24
|
||||
}
|
||||
val mask = subnetMask(scanPrefix)
|
||||
val network = local and mask
|
||||
val broadcast = network or (mask.inv() and IPV4_MASK)
|
||||
val first = network + 1
|
||||
val last = broadcast - 1
|
||||
if (first > last) return
|
||||
|
||||
for (candidate in first..last) {
|
||||
if (candidate == local) continue
|
||||
hosts.add(longToIpv4(candidate))
|
||||
if (hosts.size >= MAX_HOSTS) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun subnetMask(prefixLength: Int): Long {
|
||||
return (IPV4_MASK shl (32 - prefixLength)) and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun ipv4ToLong(address: Inet4Address): Long {
|
||||
return address.address.fold(0L) { acc, byte ->
|
||||
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
|
||||
} and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun longToIpv4(value: Long): String {
|
||||
return listOf(
|
||||
(value shr 24) and 0xFF,
|
||||
(value shr 16) and 0xFF,
|
||||
(value shr 8) and 0xFF,
|
||||
value and 0xFF,
|
||||
).joinToString(".") { it.toString() }
|
||||
}
|
||||
|
||||
private fun isScannableLanAddress(value: Long): Boolean {
|
||||
val first = ((value shr 24) and 0xFF).toInt()
|
||||
val second = ((value shr 16) and 0xFF).toInt()
|
||||
return when {
|
||||
first == 10 -> true
|
||||
first == 172 && second in 16..31 -> true
|
||||
first == 192 && second == 168 -> true
|
||||
first == 169 && second == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,64 @@ class RelayVoiceClient(
|
||||
label = "Realtime agent config update",
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH the ADR 33 background-run promotion settings. Only non-null fields
|
||||
* are sent; the relay echoes back the full [RealtimeVoiceConfig].
|
||||
*/
|
||||
suspend fun updateRealtimeAgentPromotion(
|
||||
promotionEnabled: Boolean? = null,
|
||||
promoteAfterMs: Int? = null,
|
||||
spokenHandoff: Boolean? = null,
|
||||
resultDelivery: String? = null,
|
||||
backgroundDefaultMode: String? = null,
|
||||
progressSpokenAfterMs: Int? = null,
|
||||
progressRepeatMs: Int? = null,
|
||||
maxBackgroundRuns: Int? = null,
|
||||
): Result<RealtimeVoiceConfig> = withContext(Dispatchers.IO) {
|
||||
val httpBase = resolveHttpBase()
|
||||
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
val token = resolveBearerToken()
|
||||
if (token.isNullOrBlank()) {
|
||||
return@withContext Result.failure(missingAuthError())
|
||||
}
|
||||
val payload = buildJsonObject {
|
||||
promotionEnabled?.let { put("promotion_enabled", JsonPrimitive(it)) }
|
||||
promoteAfterMs?.let { put("promote_after_ms", JsonPrimitive(it)) }
|
||||
spokenHandoff?.let { put("spoken_handoff", JsonPrimitive(it)) }
|
||||
resultDelivery?.takeIf { it.isNotBlank() }?.let {
|
||||
put("result_delivery", JsonPrimitive(it.trim()))
|
||||
}
|
||||
backgroundDefaultMode?.takeIf { it.isNotBlank() }?.let {
|
||||
put("background_default_mode", JsonPrimitive(it.trim()))
|
||||
}
|
||||
progressSpokenAfterMs?.let { put("progress_spoken_after_ms", JsonPrimitive(it)) }
|
||||
progressRepeatMs?.let { put("progress_repeat_ms", JsonPrimitive(it)) }
|
||||
maxBackgroundRuns?.let { put("max_background_runs", JsonPrimitive(it)) }
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(urlWithProfile("$httpBase/voice/realtime-agent/config"))
|
||||
.patch(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.body?.string().orEmpty()
|
||||
return@withContext Result.failure(
|
||||
IOException(describeHttpError(response.code, response.message, body))
|
||||
)
|
||||
}
|
||||
val raw = response.body?.string().orEmpty()
|
||||
Result.success(json.decodeFromString(RealtimeVoiceConfig.serializer(), raw))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Result.failure(IOException("Realtime promotion update failed: ${e.message ?: "network error"}"))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getVoiceOutputConfig(): Result<VoiceOutputConfig> = withContext(Dispatchers.IO) {
|
||||
val httpBase = resolveHttpBase()
|
||||
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
@@ -1133,6 +1191,22 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Realtime Agent voice session.
|
||||
*
|
||||
* **One-shot (default):** with [turnInputs] null, this opens a session, sends
|
||||
* the single [inputPcm]/[prompt] turn, and completes + closes the socket on
|
||||
* `voice.response.done` — the historical per-utterance behavior used by the
|
||||
* Voice Lab and the one-shot fallback.
|
||||
*
|
||||
* **Persistent (ADR follow-up, see docs/plans/2026-05-24-realtime-persistent-session.md):**
|
||||
* with [turnInputs] non-null the socket stays open across turns. The first
|
||||
* turn is [inputPcm]/[prompt]; each subsequent [RealtimeTurnInput] read from
|
||||
* the channel is sent on the *same* socket, so the provider keeps a live
|
||||
* conversation. `voice.response.done` invokes [onTurnComplete] instead of
|
||||
* closing; the call returns only when the channel closes (voice-mode exit) or
|
||||
* a fatal socket/provider error occurs.
|
||||
*/
|
||||
suspend fun runRealtimeAgent(
|
||||
prompt: String,
|
||||
inputPcm: ByteArray,
|
||||
@@ -1140,8 +1214,11 @@ class RelayVoiceClient(
|
||||
chatSessionId: String? = null,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
onEvent: (RealtimeVoiceEvent, RealtimeAgentSessionControl) -> Unit,
|
||||
): Result<RealtimeVoiceSummary> = withContext(Dispatchers.IO) {
|
||||
val persistent = turnInputs != null
|
||||
val httpBase = resolveHttpBase()
|
||||
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
val wsBase = resolveWebSocketBase()
|
||||
@@ -1172,8 +1249,11 @@ class RelayVoiceClient(
|
||||
val lastAudioEventId = AtomicLong(0L)
|
||||
val lastPlayedAudioEventId = AtomicLong(0L)
|
||||
val lastInputChunkId = AtomicLong(0L)
|
||||
val turnStartedAtMs = System.currentTimeMillis()
|
||||
val lastEventAtMs = AtomicLong(turnStartedAtMs)
|
||||
val turnStartedAtMs = AtomicLong(System.currentTimeMillis())
|
||||
val lastEventAtMs = AtomicLong(turnStartedAtMs.get())
|
||||
// True while a turn is awaiting its response. In persistent mode the idle
|
||||
// guard only applies while a turn is active; between-turn idle is normal.
|
||||
val activeTurn = AtomicBoolean(true)
|
||||
val inputChunks = buildList {
|
||||
var offset = 0
|
||||
var chunkId = 1L
|
||||
@@ -1184,8 +1264,33 @@ class RelayVoiceClient(
|
||||
offset = end
|
||||
}
|
||||
}
|
||||
// Highest input chunk id sent on this session. The relay dedups by
|
||||
// input_chunk_seq, so subsequent turns must continue past this.
|
||||
val sessionMaxChunkId = AtomicLong(inputChunks.size.toLong())
|
||||
var audioChunks = 0
|
||||
var audioBytes = 0
|
||||
|
||||
// Persistent-mode: chunk + send one more utterance on the open socket.
|
||||
fun sendTurnPcm(webSocket: WebSocket, pcm: ByteArray, sampleRate: Int) {
|
||||
var offset = 0
|
||||
var sentAny = false
|
||||
while (offset < pcm.size) {
|
||||
val end = (offset + REALTIME_INPUT_CHUNK_BYTES).coerceAtMost(pcm.size)
|
||||
val chunkId = sessionMaxChunkId.incrementAndGet()
|
||||
val encoded = Base64.getEncoder().encodeToString(pcm.copyOfRange(offset, end))
|
||||
webSocket.send(
|
||||
"""{"type":"input_audio.append","chunk_id":$chunkId,"sample_rate":$sampleRate,"audio_base64":"$encoded"}"""
|
||||
)
|
||||
sentAny = true
|
||||
offset = end
|
||||
}
|
||||
if (sentAny) {
|
||||
webSocket.send("""{"type":"input_audio.commit"}""")
|
||||
}
|
||||
turnStartedAtMs.set(System.currentTimeMillis())
|
||||
lastEventAtMs.set(System.currentTimeMillis())
|
||||
activeTurn.set(true)
|
||||
}
|
||||
fun activateSocket(webSocket: WebSocket, generation: Long): Boolean {
|
||||
while (true) {
|
||||
val activeGeneration = activeSocketGeneration.get()
|
||||
@@ -1332,24 +1437,28 @@ class RelayVoiceClient(
|
||||
inputChunkId = event.inputChunkId,
|
||||
)
|
||||
if (event.type == "voice.response.done") {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
finished.complete(
|
||||
Result.success(
|
||||
RealtimeVoiceSummary(
|
||||
provider = event.provider ?: session.provider,
|
||||
model = event.model ?: session.model,
|
||||
voice = event.voice ?: session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = event.firstAudioMs,
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
)
|
||||
)
|
||||
val summary = RealtimeVoiceSummary(
|
||||
provider = event.provider ?: session.provider,
|
||||
model = event.model ?: session.model,
|
||||
voice = event.voice ?: session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = event.firstAudioMs,
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
if (persistent) {
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
finished.complete(Result.success(summary))
|
||||
}
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
webSocket.close(1000, "done")
|
||||
} else if (event.type == "voice.error" || event.type == "voice.session.resume_failed") {
|
||||
completeFailure(event.message ?: "Realtime agent error")
|
||||
webSocket.close(1011, "provider error")
|
||||
@@ -1452,25 +1561,85 @@ class RelayVoiceClient(
|
||||
suspend fun awaitRealtimeAgentCompletion(): Result<RealtimeVoiceSummary> {
|
||||
while (true) {
|
||||
val now = System.currentTimeMillis()
|
||||
val turnElapsedMs = now - turnStartedAtMs
|
||||
val idleElapsedMs = now - lastEventAtMs.get()
|
||||
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
|
||||
throw IOException("Realtime agent exceeded the turn limit")
|
||||
// In persistent mode the turn/idle guards only apply while a turn
|
||||
// is actually in flight; between-turn idle is expected and must
|
||||
// not trip the stall timeout. The session ends when the turn
|
||||
// channel closes or a fatal error completes `finished`.
|
||||
val guardActive = !persistent || activeTurn.get()
|
||||
if (guardActive) {
|
||||
val turnElapsedMs = now - turnStartedAtMs.get()
|
||||
val idleElapsedMs = now - lastEventAtMs.get()
|
||||
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
|
||||
throw IOException("Realtime agent exceeded the turn limit")
|
||||
}
|
||||
if (idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
|
||||
throw IOException("Realtime agent stalled waiting for relay events")
|
||||
}
|
||||
val waitMs = minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
withTimeoutOrNull(waitMs) {
|
||||
finished.await()
|
||||
}?.let { return it }
|
||||
} else {
|
||||
withTimeoutOrNull(REALTIME_AGENT_WAIT_SLICE_MS) {
|
||||
finished.await()
|
||||
}?.let { return it }
|
||||
}
|
||||
if (idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
|
||||
throw IOException("Realtime agent stalled waiting for relay events")
|
||||
}
|
||||
val waitMs = minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
withTimeoutOrNull(waitMs) {
|
||||
finished.await()
|
||||
}?.let { return it }
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent mode: drain further utterances and feed each onto the open
|
||||
// socket as a new turn. Closing the channel (voice-mode exit) ends the
|
||||
// session by completing `finished`.
|
||||
val turnReader: Job? = if (turnInputs != null) {
|
||||
launch {
|
||||
try {
|
||||
for (turn in turnInputs) {
|
||||
val ws = currentSocket.get() ?: continue
|
||||
if (turn.prompt.isNotBlank() && turn.inputPcm.isEmpty()) {
|
||||
ws.send(
|
||||
buildRealtimeResponseCreate(
|
||||
text = turn.prompt,
|
||||
toolScaffold = false,
|
||||
renderMode = "verbatim",
|
||||
)
|
||||
)
|
||||
turnStartedAtMs.set(System.currentTimeMillis())
|
||||
lastEventAtMs.set(System.currentTimeMillis())
|
||||
activeTurn.set(true)
|
||||
} else {
|
||||
sendTurnPcm(ws, turn.inputPcm, turn.sampleRate)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Channel closed -> end the persistent session cleanly.
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
finished.complete(
|
||||
Result.success(
|
||||
RealtimeVoiceSummary(
|
||||
provider = session.provider,
|
||||
model = session.model,
|
||||
voice = session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = null,
|
||||
responseDoneMs = null,
|
||||
eventLogPath = session.eventLogPath,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
currentSocket.get()?.close(1000, "session ended")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val socket = openSocket(resume = false)
|
||||
val routeWatcher = startRouteResumeWatcher(
|
||||
surface = "Realtime agent",
|
||||
@@ -1491,6 +1660,7 @@ class RelayVoiceClient(
|
||||
Result.failure(IOException(e.message ?: "Realtime agent timed out", e))
|
||||
} finally {
|
||||
routeWatcher?.cancel()
|
||||
turnReader?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2080,6 +2250,8 @@ class RelayVoiceClient(
|
||||
eventLogPath = (obj["event_log_path"] as? JsonPrimitive)?.contentOrNull,
|
||||
firstAudioMs = (metrics?.get("first_audio_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||
raw = raw,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -2154,6 +2326,28 @@ data class RealtimeVoiceConfig(
|
||||
val configScope: String? = null,
|
||||
@SerialName("fallback_to_global")
|
||||
val fallbackToGlobal: Boolean = false,
|
||||
/** ADR 33 background-run promotion settings. */
|
||||
val promotion: RealtimeVoicePromotion? = null,
|
||||
)
|
||||
|
||||
/** Wire shape of the `promotion` block on `GET /voice/realtime-agent/config`. */
|
||||
@Serializable
|
||||
data class RealtimeVoicePromotion(
|
||||
val enabled: Boolean = true,
|
||||
@SerialName("promote_after_ms")
|
||||
val promoteAfterMs: Int = 6000,
|
||||
@SerialName("background_default_mode")
|
||||
val backgroundDefaultMode: String = "promote",
|
||||
@SerialName("spoken_handoff")
|
||||
val spokenHandoff: Boolean = true,
|
||||
@SerialName("progress_spoken_after_ms")
|
||||
val progressSpokenAfterMs: Int = 15000,
|
||||
@SerialName("progress_repeat_ms")
|
||||
val progressRepeatMs: Int = 30000,
|
||||
@SerialName("result_delivery")
|
||||
val resultDelivery: String = "speak_when_idle",
|
||||
@SerialName("max_background_runs")
|
||||
val maxBackgroundRuns: Int = 1,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -2392,6 +2586,9 @@ data class RealtimeVoiceEvent(
|
||||
val eventLogPath: String? = null,
|
||||
val firstAudioMs: Double? = null,
|
||||
val responseDoneMs: Double? = null,
|
||||
// ADR 33: background-run promotion fields.
|
||||
val tier: String? = null,
|
||||
val floor: String? = null,
|
||||
val raw: String,
|
||||
) {
|
||||
val isAudioDelta: Boolean
|
||||
@@ -2467,6 +2664,33 @@ data class RealtimeVoiceSummary(
|
||||
val eventLogPath: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One utterance fed into a persistent Realtime Agent session
|
||||
* (see [RelayVoiceClient.runRealtimeAgent] persistent mode). A blank [inputPcm]
|
||||
* with a non-blank [prompt] sends a text turn; otherwise the PCM is chunked and
|
||||
* committed as a spoken turn.
|
||||
*/
|
||||
data class RealtimeTurnInput(
|
||||
val inputPcm: ByteArray,
|
||||
val sampleRate: Int = 16_000,
|
||||
val prompt: String = "",
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RealtimeTurnInput) return false
|
||||
return sampleRate == other.sampleRate &&
|
||||
prompt == other.prompt &&
|
||||
inputPcm.contentEquals(other.inputPcm)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = inputPcm.contentHashCode()
|
||||
result = 31 * result + sampleRate
|
||||
result = 31 * result + prompt.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class VoiceOutputSummary(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface VoiceAudioClient {
|
||||
val route: VoiceAudioRoute
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
}
|
||||
|
||||
class RelayVoiceAudioClientAdapter(
|
||||
private val relayVoiceClient: RelayVoiceClient,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
relayVoiceClient.transcribe(audioFile)
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
relayVoiceClient.synthesize(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
|
||||
*
|
||||
* Auto preference order is **Relay first, then Standard**: a paired Relay is
|
||||
* the purpose-built mobile facade — profile-aware voice config, no dashboard
|
||||
* sign-in dependency — so users who installed the plugin keep the richer
|
||||
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
|
||||
* used whenever Relay isn't configured/paired (or fails mid-call). Power
|
||||
* users can force either route in Voice Settings.
|
||||
*/
|
||||
class AutoVoiceAudioClient(
|
||||
private val standardClient: VoiceAudioClient,
|
||||
private val relayClient: VoiceAudioClient,
|
||||
private val routeProvider: () -> VoiceAudioRoute,
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
runWithSelectedRoute { it.transcribe(audioFile) }
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
IllegalStateException(
|
||||
"Standard Hermes voice is not available — check dashboard sign-in in Manage",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
block(standardClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Relay -> {
|
||||
if (!relayReadyProvider()) {
|
||||
Result.failure(IllegalStateException("Relay voice is not available"))
|
||||
} else {
|
||||
block(relayClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Auto -> runAuto(block)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runAuto(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
var relayFailure: Result<T>? = null
|
||||
if (relayReadyProvider()) {
|
||||
val result = block(relayClient)
|
||||
if (result.isSuccess || !standardReadyProvider()) return result
|
||||
relayFailure = result
|
||||
}
|
||||
if (standardReadyProvider()) {
|
||||
val result = block(standardClient)
|
||||
if (result.isSuccess) return result
|
||||
return relayFailure ?: result
|
||||
}
|
||||
return relayFailure ?: Result.failure(
|
||||
IllegalStateException("Voice needs a reachable Hermes dashboard or Relay voice route"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
|
||||
* server** contract that hermes-desktop's voice mode uses:
|
||||
*
|
||||
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} → {ok, transcript}
|
||||
* POST {dashboard}/api/audio/speak {text} → {ok, data_url, mime_type}
|
||||
*
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md §6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
|
||||
val dataUrl = buildAudioDataUrl(audioFile)
|
||||
val payload = buildJsonObject {
|
||||
put("data_url", dataUrl)
|
||||
put("mime_type", mediaTypeForAudioFile(audioFile))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
if (transcript.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio transcribe returned an empty transcript")
|
||||
}
|
||||
transcript
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
}
|
||||
|
||||
val payload = buildJsonObject { put("text", cleanText) }
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/speak")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
}
|
||||
val mimeType = root.stringField("mime_type")
|
||||
?: root.stringField("mimeType")
|
||||
?: mimeTypeFromDataUrl(dataUrl)
|
||||
?: "audio/mpeg"
|
||||
val bytes = decodeDataUrl(dataUrl)
|
||||
if (bytes.isEmpty()) throw IOException("Hermes audio speak returned empty audio")
|
||||
|
||||
val extension = extensionForMimeType(mimeType)
|
||||
File(context.cacheDir, "hermes_voice_${System.currentTimeMillis()}.$extension")
|
||||
.also { it.writeBytes(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return Result.failure(IOException("$operation returned an empty response"))
|
||||
}
|
||||
val root = json.decodeFromString<JsonObject>(body)
|
||||
val ok = (root["ok"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toBooleanStrictOrNull()
|
||||
if (ok == false) {
|
||||
val message = root.stringField("message")
|
||||
?: root.stringField("error")
|
||||
?: "$operation failed"
|
||||
return Result.failure(IOException(message))
|
||||
}
|
||||
Result.success(root)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "network error"}", e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "parse error"}", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val body = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = body.takeIf { it.isNotBlank() } ?: response.message
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation needs dashboard sign-in - open Manage to sign in"
|
||||
404 -> "$operation unavailable on this Hermes build - update hermes-agent or use Relay"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}: $detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun buildAudioDataUrl(audioFile: File): String {
|
||||
val mimeType = mediaTypeForAudioFile(audioFile)
|
||||
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
|
||||
return "data:$mimeType;base64,$encoded"
|
||||
}
|
||||
|
||||
private fun mediaTypeForAudioFile(file: File): String =
|
||||
when (file.extension.lowercase()) {
|
||||
"wav" -> "audio/wav"
|
||||
"m4a", "mp4" -> "audio/mp4"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"ogg" -> "audio/ogg"
|
||||
"webm" -> "audio/webm"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private fun decodeDataUrl(dataUrl: String): ByteArray {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
val payload = if (comma >= 0) dataUrl.substring(comma + 1) else dataUrl
|
||||
return Base64.getDecoder().decode(payload)
|
||||
}
|
||||
|
||||
private fun mimeTypeFromDataUrl(dataUrl: String): String? {
|
||||
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
|
||||
val semi = dataUrl.indexOf(';')
|
||||
if (semi <= "data:".length) return null
|
||||
return dataUrl.substring("data:".length, semi).takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun extensionForMimeType(mimeType: String): String =
|
||||
when (mimeType.lowercase().substringBefore(';')) {
|
||||
"audio/wav", "audio/wave", "audio/x-wav" -> "wav"
|
||||
"audio/mp4", "audio/aac", "audio/m4a" -> "m4a"
|
||||
"audio/ogg" -> "ogg"
|
||||
"audio/webm" -> "webm"
|
||||
else -> "mp3"
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ object FlexibleIdNonNullSerializer : KSerializer<String> {
|
||||
data class SessionListResponse(
|
||||
val items: List<SessionItem>? = null,
|
||||
val sessions: List<SessionItem>? = null, // alternate key
|
||||
val data: List<SessionItem>? = null, // upstream /api/sessions list envelope
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@@ -127,6 +128,7 @@ data class RenameSessionRequest(
|
||||
data class MessageListResponse(
|
||||
val items: List<MessageItem>? = null,
|
||||
val messages: List<MessageItem>? = null, // alternate key
|
||||
val data: List<MessageItem>? = null, // upstream /api/sessions/{id}/messages list envelope
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@@ -300,5 +302,6 @@ data class SkillInfo(
|
||||
@Serializable
|
||||
data class SkillListResponse(
|
||||
val skills: List<SkillInfo>? = null,
|
||||
val items: List<SkillInfo>? = null
|
||||
val items: List<SkillInfo>? = null,
|
||||
val data: List<SkillInfo>? = null
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -26,11 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -44,6 +39,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -51,14 +47,11 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -69,6 +62,9 @@ import androidx.navigation.navArgument
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusBanner
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateBanner
|
||||
import com.hermesandroid.relay.update.UpdateCheckResult
|
||||
@@ -78,6 +74,10 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -94,6 +94,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
// === END PHASE3-safety-rails ===
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
|
||||
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
|
||||
@@ -104,8 +105,14 @@ import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.VoiceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.prewarmDashboardManage
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.network.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
@@ -116,6 +123,7 @@ import com.hermesandroid.relay.audio.VoiceRecorder
|
||||
import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
import com.hermesandroid.relay.audio.RealtimePcmPlayer
|
||||
import com.hermesandroid.relay.network.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.StandardHermesVoiceClient
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
||||
@@ -166,6 +174,7 @@ sealed class Screen(
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
|
||||
// Non-bottom-nav destinations — reached by explicit navigation, not the
|
||||
@@ -183,11 +192,11 @@ sealed class Screen(
|
||||
// the ConnectionsSettings "Re-pair" button targets a specific
|
||||
// connection. The "Add connection" path pre-creates a placeholder
|
||||
// via `ConnectionViewModel.beginAddConnection()` and routes here
|
||||
// with that id, so the wizard's applyPairingPayload lands in the
|
||||
// with that id, so the wizard's standard connect / applyPairingPayload lands in the
|
||||
// new connection's auth store instead of the outgoing one's.
|
||||
data object Pair : Screen(
|
||||
"pair?connectionId={connectionId}&autoStart={autoStart}",
|
||||
"Pair",
|
||||
"Connect",
|
||||
Icons.Filled.Settings,
|
||||
) {
|
||||
const val ARG_CONNECTION_ID: String = "connectionId"
|
||||
@@ -196,8 +205,8 @@ sealed class Screen(
|
||||
* Currently only `"scan"` is recognised — the "Add connection" FAB
|
||||
* on the Connections screen passes it so the camera opens
|
||||
* immediately instead of forcing the user through the Method step.
|
||||
* Re-pair flows intentionally leave this null so the full chooser
|
||||
* (Scan / Enter code / Show code) remains available.
|
||||
* Standard add/re-pair flows leave this null so the full chooser
|
||||
* remains available.
|
||||
*/
|
||||
const val ARG_AUTO_START: String = "autoStart"
|
||||
fun route(connectionId: String? = null, autoStart: String? = null): String {
|
||||
@@ -278,13 +287,6 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
private val bottomNavScreens = listOf(
|
||||
Screen.Chat,
|
||||
Screen.Terminal,
|
||||
Screen.Bridge,
|
||||
Screen.Settings
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val connectionViewModel: ConnectionViewModel = viewModel()
|
||||
@@ -364,6 +366,15 @@ fun RelayApp() {
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
|
||||
val mediaContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val voicePreferences = remember(mediaContext) { VoicePreferencesRepository(mediaContext) }
|
||||
val voiceSettings by voicePreferences.settings.collectAsState(initial = VoiceSettings())
|
||||
val selectedAudioRoute = VoiceAudioRoute.fromStorage(voiceSettings.audioRoute)
|
||||
val selectedAudioRouteState = rememberUpdatedState(selectedAudioRoute)
|
||||
val standardVoiceReady by connectionViewModel.standardVoiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
|
||||
val standardVoiceReadyState = rememberUpdatedState(standardVoiceReady)
|
||||
val relayVoiceReadyState = rememberUpdatedState(relayVoiceReady)
|
||||
|
||||
// Voice pipeline wiring — mirrors ChatViewModel.initializeMedia (above).
|
||||
// We build a dedicated OkHttpClient so voice requests don't contend with
|
||||
@@ -392,6 +403,35 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
// Standard voice talks to the dashboard web server (hermes-desktop's
|
||||
// /api/audio/* contract) and authenticates with the same per-connection
|
||||
// cookie session Manage signs in with. Dashboard URLs are connection-level
|
||||
// (no per-profile dashboard exists), so unlike chat this client does not
|
||||
// route through ProfileApiUrlResolver.
|
||||
val standardVoiceClient = remember {
|
||||
StandardHermesVoiceClient(
|
||||
context = mediaContext,
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.cookieJar(
|
||||
DynamicDashboardCookieJar {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
},
|
||||
)
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||
)
|
||||
}
|
||||
val voiceAudioClient = remember {
|
||||
AutoVoiceAudioClient(
|
||||
standardClient = standardVoiceClient,
|
||||
relayClient = RelayVoiceAudioClientAdapter(voiceClient),
|
||||
routeProvider = { selectedAudioRouteState.value },
|
||||
standardReadyProvider = { standardVoiceReadyState.value },
|
||||
relayReadyProvider = { relayVoiceReadyState.value },
|
||||
)
|
||||
}
|
||||
|
||||
// Profile Inspector client. Shares the same lazy relay URL + bearer
|
||||
// token providers as the voice client so any rotation/re-pair is
|
||||
@@ -419,6 +459,7 @@ fun RelayApp() {
|
||||
val player = VoicePlayer(mediaContext)
|
||||
voiceViewModel.initialize(
|
||||
voiceClient = voiceClient,
|
||||
voiceAudioClient = voiceAudioClient,
|
||||
chatViewModel = chatViewModel,
|
||||
recorder = recorder,
|
||||
player = player,
|
||||
@@ -451,7 +492,7 @@ fun RelayApp() {
|
||||
// 2026-04-17: persist the interaction-mode preference across
|
||||
// app restarts. VoicePreferencesRepository is the same repo
|
||||
// VoiceSettingsScreen reads/writes.
|
||||
voicePreferences = com.hermesandroid.relay.data.VoicePreferencesRepository(mediaContext),
|
||||
voicePreferences = voicePreferences,
|
||||
voiceRelayPreflight = { connectionViewModel.verifyRelayForVoice() },
|
||||
voiceHandoffReporter = { connectionViewModel.recordVoiceHandoff(it) },
|
||||
bargeInPreferences = com.hermesandroid.relay.data.BargeInPreferencesRepository(mediaContext),
|
||||
@@ -608,14 +649,8 @@ fun RelayApp() {
|
||||
val fontScale by connectionViewModel.fontScale.collectAsState()
|
||||
|
||||
HermesRelayTheme(themePreference = themePreference, fontScale = fontScale) {
|
||||
// Brief sphere intro after system splash fades
|
||||
var introComplete by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(3000L) // show sphere intro for 3s
|
||||
introComplete = true
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
@@ -633,14 +668,67 @@ fun RelayApp() {
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
|
||||
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
|
||||
val route = postOnboardingRoute
|
||||
if (onboardingCompleted && route != null) {
|
||||
postOnboardingRoute = null
|
||||
navController.navigate(route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startDestination uses the route TEMPLATE so it matches the
|
||||
// composable registered below; optional args default to null/false.
|
||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val isOnboarding = currentRoute == Screen.Onboarding.route
|
||||
var bridgePrimaryReturnRoute by remember { mutableStateOf<String?>(null) }
|
||||
var bridgePrimaryReturnLabel by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun rememberBridgeReturn(route: String, label: String) {
|
||||
bridgePrimaryReturnRoute = route
|
||||
bridgePrimaryReturnLabel = label
|
||||
}
|
||||
|
||||
fun clearBridgeReturn() {
|
||||
bridgePrimaryReturnRoute = null
|
||||
bridgePrimaryReturnLabel = null
|
||||
}
|
||||
|
||||
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let { "Return to $it" }
|
||||
val bridgeReturnSubtitle = when (bridgePrimaryReturnLabel) {
|
||||
"Chat" -> "Back to conversation"
|
||||
"Manage" -> "Back to management"
|
||||
else -> "Back to previous tab"
|
||||
}
|
||||
val bridgeReturnAction: (() -> Unit)? = bridgePrimaryReturnRoute?.let { route ->
|
||||
{
|
||||
clearBridgeReturn()
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentRoute) {
|
||||
if (
|
||||
currentRoute == Screen.Chat.route ||
|
||||
currentRoute == Screen.Manage.route ||
|
||||
currentRoute == Screen.Settings.route ||
|
||||
currentRoute == Screen.Onboarding.route
|
||||
) {
|
||||
clearBridgeReturn()
|
||||
}
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
val imeBottom = WindowInsets.ime.getBottom(density)
|
||||
val isKeyboardVisible = imeBottom > 0
|
||||
@@ -650,6 +738,81 @@ fun RelayApp() {
|
||||
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val appReady by connectionViewModel.isReady.collectAsState()
|
||||
var startupGateMinElapsed by remember { mutableStateOf(false) }
|
||||
var startupGateTimedOut by remember { mutableStateOf(false) }
|
||||
var startupGateReleased by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(650L)
|
||||
startupGateMinElapsed = true
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
delay(5_500L)
|
||||
startupGateTimedOut = true
|
||||
}
|
||||
|
||||
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
|
||||
val startupConnectionResolved = appReady && (
|
||||
!hasStartupConnection ||
|
||||
apiReachable ||
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ||
|
||||
startupGateTimedOut
|
||||
)
|
||||
LaunchedEffect(
|
||||
onboardingCompleted,
|
||||
startupGateMinElapsed,
|
||||
startupConnectionResolved,
|
||||
) {
|
||||
if (
|
||||
onboardingCompleted &&
|
||||
!startupGateReleased &&
|
||||
startupGateMinElapsed &&
|
||||
startupConnectionResolved
|
||||
) {
|
||||
startupGateReleased = true
|
||||
}
|
||||
}
|
||||
val showStartupSphere =
|
||||
onboardingCompleted &&
|
||||
!startupGateReleased &&
|
||||
!startupGateTimedOut &&
|
||||
!voiceUiState.voiceMode
|
||||
|
||||
// Pre-warm the Manage tab's payload cache when the persisted
|
||||
// dashboard snapshot says this connection was reachable and signed
|
||||
// in (or auth-free) — a cold app start then lands on populated
|
||||
// Manage data instead of skeletons. Keyed on the effective URL so a
|
||||
// LAN↔Tailscale handoff re-warms the new host's cache; the delay
|
||||
// debounces resolver flaps during startup (each key change cancels
|
||||
// the previous run). The pre-warm itself only fills cold keys.
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
|
||||
val connection = activeConnection ?: return@LaunchedEffect
|
||||
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
|
||||
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
|
||||
val dashboardUsable = snapshot.reachable &&
|
||||
(snapshot.authRequired == false || snapshot.authenticated == true)
|
||||
if (!dashboardUsable) return@LaunchedEffect
|
||||
delay(1_500L)
|
||||
// The VM's cached per-connection store — the prewarm must NOT
|
||||
// construct its own (each instance lazily pays a multi-second
|
||||
// Keystore keyset build under a process-global Tink lock).
|
||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
||||
?: return@LaunchedEffect
|
||||
prewarmDashboardManage(
|
||||
cookieStore = cookieStore,
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
}
|
||||
|
||||
// Single snackbar host for the whole app — exposed via LocalSnackbarHost
|
||||
// so voice/chat/settings screens can call showHumanError from their
|
||||
@@ -705,11 +868,24 @@ fun RelayApp() {
|
||||
masterEnabled &&
|
||||
unattendedEnabled &&
|
||||
!isOnboarding &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
val showConnectionStatusBanner =
|
||||
globalConnectionStatus != null &&
|
||||
!isOnboarding &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
val onConnectionStatusBannerClick: () -> Unit = {
|
||||
val title = globalConnectionStatus?.title.orEmpty()
|
||||
val destination = when {
|
||||
title.contains("No Hermes connection", ignoreCase = true) -> Screen.Pair.route()
|
||||
title.contains("dashboard", ignoreCase = true) -> Screen.Manage.route
|
||||
else -> Screen.ConnectionsSettings.route
|
||||
}
|
||||
navController.navigate(destination) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
// === END v0.4.1 polish ===
|
||||
|
||||
// Multi-connection switcher has moved into the AgentInfoSheet's
|
||||
@@ -779,6 +955,7 @@ fun RelayApp() {
|
||||
ConnectionStatusBanner(
|
||||
status = globalConnectionStatus,
|
||||
includeStatusBarPadding = !showUnattendedBanner && availableUpdate == null,
|
||||
onClick = onConnectionStatusBannerClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -820,83 +997,48 @@ fun RelayApp() {
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
if (!isOnboarding && !isKeyboardVisible && !voiceUiState.voiceMode) {
|
||||
NavigationBar(
|
||||
containerColor = if (isDarkTheme) {
|
||||
Color(0xFF1A1A2E).copy(alpha = 0.9f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
) {
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
bottomNavScreens.forEach { screen ->
|
||||
val isSelected = currentDestination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Box(
|
||||
modifier = if (isSelected && isDarkTheme) {
|
||||
Modifier.purpleGlow(
|
||||
radius = 18.dp,
|
||||
alpha = 0.4f,
|
||||
isDarkTheme = true
|
||||
)
|
||||
} else Modifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = screen.icon,
|
||||
contentDescription = screen.label
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(screen.label) },
|
||||
selected = isSelected,
|
||||
colors = if (isDarkTheme) {
|
||||
NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
NavigationBarItemDefaults.colors()
|
||||
},
|
||||
onClick = {
|
||||
// Chat's route is a template with an
|
||||
// optional `?openAgentSheet` arg — always
|
||||
// navigate to the concrete bare-"chat"
|
||||
// URI from bottom nav so we don't leak
|
||||
// the `{openAgentSheet}` placeholder into
|
||||
// the destination and so tab-switching
|
||||
// never re-opens the AgentInfoSheet.
|
||||
val target = when (screen) {
|
||||
is Screen.Chat -> Screen.Chat.route(openAgentSheet = false)
|
||||
else -> screen.route
|
||||
}
|
||||
navController.navigate(target) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!isOnboarding && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
|
||||
val leading = when {
|
||||
apiReachable -> "api online"
|
||||
relayReady -> "relay connected"
|
||||
else -> "offline"
|
||||
}
|
||||
val leadingColor = when {
|
||||
apiReachable -> RelayRefresh.Green
|
||||
relayReady -> RelayRefresh.Relay
|
||||
else -> RelayRefresh.Danger
|
||||
}
|
||||
val routeLabel = activeEndpoint?.displayLabel()
|
||||
?: activeConnection?.label
|
||||
?: "no route"
|
||||
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } ?: "default"
|
||||
val modelLabel = serverModelName.takeIf { it.isNotBlank() } ?: "model pending"
|
||||
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
|
||||
"safety: ${if (unattendedEnabled) "unattended" else "on"}"
|
||||
} else {
|
||||
"profile: $profileLabel"
|
||||
}
|
||||
RelayStatusStrip(
|
||||
leading = "$leading / $routeLabel",
|
||||
trailing = "$modelLabel / $safetyLabel",
|
||||
leadingColor = leadingColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
// The wizard inside OnboardingScreen now owns credential
|
||||
// application via ConnectionViewModel.applyPairingPayload,
|
||||
@@ -924,6 +1066,10 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onManageSignIn = {
|
||||
postOnboardingRoute = Screen.Manage.route
|
||||
connectionViewModel.completeOnboarding()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -973,52 +1119,215 @@ fun RelayApp() {
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToConnect = {
|
||||
navController.navigate(Screen.Pair.route()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToBridge = {
|
||||
rememberBridgeReturn(
|
||||
route = Screen.Chat.route(openAgentSheet = false),
|
||||
label = "Chat",
|
||||
)
|
||||
navController.navigate(Screen.Bridge.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToTerminal = {
|
||||
navController.navigate(Screen.Terminal.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToProfileInspector = { profileName ->
|
||||
navController.navigate(Screen.ProfileInspector.route(profileName)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Manage.route) {
|
||||
DashboardManagementScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToBridge = {
|
||||
rememberBridgeReturn(
|
||||
route = Screen.Manage.route,
|
||||
label = "Manage",
|
||||
)
|
||||
navController.navigate(Screen.Bridge.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToTerminal = {
|
||||
navController.navigate(Screen.Terminal.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Terminal.route) {
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel
|
||||
)
|
||||
}
|
||||
composable(Screen.Bridge.route) {
|
||||
if (BuildFlavor.isSideload) {
|
||||
BridgeScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToBridgeSafety = {
|
||||
navController.navigate(Screen.BridgeSafetySettings.route)
|
||||
},
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel
|
||||
)
|
||||
} else {
|
||||
BridgeCoreScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToTerminal = {
|
||||
navController.navigate(Screen.Terminal.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToVoiceSettings = {
|
||||
navController.navigate(Screen.VoiceSettings.route)
|
||||
},
|
||||
onNavigateToNotificationCompanion = {
|
||||
navController.navigate(Screen.NotificationCompanionSettings.route)
|
||||
},
|
||||
onNavigateToMediaSettings = {
|
||||
navController.navigate(Screen.MediaSettings.route)
|
||||
},
|
||||
onNavigateToRelaySessions = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
PowerFeatureGateScreen(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.Bridge.route) {
|
||||
if (coldStartAuthState !is AuthState.Paired) {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
},
|
||||
onBack = bridgeReturnAction,
|
||||
)
|
||||
} else {
|
||||
if (BuildFlavor.isSideload) {
|
||||
BridgeScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
returnTitle = bridgeReturnTitle,
|
||||
returnSubtitle = bridgeReturnSubtitle,
|
||||
returnLabel = bridgePrimaryReturnLabel ?: "Back",
|
||||
onReturn = bridgeReturnAction,
|
||||
onNavigateToBridgeSafety = {
|
||||
navController.navigate(Screen.BridgeSafetySettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToManage = {
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BridgeCoreScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
returnTitle = bridgeReturnTitle,
|
||||
returnSubtitle = bridgeReturnSubtitle,
|
||||
returnLabel = bridgePrimaryReturnLabel ?: "Back",
|
||||
onReturn = bridgeReturnAction,
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToManage = {
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToTerminal = {
|
||||
navController.navigate(Screen.Terminal.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToVoiceSettings = {
|
||||
navController.navigate(Screen.VoiceSettings.route)
|
||||
},
|
||||
onNavigateToNotificationCompanion = {
|
||||
navController.navigate(Screen.NotificationCompanionSettings.route)
|
||||
},
|
||||
onNavigateToMediaSettings = {
|
||||
navController.navigate(Screen.MediaSettings.route)
|
||||
},
|
||||
onNavigateToRelaySessions = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -1032,9 +1341,19 @@ fun RelayApp() {
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route)
|
||||
},
|
||||
onNavigateToChatSettings = {
|
||||
navController.navigate(Screen.ChatSettings.route)
|
||||
},
|
||||
onNavigateToTerminal = {
|
||||
navController.navigate(Screen.Terminal.route)
|
||||
},
|
||||
onNavigateToBridge = {
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Bridge.route)
|
||||
},
|
||||
onNavigateToMediaSettings = {
|
||||
navController.navigate(Screen.MediaSettings.route)
|
||||
},
|
||||
@@ -1072,10 +1391,24 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
composable(Screen.VoiceSettings.route) {
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
VoiceSettingsScreen(
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
selectedProfile = selectedProfile,
|
||||
standardVoiceAvailability = standardVoiceAvailability,
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
onOpenManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
@@ -1098,6 +1431,24 @@ fun RelayApp() {
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToTerminal = {
|
||||
navController.navigate(Screen.Terminal.route)
|
||||
},
|
||||
@@ -1113,21 +1464,35 @@ fun RelayApp() {
|
||||
onNavigateToRelaySessions = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// === END PHASE3-safety-rails ===
|
||||
composable(Screen.PairedDevices.route) {
|
||||
PairedDevicesScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onRequestRepair = {
|
||||
// Pop back to Settings so the user lands on the
|
||||
// "Scan Pairing QR" button rather than getting
|
||||
// stranded on an empty devices list.
|
||||
navController.popBackStack(Screen.Settings.route, inclusive = false)
|
||||
}
|
||||
)
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
PairedDevicesScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onRequestRepair = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
}
|
||||
)
|
||||
} else {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Relay sessions",
|
||||
summary = "Review and revoke devices paired with this relay.",
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
// (The `composable(Screen.ConnectionSettings.route)` block
|
||||
// that used to live here — hosting the singular, legacy
|
||||
@@ -1202,18 +1567,23 @@ fun RelayApp() {
|
||||
onAddConnection = {
|
||||
connectionSwitchScope.launch {
|
||||
// Create and switch to the placeholder before
|
||||
// opening the camera. Otherwise a fast scan can
|
||||
// save the session token into the outgoing
|
||||
// opening the wizard. Otherwise a fast scan or
|
||||
// standard save can write into the outgoing
|
||||
// connection's auth store.
|
||||
val id = connectionViewModel.beginAddConnection(
|
||||
preAllocatedId = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
navController.navigate(
|
||||
Screen.Pair.route(connectionId = id, autoStart = "scan")
|
||||
Screen.Pair.route(connectionId = id)
|
||||
)
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToPairedDevices = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
},
|
||||
@@ -1260,6 +1630,12 @@ fun RelayApp() {
|
||||
// beyond popping the backstack.
|
||||
navController.popBackStack()
|
||||
},
|
||||
onManageSignIn = {
|
||||
navController.popBackStack()
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
// If the user bailed out before completing a
|
||||
// pair, discard the placeholder we pre-created
|
||||
@@ -1355,6 +1731,18 @@ fun RelayApp() {
|
||||
val sectionArg = backStackEntry.arguments
|
||||
?.getString(Screen.ProfileInspector.ARG_SECTION)
|
||||
?: Screen.ProfileInspector.SECTION_CONFIG
|
||||
if (coldStartAuthState !is AuthState.Paired) {
|
||||
PowerFeatureGateScreen(
|
||||
title = "Profile Inspector",
|
||||
summary = "Inspect relay-backed profile config, SOUL, memory files, and skills.",
|
||||
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
|
||||
onPrimaryAction = {
|
||||
navController.navigate(Screen.Pair.route())
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
return@composable
|
||||
}
|
||||
val inspectorViewModel: ProfileInspectorViewModel = viewModel(
|
||||
viewModelStoreOwner = backStackEntry,
|
||||
key = "profile-inspector-$profileNameArg",
|
||||
@@ -1399,6 +1787,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end bridge-return wrapper column
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
} // end Column (wraps banner + Scaffold)
|
||||
@@ -1410,16 +1799,18 @@ fun RelayApp() {
|
||||
// ConnectionSwitcherSheet.kt itself is kept so any future programmatic
|
||||
// callers (deep links, automation) can still invoke it if needed.)
|
||||
|
||||
// Sphere intro overlay — fades out after 1.5s to reveal main UI
|
||||
// Startup connection gate. Keeps transient "connect" prompts hidden
|
||||
// until the first saved-connection health decision resolves.
|
||||
AnimatedVisibility(
|
||||
visible = !introComplete && onboardingCompleted,
|
||||
visible = showStartupSphere,
|
||||
enter = fadeIn(tween(300)),
|
||||
exit = fadeOut(tween(600))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF1A1A2E)),
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.14f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Sphere fills background
|
||||
@@ -1435,13 +1826,13 @@ fun RelayApp() {
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
color = RelayRefresh.Paper.copy(alpha = 0.92f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "agent interface",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
color = RelayRefresh.Muted.copy(alpha = 0.72f),
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
}
|
||||
|
||||
+97
-68
@@ -94,30 +94,21 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
|
||||
/**
|
||||
* Three tappable status rows (API / Relay / Session), always visible on
|
||||
* the active card. Replaces the old "Active Connection" quick-look card
|
||||
* that used to live at the top of `SettingsScreen` — same information
|
||||
* density, same tap-for-info-sheet behavior.
|
||||
*
|
||||
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
|
||||
* reconnect + toast; every other row falls through to the info sheet
|
||||
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
|
||||
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
|
||||
* here so users do not have to open Manage just to discover sign-in is needed.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardStatusSection(
|
||||
fun ActiveCardStandardStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
@@ -132,44 +123,74 @@ fun ActiveCardStatusSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
// ADR 24: relayRowState carries both the phase and the active
|
||||
// endpoint role. statusText appends " · <Role>" when the
|
||||
// resolver has picked one, so the chip reads "Connected · LAN"
|
||||
// etc. without any extra wiring here.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ConnectionStatusRow(
|
||||
label = "Dashboard",
|
||||
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
|
||||
statusText = when {
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
|
||||
dashboardStatus == null -> "Not checked"
|
||||
!dashboardStatus.reachable -> "Unreachable"
|
||||
dashboardSignInRequired -> "Sign-in required"
|
||||
dashboardStatus.authenticated == true -> "Signed in"
|
||||
dashboardStatus.authRequired == false -> "Available"
|
||||
else -> "Available"
|
||||
},
|
||||
onClick = onOpenDashboard,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Optional Relay status rows (transport / paired session). Kept separate from
|
||||
* [ActiveCardStandardStatusSection] so API/dashboard setup does not visually
|
||||
* read as incomplete when Relay is not paired.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardRelayStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,7 +347,7 @@ private fun ManualUrlSubsection(
|
||||
) { result ->
|
||||
isTestingApi = false
|
||||
apiVoiceSetupResult = result
|
||||
if (!result.voiceConfigReachable && result.relayAutoDerived) {
|
||||
if (!result.voiceConfigReachable && result.voiceRoute == "relay" && result.relayAutoDerived) {
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
@@ -334,9 +355,13 @@ private fun ManualUrlSubsection(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
"API and voice relay reachable"
|
||||
if (result.voiceRoute == "standard") {
|
||||
"API and standard voice reachable"
|
||||
} else {
|
||||
"API and relay voice reachable"
|
||||
}
|
||||
result.apiReachable ->
|
||||
"API reachable; relay URL needs review"
|
||||
"API reachable; voice route needs review"
|
||||
else -> "Cannot reach API server"
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
@@ -373,7 +398,7 @@ private fun ManualUrlSubsection(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
|
||||
text = "Relay is optional for voice. Standard voice uses the Hermes API; Relay voice uses this route when selected or needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -403,7 +428,7 @@ private fun ManualUrlSubsection(
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Only needed when Auto cannot reach /voice/config")
|
||||
Text("Only needed when the optional Relay route cannot be auto-derived")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -417,9 +442,13 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
if (result.voiceRoute == "standard") {
|
||||
"Voice ready via standard Hermes API"
|
||||
} else {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
}
|
||||
} else {
|
||||
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
@@ -574,7 +603,7 @@ private fun InsecureToggleSubsection(
|
||||
* the QR scanner isn't usable (no camera, headless host, bad lighting).
|
||||
*
|
||||
* 1. Copy the phone-generated code (with Refresh to regenerate)
|
||||
* 2. Run `hermes-pair --register-code <code>` on the host
|
||||
* 2. Run `hermes pair --register-code <code>` on the host
|
||||
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
|
||||
* failure through the global snackbar host
|
||||
*
|
||||
@@ -696,7 +725,7 @@ private fun ManualPairingCodeSubsection(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "hermes-pair --register-code $pairingCode",
|
||||
text = "hermes pair --register-code $pairingCode",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
@@ -705,10 +734,10 @@ private fun ManualPairingCodeSubsection(
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val cmd = "hermes-pair --register-code $pairingCode"
|
||||
val cmd = "hermes pair --register-code $pairingCode"
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
|
||||
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Command copied")
|
||||
}
|
||||
@@ -717,7 +746,7 @@ private fun ManualPairingCodeSubsection(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy hermes-pair command",
|
||||
contentDescription = "Copy hermes pair command",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -783,9 +812,9 @@ private fun ManualPairingCodeSubsection(
|
||||
text = "This is a fallback for when you can't scan the pairing QR " +
|
||||
"— for example, no camera, the host can't render a QR, or you " +
|
||||
"only have SSH access from a single device. The canonical flow " +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
|
||||
"How it works: the phone generates a 6-character code locally. " +
|
||||
"You paste that code into the host's `hermes-pair --register-code` " +
|
||||
"You paste that code into the host's `hermes pair --register-code` " +
|
||||
"command, which pre-registers it with the relay. When you tap " +
|
||||
"Connect here, the phone presents the same code to the relay " +
|
||||
"and gets a long-lived session token in return.\n\n" +
|
||||
|
||||
@@ -44,7 +44,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Visual style mirrors the status
|
||||
* cards in `PairedDevicesScreen`: surfaceVariant background, 16dp padding,
|
||||
* 10dp row spacing. Uses [ConnectionStatusBadge] for the pulsing status dot
|
||||
* so the Bridge tab looks visually consistent with the Settings → Connection
|
||||
* so the Bridge tab looks visually consistent with the Settings → Connections
|
||||
* section.
|
||||
*
|
||||
* The headline switch is `enabled = allowEnable` so users can't flip it on
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Kept distinct from
|
||||
* [BridgeMasterToggle] so that Agent safety-rails in Wave 2 can relocate the master
|
||||
* toggle without losing the status surface (and so we can reuse this card
|
||||
* in the Settings → Connection section later if desired).
|
||||
* in the Settings → Connections section later if desired).
|
||||
*/
|
||||
@Composable
|
||||
fun BridgeStatusCard(
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -59,6 +60,7 @@ fun ConnectionStatusBanner(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
@@ -95,6 +97,7 @@ fun ConnectionStatusBanner(
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -154,6 +157,15 @@ fun ConnectionStatusBanner(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.86f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val outputLines = current.entries
|
||||
.takeLast(2)
|
||||
|
||||
@@ -627,6 +627,7 @@ fun AgentInfoSheet(
|
||||
chatViewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
@@ -741,7 +742,7 @@ fun AgentInfoSheet(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SectionLabel(
|
||||
title = "Profile",
|
||||
hint = "Overlay an agent's model + SOUL",
|
||||
hint = "Host-side Hermes contexts",
|
||||
)
|
||||
|
||||
val defaultDotColor = serverDefaultProfile?.let { profile ->
|
||||
@@ -868,6 +869,13 @@ fun AgentInfoSheet(
|
||||
append("profile: ")
|
||||
append(profile.name)
|
||||
}
|
||||
if (profile.hasIsolatedApi) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("isolated API")
|
||||
} else {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("compatibility overlay")
|
||||
}
|
||||
if (isApparentActive && selectedProfile == null) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("This is the server's active profile")
|
||||
@@ -884,6 +892,19 @@ fun AgentInfoSheet(
|
||||
leadingDotContentDescription = dotA11y,
|
||||
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
|
||||
{
|
||||
ProfileMetadataBadge(
|
||||
text = if (profile.hasIsolatedApi) "API" else "Overlay",
|
||||
background = if (profile.hasIsolatedApi) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (profile.hasIsolatedApi) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (profile.skillCount > 0) {
|
||||
ProfileMetadataBadge(
|
||||
text = "${profile.skillCount} skills",
|
||||
@@ -904,7 +925,9 @@ fun AgentInfoSheet(
|
||||
if (selectedProfile?.name != profile.name) {
|
||||
connectionViewModel.selectProfile(profile)
|
||||
val display = primaryLabel
|
||||
val suffix = if (profile.systemMessage?.isNotBlank() == true) {
|
||||
val suffix = if (profile.hasIsolatedApi) {
|
||||
" — profile API active"
|
||||
} else if (profile.systemMessage?.isNotBlank() == true) {
|
||||
" — model + SOUL applied"
|
||||
} else {
|
||||
" — model applied"
|
||||
@@ -915,6 +938,21 @@ fun AgentInfoSheet(
|
||||
)
|
||||
}
|
||||
|
||||
val inspectorTarget = selectedProfile
|
||||
?: serverDefaultProfile
|
||||
?: selectableProfiles.firstOrNull()
|
||||
inspectorTarget?.let { profile ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNavigateToProfileInspector(profile.name)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Inspect ${AgentDisplay.profileDisplayName(profile) ?: profile.name}")
|
||||
}
|
||||
}
|
||||
|
||||
if (profileOverridesPersonality) {
|
||||
Text(
|
||||
text = "This profile's system message overrides the personality below.",
|
||||
@@ -1065,7 +1103,7 @@ fun AgentInfoSheet(
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> "$hostname • Not paired"
|
||||
connection.pairedAt == null -> "$hostname • Standard"
|
||||
else -> "$hostname • Paired"
|
||||
}
|
||||
ProfileRadioRow(
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ private fun ConnectionRow(
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Not paired"
|
||||
"$hostname • Standard"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,15 +19,19 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -42,9 +46,11 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.network.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -72,15 +78,40 @@ fun EndpointsCard(
|
||||
onClearOverride: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
/** True while a user-triggered route probe is in flight. */
|
||||
isProbing: Boolean = false,
|
||||
/**
|
||||
* Last probe verdict for a route, or null when it has never been
|
||||
* probed. Lambda (not a map) so the card stays decoupled from the
|
||||
* resolver's cache-key scheme.
|
||||
*/
|
||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
||||
/**
|
||||
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||
* Edit/Remove only appear on fallback rows (priority > 0); the primary
|
||||
* row mirrors the connection's API URL and is edited there.
|
||||
*/
|
||||
onAddRoute: (() -> Unit)? = null,
|
||||
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
) {
|
||||
if (endpoints.isEmpty()) {
|
||||
Text(
|
||||
text = "No route candidates stored for this device yet. " +
|
||||
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
|
||||
"switching — LAN + Tailscale + public URLs.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "No route candidates stored for this connection yet. " +
|
||||
"Add a remote route (Tailscale, public URL) for automatic " +
|
||||
"switching when the phone leaves this network — or scan a " +
|
||||
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onAddRoute != null) {
|
||||
TextButton(onClick = onAddRoute) {
|
||||
Text("Add route")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,12 +130,25 @@ fun EndpointsCard(
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
isProbing = isProbing,
|
||||
outcome = outcomeFor(candidate),
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onProbeNow = onProbeNow,
|
||||
onViewPin = onViewPin,
|
||||
onEdit = onEditRoute?.takeIf { candidate.priority > 0 }
|
||||
?.let { edit -> { edit(candidate) } },
|
||||
onRemove = onRemoveRoute?.takeIf { candidate.priority > 0 }
|
||||
?.let { remove -> { remove(candidate) } },
|
||||
)
|
||||
}
|
||||
|
||||
if (onAddRoute != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Add route")
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredRole != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -122,12 +166,17 @@ private fun EndpointRow(
|
||||
candidate: EndpointCandidate,
|
||||
isActive: Boolean,
|
||||
isPreferred: Boolean,
|
||||
isProbing: Boolean = false,
|
||||
outcome: RouteProbeOutcome? = null,
|
||||
onPrefer: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -174,13 +223,35 @@ private fun EndpointRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Full URL, scheme included: http vs https decides whether
|
||||
// the health probe TLS-handshakes, so two rows that both
|
||||
// read "host:8642" can behave completely differently. The
|
||||
// scheme must be visible to be debuggable.
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}" +
|
||||
text = candidate.api.url +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = "Checking…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome == null -> Unit // never probed — say nothing
|
||||
outcome.reachable -> Text(
|
||||
text = "Reachable",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> Text(
|
||||
text = "Unreachable — ${outcome.detail ?: "no detail"}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
@@ -228,11 +299,54 @@ private fun EndpointRow(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onEdit()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onRemove != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
confirmRemove = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove && onRemove != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = false },
|
||||
title = { Text("Remove ${candidate.displayLabel()} route?") },
|
||||
text = {
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
|
||||
"probed as a fallback. You can add it back any time.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmRemove = false
|
||||
onRemove()
|
||||
},
|
||||
) { Text("Remove") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
@@ -328,3 +442,154 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
"public" -> Icons.Filled.Public
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit dialog for an extra fallback route — the manual counterpart of a
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||
*
|
||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
||||
*
|
||||
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||
* URL, keeps the stored priority).
|
||||
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
|
||||
* receives a user-facing error string to render inline, or null on
|
||||
* success (the dialog then closes itself).
|
||||
*/
|
||||
@Composable
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val knownRoles = listOf("tailscale", "public")
|
||||
var selectedRole by remember {
|
||||
mutableStateOf(
|
||||
when (original?.role?.lowercase()) {
|
||||
null -> "tailscale"
|
||||
in knownRoles -> original.role.lowercase()
|
||||
else -> CUSTOM_ROLE
|
||||
},
|
||||
)
|
||||
}
|
||||
var customRole by remember {
|
||||
mutableStateOf(
|
||||
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
|
||||
)
|
||||
}
|
||||
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
|
||||
val saveEnabled = !saving &&
|
||||
url.isNotBlank() &&
|
||||
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(if (original == null) "Add route" else "Edit route") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "A fallback route the phone switches to when the " +
|
||||
"primary stops answering — e.g. your server's " +
|
||||
"Tailscale or public URL.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = selectedRole == "tailscale",
|
||||
onClick = { selectedRole = "tailscale" },
|
||||
label = { Text("Tailscale") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == "public",
|
||||
onClick = { selectedRole = "public" },
|
||||
label = { Text("Public") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == CUSTOM_ROLE,
|
||||
onClick = { selectedRole = CUSTOM_ROLE },
|
||||
label = { Text("Custom") },
|
||||
)
|
||||
}
|
||||
if (selectedRole == CUSTOM_ROLE) {
|
||||
OutlinedTextField(
|
||||
value = customRole,
|
||||
onValueChange = { customRole = it },
|
||||
label = { Text("Route name") },
|
||||
placeholder = { Text("wireguard-home") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
// Live preview of what will actually be saved — scheme and
|
||||
// port defaults applied — so "what, which port, http or
|
||||
// https?" is answered before Save, not after a failed probe.
|
||||
val previewCandidate = remember(url, effectiveRole) {
|
||||
url.takeIf { it.isNotBlank() }?.let {
|
||||
Connection.endpointCandidateFromApiUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
apiServerUrl = Connection.normalizeApiUrlInput(it),
|
||||
relayUrl = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
errorText = null
|
||||
},
|
||||
label = { Text("API server URL or host") },
|
||||
placeholder = { Text("100.71.8.56 or http://host:8642") },
|
||||
singleLine = true,
|
||||
isError = errorText != null,
|
||||
supportingText = {
|
||||
Text(
|
||||
text = errorText ?: when {
|
||||
url.isBlank() ->
|
||||
"Use the API server port (8642 by default) — " +
|
||||
"not the dashboard's 9119. http:// is " +
|
||||
"assumed unless you type https://."
|
||||
previewCandidate != null ->
|
||||
"Will save: ${previewCandidate.api.url} — relay " +
|
||||
"and dashboard URLs are derived from the host"
|
||||
else ->
|
||||
"Enter a host/IP or an http(s):// URL " +
|
||||
"(API port 8642, not dashboard 9119)"
|
||||
},
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = saveEnabled,
|
||||
onClick = {
|
||||
saving = true
|
||||
onSave(effectiveRole, url) { error ->
|
||||
saving = false
|
||||
if (error == null) {
|
||||
onDismiss()
|
||||
} else {
|
||||
errorText = error
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val CUSTOM_ROLE = "__custom__"
|
||||
|
||||
@@ -27,8 +27,13 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -56,6 +61,11 @@ fun MessageBubble(
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Quote this message into the input field. Null hides the Quote entry in
|
||||
* the long-press menu, so legacy call sites keep the copy-only behavior.
|
||||
*/
|
||||
onQuoteMessage: ((String) -> Unit)? = null,
|
||||
/**
|
||||
* Invoked when the user taps a FAILED inbound attachment card.
|
||||
* `attachmentIndex` is the position in [ChatMessage.attachments] so the
|
||||
@@ -191,6 +201,31 @@ fun MessageBubble(
|
||||
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f))
|
||||
)
|
||||
}
|
||||
// Long-press opens a compact action menu when a quote handler is
|
||||
// wired; with copy as the only action it stays a direct copy so the
|
||||
// one-action case doesn't pay a menu tap.
|
||||
var showMessageActions by remember { mutableStateOf(false) }
|
||||
if (onQuoteMessage != null) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(message.content)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Quote in reply") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.content)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
@@ -206,7 +241,13 @@ fun MessageBubble(
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { onCopyMessage(message.content) }
|
||||
onLongClick = {
|
||||
if (onQuoteMessage != null) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(message.content)
|
||||
}
|
||||
}
|
||||
)
|
||||
.semantics { contentDescription = a11yDescription }
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
enum class PowerFeatureGateStatus(
|
||||
val label: String,
|
||||
val actionLabel: String,
|
||||
val explanation: String,
|
||||
) {
|
||||
RequiresPairing(
|
||||
label = "Requires pairing",
|
||||
actionLabel = "Pair to unlock",
|
||||
explanation = "This feature uses relay grants and requires a paired device session.",
|
||||
),
|
||||
PairingExpired(
|
||||
label = "Pairing expired",
|
||||
actionLabel = "Pair again",
|
||||
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
|
||||
),
|
||||
Unavailable(
|
||||
label = "Unavailable on this server",
|
||||
actionLabel = "View connection",
|
||||
explanation = "This server does not currently expose the relay capability this feature needs.",
|
||||
),
|
||||
DashboardSignInRequired(
|
||||
label = "Dashboard sign-in required",
|
||||
actionLabel = "Open sign-in",
|
||||
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromRelayAuth(authState: AuthState): PowerFeatureGateStatus {
|
||||
return when (authState) {
|
||||
is AuthState.Failed -> {
|
||||
val reason = authState.reason.lowercase()
|
||||
if ("expired" in reason || "token" in reason || "session" in reason) {
|
||||
PairingExpired
|
||||
} else {
|
||||
RequiresPairing
|
||||
}
|
||||
}
|
||||
else -> RequiresPairing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PowerFeatureGateScreen(
|
||||
title: String,
|
||||
summary: String,
|
||||
status: PowerFeatureGateStatus,
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PowerFeatureGateCard(
|
||||
title = title,
|
||||
summary = summary,
|
||||
status = status,
|
||||
onPrimaryAction = onPrimaryAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PowerFeatureGateCard(
|
||||
title: String,
|
||||
summary: String,
|
||||
status: PowerFeatureGateStatus,
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = status.explanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Button(
|
||||
onClick = onPrimaryAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(status.actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PowerFeatureGatePreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PowerFeatureGateExpiredPreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,10 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -132,7 +134,7 @@ import kotlin.math.max
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The top-level fields configure the direct-chat Hermes API server. The
|
||||
* The top-level fields configure the direct Hermes API server. The
|
||||
* optional [relay] block configures the Hermes-Relay WSS connection used by
|
||||
* the terminal and bridge channels. The [endpoints] list (v3+) carries an
|
||||
* ordered array of candidate endpoints; the phone picks the highest-priority
|
||||
@@ -217,12 +219,17 @@ private val json = Json {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a scanned string as a Hermes pairing QR payload.
|
||||
* Try to parse a scanned string as a Hermes connection QR payload.
|
||||
*
|
||||
* Accepts v1, v2, and v3 (or anything without a `hermes` field — we default
|
||||
* to `1`). Returns null when the payload is not valid JSON, has no `host`
|
||||
* field, or fails strict decoding.
|
||||
*
|
||||
* For standard Hermes setup, also accepts generic API-only QRs:
|
||||
* - a plain `http://host:8642` or `https://host:8642` URL
|
||||
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
|
||||
* optional `api_key`, `apiKey`, or `key`
|
||||
*
|
||||
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
|
||||
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
|
||||
* from the top-level fields so downstream code can always iterate
|
||||
@@ -233,6 +240,13 @@ private val json = Json {
|
||||
* role case, priority order, and unknown roles are all preserved.
|
||||
*/
|
||||
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
val trimmed = raw.trim()
|
||||
return parseHermesRelayQr(trimmed)
|
||||
?: parseGenericApiJsonQr(trimmed)
|
||||
?: parseGenericApiUrlQr(trimmed)
|
||||
}
|
||||
|
||||
private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
// Quick check: must contain a `host` field and be valid JSON. We no
|
||||
// longer reject based on the `hermes` version int — future v4+ QRs
|
||||
@@ -263,6 +277,85 @@ fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGenericApiJsonQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
val apiUrl = firstString(
|
||||
obj,
|
||||
"api_url",
|
||||
"apiUrl",
|
||||
"server_url",
|
||||
"serverUrl",
|
||||
"url",
|
||||
) ?: return null
|
||||
val apiKey = firstString(obj, "api_key", "apiKey", "key").orEmpty()
|
||||
payloadFromApiUrl(apiUrl, apiKey)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGenericApiUrlQr(raw: String): HermesPairingPayload? {
|
||||
return payloadFromApiUrl(raw, apiKey = "")
|
||||
}
|
||||
|
||||
private fun firstString(obj: JsonObject, vararg names: String): String? {
|
||||
return names.firstNotNullOfOrNull { name ->
|
||||
obj[name]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayload? {
|
||||
val uri = runCatching { URI(apiUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val payload = HermesPairingPayload(
|
||||
host = host,
|
||||
port = if (uri.port > 0) uri.port else 8642,
|
||||
key = apiKey.trim(),
|
||||
tls = tls,
|
||||
relay = null,
|
||||
)
|
||||
return payload.copy(endpoints = listOf(synthesizeGenericEndpoint(payload)))
|
||||
}
|
||||
|
||||
private fun synthesizeGenericEndpoint(payload: HermesPairingPayload): EndpointCandidate {
|
||||
val host = payload.host.lowercase()
|
||||
val role = when {
|
||||
host.endsWith(".ts.net") || host.startsWith("100.") -> "tailscale"
|
||||
isPrivateLanHost(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role,
|
||||
priority = 0,
|
||||
api = ApiEndpoint(
|
||||
host = payload.host,
|
||||
port = payload.port,
|
||||
tls = payload.tls,
|
||||
),
|
||||
relay = RelayEndpoint(url = "", transportHint = null),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isPrivateLanHost(host: String): Boolean {
|
||||
if (host == "localhost" || host == "127.0.0.1" || host == "::1") return true
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single priority-0 [EndpointCandidate] from a v1/v2 pairing payload
|
||||
* that lacked an `endpoints` array. Preserves the top-level API coordinates
|
||||
@@ -635,7 +728,7 @@ fun QrPairingScanner(
|
||||
// Instructions
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Icon(
|
||||
@@ -645,14 +738,14 @@ fun QrPairingScanner(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Point at a Hermes pairing QR code",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
text = "Scan a Hermes setup QR",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Generate one on your server with: hermes-pair",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Hermes-Relay plugin.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayDottedOverlay
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import com.hermesandroid.relay.ui.theme.relayPanel
|
||||
import com.hermesandroid.relay.ui.theme.relaySelectedPanel
|
||||
|
||||
enum class RelayPrimaryMode(val label: String) {
|
||||
Chat("Chat"),
|
||||
Manage("Manage"),
|
||||
Bridge("Bridge"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayModeStrip(
|
||||
selected: RelayPrimaryMode,
|
||||
onModeSelected: (RelayPrimaryMode) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RelayPrimaryMode.entries.forEach { mode ->
|
||||
val active = mode == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp)
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.then(
|
||||
if (active) {
|
||||
Modifier.relaySelectedPanel()
|
||||
} else {
|
||||
Modifier.relayPanel(
|
||||
background = RelayRefresh.Background.copy(alpha = 0.45f),
|
||||
)
|
||||
},
|
||||
)
|
||||
.clickable { onModeSelected(mode) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = mode.label,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (active) RelayRefresh.Paper else RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayStatusStrip(
|
||||
leading: String,
|
||||
trailing: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingColor: Color = RelayRefresh.Green,
|
||||
) {
|
||||
// Floating capsule, not an edge-to-edge bar: a full-width bordered
|
||||
// rectangle clashes with rounded display corners and reads as a hard
|
||||
// shelf. Insets are applied BEFORE the margins so the pill floats above
|
||||
// the gesture area with the app background showing around it.
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
|
||||
),
|
||||
)
|
||||
.padding(start = 14.dp, end = 14.dp, top = 2.dp, bottom = 8.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.relayPanel(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
|
||||
borderColor = RelayRefresh.Line,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = leading,
|
||||
style = relayMetadataStyle(),
|
||||
color = leadingColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = trailing,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayReturnStrip(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "Back",
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.relaySelectedPanel()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.58f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Paper,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Navy3.copy(alpha = 0.72f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Relay,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Relay,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayHeroPanel(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
accent: Color = RelayRefresh.Relay,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.relaySelectedPanel(),
|
||||
) {
|
||||
RelayDottedOverlay(alpha = 0.18f)
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = RelayRefresh.Ink.copy(alpha = 0.86f),
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayMetricCard(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
valueColor: Color = RelayRefresh.Paper,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.relayPanel()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayNavTile(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val base = if (selected) {
|
||||
Modifier.relaySelectedPanel()
|
||||
} else {
|
||||
Modifier.relayPanel(background = RelayRefresh.Navy2.copy(alpha = 0.72f))
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.then(base)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Navy3.copy(alpha = if (enabled) 0.86f else 0.38f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) RelayRefresh.Relay else RelayRefresh.Dim,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (enabled) RelayRefresh.Paper else RelayRefresh.Dim,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (trailing != null) {
|
||||
trailing()
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelaySectionCaption(
|
||||
title: String,
|
||||
meta: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
)
|
||||
meta?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayStatusPill(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.relayPanel(
|
||||
background = if (active) RelayRefresh.Green.copy(alpha = 0.12f) else RelayRefresh.Navy3.copy(alpha = 0.7f),
|
||||
borderColor = if (active) RelayRefresh.Green.copy(alpha = 0.36f) else RelayRefresh.Line,
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (active) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = null,
|
||||
tint = if (active) RelayRefresh.Green else RelayRefresh.Muted,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = relayMetadataStyle(),
|
||||
color = if (active) RelayRefresh.Green else RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayChromeIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(38.dp),
|
||||
shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.52f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = RelayRefresh.Paper,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,14 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -36,10 +40,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private enum class SessionDrawerFilter(val label: String) {
|
||||
All("All"),
|
||||
Pinned("Pinned"),
|
||||
Archive("Archive"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
@@ -53,8 +65,35 @@ fun SessionDrawerContent(
|
||||
) {
|
||||
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val visibleSessions = sessions
|
||||
.asSequence()
|
||||
.filter { session ->
|
||||
when (filter) {
|
||||
SessionDrawerFilter.All -> session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Pinned ->
|
||||
session.sessionId in pinnedSessionIds &&
|
||||
session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Archive -> session.sessionId in archivedSessionIds
|
||||
}
|
||||
}
|
||||
.filter { session ->
|
||||
val needle = query.trim()
|
||||
needle.isBlank() ||
|
||||
session.sessionId.contains(needle, ignoreCase = true) ||
|
||||
session.title.orEmpty().contains(needle, ignoreCase = true) ||
|
||||
session.model.orEmpty().contains(needle, ignoreCase = true)
|
||||
}
|
||||
.toList()
|
||||
|
||||
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.width(320.dp),
|
||||
drawerContainerColor = RelayRefresh.Background,
|
||||
drawerContentColor = RelayRefresh.Ink,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header
|
||||
Text(
|
||||
@@ -84,12 +123,41 @@ fun SessionDrawerContent(
|
||||
Text("New Chat")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text("Search sessions or id...") },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SessionDrawerFilter.entries.forEach { item ->
|
||||
FilterChip(
|
||||
selected = filter == item,
|
||||
onClick = { filter = item },
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = relayMetadataStyle(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
if (visibleSessions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -97,7 +165,7 @@ fun SessionDrawerContent(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "No sessions yet",
|
||||
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -109,11 +177,27 @@ fun SessionDrawerContent(
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(sessions, key = { it.sessionId }) { session ->
|
||||
items(visibleSessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
isActive = session.sessionId == currentSessionId,
|
||||
pinned = session.sessionId in pinnedSessionIds,
|
||||
archived = session.sessionId in archivedSessionIds,
|
||||
onClick = { onSelectSession(session.sessionId) },
|
||||
onTogglePinned = {
|
||||
pinnedSessionIds = if (session.sessionId in pinnedSessionIds) {
|
||||
pinnedSessionIds - session.sessionId
|
||||
} else {
|
||||
pinnedSessionIds + session.sessionId
|
||||
}
|
||||
},
|
||||
onToggleArchived = {
|
||||
archivedSessionIds = if (session.sessionId in archivedSessionIds) {
|
||||
archivedSessionIds - session.sessionId
|
||||
} else {
|
||||
archivedSessionIds + session.sessionId
|
||||
}
|
||||
},
|
||||
onRename = { renameDialogSession = session },
|
||||
onDelete = { deleteDialogSession = session }
|
||||
)
|
||||
@@ -184,7 +268,11 @@ fun SessionDrawerContent(
|
||||
private fun SessionItem(
|
||||
session: ChatSession,
|
||||
isActive: Boolean,
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
@@ -234,6 +322,20 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onTogglePinned, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = if (pinned) "Unpin session" else "Pin session",
|
||||
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleArchived, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Archive,
|
||||
contentDescription = if (archived) "Restore session" else "Archive session",
|
||||
tint = if (archived) RelayRefresh.Relay else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
|
||||
@@ -209,7 +209,7 @@ private fun defaultOptionIndex(options: List<TtlOption>): Int =
|
||||
|
||||
/**
|
||||
* Compute the default TTL for a new pair based on:
|
||||
* - QR payload's `ttlSeconds` (operator intent via `hermes-pair --ttl`)
|
||||
* - QR payload's `ttlSeconds` (operator intent via `hermes pair --ttl`)
|
||||
* - Transport hint (`"wss"` → 30d, `"ws"` → 7d)
|
||||
* - Tailscale detected → 30d
|
||||
* - Fallback → 30d
|
||||
|
||||
@@ -301,6 +301,28 @@ fun VoiceModeOverlay(
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = uiState.backgroundRun != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = uiState.backgroundRun?.message
|
||||
?: "Working on it in the background…",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
DestructiveCountdownRow(
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -40,6 +41,7 @@ fun OnboardingPage(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
transparentHero: Boolean = false,
|
||||
heroContent: @Composable BoxScope.() -> Unit = {
|
||||
FeatureHero(
|
||||
icon = icon,
|
||||
@@ -82,14 +84,14 @@ fun OnboardingPage(
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
.background(heroBrush),
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
|
||||
@@ -24,8 +24,8 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.PhonelinkSetup
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -48,13 +48,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
@@ -63,18 +62,16 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Page identifiers for dynamic onboarding flow. */
|
||||
private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect }
|
||||
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
|
||||
|
||||
/**
|
||||
* Three-stage onboarding:
|
||||
* Standard-first onboarding:
|
||||
*
|
||||
* 1. **Feature pages** (Welcome / Chat / Terminal / Bridge) — informational
|
||||
* swipe-able introduction. Bridge / Terminal pages only show when the
|
||||
* relay feature is enabled (Developer Options).
|
||||
* 1. **Feature pages** (Welcome / Chat / Manage / Power tools) — standard
|
||||
* Hermes API/dashboard features first, Relay-only power tools second.
|
||||
* 2. **Connect page** — embeds the shared [ConnectionWizard] so onboarding
|
||||
* uses the exact same scan → confirm → verify flow as Settings → Connection.
|
||||
* The wizard owns credential application; on success / skip it calls back
|
||||
* into [onComplete] to finish onboarding and navigate to chat.
|
||||
* uses the exact same Standard API/dashboard and optional Relay pairing
|
||||
* flow as Settings → Connections.
|
||||
*
|
||||
* The previous separate "ConnectPage" + "RelayPage" pair has been removed —
|
||||
* it discarded the QR's relay block, never applied per-channel grants, never
|
||||
@@ -102,19 +99,14 @@ fun OnboardingScreen(
|
||||
// lands on the right VM and survives the Onboarding→Chat transition.
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit = onComplete,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Build page list dynamically based on feature flags
|
||||
val pages = remember(relayEnabled) {
|
||||
val pages = remember {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
if (relayEnabled) {
|
||||
add(OnboardingPage.Terminal)
|
||||
add(OnboardingPage.Bridge)
|
||||
}
|
||||
add(OnboardingPage.Manage)
|
||||
add(OnboardingPage.Power)
|
||||
add(OnboardingPage.Connect)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +126,11 @@ fun OnboardingScreen(
|
||||
onDismissRequest = { showSkipConfirm = false },
|
||||
title = { Text("Skip setup?") },
|
||||
text = {
|
||||
Text("You can configure your server connection later in Settings → Connection. Without pairing, chat and voice features won't work yet.")
|
||||
Text(
|
||||
"You can configure your Hermes connection later in Settings → Connections. " +
|
||||
"Without a connection, Chat and Manage won't load. Relay pairing can " +
|
||||
"be added later for power tools."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
@@ -185,11 +181,12 @@ fun OnboardingScreen(
|
||||
when (pages[pageIndex]) {
|
||||
OnboardingPage.Welcome -> WelcomePage()
|
||||
OnboardingPage.Chat -> ChatPage()
|
||||
OnboardingPage.Terminal -> TerminalPage()
|
||||
OnboardingPage.Bridge -> BridgePage()
|
||||
OnboardingPage.Manage -> ManagePage()
|
||||
OnboardingPage.Power -> PowerToolsPage()
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onManageSignIn = onManageSignIn,
|
||||
onSkip = { showSkipConfirm = true },
|
||||
)
|
||||
}
|
||||
@@ -260,14 +257,15 @@ private fun WelcomePage() {
|
||||
val context = LocalContext.current
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
title = "Hermes-Relay",
|
||||
description = "Your Hermes agent, in your pocket.",
|
||||
title = "Hermes-Relay for Android",
|
||||
description = "Chat with Hermes and manage your dashboard from your phone.",
|
||||
transparentHero = true,
|
||||
heroContent = {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
MorphingSphere(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
state = SphereState.Idle,
|
||||
intensity = 0.12f,
|
||||
)
|
||||
@@ -287,27 +285,48 @@ private fun WelcomePage() {
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 18.dp)
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)),
|
||||
contentAlignment = Alignment.Center
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
|
||||
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "Hermes-Relay logo",
|
||||
modifier = Modifier.size(42.dp)
|
||||
contentDescription = "Hermes logo",
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Standard",
|
||||
description = "Connect to your running Hermes dashboard and API. No Relay install or pairing required.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Advanced",
|
||||
description = "Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.",
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Read the app guide, browse the repo, or jump to Hermes Agent docs while you finish server setup.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = "The setup guide has copy/paste commands when you need to start Hermes on a computer or server.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -318,7 +337,7 @@ private fun WelcomePage() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -329,71 +348,158 @@ private fun WelcomePage() {
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("User Guide")
|
||||
Text("Setup Guide")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_github),
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("GitHub")
|
||||
Text("Hermes Docs")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "hermes-agent.nousresearch.com",
|
||||
text = "Hermes API server docs",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com")))
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPathSummary(
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(78.dp),
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Forum,
|
||||
title = "Chat",
|
||||
description = "Talk to any Hermes agent profile with real-time streaming responses, tool progress, and full markdown."
|
||||
)
|
||||
description = "Your Hermes agent, streaming in real time.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Streaming",
|
||||
description = "Live responses with tool progress, markdown, and rich cards as the agent works.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Profiles",
|
||||
description = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Voice",
|
||||
description = "Tap the mic to talk. Speech runs through your Hermes server — no extra install.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalPage() {
|
||||
private fun ManagePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = "Manage",
|
||||
description = "Your Hermes dashboard, pocket-sized.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Control",
|
||||
description = "Profiles, skills, automations, MCP servers, models, and provider keys.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Skills hub",
|
||||
description = "Search the hub, read a skill before installing, and install from your phone.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "One sign-in",
|
||||
description = "Signing into the dashboard once also unlocks voice for this connection.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerToolsPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Terminal,
|
||||
title = "Terminal",
|
||||
description = "Secure remote shell access to your server via tmux. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.PhonelinkSetup,
|
||||
title = "Bridge",
|
||||
description = "Let your agent control your device — taps, typing, screenshots, and automation. Coming soon."
|
||||
)
|
||||
title = "Power tools",
|
||||
description = "Pair the optional Relay when you want more than standard.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Terminal",
|
||||
description = "A real tmux session on your server, in your pocket.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Bridge",
|
||||
description = "Let the agent operate this phone — with safety rails (sideload builds).",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Realtime",
|
||||
description = "Provider-native realtime voice agent and profile-aware voice providers.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -406,6 +512,7 @@ private fun ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onCancel = onSkip,
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,6 +251,18 @@ fun AppearanceSettingsScreen(
|
||||
enabled = animEnabled
|
||||
)
|
||||
}
|
||||
|
||||
// The ambient-mode entry is a gesture with no visible
|
||||
// control — this line is its discoverable documentation
|
||||
// (including for screen-reader users browsing settings).
|
||||
if (animEnabled) {
|
||||
Text(
|
||||
text = "Tip: long-press the chat background for a fullscreen " +
|
||||
"ambient sphere; tap anywhere to return.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -40,7 +43,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayNavTile
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelaySectionCaption
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
|
||||
/**
|
||||
@@ -55,20 +69,42 @@ import com.hermesandroid.relay.viewmodel.statusText
|
||||
fun BridgeCoreScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToVoiceSettings: () -> Unit,
|
||||
onNavigateToNotificationCompanion: () -> Unit,
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToRelaySessions: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
returnTitle: String? = null,
|
||||
returnSubtitle: String = "",
|
||||
returnLabel: String = "Back",
|
||||
onReturn: (() -> Unit)? = null,
|
||||
) {
|
||||
val relayState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayConnected = relayState == RelayUiState.Connected
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = "Terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f),
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -77,10 +113,52 @@ fun BridgeCoreScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
title = returnTitle,
|
||||
subtitle = returnSubtitle,
|
||||
label = returnLabel,
|
||||
onClick = onReturn,
|
||||
)
|
||||
}
|
||||
RelayHeroPanel(
|
||||
title = if (relayConnected) "Phone bridge is paired" else "Bridge Core is waiting",
|
||||
subtitle = if (relayConnected) {
|
||||
"Terminal, voice, notification, media, and relay-session controls share this grant."
|
||||
} else {
|
||||
"Pair Relay to use Terminal and phone bridge tools. Chat and Manage continue over standard Hermes API."
|
||||
},
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayStatusPill(
|
||||
text = relayState.statusText("connected").lowercase(),
|
||||
active = relayConnected,
|
||||
)
|
||||
RelayStatusPill(
|
||||
text = "safety on",
|
||||
active = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -161,59 +239,47 @@ fun BridgeCoreScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Bridge Features",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Connections",
|
||||
subtitle = "Pair, switch, and verify relay routes",
|
||||
onClick = onNavigateToConnections,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Attach to your Hermes relay terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
title = "Voice",
|
||||
subtitle = "Configure STT, TTS, provider, and voice mode",
|
||||
onClick = onNavigateToVoiceSettings,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notification companion",
|
||||
subtitle = "Forward notifications you grant Android access to share",
|
||||
onClick = onNavigateToNotificationCompanion,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
subtitle = "Manage inbound attachments and cache behavior",
|
||||
onClick = onNavigateToMediaSettings,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
subtitle = "Review active grants for this server",
|
||||
onClick = onNavigateToRelaySessions,
|
||||
)
|
||||
}
|
||||
}
|
||||
RelaySectionCaption(
|
||||
title = "Bridge Surface",
|
||||
meta = "not hidden in settings",
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Connections",
|
||||
subtitle = "Pair, switch, and verify relay routes",
|
||||
onClick = onNavigateToConnections,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Attach to your Hermes relay terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
selected = relayConnected,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
title = "Voice",
|
||||
subtitle = "Provider, model, output voice",
|
||||
onClick = onNavigateToVoiceSettings,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notifications",
|
||||
subtitle = "Shared app notifications",
|
||||
onClick = onNavigateToNotificationCompanion,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
subtitle = "Inbound attachments and cache behavior",
|
||||
onClick = onNavigateToMediaSettings,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
subtitle = "Review active grants for this server",
|
||||
onClick = onNavigateToRelaySessions,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,6 +25,8 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -62,9 +65,17 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.BridgeActivityLog
|
||||
import com.hermesandroid.relay.ui.components.BridgeMasterToggle
|
||||
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
// === v0.4.1 unattended-access ===
|
||||
import com.hermesandroid.relay.ui.components.UnattendedAccessRow
|
||||
// === END v0.4.1 unattended-access ===
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.BridgeViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -105,6 +116,13 @@ fun BridgeScreen(
|
||||
// === PHASE3-safety-rails: safety summary card ===
|
||||
onNavigateToBridgeSafety: () -> Unit = {},
|
||||
// === END PHASE3-safety-rails ===
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
returnTitle: String? = null,
|
||||
returnSubtitle: String = "",
|
||||
returnLabel: String = "Back",
|
||||
onReturn: (() -> Unit)? = null,
|
||||
) {
|
||||
val masterToggle by viewModel.masterToggle.collectAsState()
|
||||
val permissionStatus by viewModel.permissionStatus.collectAsState()
|
||||
@@ -197,8 +215,16 @@ fun BridgeScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -207,10 +233,45 @@ fun BridgeScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
title = returnTitle,
|
||||
subtitle = returnSubtitle,
|
||||
label = returnLabel,
|
||||
onClick = onReturn,
|
||||
)
|
||||
}
|
||||
RelayHeroPanel(
|
||||
title = if (relayReady) "Phone bridge is paired" else "Bridge controls are staged",
|
||||
subtitle = if (relayReady) {
|
||||
"Terminal, voice, notification, media, and advanced phone controls share this grant."
|
||||
} else {
|
||||
"Pair Relay to receive bridge commands. You can still configure permissions and safety before pairing."
|
||||
},
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayStatusPill("relay", relayReady)
|
||||
RelayStatusPill("safety", true)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Relay-not-connected banner. Bridge commands arrive over the
|
||||
// relay's WSS — when relay is Unpaired / Disconnected / URL
|
||||
// blank, the AccessibilityService + foreground service will
|
||||
@@ -251,7 +312,7 @@ fun BridgeScreen(
|
||||
)
|
||||
Text(
|
||||
text = "Bridge commands travel over the relay. " +
|
||||
"Pair a relay in Settings → Connection for " +
|
||||
"Pair a relay in Settings → Connections for " +
|
||||
"the bridge to actually do anything.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
|
||||
@@ -30,17 +30,23 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -128,6 +134,9 @@ import com.hermesandroid.relay.ui.components.CompactToolCall
|
||||
import com.hermesandroid.relay.ui.components.InlineAutocomplete
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||
import com.hermesandroid.relay.ui.components.SlashCommand
|
||||
@@ -136,6 +145,8 @@ import com.hermesandroid.relay.ui.components.ToolProgressCard
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -187,6 +198,12 @@ fun ChatScreen(
|
||||
// screen. Default no-op preserves existing test/preview call sites that
|
||||
// don't wire navigation.
|
||||
onNavigateToConnections: () -> Unit = {},
|
||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToBridge: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
||||
@@ -249,11 +266,13 @@ fun ChatScreen(
|
||||
var voiceOutputConfig by remember { mutableStateOf<VoiceOutputConfig?>(null) }
|
||||
var realtimeAgentConfig by remember { mutableStateOf<RealtimeVoiceConfig?>(null) }
|
||||
val chatReady by connectionViewModel.chatReady.collectAsState()
|
||||
// Voice mode's /voice/transcribe and /voice/synthesize calls both go
|
||||
// over the relay, but voice can authenticate with the saved Hermes API
|
||||
// key or a paired Relay session. Gate the Mic button on voiceReady
|
||||
// so chat+voice-only setups don't need the full pairing flow.
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
@@ -835,7 +854,8 @@ fun ChatScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.radialNavyBackground(isDarkTheme = isDarkTheme)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.14f)
|
||||
.imePadding()
|
||||
.alpha(chatAlpha)
|
||||
) {
|
||||
@@ -992,7 +1012,7 @@ fun ChatScreen(
|
||||
activeEndpoint?.let { ep ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
color = RelayRefresh.Navy3.copy(alpha = 0.78f),
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.clickable { onNavigateToConnections() },
|
||||
@@ -1000,7 +1020,7 @@ fun ChatScreen(
|
||||
Text(
|
||||
text = ep.displayLabel(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
color = RelayRefresh.Relay,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 4.dp,
|
||||
@@ -1008,25 +1028,45 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Ambient mode toggle (show/hide sphere visualization).
|
||||
// Profile + personality pickers moved into the agent sheet
|
||||
// that opens on title tap — the top bar no longer owns
|
||||
// those chips, reclaiming the horizontal space for a
|
||||
// cleaner title block.
|
||||
if (animationEnabled) {
|
||||
IconButton(onClick = { ambientMode = !ambientMode }) {
|
||||
Icon(
|
||||
imageVector = if (ambientMode) Icons.Filled.ChatBubble else Icons.Filled.AutoAwesome,
|
||||
contentDescription = if (ambientMode) "Show chat" else "Ambient mode",
|
||||
tint = if (ambientMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Share,
|
||||
contentDescription = "Share conversation",
|
||||
onClick = { shareConversation(context, messages) },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = "Terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
// Ambient mode (fullscreen sphere) has no top-bar toggle —
|
||||
// it's a quiet gesture: long-press the conversation
|
||||
// background to enter, tap anywhere to return. A hint pill
|
||||
// on entry teaches the way back.
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
)
|
||||
)
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Chat,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> Unit
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> onNavigateToBridge()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Error banner with retry
|
||||
AnimatedVisibility(visible = error != null) {
|
||||
@@ -1073,12 +1113,25 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient mode: fullscreen sphere visualization
|
||||
// Ambient mode: fullscreen sphere visualization. Tap (or
|
||||
// long-press) anywhere to return; a transient hint pill teaches
|
||||
// the exit on every entry.
|
||||
if (ambientMode && animationEnabled) {
|
||||
var showAmbientHint by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(2_800)
|
||||
showAmbientHint = false
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { ambientMode = false },
|
||||
onLongPress = { ambientMode = false },
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MorphingSphere(
|
||||
@@ -1087,6 +1140,24 @@ fun ChatScreen(
|
||||
intensity = streamingIntensity,
|
||||
toolCallBurst = toolCallBurst
|
||||
)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = showAmbientHint,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 18.dp),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "tap to return to chat",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f))
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Message list or empty state
|
||||
@@ -1129,42 +1200,61 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Start a conversation",
|
||||
text = if (chatReady) "Start a conversation" else "Connect to Hermes",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (!chatReady) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Configure API server in Settings",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Suggestion chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
AssistChip(
|
||||
onClick = { inputText = suggestion },
|
||||
label = {
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Chat needs a Standard Hermes API connection.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
)
|
||||
Button(
|
||||
onClick = onNavigateToConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Connect Standard Hermes")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Suggestion chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
AssistChip(
|
||||
onClick = { inputText = suggestion },
|
||||
label = {
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1176,6 +1266,17 @@ fun ChatScreen(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
// Quiet entry to ambient mode: long-press the
|
||||
// conversation background. Bubbles keep their own
|
||||
// long-press (copy) — they consume the gesture first,
|
||||
// so only presses on empty space land here.
|
||||
.pointerInput(animationEnabled) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
if (animationEnabled) ambientMode = true
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Ambient sphere behind messages
|
||||
if (animationEnabled && animationBehindChat && !ambientMode) {
|
||||
@@ -1247,6 +1348,18 @@ fun ChatScreen(
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
.trim()
|
||||
.lines()
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
inputText = if (inputText.isBlank()) {
|
||||
"$quoted\n\n"
|
||||
} else {
|
||||
"$inputText\n$quoted\n\n"
|
||||
}
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
@@ -1350,6 +1463,7 @@ fun ChatScreen(
|
||||
inputText = if (cmd.command.contains(" ")) cmd.command + " " else "$base "
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1556,10 +1670,6 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Gate on voiceReady — voice mode needs a relay
|
||||
// route, but the app can derive it from the API URL
|
||||
// and authenticate with the saved Hermes API key or
|
||||
// a paired Relay session.
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (voiceReady) {
|
||||
@@ -1567,7 +1677,16 @@ fun ChatScreen(
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
"Voice needs API key or pairing, plus a reachable relay route",
|
||||
when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
"Voice needs a reachable Hermes dashboard or Relay voice route"
|
||||
},
|
||||
android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
@@ -1734,6 +1853,7 @@ fun ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
onDismiss = { showAgentInfo = false },
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
onNavigateToProfileInspector = onNavigateToProfileInspector,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1789,3 +1909,37 @@ private fun DateSeparator(timestamp: Long) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the visible conversation as Markdown via the system share sheet.
|
||||
* Role names are matched as strings so this helper stays decoupled from the
|
||||
* MessageRole enum's package.
|
||||
*/
|
||||
private fun shareConversation(
|
||||
context: android.content.Context,
|
||||
messages: List<com.hermesandroid.relay.data.ChatMessage>,
|
||||
) {
|
||||
val body = buildString {
|
||||
appendLine("# Hermes conversation")
|
||||
appendLine()
|
||||
messages.forEach { message ->
|
||||
if (message.content.isBlank()) return@forEach
|
||||
val speaker = when {
|
||||
message.role.name.equals("user", ignoreCase = true) -> "**You:**"
|
||||
message.role.name.equals("assistant", ignoreCase = true) -> "**Hermes:**"
|
||||
else -> "**System:**"
|
||||
}
|
||||
appendLine(speaker)
|
||||
appendLine(message.content.trim())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, body)
|
||||
putExtra(android.content.Intent.EXTRA_SUBJECT, "Hermes conversation")
|
||||
}
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(intent, "Share conversation"),
|
||||
)
|
||||
}
|
||||
|
||||
+370
-31
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -53,12 +55,16 @@ import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardSecurityPosture
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardStatusSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardRelayStatusSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardStandardStatusSection
|
||||
import com.hermesandroid.relay.ui.components.ApiServerInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.EndpointsCard
|
||||
import com.hermesandroid.relay.ui.components.RouteEditorDialog
|
||||
import com.hermesandroid.relay.ui.components.InsecureConnectionAckDialog
|
||||
import com.hermesandroid.relay.ui.components.RelayInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.SessionInfoSheet
|
||||
import com.hermesandroid.relay.network.RelayUrlDeriver
|
||||
import com.hermesandroid.relay.network.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
@@ -108,6 +114,7 @@ fun ConnectionsSettingsScreen(
|
||||
onRemoveConnection: (id: String) -> Unit,
|
||||
onAddConnection: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
// Opens `PairedDevicesScreen` for the server-side session list. Wired
|
||||
// via the "Relay sessions" row inside the active card's security
|
||||
// posture strip. Must not be null — the row is always rendered.
|
||||
@@ -126,6 +133,12 @@ fun ConnectionsSettingsScreen(
|
||||
// (HTTP-only) is unaffected.
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val activeRelayConfigured: Boolean = if (connectionViewModel != null) {
|
||||
val configured by connectionViewModel.relayConfigured.collectAsState()
|
||||
configured
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
// Kick a WSS reconnect on screen entry in case the user landed here
|
||||
// from a Stale chip. Moved here from the deleted singular
|
||||
@@ -190,7 +203,7 @@ fun ConnectionsSettingsScreen(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Tap Add connection to pair with a Hermes server.",
|
||||
text = "Tap Add connection to connect to Standard Hermes.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -219,6 +232,11 @@ fun ConnectionsSettingsScreen(
|
||||
// + action row) and don't collect any VM flows.
|
||||
activeConnectionViewModel = if (isActive) connectionViewModel else null,
|
||||
relayEnabled = relayEnabled,
|
||||
relayConfigured = if (isActive) {
|
||||
activeRelayConfigured
|
||||
} else {
|
||||
connection.hasConfiguredRelay()
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
onReconnect = onReconnectActive,
|
||||
onRename = { newLabel -> onRenameConnection(connection.id, newLabel) },
|
||||
@@ -226,6 +244,7 @@ fun ConnectionsSettingsScreen(
|
||||
onRevoke = { onRevokeConnection(connection.id) },
|
||||
onRemove = { onRemoveConnection(connection.id) },
|
||||
onOpenApiInfo = { showApiInfoSheet = true },
|
||||
onOpenDashboard = onNavigateToManage,
|
||||
onOpenRelayInfo = { showRelayInfoSheet = true },
|
||||
onOpenSessionInfo = { showSessionInfoSheet = true },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
@@ -297,6 +316,7 @@ private fun ConnectionCard(
|
||||
liveState: RelayUiState?,
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayEnabled: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
@@ -304,6 +324,7 @@ private fun ConnectionCard(
|
||||
onRevoke: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -315,8 +336,11 @@ private fun ConnectionCard(
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
var endpointsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Active card: muted indigo wash instead of the full-strength Electric
|
||||
// primaryContainer — a card-sized fill of the brand blue overwhelmed the
|
||||
// body text (2026-06-10 feedback); small accents keep the vivid blue.
|
||||
val containerColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
@@ -344,6 +368,20 @@ private fun ConnectionCard(
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val routeProbeStatus: ConnectionViewModel.RouteProbeStatus =
|
||||
if (activeConnectionViewModel != null) {
|
||||
val status by activeConnectionViewModel.routeProbeStatus.collectAsState()
|
||||
status
|
||||
} else {
|
||||
ConnectionViewModel.RouteProbeStatus.Idle
|
||||
}
|
||||
val routeProbeOutcomes: Map<String, RouteProbeOutcome> =
|
||||
if (activeConnectionViewModel != null) {
|
||||
val outcomes by activeConnectionViewModel.routeProbeOutcomes.collectAsState()
|
||||
outcomes
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -378,9 +416,13 @@ private fun ConnectionCard(
|
||||
|
||||
// ── Subtitle: hostname + status + endpoints roles ──────────
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hasStandardApi = connection.apiServerUrl.isNotBlank()
|
||||
val pairedStatus = when {
|
||||
liveState != null -> liveState.statusText(connectedLabel = "Connected")
|
||||
liveState != null &&
|
||||
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
|
||||
liveState.statusText(connectedLabel = "Connected")
|
||||
connection.pairedAt != null -> formatPairedRelative(connection.pairedAt)
|
||||
hasStandardApi -> "Standard · Relay not paired"
|
||||
else -> "Not paired"
|
||||
}
|
||||
// ADR 24 — active-only endpoint role summary.
|
||||
@@ -406,8 +448,16 @@ private fun ConnectionCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
ConnectionSurfaceSummary(
|
||||
connection = connection,
|
||||
isActive = isActive,
|
||||
liveState = liveState,
|
||||
activeConnectionViewModel = activeConnectionViewModel,
|
||||
relayConfigured = relayConfigured,
|
||||
)
|
||||
|
||||
// ── Single-endpoint nudge (active only) ──────────────────────
|
||||
if (isActive && endpoints.size == 1) {
|
||||
if (isActive && connection.pairedAt != null && endpoints.size == 1) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -449,8 +499,12 @@ private fun ConnectionCard(
|
||||
TextButton(onClick = onReconnect) { Text("Reconnect") }
|
||||
}
|
||||
TextButton(onClick = { showRenameDialog = true }) { Text("Rename") }
|
||||
TextButton(onClick = onRepair) { Text("Re-pair") }
|
||||
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
|
||||
TextButton(onClick = onRepair) {
|
||||
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
|
||||
}
|
||||
if (connection.pairedAt != null) {
|
||||
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
|
||||
}
|
||||
TextButton(onClick = { showRemoveConfirm = true }) {
|
||||
Text(text = "Remove", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
@@ -463,21 +517,29 @@ private fun ConnectionCard(
|
||||
if (isActive && activeConnectionViewModel != null) {
|
||||
HorizontalDivider()
|
||||
|
||||
// ── Connection health section ────────────────────────────
|
||||
SectionHeader(text = "Connection health")
|
||||
SectionCaption(text = "Tap any row for details.")
|
||||
// ── Standard section ─────────────────────────────────────
|
||||
SectionHeader(text = "Standard")
|
||||
SectionCaption(text = "API and dashboard setup for Chat and Manage.")
|
||||
|
||||
// Status section (3 tappable rows → info sheets). Always
|
||||
// visible on the active card — the "health dashboard"
|
||||
// replacing the old Settings-top quick-look card.
|
||||
ActiveCardStatusSection(
|
||||
ActiveCardStandardStatusSection(
|
||||
connectionViewModel = activeConnectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
onOpenApiInfo = onOpenApiInfo,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
HorizontalDivider()
|
||||
SectionHeader(text = "Relay")
|
||||
SectionCaption(
|
||||
text = "Optional power tools: Terminal, Bridge, relay sessions, and grants.",
|
||||
)
|
||||
ActiveCardRelayStatusSection(
|
||||
connectionViewModel = activeConnectionViewModel,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Routes section (conditional on having endpoints) ─────
|
||||
// ADR 24 behavior preserved verbatim from pre-refactor;
|
||||
// user-facing copy now reads "Routes" instead of
|
||||
@@ -493,6 +555,10 @@ private fun ConnectionCard(
|
||||
var preferredRole by remember(connection.id) {
|
||||
mutableStateOf(activeConnectionViewModel.getPreferredEndpointRole())
|
||||
}
|
||||
var routeEditorOpen by remember(connection.id) { mutableStateOf(false) }
|
||||
var routeEditorOriginal by remember(connection.id) {
|
||||
mutableStateOf<EndpointCandidate?>(null)
|
||||
}
|
||||
val hasTailscaleRoute = endpoints.any {
|
||||
it.role.equals("tailscale", ignoreCase = true)
|
||||
}
|
||||
@@ -507,16 +573,50 @@ private fun ConnectionCard(
|
||||
val tailscaleLaunchIntent = remember(context) {
|
||||
context.packageManager.getLaunchIntentForPackage("com.tailscale.ipn")
|
||||
}
|
||||
val activeRouteLabel = activeEndpoint?.displayLabel() ?: "Resolving"
|
||||
val activeRouteHost = activeEndpoint?.let {
|
||||
"${it.api.host}:${it.api.port}"
|
||||
} ?: connection.relayUrl
|
||||
val isRouteProbing =
|
||||
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Probing
|
||||
// Last user-triggered probe finished with NO winner: say
|
||||
// so explicitly. The old UI sat on "Resolving" forever
|
||||
// and showed the (internal) relay URL underneath, which
|
||||
// read as "stuck on the internal route".
|
||||
val probeCameUpEmpty = activeEndpoint == null &&
|
||||
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
|
||||
routeProbeStatus.winner == null
|
||||
val activeRouteLabel = when {
|
||||
activeEndpoint != null -> activeEndpoint.displayLabel()
|
||||
isRouteProbing -> "Checking routes…"
|
||||
probeCameUpEmpty -> "No route reachable"
|
||||
else -> "Resolving"
|
||||
}
|
||||
// Full URL (scheme included) — http vs https is the
|
||||
// difference between a working route and a TLS-failing
|
||||
// one, so never hide it. With no resolved route, show the
|
||||
// saved API URL the app is actually falling back to.
|
||||
val activeRouteHost = activeEndpoint?.api?.url
|
||||
?: "Using saved URL: ${
|
||||
connection.apiServerUrl.ifBlank { connection.relayUrl }
|
||||
}"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = "Current: $activeRouteLabel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Current: $activeRouteLabel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (probeCameUpEmpty) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
if (isRouteProbing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = activeRouteHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -524,6 +624,15 @@ private fun ConnectionCard(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (probeCameUpEmpty) {
|
||||
Text(
|
||||
text = "None of the saved routes answered a health " +
|
||||
"probe. Expand the routes below for per-route " +
|
||||
"reasons.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showTailscaleUnavailableHint) {
|
||||
Surface(
|
||||
@@ -566,23 +675,69 @@ private fun ConnectionCard(
|
||||
}
|
||||
TextButton(
|
||||
onClick = { activeConnectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 0.dp,
|
||||
),
|
||||
) {
|
||||
Text("Re-check")
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isTailscaleDetected && !hasTailscaleRoute) {
|
||||
// Inverse of the hint above: the phone is on Tailscale
|
||||
// but this connection has nothing to roam to. This is
|
||||
// the strongest signal a user wants remote access and
|
||||
// simply never configured it — offer the route editor
|
||||
// directly instead of hoping they find Show routes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Phone is on Tailscale — no Tailscale route yet",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Add your server's Tailscale URL so Hermes " +
|
||||
"keeps working when this phone leaves the " +
|
||||
"server's network.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
routeEditorOriginal = null
|
||||
routeEditorOpen = true
|
||||
},
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 0.dp,
|
||||
),
|
||||
) {
|
||||
Text("Add Tailscale route")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { activeConnectionViewModel.probeNow() }) {
|
||||
Text("Re-check")
|
||||
TextButton(
|
||||
onClick = { activeConnectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
) {
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
}
|
||||
if (preferredRole != null) {
|
||||
TextButton(
|
||||
@@ -621,6 +776,10 @@ private fun ConnectionCard(
|
||||
EndpointsCard(
|
||||
endpoints = endpoints,
|
||||
activeEndpoint = activeEndpoint,
|
||||
isProbing = isRouteProbing,
|
||||
outcomeFor = { candidate ->
|
||||
routeProbeOutcomes[activeConnectionViewModel.routeOutcomeKey(candidate)]
|
||||
},
|
||||
preferredRole = preferredRole,
|
||||
onPreferEndpoint = { candidate ->
|
||||
activeConnectionViewModel.setPreferredEndpointRole(candidate.role)
|
||||
@@ -634,6 +793,34 @@ private fun ConnectionCard(
|
||||
onViewPin = { candidate ->
|
||||
activeConnectionViewModel.lookupEndpointPin(candidate)
|
||||
},
|
||||
onAddRoute = {
|
||||
routeEditorOriginal = null
|
||||
routeEditorOpen = true
|
||||
},
|
||||
onEditRoute = { candidate ->
|
||||
routeEditorOriginal = candidate
|
||||
routeEditorOpen = true
|
||||
},
|
||||
onRemoveRoute = { candidate ->
|
||||
activeConnectionViewModel.removeExtraRoute(candidate)
|
||||
},
|
||||
)
|
||||
}
|
||||
// Rendered outside the routes expander so the "Add
|
||||
// Tailscale route" nudge above can open it while the
|
||||
// routes list is collapsed.
|
||||
if (routeEditorOpen) {
|
||||
RouteEditorDialog(
|
||||
original = routeEditorOriginal,
|
||||
onSave = { role, apiUrl, onResult ->
|
||||
activeConnectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
apiUrl = apiUrl,
|
||||
original = routeEditorOriginal,
|
||||
onResult = onResult,
|
||||
)
|
||||
},
|
||||
onDismiss = { routeEditorOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -643,11 +830,11 @@ private fun ConnectionCard(
|
||||
// ── Advanced section ─────────────────────────────────────
|
||||
// Header + caption above the collapsed Advanced card so
|
||||
// users understand this branch is a power-user surface,
|
||||
// not something they're expected to touch after QR pairing.
|
||||
// not something they're expected to touch after Standard setup.
|
||||
SectionHeader(text = "Advanced")
|
||||
SectionCaption(
|
||||
text = "Manual setup — most people don't need this " +
|
||||
"after QR pairing.",
|
||||
"after Standard Hermes setup.",
|
||||
)
|
||||
|
||||
// Advanced expander: manual URL config + insecure toggle
|
||||
@@ -739,6 +926,158 @@ private fun ConnectionCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionSurfaceSummary(
|
||||
connection: Connection,
|
||||
isActive: Boolean,
|
||||
liveState: RelayUiState?,
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayConfigured: Boolean,
|
||||
) {
|
||||
val activeApiReachable: Boolean? = if (activeConnectionViewModel != null) {
|
||||
val reachable by activeConnectionViewModel.apiServerReachable.collectAsState()
|
||||
reachable
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val activeApiHealth: ConnectionViewModel.HealthStatus? = if (activeConnectionViewModel != null) {
|
||||
val health by activeConnectionViewModel.apiServerHealth.collectAsState()
|
||||
health
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val activeConnection: Connection? = if (activeConnectionViewModel != null) {
|
||||
val current by activeConnectionViewModel.activeConnection.collectAsState()
|
||||
current
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val dashboardStatus = (activeConnection ?: connection).dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
val apiText = when {
|
||||
connection.apiServerUrl.isBlank() -> "Missing"
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
|
||||
activeApiReachable == true -> "Ready"
|
||||
isActive && activeApiReachable == false -> "Offline"
|
||||
else -> "Configured"
|
||||
}
|
||||
val apiTone = when (apiText) {
|
||||
"Ready" -> SummaryTone.Good
|
||||
"Offline", "Missing" -> SummaryTone.Warning
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
val dashboardText = when {
|
||||
connection.resolvedDashboardUrl.isBlank() -> "Missing"
|
||||
dashboardStatus == null -> "Unchecked"
|
||||
!dashboardStatus.reachable -> "Offline"
|
||||
dashboardSignInRequired -> "Sign in"
|
||||
dashboardStatus.authenticated == true -> "Signed in"
|
||||
else -> "Available"
|
||||
}
|
||||
val dashboardTone = when (dashboardText) {
|
||||
"Signed in", "Available" -> SummaryTone.Good
|
||||
"Sign in" -> SummaryTone.Info
|
||||
"Offline", "Missing" -> SummaryTone.Warning
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
val relayText = when {
|
||||
!relayConfigured -> "Optional"
|
||||
liveState != null -> liveState.statusText(connectedLabel = "Ready")
|
||||
connection.pairedAt != null -> "Paired"
|
||||
connection.relayUrl.isNotBlank() -> "Configured"
|
||||
else -> "Configure"
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayConfigured -> SummaryTone.Neutral
|
||||
liveState == RelayUiState.Connected -> SummaryTone.Good
|
||||
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected -> SummaryTone.Warning
|
||||
else -> SummaryTone.Info
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConnectionSurfacePill(
|
||||
label = "API",
|
||||
value = apiText,
|
||||
tone = apiTone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ConnectionSurfacePill(
|
||||
label = "Dashboard",
|
||||
value = dashboardText,
|
||||
tone = dashboardTone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ConnectionSurfacePill(
|
||||
label = "Relay",
|
||||
value = relayText,
|
||||
tone = relayTone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SummaryTone { Neutral, Good, Info, Warning }
|
||||
|
||||
@Composable
|
||||
private fun ConnectionSurfacePill(
|
||||
label: String,
|
||||
value: String,
|
||||
tone: SummaryTone,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = when (tone) {
|
||||
SummaryTone.Good -> MaterialTheme.colorScheme.primaryContainer
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.errorContainer
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val content = when (tone) {
|
||||
SummaryTone.Good -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = container,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = content,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = content,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.hasConfiguredRelay(): Boolean {
|
||||
val trimmedRelayUrl = relayUrl.trim()
|
||||
return pairedAt != null ||
|
||||
trimmedRelayUrl.isNotBlank() &&
|
||||
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameConnectionDialog(
|
||||
initialLabel: String,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,8 @@ fun DeveloperSettingsScreen(
|
||||
// Data management local state — unfolded from the private
|
||||
// DataManagementSection helper in the old SettingsScreen.
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var backupJson by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// SAF file picker for export
|
||||
@@ -196,17 +198,12 @@ fun DeveloperSettingsScreen(
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Save settings to a file (no tokens or API keys)",
|
||||
text = "Full backup with API keys, tokens, and dashboard cookies",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-backup.json")
|
||||
}
|
||||
}) {
|
||||
IconButton(onClick = { showExportDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FileDownload,
|
||||
contentDescription = "Export settings"
|
||||
@@ -226,14 +223,12 @@ fun DeveloperSettingsScreen(
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Restore settings from a backup file",
|
||||
text = "Restore full backup and replace saved connections",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}) {
|
||||
IconButton(onClick = { showImportDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FileUpload,
|
||||
contentDescription = "Import settings"
|
||||
@@ -400,12 +395,73 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExportDialog = false },
|
||||
title = { Text("Export sensitive backup?") },
|
||||
text = {
|
||||
Text(
|
||||
"This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Export")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExportDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = { Text("Import backup?") },
|
||||
text = {
|
||||
Text(
|
||||
"Importing a backup can restore API keys, relay tokens, device IDs, and dashboard cookies. It replaces the saved connection list on this device."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showImportDialog = false
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}
|
||||
) {
|
||||
Text("Choose file")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation dialog for data reset
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset All Data?") },
|
||||
text = { Text("This will clear all settings, API keys, authentication tokens, and cached data. You'll need to reconfigure your API server and re-pair with your relay. This cannot be undone.") },
|
||||
title = { Text("Reset all app data?") },
|
||||
text = {
|
||||
Text(
|
||||
"This clears saved connections, API keys, Relay tokens, dashboard cookies, device IDs, settings, and cached data. Use dashboard sign out or Relay pairing controls when you only need to clear one connection path. This cannot be undone."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
||||
@@ -122,10 +122,17 @@ fun MediaSettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Controls how the app handles files sent by tool results (screenshots, PDFs, etc.) over the relay.",
|
||||
text = "Controls how the app handles files sent by tool results " +
|
||||
"(screenshots, PDFs, etc.) over the relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Relay only — these settings don't affect images you attach " +
|
||||
"in chat or anything on a standard (no-Relay) connection.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Max inbound attachment size — 5..100 MB in 5 MB steps
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
|
||||
@@ -21,19 +21,17 @@ import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
/**
|
||||
* Full-screen pairing route. Wraps [ConnectionWizard] in a real Scaffold so
|
||||
* Full-screen connection route. Wraps [ConnectionWizard] in a real Scaffold so
|
||||
* the chooser tiles, manual-entry forms, and camera viewport all get the
|
||||
* actual window — not a Compose Dialog that leaked the Settings cards
|
||||
* underneath. Reached via Settings → Connection → Pair (or any "Re-pair"
|
||||
* underneath. Reached via Settings → Connections → Add/Pair Relay (or any "Re-pair"
|
||||
* button), and pops back to wherever it came from on complete or cancel.
|
||||
*
|
||||
* [autoStart] lets the caller deep-link into a specific pair method. When
|
||||
* set to `"scan"`, the wizard jumps straight to camera-permission-request
|
||||
* → scanner on first composition. Null (default) shows the full Method
|
||||
* chooser so users can pick Scan / Enter code / Show code. The "Add
|
||||
* connection" FAB sets this to `"scan"` because there's exactly one
|
||||
* obvious next step after "I want a new connection"; re-pair flows
|
||||
* intentionally leave it null.
|
||||
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
|
||||
* method.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -41,6 +39,7 @@ fun PairScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onManageSignIn: (() -> Unit)? = null,
|
||||
autoStart: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -56,7 +55,7 @@ fun PairScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Pair with your server") },
|
||||
title = { Text("Connect to Hermes") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
@@ -77,10 +76,11 @@ fun PairScreen(
|
||||
ConnectionWizard(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {
|
||||
Toast.makeText(context, "Paired successfully", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Connection updated", Toast.LENGTH_SHORT).show()
|
||||
onComplete()
|
||||
},
|
||||
onCancel = onCancel,
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = false,
|
||||
autoStart = autoStart,
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.Security
|
||||
// === END PHASE3-safety-rails ===
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -109,7 +110,10 @@ fun SettingsScreen(
|
||||
// + manual URL + insecure toggle + manual pairing code surface via
|
||||
// expandable sections, so there's nothing left to link to twice.
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToChatSettings: () -> Unit,
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToBridge: () -> Unit,
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToAppearanceSettings: () -> Unit,
|
||||
onNavigateToAnalytics: () -> Unit,
|
||||
@@ -255,6 +259,14 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Hermes management",
|
||||
subtitle = "Skills, cron, MCP, profiles, models, config",
|
||||
onClick = onNavigateToManage,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
title = "Chat",
|
||||
@@ -281,18 +293,6 @@ fun SettingsScreen(
|
||||
)
|
||||
// === END PHASE3-notif-listener-followup ===
|
||||
|
||||
if (BuildFlavor.isSideload) {
|
||||
// === PHASE3-safety-rails: bridge safety entry-point ===
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Bridge safety",
|
||||
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
|
||||
onClick = onNavigateToBridgeSafety,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
// === END PHASE3-safety-rails ===
|
||||
}
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
@@ -309,6 +309,24 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Power tools")
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Server shell access through a paired relay session",
|
||||
onClick = onNavigateToTerminal,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
title = "Bridge",
|
||||
subtitle = "Relay-granted phone bridge controls",
|
||||
onClick = onNavigateToBridge,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
@@ -317,6 +335,18 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
if (BuildFlavor.isSideload) {
|
||||
// === PHASE3-safety-rails: bridge safety entry-point ===
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Bridge safety",
|
||||
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
|
||||
onClick = onNavigateToBridgeSafety,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
// === END PHASE3-safety-rails ===
|
||||
}
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Analytics,
|
||||
title = "Analytics",
|
||||
@@ -366,6 +396,7 @@ fun SettingsScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
onDismiss = { showAgentSheet = false },
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
onNavigateToProfileInspector = onNavigateToProfileInspector,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -503,6 +534,18 @@ private fun ActiveAgentCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(label: String) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the root Settings category list. Matches the visual style of
|
||||
* the existing Voice navigation row that was previously inline in the
|
||||
|
||||
@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
@@ -75,6 +76,7 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.VoiceSettingsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -97,6 +99,15 @@ fun VoiceSettingsScreen(
|
||||
voiceViewModel: VoiceViewModel,
|
||||
voiceClient: RelayVoiceClient?,
|
||||
selectedProfile: Profile? = null,
|
||||
standardVoiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
|
||||
/**
|
||||
* Non-null endpoint display label (e.g. "Tailscale") when the sign-in
|
||||
* gate is up because the resolver moved the dashboard to a route the
|
||||
* user hasn't signed in on yet — dashboard cookies are per-host.
|
||||
*/
|
||||
standardVoiceSignInRouteHint: String? = null,
|
||||
relayVoiceReady: Boolean = false,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
) {
|
||||
@@ -106,6 +117,7 @@ fun VoiceSettingsScreen(
|
||||
val prefsRepo = remember { VoicePreferencesRepository(context) }
|
||||
val voiceSettings by prefsRepo.settings.collectAsState(initial = VoiceSettings())
|
||||
val currentEngine = VoiceEngineMode.fromStorage(voiceSettings.engineMode)
|
||||
val currentAudioRoute = VoiceAudioRoute.fromStorage(voiceSettings.audioRoute)
|
||||
|
||||
val bargeInPrefs by settingsViewModel.bargeInPrefs.collectAsState()
|
||||
val aecAvailable = settingsViewModel.aecAvailable
|
||||
@@ -150,8 +162,21 @@ fun VoiceSettingsScreen(
|
||||
// shown as snackbars here as well as the inline "unavailable" label below.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
|
||||
LaunchedEffect(voiceClient, selectedProfile?.name) {
|
||||
LaunchedEffect(voiceClient, selectedProfile?.name, relayVoiceReady) {
|
||||
val client = voiceClient ?: return@LaunchedEffect
|
||||
if (!relayVoiceReady) {
|
||||
// Standard-only connection: there is no Relay voice surface to
|
||||
// query. Fetching anyway would only manufacture error snackbars
|
||||
// for a route the user isn't using — stay quiet and let the
|
||||
// relay-backed sections render their "not configured" line.
|
||||
voiceConfig = null
|
||||
voiceConfigError = null
|
||||
voiceOutputConfig = null
|
||||
voiceOutputConfigError = null
|
||||
realtimeConfig = null
|
||||
realtimeConfigError = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val voiceResult = client.getVoiceConfig()
|
||||
if (voiceResult.isSuccess) {
|
||||
voiceConfig = voiceResult.getOrNull()
|
||||
@@ -365,12 +390,13 @@ fun VoiceSettingsScreen(
|
||||
listOf(
|
||||
VoiceEngineMode.HermesVoiceOutput to Triple(
|
||||
"Hermes Chat + Voice Output",
|
||||
"Hermes handles chat, tools, memory, and renders speech through the relay.",
|
||||
"Hermes handles chat, tools, and memory; speech runs over the standard " +
|
||||
"Hermes dashboard or Relay — whichever the STT/TTS route below picks.",
|
||||
false,
|
||||
),
|
||||
VoiceEngineMode.RealtimeAgent to Triple(
|
||||
"Realtime Agent",
|
||||
"Provider-native realtime speech with Hermes-brokered tools.",
|
||||
"Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.",
|
||||
true,
|
||||
),
|
||||
).forEach { (engine, copy) ->
|
||||
@@ -408,6 +434,150 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent && !relayVoiceReady) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "No Relay is configured for this connection, so the Realtime " +
|
||||
"Agent can't start. Pair Relay in Settings → Connections, or " +
|
||||
"switch back to Hermes Chat + Voice Output.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
SectionCard(title = "Stable STT/TTS Route") {
|
||||
val standardStatus = when (standardVoiceAvailability) {
|
||||
StandardVoiceAvailability.Ready -> "Ready"
|
||||
StandardVoiceAvailability.SignInRequired -> "Dashboard sign-in required"
|
||||
StandardVoiceAvailability.Unreachable -> "Dashboard unreachable"
|
||||
StandardVoiceAvailability.Unsupported -> "Not available on this Hermes build"
|
||||
StandardVoiceAvailability.Unknown -> "Checking..."
|
||||
}
|
||||
val standardOk = standardVoiceAvailability == StandardVoiceAvailability.Ready
|
||||
val relayStatus = if (relayVoiceReady) "Ready" else "Relay not configured"
|
||||
val autoStatus = when {
|
||||
relayVoiceReady -> "Ready — using Relay"
|
||||
standardOk -> "Ready — using standard Hermes"
|
||||
else -> "No route available yet"
|
||||
}
|
||||
listOf(
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Auto,
|
||||
label = "Auto",
|
||||
detail = "Relay when paired; otherwise the standard Hermes dashboard. Recommended.",
|
||||
status = autoStatus,
|
||||
statusOk = relayVoiceReady || standardOk,
|
||||
),
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Standard,
|
||||
label = "Standard Hermes",
|
||||
detail = "The dashboard audio path Hermes Desktop uses — works on a " +
|
||||
"vanilla Hermes install, no Relay plugin required.",
|
||||
status = standardStatus,
|
||||
statusOk = standardOk,
|
||||
),
|
||||
RouteOption(
|
||||
route = VoiceAudioRoute.Relay,
|
||||
label = "Relay",
|
||||
detail = "Relay plugin voice — profile-aware providers and streaming voice output.",
|
||||
status = relayStatus,
|
||||
statusOk = relayVoiceReady,
|
||||
badge = "Optional",
|
||||
),
|
||||
).forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentAudioRoute == option.route,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setAudioRoute(option.route) }
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = currentAudioRoute == option.route,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(option.label, style = MaterialTheme.typography.bodyMedium)
|
||||
option.badge?.let { ExperimentalBadge(it) }
|
||||
}
|
||||
Text(
|
||||
text = option.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = option.status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (option.statusOk) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (standardVoiceAvailability) {
|
||||
StandardVoiceAvailability.SignInRequired -> {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = if (standardVoiceSignInRouteHint != null) {
|
||||
"You're connected over the $standardVoiceSignInRouteHint " +
|
||||
"route, and dashboard sign-ins are per-host — a sign-in " +
|
||||
"from your home network doesn't carry over. Sign in once " +
|
||||
"in Manage while on this route to unlock voice here too."
|
||||
} else {
|
||||
"Your Hermes dashboard requires sign-in before standard " +
|
||||
"voice can transcribe or speak. Signing in once in Manage " +
|
||||
"unlocks it for this connection."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onOpenManage != null) {
|
||||
TextButton(onClick = onOpenManage) {
|
||||
Text("Sign in via Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
StandardVoiceAvailability.Unsupported -> {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "This Hermes server build doesn't expose the dashboard " +
|
||||
"audio routes yet. Update hermes-agent on the server, or " +
|
||||
"pair Relay to use Relay voice.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Global Voice Controls ---
|
||||
@@ -503,7 +673,20 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
|
||||
// --- Global Fallback Text-to-Speech ---
|
||||
SectionCard(title = "Global Fallback Text-to-Speech") {
|
||||
if (!relayVoiceReady) {
|
||||
SectionCard(title = "Voice Providers") {
|
||||
Text(
|
||||
text = "This connection speaks through your Hermes server's " +
|
||||
"configured TTS and STT (config.yaml on the server, or the " +
|
||||
"dashboard's Audio settings). Pair Relay to pick providers, " +
|
||||
"models, and voices from the phone.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayVoiceReady) SectionCard(title = "Global Fallback Text-to-Speech") {
|
||||
Text(
|
||||
text = "Always available as the stable speech safety net when provider-native voice is unavailable.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -543,8 +726,10 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput && relayVoiceReady) {
|
||||
// --- Hermes Chat + Voice Output ---
|
||||
// Relay-backed provider editing; standard-only connections get
|
||||
// the quiet "Voice Providers" card above instead.
|
||||
SectionCard(title = "Hermes Chat + Voice Output") {
|
||||
ProviderRow(
|
||||
label = "Status",
|
||||
@@ -912,8 +1097,11 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent) {
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent && relayVoiceReady) {
|
||||
// --- Realtime Agent ---
|
||||
// Relay-only engine; without Relay the engine picker above
|
||||
// already shows the requirement, so skip the config card
|
||||
// rather than rendering permanent "loading..." rows.
|
||||
SectionCard(title = "Realtime Agent", badge = "Experimental") {
|
||||
Text(
|
||||
text = "Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.",
|
||||
@@ -941,6 +1129,26 @@ fun VoiceSettingsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Persistent session", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Keep one provider conversation open across turns. Turn off to fall back to a fresh session per utterance.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = voiceSettings.realtimePersistentSession,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { prefsRepo.setRealtimePersistentSession(enabled) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ProviderRow(
|
||||
label = "Status",
|
||||
@@ -975,6 +1183,109 @@ fun VoiceSettingsScreen(
|
||||
ProviderRow(label = "Auth", value = realtimeAuthLabel(config))
|
||||
}
|
||||
|
||||
realtimeConfig?.promotion?.let { promo ->
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text("Background tasks", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "Long Hermes tasks keep running in the background so the conversation stays responsive; the answer is spoken when it's ready.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Promote long tasks", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Detach a slow run after ${promo.promoteAfterMs} ms instead of waiting silently",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = promo.enabled,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch {
|
||||
val client = voiceClient ?: return@launch
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
promotionEnabled = enabled,
|
||||
)
|
||||
if (result.isSuccess) realtimeConfig = result.getOrNull()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (promo.enabled) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Spoken handoff", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Say a short \"I'm on it\" when a task moves to the background",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = promo.spokenHandoff,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch {
|
||||
val client = voiceClient ?: return@launch
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
spokenHandoff = enabled,
|
||||
)
|
||||
if (result.isSuccess) realtimeConfig = result.getOrNull()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"When the answer is ready",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val deliveryOptions = listOf(
|
||||
"speak_when_idle",
|
||||
"notify_then_speak",
|
||||
"visual_only",
|
||||
)
|
||||
val deliveryLabels = listOf("Speak", "Notify", "Show only")
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
deliveryOptions.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = deliveryOptions.size,
|
||||
),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val client = voiceClient ?: return@launch
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
resultDelivery = option,
|
||||
)
|
||||
if (result.isSuccess) realtimeConfig = result.getOrNull()
|
||||
}
|
||||
},
|
||||
selected = option == promo.resultDelivery,
|
||||
) {
|
||||
Text(
|
||||
deliveryLabels[index],
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Row(
|
||||
@@ -1313,25 +1624,34 @@ fun VoiceSettingsScreen(
|
||||
|
||||
// --- Speech-to-Text ---
|
||||
SectionCard(title = "Speech-to-Text") {
|
||||
ProviderRow(
|
||||
label = "Provider",
|
||||
value = voiceConfig?.stt?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
voiceConfig?.stt?.let { stt ->
|
||||
ProviderRow(label = "Enabled", value = if (stt.isEnabled) "yes" else "no")
|
||||
}
|
||||
voiceConfig?.stt?.model?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
voiceConfig?.let { config ->
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceProfileLabel(config.profile, selectedProfile),
|
||||
if (!relayVoiceReady) {
|
||||
Text(
|
||||
text = "Transcription runs on your Hermes server with its " +
|
||||
"configured STT provider.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ProviderRow(
|
||||
label = "Scope",
|
||||
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
|
||||
label = "Provider",
|
||||
value = voiceConfig?.stt?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
voiceConfig?.stt?.let { stt ->
|
||||
ProviderRow(label = "Enabled", value = if (stt.isEnabled) "yes" else "no")
|
||||
}
|
||||
voiceConfig?.stt?.model?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
voiceConfig?.let { config ->
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceProfileLabel(config.profile, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Scope",
|
||||
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
@@ -1387,23 +1707,28 @@ fun VoiceSettingsScreen(
|
||||
},
|
||||
)
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceOutputConfig?.let { config ->
|
||||
voiceProfileLabel(config.profile, selectedProfile)
|
||||
} ?: voiceProfileLabel(null, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Voice",
|
||||
value = listOfNotNull(
|
||||
voiceOutputConfig?.default_provider,
|
||||
voiceOutputConfig?.default_model,
|
||||
voiceOutputConfig?.default_voice,
|
||||
).joinToString(" / ").ifBlank { "loading..." },
|
||||
)
|
||||
if (relayVoiceReady) {
|
||||
ProviderRow(
|
||||
label = "Profile",
|
||||
value = voiceOutputConfig?.let { config ->
|
||||
voiceProfileLabel(config.profile, selectedProfile)
|
||||
} ?: voiceProfileLabel(null, selectedProfile),
|
||||
)
|
||||
ProviderRow(
|
||||
label = "Voice",
|
||||
value = listOfNotNull(
|
||||
voiceOutputConfig?.default_provider,
|
||||
voiceOutputConfig?.default_model,
|
||||
voiceOutputConfig?.default_voice,
|
||||
).joinToString(" / ").ifBlank { "loading..." },
|
||||
)
|
||||
} else {
|
||||
ProviderRow(label = "Route", value = "standard Hermes")
|
||||
ProviderRow(label = "Voice", value = "server-configured TTS")
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
text = "Play the saved Voice Output renderer for the active profile.",
|
||||
text = "Play a short sample through the active voice route.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1542,6 +1867,15 @@ private fun voiceScopeLabel(scope: String?, fallbackToGlobal: Boolean): String {
|
||||
return if (fallbackToGlobal) "$base fallback" else base
|
||||
}
|
||||
|
||||
private data class RouteOption(
|
||||
val route: VoiceAudioRoute,
|
||||
val label: String,
|
||||
val detail: String,
|
||||
val status: String,
|
||||
val statusOk: Boolean,
|
||||
val badge: String? = null,
|
||||
)
|
||||
|
||||
private data class VoiceChoice(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object RelayRefresh {
|
||||
val Ink = Color(0xFFF7F6F0)
|
||||
val Paper = Color(0xFFF7F3EA)
|
||||
val Muted = Color(0xFFA7A4B7)
|
||||
val Dim = Color(0xFF68647D)
|
||||
val Background = Color(0xFF08090D)
|
||||
val Navy = Color(0xFF121426)
|
||||
val Navy2 = Color(0xFF191B31)
|
||||
val Navy3 = Color(0xFF22243C)
|
||||
val Relay = Color(0xFFAEBFFF)
|
||||
val Purple = Color(0xFF8C5CFF)
|
||||
val Electric = Color(0xFF111DFF)
|
||||
|
||||
/**
|
||||
* Softened Electric for large filled surfaces (e.g. the active
|
||||
* connection card). Full-strength Electric stays for small accents and
|
||||
* the alpha-blended selected panels, where the saturation reads as brand
|
||||
* rather than glare — 2026-06-10/11 feedback: the blue was right
|
||||
* everywhere except as a full-card fill against body text.
|
||||
*/
|
||||
val ElectricMuted = Color(0xFF4F5BD5)
|
||||
val Cyan = Color(0xFF6BDCFF)
|
||||
val Green = Color(0xFF58D36F)
|
||||
val Amber = Color(0xFFF2B14B)
|
||||
val Danger = Color(0xFFFF6B78)
|
||||
val Line = Color(0x24F7F6F0)
|
||||
val LineStrong = Color(0x47F7F6F0)
|
||||
val CardRadius = 8.dp
|
||||
val Mono = FontFamily.Monospace
|
||||
}
|
||||
|
||||
fun Modifier.relayPanel(
|
||||
shape: Shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
background: Color = RelayRefresh.Navy2.copy(alpha = 0.78f),
|
||||
borderColor: Color = RelayRefresh.Line,
|
||||
): Modifier = this
|
||||
.background(background, shape)
|
||||
.border(1.dp, borderColor, shape)
|
||||
|
||||
fun Modifier.relaySelectedPanel(
|
||||
shape: Shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
): Modifier = this
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
RelayRefresh.Electric.copy(alpha = 0.52f),
|
||||
RelayRefresh.Purple.copy(alpha = 0.18f),
|
||||
),
|
||||
),
|
||||
shape,
|
||||
)
|
||||
.border(1.dp, RelayRefresh.Electric.copy(alpha = 0.72f), shape)
|
||||
|
||||
fun Modifier.relayGridTexture(
|
||||
grid: Dp = 42.dp,
|
||||
dot: Dp = 10.dp,
|
||||
alpha: Float = 0.18f,
|
||||
): Modifier = drawBehind {
|
||||
val gridPx = grid.toPx().coerceAtLeast(1f)
|
||||
val dotPx = dot.toPx().coerceAtLeast(1f)
|
||||
var x = 0f
|
||||
while (x <= size.width) {
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = 0.018f * alpha * 5f),
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, size.height),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
x += gridPx
|
||||
}
|
||||
var y = 0f
|
||||
while (y <= size.height) {
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = 0.026f * alpha * 5f),
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
y += gridPx
|
||||
}
|
||||
var dy = 0f
|
||||
while (dy <= size.height) {
|
||||
var dx = 0f
|
||||
while (dx <= size.width) {
|
||||
drawCircle(
|
||||
color = RelayRefresh.Relay.copy(alpha = 0.16f * alpha * 5f),
|
||||
radius = 1.15f,
|
||||
center = Offset(dx + 1f, dy + 1f),
|
||||
)
|
||||
dx += dotPx
|
||||
}
|
||||
dy += dotPx
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayTextureBox(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.18f),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayDottedOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
alpha: Float = 0.16f,
|
||||
) {
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val step = 10.dp.toPx()
|
||||
var y = 0f
|
||||
while (y <= size.height) {
|
||||
var x = 0f
|
||||
while (x <= size.width) {
|
||||
drawCircle(
|
||||
color = RelayRefresh.Relay.copy(alpha = alpha),
|
||||
radius = 1.1f,
|
||||
center = Offset(x + 1f, y + 1f),
|
||||
)
|
||||
x += step
|
||||
}
|
||||
y += step
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun relayMetadataStyle(): TextStyle =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = RelayRefresh.Mono,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.sp,
|
||||
)
|
||||
@@ -1,81 +1,77 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
|
||||
// Brand palette — derived from assets/logo.svg
|
||||
private val HermesPrimary = Color(0xFF6B35E8) // Logo primary purple
|
||||
private val HermesPrimaryLight = Color(0xFF9B6BF0) // Logo accent purple
|
||||
private val HermesPrimaryDark = Color(0xFF4A1DB8) // Deeper variant for containers
|
||||
private val HermesNavy = Color(0xFF1A1A2E) // Logo background navy
|
||||
private val HermesNavySurface = Color(0xFF1E1E34) // Slightly lifted surface
|
||||
private val HermesNavyVariant = Color(0xFF2A2A44) // Card/surface variant
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = HermesPrimaryLight,
|
||||
onPrimary = Color(0xFF1A0049),
|
||||
primaryContainer = HermesPrimary,
|
||||
onPrimaryContainer = Color(0xFFE8DEFF),
|
||||
secondary = Color(0xFFB8AACC),
|
||||
onSecondary = Color(0xFF2B2040),
|
||||
secondaryContainer = Color(0xFF413558),
|
||||
onSecondaryContainer = Color(0xFFE8DEFF),
|
||||
tertiary = Color(0xFF9B6BF0),
|
||||
onTertiary = Color(0xFF1A0049),
|
||||
tertiaryContainer = Color(0xFF3D1F8C),
|
||||
onTertiaryContainer = Color(0xFFE8DEFF),
|
||||
background = HermesNavy,
|
||||
onBackground = Color(0xFFE4E1E9),
|
||||
surface = HermesNavy,
|
||||
onSurface = Color(0xFFE4E1E9),
|
||||
surfaceVariant = HermesNavyVariant,
|
||||
onSurfaceVariant = Color(0xFFC9C3D4),
|
||||
surfaceContainerLowest = Color(0xFF151524),
|
||||
surfaceContainerLow = Color(0xFF1C1C30),
|
||||
surfaceContainer = HermesNavySurface,
|
||||
surfaceContainerHigh = Color(0xFF24243C),
|
||||
surfaceContainerHighest = Color(0xFF2E2E48),
|
||||
outline = Color(0xFF5A5470),
|
||||
outlineVariant = Color(0xFF3D3854)
|
||||
primary = RelayRefresh.Relay,
|
||||
onPrimary = RelayRefresh.Background,
|
||||
primaryContainer = RelayRefresh.Electric,
|
||||
onPrimaryContainer = RelayRefresh.Paper,
|
||||
secondary = RelayRefresh.Purple,
|
||||
onSecondary = RelayRefresh.Paper,
|
||||
secondaryContainer = RelayRefresh.Navy3,
|
||||
onSecondaryContainer = RelayRefresh.Paper,
|
||||
tertiary = RelayRefresh.Cyan,
|
||||
onTertiary = RelayRefresh.Background,
|
||||
tertiaryContainer = RelayRefresh.Purple.copy(alpha = 0.42f),
|
||||
onTertiaryContainer = RelayRefresh.Paper,
|
||||
background = RelayRefresh.Background,
|
||||
onBackground = RelayRefresh.Ink,
|
||||
surface = RelayRefresh.Background,
|
||||
onSurface = RelayRefresh.Ink,
|
||||
surfaceVariant = RelayRefresh.Navy2,
|
||||
onSurfaceVariant = RelayRefresh.Muted,
|
||||
surfaceContainerLowest = Color(0xFF05060A),
|
||||
surfaceContainerLow = Color(0xFF0B0C12),
|
||||
surfaceContainer = RelayRefresh.Navy,
|
||||
surfaceContainerHigh = RelayRefresh.Navy2,
|
||||
surfaceContainerHighest = RelayRefresh.Navy3,
|
||||
error = RelayRefresh.Danger,
|
||||
onError = RelayRefresh.Background,
|
||||
errorContainer = RelayRefresh.Danger.copy(alpha = 0.18f),
|
||||
onErrorContainer = RelayRefresh.Paper,
|
||||
outline = RelayRefresh.LineStrong,
|
||||
outlineVariant = RelayRefresh.Line,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = HermesPrimary,
|
||||
primary = RelayRefresh.Electric,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE8DEFF),
|
||||
onPrimaryContainer = Color(0xFF1A0049),
|
||||
secondary = Color(0xFF5E5474),
|
||||
primaryContainer = RelayRefresh.Relay,
|
||||
onPrimaryContainer = RelayRefresh.Background,
|
||||
secondary = RelayRefresh.Purple,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE8DEFF),
|
||||
onSecondaryContainer = Color(0xFF1B1030),
|
||||
tertiary = HermesPrimaryDark,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFE8DEFF),
|
||||
onTertiaryContainer = Color(0xFF1A0049),
|
||||
background = Color(0xFFFCF8FF),
|
||||
onBackground = Color(0xFF1B1B22),
|
||||
surface = Color(0xFFFCF8FF),
|
||||
onSurface = Color(0xFF1B1B22),
|
||||
surfaceVariant = Color(0xFFEAE4F2),
|
||||
onSurfaceVariant = Color(0xFF48444E),
|
||||
secondaryContainer = Color(0xFFE5E7FF),
|
||||
onSecondaryContainer = RelayRefresh.Background,
|
||||
tertiary = RelayRefresh.Cyan,
|
||||
onTertiary = RelayRefresh.Background,
|
||||
tertiaryContainer = Color(0xFFDDF8FF),
|
||||
onTertiaryContainer = RelayRefresh.Background,
|
||||
background = RelayRefresh.Paper,
|
||||
onBackground = RelayRefresh.Background,
|
||||
surface = RelayRefresh.Paper,
|
||||
onSurface = RelayRefresh.Background,
|
||||
surfaceVariant = Color(0xFFE9E8F1),
|
||||
onSurfaceVariant = Color(0xFF38384A),
|
||||
surfaceContainerLowest = Color.White,
|
||||
surfaceContainerLow = Color(0xFFF7F2FC),
|
||||
surfaceContainer = Color(0xFFF1ECF6),
|
||||
surfaceContainerHigh = Color(0xFFEBE6F0),
|
||||
surfaceContainerHighest = Color(0xFFE5E0EA),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCBC4D0)
|
||||
surfaceContainerLow = Color(0xFFF4F2F8),
|
||||
surfaceContainer = Color(0xFFEDEBF4),
|
||||
surfaceContainerHigh = Color(0xFFE3E1EC),
|
||||
surfaceContainerHighest = Color(0xFFDAD7E6),
|
||||
error = RelayRefresh.Danger,
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFD9DE),
|
||||
onErrorContainer = Color(0xFF410006),
|
||||
outline = Color(0xFF777386),
|
||||
outlineVariant = Color(0xFFCAC7D8),
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -90,16 +86,7 @@ fun HermesRelayTheme(
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
// Dynamic colors available on Android 12+ (API 31)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (useDarkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
useDarkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val colorScheme = if (useDarkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
// Compose-wide font scaling. We multiply the user's chosen scale into the
|
||||
// current LocalDensity.fontScale (which already reflects the system font
|
||||
|
||||
@@ -8,50 +8,52 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
lineHeight = 34.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
letterSpacing = 0.sp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -139,21 +139,24 @@ fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
// happen to contain HTTP-ish substrings. Only fall through to the
|
||||
// message scan once we know it's a plain IOException.
|
||||
return when (t) {
|
||||
// Bodies say "server", not "relay" — these exceptions also surface
|
||||
// from the standard API/dashboard routes, where relay wording would
|
||||
// send users debugging the wrong box.
|
||||
is UnknownHostException -> HumanError(
|
||||
title = "Can't reach server",
|
||||
body = "Check your network and the relay URL in Settings",
|
||||
body = "Check your network and the server URL in Settings",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
is ConnectException -> HumanError(
|
||||
title = "Connection refused",
|
||||
body = "The relay isn't accepting connections — make sure it's running",
|
||||
body = "The server isn't accepting connections — make sure it's running",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
is SocketTimeoutException -> HumanError(
|
||||
title = "Network timeout",
|
||||
body = "The relay took too long to respond",
|
||||
body = "The server took too long to respond",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
@@ -172,14 +175,18 @@ fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
)
|
||||
is IllegalStateException -> HumanError(
|
||||
title = titlePrefix(context),
|
||||
body = "Not ready — check that the relay is paired and online",
|
||||
// Voice routing throws IllegalStateException with actionable copy
|
||||
// ("needs dashboard sign-in — open Manage"); preserve it instead
|
||||
// of rewriting every not-ready state into relay advice.
|
||||
body = t.message?.takeIf { it.isNotBlank() }
|
||||
?: "Not ready — check that the relay is paired and online",
|
||||
retryable = true,
|
||||
)
|
||||
is IOException -> {
|
||||
if ("timeout" in msg) {
|
||||
HumanError(
|
||||
title = "Network timeout",
|
||||
body = "The relay took too long to respond",
|
||||
body = "The server took too long to respond",
|
||||
retryable = true,
|
||||
actionLabel = "Retry",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,7 @@ enum class ConnectionStatusTone {
|
||||
data class ConnectionStatusSnapshot(
|
||||
val title: String,
|
||||
val route: String? = null,
|
||||
val actionLabel: String? = null,
|
||||
val active: Boolean = false,
|
||||
val success: Boolean = false,
|
||||
val tone: ConnectionStatusTone = ConnectionStatusTone.Info,
|
||||
@@ -122,6 +123,7 @@ fun ConnectionHandoffStatus.asConnectionStatusSnapshot(): ConnectionStatusSnapsh
|
||||
ConnectionStatusSnapshot(
|
||||
title = title,
|
||||
route = route,
|
||||
actionLabel = null,
|
||||
active = active,
|
||||
success = success,
|
||||
tone = when {
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.hermesandroid.relay.data.BargeInPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
@@ -25,9 +26,13 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.network.RealtimeAgentSessionControl
|
||||
import com.hermesandroid.relay.network.RealtimeTurnInput
|
||||
import com.hermesandroid.relay.network.RealtimeVoiceSummary
|
||||
import com.hermesandroid.relay.network.RealtimeVoiceEvent
|
||||
import com.hermesandroid.relay.network.VoiceHandoffEvent
|
||||
import com.hermesandroid.relay.network.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
@@ -63,6 +68,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
|
||||
/**
|
||||
* Where we are in the voice conversation cycle. Used to drive the UI
|
||||
@@ -131,6 +137,21 @@ data class VoiceUiState(
|
||||
* fresh turn (mic tap), whichever comes first.
|
||||
*/
|
||||
val permissionDeniedCallout: PermissionDeniedCallout? = null,
|
||||
/**
|
||||
* ADR 33: non-null while a Hermes run has been promoted to (or started as) a
|
||||
* background task. Drives a persistent "working on it" chip so the user
|
||||
* knows a long task is still running after the spoken handoff. Cleared when
|
||||
* the background run completes, is cancelled, or errors.
|
||||
*/
|
||||
val backgroundRun: BackgroundRunState? = null,
|
||||
)
|
||||
|
||||
/** ADR 33 background/promoted Hermes run surface for the voice overlay. */
|
||||
data class BackgroundRunState(
|
||||
val runId: String? = null,
|
||||
/** "promoted" (auto-detached long run) or "durable" (explicit mode=background). */
|
||||
val tier: String = "promoted",
|
||||
val message: String = "Working on it in the background…",
|
||||
)
|
||||
|
||||
data class VoiceHandoffStatus(
|
||||
@@ -238,9 +259,9 @@ data class VoiceStats(
|
||||
* Idle → Listening (record mic) → Transcribing (upload) → Thinking
|
||||
* → Speaking (sentence-buffered TTS) → Idle
|
||||
*
|
||||
* Requires [VoiceRecorder], [VoicePlayer], [RelayVoiceClient], and a
|
||||
* Requires [VoiceRecorder], [VoicePlayer], [VoiceAudioClient], and a
|
||||
* [ChatViewModel] for sending the transcribed text through the normal
|
||||
* chat pipeline. All four are wired via [initialize] after construction.
|
||||
* chat pipeline. Realtime Agent still uses [RelayVoiceClient].
|
||||
*
|
||||
* ### Sentence-boundary streaming TTS
|
||||
* The SSE stream emits text one token at a time, but TTS wants whole
|
||||
@@ -265,10 +286,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val TTS_CACHE_CAP = 6 // keep the last N mp3s on disk
|
||||
private const val MAX_BROKERED_TOOL_STATUS_PER_MESSAGE = 2
|
||||
private const val STABLE_VOICE_INTERFACE_CONTEXT =
|
||||
"Hermes Relay interface context for this turn:\n" +
|
||||
"Hermes Android voice interface context for this turn:\n" +
|
||||
"- Active voice engine: Hermes chat + voice output (hermes_voice_output).\n" +
|
||||
"- Active route: Android mic -> relay STT /voice/transcribe -> " +
|
||||
"normal Hermes chat stream -> relay voice output playback.\n" +
|
||||
"- Active route: Android mic -> selected Hermes STT route -> " +
|
||||
"normal Hermes chat stream -> selected Hermes TTS route playback.\n" +
|
||||
"- This is not Realtime Agent mode. If the user asks which " +
|
||||
"interface, path, or mode is active, answer from this context."
|
||||
|
||||
@@ -373,6 +394,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// --- Dependencies (injected via initialize) --------------------------
|
||||
|
||||
private var voiceClient: RelayVoiceClient? = null
|
||||
private var voiceAudioClient: VoiceAudioClient? = null
|
||||
private var chatViewModel: ChatViewModel? = null
|
||||
private var recorder: VoiceRecorder? = null
|
||||
private var player: VoicePlayer? = null
|
||||
@@ -386,8 +408,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
private var realtimePersistentSession: Boolean = true
|
||||
private var realtimeAgentControl: RealtimeAgentSessionControl? = null
|
||||
private var realtimeConfirmationControl: RealtimeAgentSessionControl? = null
|
||||
|
||||
// === Persistent realtime-agent session (one socket across turns) ===
|
||||
// The long-lived call to RelayVoiceClient.runRealtimeAgent(persistent) runs
|
||||
// in realtimeSessionJob; further utterances are fed on realtimeTurnChannel.
|
||||
// The per-turn event holders below are hoisted to fields so the single
|
||||
// session-lived event callback can serve every turn; submitRealtimeTurn /
|
||||
// the open path reset them at each turn boundary.
|
||||
private var realtimeSessionJob: Job? = null
|
||||
private var realtimeTurnChannel: kotlinx.coroutines.channels.Channel<RealtimeTurnInput>? = null
|
||||
private var rtUserText: String = ""
|
||||
private var rtAssistantMessageId: String = ""
|
||||
private var rtConversationContext: List<RealtimeConversationContextMessage> = emptyList()
|
||||
private val audioSeen = AtomicBoolean(false)
|
||||
private val audioBytes = AtomicInteger(0)
|
||||
private val bargeInStarted = AtomicBoolean(false)
|
||||
private val lastRealtimeAudioEventId = AtomicLong(0L)
|
||||
private var responseText = StringBuilder()
|
||||
private var inputTranscript = StringBuilder()
|
||||
private val spokenStatusKeys = mutableSetOf<String>()
|
||||
private var voiceRelayPreflight: (suspend () -> Result<Unit>)? = null
|
||||
|
||||
// === PHASE3-voice-intents: voice→bridge intent routing ===
|
||||
@@ -697,6 +739,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
fun initialize(
|
||||
voiceClient: RelayVoiceClient,
|
||||
voiceAudioClient: VoiceAudioClient? = null,
|
||||
chatViewModel: ChatViewModel,
|
||||
recorder: VoiceRecorder,
|
||||
player: VoicePlayer,
|
||||
@@ -734,6 +777,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
|
||||
) {
|
||||
this.voiceClient = voiceClient
|
||||
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
|
||||
this.chatViewModel = chatViewModel
|
||||
this.recorder = recorder
|
||||
this.player = player
|
||||
@@ -811,8 +855,18 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
"realtimeTraceDetails=${settings.realtimeTraceDetails}",
|
||||
)
|
||||
}
|
||||
// Switching engine away from Realtime Agent (or disabling the
|
||||
// persistent toggle) must drop any open persistent session.
|
||||
if (
|
||||
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
|
||||
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
|
||||
(realtimePersistentSession && !settings.realtimePersistentSession)
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
realtimePersistentSession = settings.realtimePersistentSession
|
||||
val mode = when (settings.interactionMode.lowercase()) {
|
||||
"hold" -> InteractionMode.HoldToTalk
|
||||
"continuous" -> InteractionMode.Continuous
|
||||
@@ -1068,6 +1122,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Chime BEFORE teardown — AudioTrack release would cut it off otherwise.
|
||||
try { sfxPlayer?.playExit() } catch (_: Exception) { /* ignore */ }
|
||||
cancelRealtimeAgentTurn("exit voice mode")
|
||||
closeRealtimeSession()
|
||||
// B4: tear down the barge-in listener + timers before we kill the
|
||||
// player so AEC doesn't try to track a released audio session.
|
||||
stopBargeInListener()
|
||||
@@ -1514,9 +1569,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
val app = getApplication<Application>()
|
||||
val client = voiceClient
|
||||
val audioClient = voiceAudioClient
|
||||
val relayClient = voiceClient
|
||||
val p = player
|
||||
if (client == null || p == null) {
|
||||
if (audioClient == null || p == null) {
|
||||
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
|
||||
Toast.makeText(app, "Voice test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
|
||||
setError("Voice pipeline not initialized")
|
||||
@@ -1524,7 +1580,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
val triggerToast = Toast.makeText(app, "Testing voice…", Toast.LENGTH_SHORT).also { it.show() }
|
||||
viewModelScope.launch {
|
||||
val profileAwareResult = testVoiceViaVoiceOutput(client, sample)
|
||||
val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) {
|
||||
testVoiceViaVoiceOutput(relayClient, sample)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = if (profileAwareResult != null) {
|
||||
if (profileAwareResult.isSuccess) {
|
||||
triggerToast.cancel()
|
||||
@@ -1533,9 +1593,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return@launch
|
||||
}
|
||||
Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}")
|
||||
client.synthesize(sample)
|
||||
audioClient.synthesize(sample)
|
||||
} else {
|
||||
client.synthesize(sample)
|
||||
audioClient.synthesize(sample)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
triggerToast.cancel()
|
||||
@@ -1705,9 +1765,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inputPcm: ByteArray,
|
||||
inputSampleRate: Int,
|
||||
) {
|
||||
val client = voiceClient
|
||||
val relayClient = voiceClient
|
||||
val audioClient = voiceAudioClient
|
||||
val chatVm = chatViewModel
|
||||
if (client == null || chatVm == null) {
|
||||
if (audioClient == null || chatVm == null) {
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
@@ -1724,6 +1785,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
|
||||
if (engineModeForTurn == VoiceEngineMode.RealtimeAgent) {
|
||||
val client = relayClient
|
||||
if (client == null) {
|
||||
setError("Realtime Agent needs a Relay voice route")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Voice input routed to Realtime Agent")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -1740,6 +1806,31 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = "",
|
||||
)
|
||||
}
|
||||
if (realtimePersistentSession) {
|
||||
// Persistent conversation: one socket across turns. Open lazily on
|
||||
// the first turn (the long-lived call runs in realtimeSessionJob),
|
||||
// then feed further utterances on the channel. Fallback to one-shot
|
||||
// if the toggle is off (Voice Settings).
|
||||
if (realtimeSessionJob?.isActive == true && realtimeTurnChannel != null) {
|
||||
submitRealtimeTurn(chatVm, inputPcm, inputSampleRate)
|
||||
} else {
|
||||
closeRealtimeSession()
|
||||
realtimeTurnChannel = kotlinx.coroutines.channels.Channel(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
realtimeSessionJob = viewModelScope.launch {
|
||||
runRealtimeAgentTurn(
|
||||
client = client,
|
||||
chatVm = chatVm,
|
||||
userText = "",
|
||||
inputPcm = inputPcm,
|
||||
inputSampleRate = inputSampleRate,
|
||||
persistentOpen = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
runRealtimeAgentTurn(
|
||||
client = client,
|
||||
chatVm = chatVm,
|
||||
@@ -1755,15 +1846,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Voice turn started",
|
||||
detail = "Hermes voice output",
|
||||
detail = "Hermes voice output (${audioClient.route.storageValue})",
|
||||
)
|
||||
if (!runVoiceRelayPreflight("Hermes voice output")) return
|
||||
|
||||
// Transcribe
|
||||
_uiState.update { it.copy(state = VoiceState.Transcribing, outputAudioActive = false) }
|
||||
val sttStartedAtMs = System.currentTimeMillis()
|
||||
val audioBytes = try { audioFile.length() } catch (_: Exception) { 0L }
|
||||
val transcribeResult = client.transcribe(audioFile)
|
||||
val transcribeResult = audioClient.transcribe(audioFile)
|
||||
val sttLatencyMs = System.currentTimeMillis() - sttStartedAtMs
|
||||
if (transcribeResult.isFailure) {
|
||||
val err = transcribeResult.exceptionOrNull()
|
||||
@@ -2004,6 +2094,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
userText: String,
|
||||
inputPcm: ByteArray,
|
||||
inputSampleRate: Int,
|
||||
persistentOpen: Boolean = false,
|
||||
) {
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
streamObserverJob?.cancel()
|
||||
@@ -2031,19 +2122,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
|
||||
val conversationContext = chatVm.realtimeAgentContextMessages()
|
||||
val assistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
// Per-turn event state is hoisted to fields so one session-lived callback
|
||||
// serves every turn in persistent mode; reset them for this turn.
|
||||
audioSeen.set(false)
|
||||
audioBytes.set(0)
|
||||
bargeInStarted.set(false)
|
||||
lastRealtimeAudioEventId.set(0L)
|
||||
responseText = StringBuilder()
|
||||
inputTranscript = StringBuilder()
|
||||
spokenStatusKeys.clear()
|
||||
rtUserText = userText
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
userText = userText,
|
||||
chatSessionId = chatVm.currentSessionId.value,
|
||||
)
|
||||
val conversationContext = rtConversationContext
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
val audioSeen = AtomicBoolean(false)
|
||||
val audioBytes = AtomicInteger(0)
|
||||
val bargeInStarted = AtomicBoolean(false)
|
||||
val lastRealtimeAudioEventId = AtomicLong(0L)
|
||||
val responseText = StringBuilder()
|
||||
val inputTranscript = StringBuilder()
|
||||
val spokenStatusKeys = mutableSetOf<String>()
|
||||
|
||||
fun emitStatus(
|
||||
key: String,
|
||||
@@ -2105,10 +2200,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
chatSessionId = chatVm.currentSessionId.value,
|
||||
conversationContext = conversationContext,
|
||||
onHandoff = ::recordVoiceHandoff,
|
||||
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
|
||||
onTurnComplete = { summary -> onRealtimeTurnComplete(summary) },
|
||||
) { event, control ->
|
||||
realtimeAgentControl = control
|
||||
chatVm.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantMessageId,
|
||||
assistantMessageId = rtAssistantMessageId,
|
||||
event = event,
|
||||
showDetailedTrace = realtimeTraceDetails,
|
||||
)
|
||||
@@ -2119,13 +2216,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
it.copy(
|
||||
state = VoiceState.Listening,
|
||||
outputAudioActive = false,
|
||||
transcribedText = inputTranscript.toString().ifBlank { userText },
|
||||
transcribedText = inputTranscript.toString().ifBlank { rtUserText },
|
||||
)
|
||||
}
|
||||
}
|
||||
"voice.input_transcript.final" -> {
|
||||
inputTranscript.clear()
|
||||
inputTranscript.append(event.text ?: userText)
|
||||
inputTranscript.append(event.text ?: rtUserText)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
@@ -2135,6 +2232,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
"voice.response.started", "hermes.run.started" -> {
|
||||
if (event.type == "hermes.run.started") {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime Hermes run started run=${event.runId ?: "?"} " +
|
||||
"session=${event.chatSessionId ?: "?"}",
|
||||
)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Thinking, outputAudioActive = false)
|
||||
}
|
||||
@@ -2172,6 +2276,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
"hermes.run.progress" -> {
|
||||
val line = realtimeHermesProgressLine(event.message)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime Hermes progress tier=${event.tier ?: "?"} " +
|
||||
"floor=${event.floor ?: "?"} status=${event.statusKey ?: "?"} " +
|
||||
"shouldSpeak=${event.shouldSpeak} run=${event.runId ?: "?"}",
|
||||
)
|
||||
if (event.shouldSpeak) {
|
||||
emitStatus(
|
||||
key = "progress-${event.runId ?: "run"}-${event.statusKey ?: line}",
|
||||
@@ -2188,6 +2298,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
}
|
||||
"hermes.run.promoted" -> {
|
||||
// The run detached to the background; the provider speaks the
|
||||
// handoff. Surface a persistent chip so the user knows a long
|
||||
// task is still in flight (ADR 33 Tier B/C).
|
||||
val tier = event.tier ?: "promoted"
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime run promoted to background tier=$tier " +
|
||||
"run=${event.runId ?: "?"} session=${event.chatSessionId ?: "?"}",
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
backgroundRun = BackgroundRunState(
|
||||
runId = event.runId,
|
||||
tier = tier,
|
||||
message = if (tier == "durable") {
|
||||
"Started a background task — I'll report back."
|
||||
} else {
|
||||
"This is taking a moment — working on it in the background."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
"hermes.run.background_completed" -> {
|
||||
// Background run finished; the spoken summary follows via the
|
||||
// provider's forced-summary turn. Clear the chip.
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime background run completed run=${event.runId ?: "?"} " +
|
||||
"ok=${event.success != false}",
|
||||
)
|
||||
_uiState.update { it.copy(backgroundRun = null) }
|
||||
}
|
||||
"hermes.confirmation.requested" -> {
|
||||
val confirmationId = event.confirmationId
|
||||
if (!confirmationId.isNullOrBlank()) {
|
||||
@@ -2269,6 +2413,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
"hermes.run.cancelled" -> {
|
||||
Log.i(TAG, "Realtime Hermes run cancelled run=${event.runId ?: "?"}")
|
||||
realtimeConfirmationControl = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -2276,6 +2421,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
outputAudioActive = false,
|
||||
responseText = "Cancelled.",
|
||||
hermesConfirmation = null,
|
||||
backgroundRun = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2294,19 +2440,22 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
"voice.error" -> {
|
||||
realtimeConfirmationControl = null
|
||||
val rawDetail = event.message ?: "Realtime agent failed"
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Realtime voice error",
|
||||
detail = event.message ?: "Realtime agent failed",
|
||||
detail = rawDetail,
|
||||
)
|
||||
_uiState.update { it.copy(hermesConfirmation = null) }
|
||||
// Route the raw relay message through the classifier so
|
||||
// provider-auth (and other) failures surface a clear,
|
||||
// actionable message + a Voice-settings snackbar action
|
||||
// instead of a raw "xAI Realtime auth ..." provider string.
|
||||
surfaceError(
|
||||
java.io.IOException(rawDetail),
|
||||
context = "voice_config",
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Error,
|
||||
error = event.message ?: "Realtime agent failed",
|
||||
hermesConfirmation = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2335,9 +2484,78 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
title = "Realtime voice turn failed",
|
||||
detail = err?.message ?: "Unknown error",
|
||||
)
|
||||
// A persistent session ending in error must drop so the next turn opens
|
||||
// a fresh one rather than submitting into a dead channel.
|
||||
closeRealtimeSession()
|
||||
surfaceError(err, context = "voice_config")
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn completion in a persistent realtime session (ADR follow-up). The
|
||||
* socket stays open; this just finalizes the spoken turn and re-arms
|
||||
* continuous listening if enabled.
|
||||
*/
|
||||
private fun onRealtimeTurnComplete(summary: RealtimeVoiceSummary) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime voice turn complete",
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime persistent turn complete provider=${summary.provider} " +
|
||||
"audioChunks=${summary.audioChunks}",
|
||||
)
|
||||
pendingInTtsQueue.set(0)
|
||||
maybeAutoResume()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a follow-up utterance onto the already-open persistent session
|
||||
* (turns 2+). Mirrors the per-turn reset the open path does for turn 1.
|
||||
*/
|
||||
private fun submitRealtimeTurn(chatVm: ChatViewModel, inputPcm: ByteArray, inputSampleRate: Int) {
|
||||
val channel = realtimeTurnChannel ?: return
|
||||
drainQueuedLocalTts()
|
||||
try { player?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
firstFrameWatchdogJob?.cancel(); firstFrameWatchdogJob = null
|
||||
clearSpokenChunksState()
|
||||
audioSeen.set(false)
|
||||
audioBytes.set(0)
|
||||
bargeInStarted.set(false)
|
||||
lastRealtimeAudioEventId.set(0L)
|
||||
responseText = StringBuilder()
|
||||
inputTranscript = StringBuilder()
|
||||
spokenStatusKeys.clear()
|
||||
rtUserText = ""
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
userText = "",
|
||||
chatSessionId = chatVm.currentSessionId.value,
|
||||
)
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
transcribedText = null,
|
||||
responseText = "",
|
||||
)
|
||||
}
|
||||
val sent = channel.trySend(RealtimeTurnInput(inputPcm, inputSampleRate)).isSuccess
|
||||
Log.i(TAG, "Realtime persistent turn submitted sent=$sent pcmBytes=${inputPcm.size}")
|
||||
}
|
||||
|
||||
/** Tear down the persistent realtime session (voice-mode exit / engine switch). */
|
||||
private fun closeRealtimeSession() {
|
||||
val hadSession = realtimeTurnChannel != null || realtimeSessionJob != null
|
||||
realtimeTurnChannel?.close()
|
||||
realtimeTurnChannel = null
|
||||
realtimeSessionJob?.cancel()
|
||||
realtimeSessionJob = null
|
||||
if (hadSession) Log.i(TAG, "Realtime persistent session closed")
|
||||
}
|
||||
|
||||
private fun realtimeToolStatusLine(toolName: String?): String {
|
||||
val normalized = toolName.orEmpty().lowercase()
|
||||
return when {
|
||||
@@ -2615,7 +2833,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private fun shouldPreferRealtimeVoice(): Boolean =
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null
|
||||
voiceClient != null &&
|
||||
voiceAudioClient?.route == VoiceAudioRoute.Relay
|
||||
|
||||
private fun drainSentences() {
|
||||
while (true) {
|
||||
@@ -2686,12 +2905,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// TTS consumer — two-coroutine pipeline: synth runs ahead of playback
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// Provider-neutral voice output is now the preferred path. It uses
|
||||
// /voice/output/* to stream renderer PCM through the relay and writes
|
||||
// those chunks directly to AudioTrack. The legacy synth/play workers stay
|
||||
// alive underneath as the fallback path when the relay does not expose the
|
||||
// output route, provider auth is missing, or a renderer fails before
|
||||
// audio starts.
|
||||
// Relay-selected voice output can stream renderer PCM through
|
||||
// /voice/output/* and write chunks directly to AudioTrack. Standard
|
||||
// upstream audio and Relay fallback both use the synth/play workers.
|
||||
|
||||
private fun startRealtimeTtsConsumer() {
|
||||
realtimeTtsConsumerJob?.cancel()
|
||||
@@ -3025,9 +3241,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
synthesize = { sentence ->
|
||||
val synthStartedAtMs = System.currentTimeMillis()
|
||||
try {
|
||||
val client = voiceClient
|
||||
val client = voiceAudioClient
|
||||
val result = if (client == null) {
|
||||
Result.failure(IllegalStateException("voiceClient not initialized"))
|
||||
Result.failure(IllegalStateException("voice audio client not initialized"))
|
||||
} else {
|
||||
client.synthesize(sentence)
|
||||
}
|
||||
@@ -3899,6 +4115,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
closeRealtimeSession()
|
||||
// B4: release the listener + VAD engine native resources before
|
||||
// anything else. stopBargeInListener is defensive / idempotent.
|
||||
stopBargeInListener()
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
@@ -72,6 +73,7 @@ class VoicePlayerTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var exoPlayer: ExoPlayer
|
||||
private var listener: Player.Listener? = null
|
||||
private var analyticsListener: AnalyticsListener? = null
|
||||
|
||||
// Mirrors the real ExoPlayer's counter so the test's view and the
|
||||
// VoicePlayer's view agree without having to drive every listener
|
||||
@@ -99,6 +101,14 @@ class VoicePlayerTest {
|
||||
listener = listenerSlot.captured
|
||||
}
|
||||
|
||||
// Capture the AnalyticsListener too — VoicePlayer registers one to
|
||||
// mirror the audio session id onto the main thread (the barge-in
|
||||
// wrong-thread crash fix).
|
||||
val analyticsSlot = slot<AnalyticsListener>()
|
||||
every { exoPlayer.addAnalyticsListener(capture(analyticsSlot)) } answers {
|
||||
analyticsListener = analyticsSlot.captured
|
||||
}
|
||||
|
||||
// Queue + state inspection read from the fake counters so the
|
||||
// VoicePlayer sees consistent values whether it reads them in
|
||||
// play(), stop(), or from the listener callback.
|
||||
@@ -139,6 +149,7 @@ class VoicePlayerTest {
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
listener = null
|
||||
analyticsListener = null
|
||||
fakeMediaItemCount = 0
|
||||
fakeIsPlaying = false
|
||||
fakePlaybackState = Player.STATE_IDLE
|
||||
@@ -227,4 +238,33 @@ class VoicePlayerTest {
|
||||
verify { exoPlayer.volume = 1.0f }
|
||||
assertEquals(1.0f, fakeVolume, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audioSessionId is served from the cached id, not the thread-confined getter`() {
|
||||
// Regression: ExoPlayer is thread-confined, so reading
|
||||
// exoPlayer.audioSessionId off the main thread (BargeInListener's
|
||||
// Dispatchers.IO reader) throws "Player is accessed on the wrong
|
||||
// thread" and crashed voice mode mid-playback. The getter must read
|
||||
// a cached value instead — populated from main-thread callbacks.
|
||||
val voicePlayer = VoicePlayer(context) { exoPlayer }
|
||||
|
||||
// Media3 reports a freshly-allocated session id on the main thread.
|
||||
analyticsListener?.onAudioSessionIdChanged(mockk(relaxed = true), 42)
|
||||
|
||||
assertEquals(42, voicePlayer.audioSessionId)
|
||||
|
||||
// Reading the property again must not touch the raw ExoPlayer getter
|
||||
// (that's the off-main call that throws). The only legitimate read of
|
||||
// exoPlayer.audioSessionId happens inside onIsPlayingChanged on the
|
||||
// main thread, which this test never triggers.
|
||||
voicePlayer.audioSessionId
|
||||
voicePlayer.audioSessionId
|
||||
verify(exactly = 0) { exoPlayer.audioSessionId }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audioSessionId defaults to zero before any session is allocated`() {
|
||||
val voicePlayer = VoicePlayer(context) { exoPlayer }
|
||||
assertEquals(0, voicePlayer.audioSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionDashboardFieldsTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultDashboardUrl_usesSameHostAndDashboardPort() {
|
||||
assertEquals(
|
||||
"http://example.local:9119",
|
||||
Connection.deriveDefaultDashboardUrl("http://example.local:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultDashboardUrl_preservesHttpsScheme() {
|
||||
assertEquals(
|
||||
"https://hermes.example.com:9119",
|
||||
Connection.deriveDefaultDashboardUrl("https://hermes.example.com:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultDashboardUrl_wrapsIpv6Host() {
|
||||
assertEquals(
|
||||
"http://[::1]:9119",
|
||||
Connection.deriveDefaultDashboardUrl("http://[::1]:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultDashboardUrl_rejectsUnsupportedScheme() {
|
||||
assertNull(Connection.deriveDefaultDashboardUrl("ws://localhost:8767"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvedDashboardUrl_usesExplicitOverride() {
|
||||
val connection = sampleConnection(
|
||||
dashboardUrl = "https://dashboard.example.com",
|
||||
)
|
||||
|
||||
assertEquals("https://dashboard.example.com", connection.resolvedDashboardUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvedDashboardUrl_derivesWhenMissing() {
|
||||
val connection = sampleConnection(dashboardUrl = null)
|
||||
|
||||
assertEquals("http://localhost:9119", connection.resolvedDashboardUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacySerializedConnection_decodesWithDashboardDefaults() {
|
||||
val legacyJson = """
|
||||
{
|
||||
"id": "conn-1",
|
||||
"label": "local",
|
||||
"apiServerUrl": "http://localhost:8642",
|
||||
"relayUrl": "ws://localhost:8767",
|
||||
"tokenStoreKey": "hermes_auth_conn"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val connection = json.decodeFromString<Connection>(legacyJson)
|
||||
|
||||
assertNull(connection.dashboardUrl)
|
||||
assertEquals("http://localhost:9119", connection.resolvedDashboardUrl)
|
||||
assertTrue(Connection.isAutoManagedDashboardUrl(connection.dashboardUrl, connection.apiServerUrl))
|
||||
assertEquals(0, connection.routeCandidates.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildRouteCandidates_createsLanAndTailscaleRoutes() {
|
||||
val routes = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://192.168.1.25:8642",
|
||||
relayUrl = "ws://192.168.1.25:8767",
|
||||
extraApiUrls = listOf("tailscale" to "https://hermes.tail1234.ts.net:8642"),
|
||||
)
|
||||
|
||||
assertEquals(2, routes.size)
|
||||
assertEquals("lan", routes[0].role)
|
||||
assertEquals("192.168.1.25", routes[0].api.host)
|
||||
assertEquals("ws://192.168.1.25:8767", routes[0].relay.url)
|
||||
assertEquals("tailscale", routes[1].role)
|
||||
assertEquals("hermes.tail1234.ts.net", routes[1].api.host)
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inferRouteRole_detectsTailscaleCgnat() {
|
||||
assertEquals("tailscale", Connection.inferRouteRole("https://100.75.1.2:8642"))
|
||||
assertEquals("lan", Connection.inferRouteRole("http://10.0.0.5:8642"))
|
||||
assertEquals("public", Connection.inferRouteRole("https://hermes.example.com:8642"))
|
||||
}
|
||||
|
||||
private fun sampleConnection(
|
||||
dashboardUrl: String? = null,
|
||||
): Connection = Connection(
|
||||
id = "conn-1",
|
||||
label = "local",
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
relayUrl = "ws://localhost:8767",
|
||||
tokenStoreKey = "hermes_auth_conn",
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [Connection.mergeRouteCandidates] — URL edits must not wipe the stored
|
||||
* fallback routes. Before this helper, `updateApiServerUrl` /
|
||||
* `updateRelayUrl` / `saveApiAndProbeVoice` rebuilt `routeCandidates` from
|
||||
* just the edited URL, silently dropping the setup wizard's Tailscale route
|
||||
* (or a pairing payload's extra endpoints) — after which LAN↔Tailscale
|
||||
* roaming had nothing to roam to.
|
||||
*/
|
||||
class ConnectionRouteCandidateMergeTest {
|
||||
|
||||
private fun candidate(
|
||||
role: String,
|
||||
priority: Int,
|
||||
host: String,
|
||||
port: Int = 8642,
|
||||
relayUrl: String = "ws://$host:8767",
|
||||
): EndpointCandidate = EndpointCandidate(
|
||||
role = role,
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = false),
|
||||
relay = RelayEndpoint(url = relayUrl),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `preserves stored tailscale extra when primary is rebuilt`() {
|
||||
val rebuilt = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://192.168.1.50:8642",
|
||||
relayUrl = "ws://192.168.1.50:8767",
|
||||
)
|
||||
val existing = listOf(
|
||||
candidate(role = "lan", priority = 0, host = "192.168.1.50"),
|
||||
candidate(role = "tailscale", priority = 1, host = "100.64.0.7"),
|
||||
)
|
||||
|
||||
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
assertEquals("lan", merged[0].role)
|
||||
assertEquals("tailscale", merged[1].role)
|
||||
assertEquals("100.64.0.7", merged[1].api.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored extras keep their relay url verbatim`() {
|
||||
val rebuilt = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://192.168.1.50:8642",
|
||||
relayUrl = "ws://192.168.1.50:8767",
|
||||
)
|
||||
// Pairing payloads can carry relay URLs that differ from the
|
||||
// derive-from-api default (e.g. a reverse-proxied wss path).
|
||||
val payloadRelay = "wss://hermes.tail1234.ts.net:8767/relay"
|
||||
val existing = listOf(
|
||||
candidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
host = "hermes.tail1234.ts.net",
|
||||
relayUrl = payloadRelay,
|
||||
),
|
||||
)
|
||||
|
||||
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
|
||||
|
||||
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rebuilt entry wins a host-port collision with a stored extra`() {
|
||||
val rebuilt = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://192.168.1.50:8642",
|
||||
relayUrl = "ws://192.168.1.50:8767",
|
||||
extraApiUrls = listOf("tailscale" to "http://100.64.0.7:8642"),
|
||||
)
|
||||
val existing = listOf(
|
||||
// Same host:port as the rebuilt extra but stale role/relay —
|
||||
// must NOT survive alongside it.
|
||||
candidate(
|
||||
role = "vpn-old",
|
||||
priority = 2,
|
||||
host = "100.64.0.7",
|
||||
relayUrl = "ws://stale.example:8767",
|
||||
),
|
||||
)
|
||||
|
||||
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
val tailscale = merged.first { it.api.host == "100.64.0.7" }
|
||||
assertEquals("tailscale", tailscale.role)
|
||||
assertEquals("ws://100.64.0.7:8767", tailscale.relay.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored primary is never preserved - rebuilt primary replaces it`() {
|
||||
val rebuilt = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://10.0.0.99:8642",
|
||||
relayUrl = "ws://10.0.0.99:8767",
|
||||
)
|
||||
val existing = listOf(
|
||||
candidate(role = "lan", priority = 0, host = "192.168.1.50"),
|
||||
candidate(role = "tailscale", priority = 1, host = "100.64.0.7"),
|
||||
)
|
||||
|
||||
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
assertTrue(
|
||||
"old priority-0 host must be replaced by the edited URL",
|
||||
merged.none { it.api.host == "192.168.1.50" },
|
||||
)
|
||||
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api.host)
|
||||
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge with no stored extras returns rebuilt list unchanged`() {
|
||||
val rebuilt = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://192.168.1.50:8642",
|
||||
relayUrl = "ws://192.168.1.50:8767",
|
||||
)
|
||||
|
||||
assertEquals(rebuilt, Connection.mergeRouteCandidates(rebuilt, emptyList()))
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [Connection.normalizeApiUrlInput] — hand-typed URL forgiveness added with
|
||||
* the route-probe visibility work (2026-06-11).
|
||||
*
|
||||
* Contract: bare hosts/IPs get `http://` and the surface's default port;
|
||||
* anything with an explicit scheme passes through verbatim (an
|
||||
* `https://host` may be a reverse proxy on 443 — appending :8642 would
|
||||
* break it, and a wrong scheme like `ws://` must survive so the field
|
||||
* validators can name the actual mistake).
|
||||
*/
|
||||
class ConnectionUrlInputNormalizationTest {
|
||||
|
||||
@Test
|
||||
fun bareIp_getsSchemeAndDefaultPort() {
|
||||
assertEquals(
|
||||
"http://100.71.8.56:8642",
|
||||
Connection.normalizeApiUrlInput("100.71.8.56"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareHostWithPort_getsSchemeOnly() {
|
||||
assertEquals(
|
||||
"http://my-server:8642",
|
||||
Connection.normalizeApiUrlInput("my-server:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitHttpUrl_passesVerbatim() {
|
||||
assertEquals(
|
||||
"http://192.168.1.10:8642",
|
||||
Connection.normalizeApiUrlInput("http://192.168.1.10:8642"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitHttpsWithoutPort_isNotPortDefaulted() {
|
||||
// Could be a reverse proxy on 443 — never append :8642 to an
|
||||
// explicitly-schemed URL.
|
||||
assertEquals(
|
||||
"https://hermes.example.com",
|
||||
Connection.normalizeApiUrlInput("https://hermes.example.com"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongScheme_passesVerbatimForValidatorsToCatch() {
|
||||
assertEquals(
|
||||
"ws://host:8767",
|
||||
Connection.normalizeApiUrlInput("ws://host:8767"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whitespaceAndTrailingSlash_areTrimmed() {
|
||||
assertEquals(
|
||||
"http://100.71.8.56:8642",
|
||||
Connection.normalizeApiUrlInput(" 100.71.8.56/ "),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankInput_staysBlank() {
|
||||
assertEquals("", Connection.normalizeApiUrlInput(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareHostWithPath_getsSchemeButNoPort() {
|
||||
// A path makes naive ":8642" suffixing wrong — scheme only.
|
||||
assertEquals(
|
||||
"http://host/api",
|
||||
Connection.normalizeApiUrlInput("host/api"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardDefaultPort_isHonored() {
|
||||
assertEquals(
|
||||
"http://192.168.1.10:9119",
|
||||
Connection.normalizeApiUrlInput(
|
||||
"192.168.1.10",
|
||||
defaultPort = Connection.DEFAULT_DASHBOARD_PORT,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareTailscaleHost_roundTripsThroughCandidateBuilder() {
|
||||
// End-to-end: the exact user journey from the bug report — typing a
|
||||
// bare Tailscale IP must yield a plain-HTTP (tls=false) candidate on
|
||||
// port 8642 with the tailscale role inferred.
|
||||
val normalized = Connection.normalizeApiUrlInput("100.71.8.56")
|
||||
val candidate = Connection.endpointCandidateFromApiUrl(
|
||||
role = "",
|
||||
priority = 1,
|
||||
apiServerUrl = normalized,
|
||||
relayUrl = "",
|
||||
)
|
||||
assertEquals("tailscale", candidate?.role)
|
||||
assertEquals("100.71.8.56", candidate?.api?.host)
|
||||
assertEquals(8642, candidate?.api?.port)
|
||||
assertEquals(false, candidate?.api?.tls)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -99,45 +100,65 @@ class DataManagerTest {
|
||||
assertTrue("JSON should contain 'onboardingCompleted'", jsonStr.contains("\"onboardingCompleted\""))
|
||||
}
|
||||
|
||||
// --- Backup does NOT contain sensitive data ---
|
||||
// --- Sensitive full-backup marker ---
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainApiKey() {
|
||||
fun backup_marksSensitiveDataByDefault() {
|
||||
val backup = DataManager.AppBackup()
|
||||
|
||||
assertTrue(backup.containsSensitiveData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_connectionSecrets_roundTrip() {
|
||||
val backup = DataManager.AppBackup(
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
theme = "auto"
|
||||
connections = listOf(
|
||||
Connection(
|
||||
id = "id-a",
|
||||
label = "local",
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
relayUrl = "ws://localhost:8767",
|
||||
tokenStoreKey = "hermes_auth_id-a",
|
||||
),
|
||||
),
|
||||
activeConnectionId = "id-a",
|
||||
connectionSecrets = listOf(
|
||||
DataManager.ConnectionSecretBackup(
|
||||
connectionId = "id-a",
|
||||
tokenStoreKey = "hermes_auth_id-a",
|
||||
auth = ConnectionAuthSecrets(
|
||||
sessionToken = "relay-session-token",
|
||||
refreshToken = "refresh-token",
|
||||
deviceId = "device-id",
|
||||
apiKey = "api-key",
|
||||
pairedSessionMetaJson = """{"transport_hint":"wss"}""",
|
||||
),
|
||||
dashboardCookies = listOf(
|
||||
DataManager.DashboardCookieBackup(
|
||||
name = "hermes_session",
|
||||
value = "cookie-value",
|
||||
expiresAt = Long.MAX_VALUE,
|
||||
domain = "localhost",
|
||||
path = "/",
|
||||
secure = false,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
persistent = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertFalse("Backup should not contain 'apiKey'", jsonStr.contains("\"apiKey\""))
|
||||
assertFalse("Backup should not contain 'api_key'", jsonStr.contains("\"api_key\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainSessionToken() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'session_token'", jsonStr.contains("\"session_token\""))
|
||||
assertFalse("Backup should not contain 'sessionToken'", jsonStr.contains("\"sessionToken\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainDeviceId() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'device_id'", jsonStr.contains("\"device_id\""))
|
||||
assertFalse("Backup should not contain 'deviceId'", jsonStr.contains("\"deviceId\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainBearerToken() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'token'", jsonStr.contains("\"token\""))
|
||||
assertFalse("Backup should not contain 'bearer'", jsonStr.lowercase().contains("\"bearer\""))
|
||||
assertTrue(jsonStr.contains("relay-session-token"))
|
||||
assertTrue(jsonStr.contains("api-key"))
|
||||
assertEquals("id-a", restored.activeConnectionId)
|
||||
assertEquals("relay-session-token", restored.connectionSecrets[0].auth.sessionToken)
|
||||
assertEquals("api-key", restored.connectionSecrets[0].auth.apiKey)
|
||||
assertEquals("cookie-value", restored.connectionSecrets[0].dashboardCookies[0].value)
|
||||
}
|
||||
|
||||
// --- Serialization round-trip ---
|
||||
@@ -249,7 +270,7 @@ class DataManagerTest {
|
||||
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(4, result.version)
|
||||
assertEquals(5, result.version)
|
||||
assertNull(result.serverUrl)
|
||||
assertNull(result.apiServerUrl)
|
||||
assertNull(result.relayUrl)
|
||||
@@ -280,9 +301,9 @@ class DataManagerTest {
|
||||
// --- Format version handling ---
|
||||
|
||||
@Test
|
||||
fun backup_defaultVersion_isFour() {
|
||||
fun backup_defaultVersion_isFive() {
|
||||
val backup = DataManager.AppBackup()
|
||||
assertEquals(4, backup.version)
|
||||
assertEquals(5, backup.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -348,6 +369,21 @@ class DataManagerTest {
|
||||
apiServerUrl = "http://10.0.0.5:8642",
|
||||
relayUrl = "wss://10.0.0.5:8767",
|
||||
tokenStoreKey = "hermes_auth_id-b",
|
||||
routeCandidates = listOf(
|
||||
EndpointCandidate(
|
||||
role = "lan",
|
||||
priority = 0,
|
||||
api = ApiEndpoint(host = "10.0.0.5", port = 8642, tls = false),
|
||||
relay = RelayEndpoint(url = "ws://10.0.0.5:8767", transportHint = "ws"),
|
||||
),
|
||||
EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint(host = "hermes.ts.net", port = 8642, tls = true),
|
||||
relay = RelayEndpoint(url = "wss://hermes.ts.net:8767", transportHint = "wss"),
|
||||
),
|
||||
),
|
||||
preferredRouteRole = "tailscale",
|
||||
pairedAt = 1_700_000_000L,
|
||||
transportHint = "wss",
|
||||
expiresAt = 1_700_100_000L,
|
||||
@@ -359,6 +395,8 @@ class DataManagerTest {
|
||||
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertEquals(connections, restored.connections)
|
||||
assertEquals("tailscale", restored.connections[1].preferredRouteRole)
|
||||
assertEquals("hermes.ts.net", restored.connections[1].routeCandidates[1].api.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowNetwork
|
||||
|
||||
/**
|
||||
* Standard-route (no relay socket) coverage for [ConnectionManager]'s ADR 24
|
||||
* network-aware switching, added when the machinery was decoupled from the
|
||||
* WSS connect path:
|
||||
*
|
||||
* 1. The [ConnectivityManager.NetworkCallback] registers at construction —
|
||||
* previously only `connect()` registered it, so standard (no-Relay)
|
||||
* connections never saw network changes at all.
|
||||
* 2. A network `onAvailable` with **no relay socket** still re-resolves and
|
||||
* publishes [ConnectionManager.activeEndpoint], which is what the
|
||||
* HTTP-only surfaces (chat, dashboard, standard voice) follow.
|
||||
* 3. `refreshActiveEndpoint(clearProbeCache = true)` forgets a
|
||||
* cached-reachable route so a just-died endpoint can't win the resolve
|
||||
* for the remainder of the 60s positive cache TTL.
|
||||
*
|
||||
* Runs under Robolectric for ConnectivityManager + a real [MockWebServer]
|
||||
* for the resolver's `HEAD /health` probes — same probe contract as
|
||||
* [EndpointResolverTest].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ConnectionManagerRouteTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
private lateinit var server: MockWebServer
|
||||
private val managers = mutableListOf<ConnectionManager>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
connectivityManager = context.getSystemService(ConnectivityManager::class.java)!!
|
||||
server = MockWebServer()
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
if (request.path?.endsWith("/health") == true) {
|
||||
MockResponse().setResponseCode(200)
|
||||
} else {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
managers.forEach { runCatching { it.shutdown() } }
|
||||
managers.clear()
|
||||
runCatching { server.shutdown() }
|
||||
}
|
||||
|
||||
private fun registeredCallbacks(): Set<ConnectivityManager.NetworkCallback> =
|
||||
shadowOf(connectivityManager).networkCallbacks.toSet()
|
||||
|
||||
private fun candidate(role: String = "tailscale"): EndpointCandidate =
|
||||
EndpointCandidate(
|
||||
role = role,
|
||||
priority = 0,
|
||||
api = ApiEndpoint(host = server.hostName, port = server.port, tls = false),
|
||||
relay = RelayEndpoint(url = "ws://${server.hostName}:${server.port}"),
|
||||
)
|
||||
|
||||
private fun buildManager(
|
||||
candidates: () -> List<EndpointCandidate>,
|
||||
): ConnectionManager = ConnectionManager(
|
||||
ChannelMultiplexer(),
|
||||
context = context,
|
||||
endpointResolver = EndpointResolver(httpClient = OkHttpClient()),
|
||||
endpointCandidatesProvider = { candidates() },
|
||||
).also { managers.add(it) }
|
||||
|
||||
@Test
|
||||
fun `network callback registers at construction without connect`() {
|
||||
val before = registeredCallbacks()
|
||||
|
||||
buildManager { emptyList() }
|
||||
|
||||
assertEquals(
|
||||
"ConnectionManager must register its NetworkCallback at construction " +
|
||||
"so standard (no-Relay) connections follow network changes",
|
||||
before.size + 1,
|
||||
registeredCallbacks().size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shutdown unregisters the construction-time network callback`() {
|
||||
val before = registeredCallbacks()
|
||||
val manager = buildManager { emptyList() }
|
||||
|
||||
manager.shutdown()
|
||||
|
||||
assertEquals(before, registeredCallbacks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onAvailable with no relay socket re-resolves and publishes activeEndpoint`() {
|
||||
val before = registeredCallbacks()
|
||||
val manager = buildManager { listOf(candidate()) }
|
||||
assertNull("no endpoint should be published before any trigger", manager.activeEndpoint.value)
|
||||
|
||||
val callback = (registeredCallbacks() - before).single()
|
||||
callback.onAvailable(ShadowNetwork.newInstance(101))
|
||||
|
||||
val published = runBlocking {
|
||||
withTimeout(10_000) { manager.activeEndpoint.first { it != null } }
|
||||
}
|
||||
assertEquals("tailscale", published!!.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshActiveEndpoint returns stale cached winner unless clearProbeCache`() {
|
||||
val manager = buildManager { listOf(candidate()) }
|
||||
|
||||
// Prime: server up → candidate resolves and its probe result caches.
|
||||
val first = runBlocking { manager.refreshActiveEndpoint() }
|
||||
assertNotNull("expected the live candidate to resolve", first)
|
||||
|
||||
// Route dies inside the positive cache TTL.
|
||||
server.shutdown()
|
||||
|
||||
// Without clearing, the 60s positive cache still vouches for it.
|
||||
val stale = runBlocking { manager.refreshActiveEndpoint() }
|
||||
assertEquals(
|
||||
"cached-reachable entry should still win within the TTL",
|
||||
"tailscale",
|
||||
stale?.role,
|
||||
)
|
||||
|
||||
// Clearing the cache forces a fresh probe, which now fails.
|
||||
val fresh = runBlocking { manager.refreshActiveEndpoint(clearProbeCache = true) }
|
||||
assertNull("fresh probe against the dead route must yield no winner", fresh)
|
||||
assertNull(manager.activeEndpoint.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probeAndReconnectNow publishes the winner on the standard (no socket) path`() {
|
||||
val manager = buildManager { listOf(candidate()) }
|
||||
|
||||
val winner = runBlocking { manager.probeAndReconnectNow() }
|
||||
|
||||
assertEquals("tailscale", winner?.role)
|
||||
assertEquals(
|
||||
"probeAndReconnectNow must publish activeEndpoint even with no relay socket",
|
||||
"tailscale",
|
||||
manager.activeEndpoint.value?.role,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probeAndReconnectNow publishes null when every route fails its probe`() {
|
||||
val manager = buildManager { listOf(candidate()) }
|
||||
|
||||
// Prime a winner, then kill the route. The old implementation
|
||||
// early-returned here without publishing, leaving the Routes card
|
||||
// stuck on the stale winner / "Resolving" with no feedback.
|
||||
runBlocking { manager.refreshActiveEndpoint() }
|
||||
assertNotNull(manager.activeEndpoint.value)
|
||||
server.shutdown()
|
||||
|
||||
val winner = runBlocking { manager.probeAndReconnectNow() }
|
||||
|
||||
assertNull("no reachable route → null winner", winner)
|
||||
assertNull(
|
||||
"the failed outcome must be published, not silently swallowed",
|
||||
manager.activeEndpoint.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardApiClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStatus_parsesAuthRequiredAndProviders() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"auth_required": true,
|
||||
"auth_providers": ["basic", "nous"]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val status = client.getStatus().getOrThrow()
|
||||
val request = server.takeRequest()
|
||||
|
||||
assertEquals("/api/status", request.path)
|
||||
assertTrue(status.authRequired)
|
||||
assertEquals(listOf("basic", "nous"), status.authProviders)
|
||||
assertEquals("basic", status.authProviderDetails.first().name)
|
||||
assertEquals("0.16.0", status.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStatus_acceptsProviderObjects() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"auth": {
|
||||
"required": true,
|
||||
"providers": [
|
||||
{"id": "basic", "label": "Username & Password"},
|
||||
{"type": "oauth", "name": "nous"}
|
||||
]
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val status = client.getStatus().getOrThrow()
|
||||
|
||||
assertTrue(status.authRequired)
|
||||
assertEquals(listOf("basic", "nous"), status.authProviders)
|
||||
assertTrue(status.authProviderDetails.first { it.name == "basic" }.supportsPassword)
|
||||
assertFalse(status.authProviderDetails.first { it.name == "nous" }.supportsPassword)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAuthProviders_parsesProviderMetadata() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "basic",
|
||||
"display_name": "Username & Password",
|
||||
"supports_password": true
|
||||
},
|
||||
{
|
||||
"name": "nous",
|
||||
"display_name": "Nous Research",
|
||||
"supports_password": false
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val providers = client.getAuthProviders().getOrThrow()
|
||||
|
||||
assertEquals("/api/auth/providers", server.takeRequest().path)
|
||||
assertEquals("Username & Password", providers[0].displayName)
|
||||
assertTrue(providers[0].supportsPassword)
|
||||
assertEquals("nous", providers[1].name)
|
||||
assertTrue(providers[1].isRedirectProvider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAuthProviders_acceptsProviderMapMetadata() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"providers": {
|
||||
"basic": {
|
||||
"display_name": "Username & Password",
|
||||
"supports_password": true
|
||||
},
|
||||
"nous": {
|
||||
"type": "oauth",
|
||||
"display_name": "Nous Research"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val providers = client.getAuthProviders().getOrThrow()
|
||||
|
||||
assertEquals(listOf("basic", "nous"), providers.map { it.name })
|
||||
assertTrue(providers.first { it.name == "basic" }.supportsPassword)
|
||||
assertEquals("Nous Research", providers.first { it.name == "nous" }.displayName)
|
||||
assertTrue(providers.first { it.name == "nous" }.isRedirectProvider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authUrlAndGatewayWebSocketUrl_preserveReverseProxyPrefix() {
|
||||
val authUrl = DashboardApiClient.authLoginUrl(
|
||||
baseUrl = "https://example.com/hermes/",
|
||||
provider = "nous",
|
||||
next = "/chat",
|
||||
)
|
||||
val wsUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
baseUrl = "https://example.com/hermes/",
|
||||
ticket = "abc/123",
|
||||
)
|
||||
val landingPath = DashboardApiClient.authLandingPath("https://example.com/hermes/")
|
||||
|
||||
assertEquals(
|
||||
"https://example.com/hermes/auth/login?provider=nous&next=%2Fchat",
|
||||
authUrl,
|
||||
)
|
||||
assertEquals(
|
||||
"wss://example.com/hermes/api/ws?ticket=abc%2F123",
|
||||
wsUrl,
|
||||
)
|
||||
assertEquals("/hermes/", landingPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importDashboardCookieHeader_storesWebViewCookiesForDashboardClient() {
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
val imported = importDashboardCookieHeader(
|
||||
store = store,
|
||||
url = "https://example.com/hermes/",
|
||||
cookieHeader = "hermes_session_at=access; hermes_session_rt=refresh",
|
||||
)
|
||||
val client = DashboardCookieJar(store)
|
||||
val cookies = client.loadForRequest(
|
||||
"https://example.com/hermes/api/auth/me".toHttpUrl(),
|
||||
)
|
||||
|
||||
assertEquals(2, imported)
|
||||
assertEquals(listOf("hermes_session_at", "hermes_session_rt"), cookies.map { it.name })
|
||||
assertTrue(cookies.all { it.secure })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importDashboardCookieHeader_callbackPathStillMatchesApiSession() {
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
val imported = importDashboardCookieHeader(
|
||||
store = store,
|
||||
url = "https://example.com/auth/callback?nous=ok",
|
||||
cookieHeader = "hermes_session=abc123",
|
||||
)
|
||||
val client = DashboardCookieJar(store)
|
||||
val cookies = client.loadForRequest(
|
||||
"https://example.com/api/auth/me".toHttpUrl(),
|
||||
)
|
||||
|
||||
assertEquals(1, imported)
|
||||
assertEquals(listOf("hermes_session"), cookies.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStatus_defaultsMissingAuthFieldsForOlderDashboard() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"version": "legacy"}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val status = client.getStatus().getOrThrow()
|
||||
|
||||
assertFalse(status.authRequired)
|
||||
assertEquals(emptyList<String>(), status.authProviders)
|
||||
assertEquals("legacy", status.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordLogin_postsExpectedBodyAndPersistsSessionCookie() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.addHeader("Set-Cookie", "hermes_session=abc123; Path=/; HttpOnly")
|
||||
.setBody("""{"ok": true, "next": "/"}"""),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"authenticated": true, "username": "bailey", "provider": "basic"}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val login = client.loginPassword(
|
||||
username = "bailey",
|
||||
password = "secret",
|
||||
).getOrThrow()
|
||||
val session = client.currentSession().getOrThrow()
|
||||
|
||||
val loginRequest = server.takeRequest()
|
||||
val sessionRequest = server.takeRequest()
|
||||
|
||||
assertEquals("/auth/password-login", loginRequest.path)
|
||||
val body = loginRequest.body.readUtf8()
|
||||
assertTrue(body.contains(""""provider":"basic""""))
|
||||
assertTrue(body.contains(""""username":"bailey""""))
|
||||
assertTrue(body.contains(""""password":"secret""""))
|
||||
assertTrue(login.ok)
|
||||
assertEquals("/", login.next)
|
||||
|
||||
assertEquals("/api/auth/me", sessionRequest.path)
|
||||
assertEquals("hermes_session=abc123", sessionRequest.getHeader("Cookie"))
|
||||
assertTrue(session.authenticated)
|
||||
assertEquals("bailey", session.username)
|
||||
assertEquals("basic", session.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentSession_mapsUnauthorizedToUnauthenticated() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val session = client.currentSession().getOrThrow()
|
||||
|
||||
assertFalse(session.authenticated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentSession_acceptsUpstreamFlatDashboardSession() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"user_id": "user_123",
|
||||
"email": "bailey@example.com",
|
||||
"display_name": "Bailey",
|
||||
"provider": "nous",
|
||||
"expires_at": 1893456000
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val session = client.currentSession().getOrThrow()
|
||||
|
||||
assertEquals("/api/auth/me", server.takeRequest().path)
|
||||
assertTrue(session.authenticated)
|
||||
assertEquals("Bailey", session.username)
|
||||
assertEquals("nous", session.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardRequest_reportsUnsupportedEndpointAsHttpFailure() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setBody("""{"detail": "not found"}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val failure = client.getJsonObject("/api/mcp/servers").exceptionOrNull()
|
||||
|
||||
assertEquals("/api/mcp/servers", server.takeRequest().path)
|
||||
assertTrue(failure?.message.orEmpty().contains("/api/mcp/servers failed - HTTP 404"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getJsonElement_acceptsTopLevelArray() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""[{"name":"default"}]"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val root = client.getJsonElement("/api/profiles").getOrThrow()
|
||||
|
||||
assertEquals("/api/profiles", server.takeRequest().path)
|
||||
assertTrue(root is JsonArray)
|
||||
assertEquals(1, (root as JsonArray).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toggleSkill_putsExpectedBody() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok": true, "name": "research", "enabled": false}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.toggleSkill("research", enabled = false).getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("PUT", request.method)
|
||||
assertEquals("/api/skills/toggle", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains(""""name":"research""""))
|
||||
assertTrue(body.contains(""""enabled":false"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cronActions_encodeJobIdAndProfile() = runTest {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"runs": []}"""))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.pauseCronJob("daily report", profile = "work profile").getOrThrow()
|
||||
client.getCronJobRuns("daily report", profile = "work profile", limit = 250).getOrThrow()
|
||||
|
||||
val pause = server.takeRequest()
|
||||
val runs = server.takeRequest()
|
||||
assertEquals("POST", pause.method)
|
||||
assertEquals("/api/cron/jobs/daily%20report/pause?profile=work%20profile", pause.path)
|
||||
assertEquals("GET", runs.method)
|
||||
assertEquals("/api/cron/jobs/daily%20report/runs?profile=work%20profile&limit=100", runs.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpActions_useEnabledTestAndRemoveRoutes() = runTest {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true, "tools": []}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true, "background": false}"""))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.setMcpServerEnabled("github tools", enabled = true).getOrThrow()
|
||||
client.testMcpServer("github tools").getOrThrow()
|
||||
client.removeMcpServer("github tools").getOrThrow()
|
||||
client.installMcpCatalogEntry(
|
||||
name = "linear",
|
||||
env = mapOf("LINEAR_API_KEY" to "secret"),
|
||||
enable = false,
|
||||
).getOrThrow()
|
||||
|
||||
val enable = server.takeRequest()
|
||||
val test = server.takeRequest()
|
||||
val remove = server.takeRequest()
|
||||
val install = server.takeRequest()
|
||||
assertEquals("PUT", enable.method)
|
||||
assertEquals("/api/mcp/servers/github%20tools/enabled", enable.path)
|
||||
assertTrue(enable.body.readUtf8().contains(""""enabled":true"""))
|
||||
assertEquals("POST", test.method)
|
||||
assertEquals("/api/mcp/servers/github%20tools/test", test.path)
|
||||
assertEquals("DELETE", remove.method)
|
||||
assertEquals("/api/mcp/servers/github%20tools", remove.path)
|
||||
assertEquals("POST", install.method)
|
||||
assertEquals("/api/mcp/catalog/install", install.path)
|
||||
val body = install.body.readUtf8()
|
||||
assertTrue(body.contains(""""name":"linear""""))
|
||||
assertTrue(body.contains(""""LINEAR_API_KEY":"secret""""))
|
||||
assertTrue(body.contains(""""enable":false"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileActions_useActiveAndDeleteRoutes() = runTest {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"content": "soul", "exists": true}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.setActiveProfile("research").getOrThrow()
|
||||
client.getProfileSoul("research profile").getOrThrow()
|
||||
client.deleteProfile("old profile").getOrThrow()
|
||||
|
||||
val active = server.takeRequest()
|
||||
val soul = server.takeRequest()
|
||||
val delete = server.takeRequest()
|
||||
assertEquals("POST", active.method)
|
||||
assertEquals("/api/profiles/active", active.path)
|
||||
assertTrue(active.body.readUtf8().contains(""""name":"research""""))
|
||||
assertEquals("GET", soul.method)
|
||||
assertEquals("/api/profiles/research%20profile/soul", soul.path)
|
||||
assertEquals("DELETE", delete.method)
|
||||
assertEquals("/api/profiles/old%20profile", delete.path)
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,79 @@ class EndpointResolverTest {
|
||||
assertEquals("tailscale", winner!!.role)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Test 8 — probeOutcomes records UI-facing verdicts per candidate
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun probeOutcomes_recordsReachableAndUnreachableVerdicts() = runTest {
|
||||
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
|
||||
val lan = candidate("lan", priority = 0, server = reachableServer)
|
||||
|
||||
reachableServer.dispatcher = healthDispatcher(statusCode = 200)
|
||||
resolver.resolve(listOf(lan))
|
||||
val reachableOutcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)]
|
||||
assertNotNull("successful probe must record an outcome", reachableOutcome)
|
||||
assertTrue(reachableOutcome!!.reachable)
|
||||
assertNull("reachable outcomes carry no failure detail", reachableOutcome.detail)
|
||||
|
||||
// Flip to 500 past the positive TTL so a real probe fires again.
|
||||
reachableServer.dispatcher = healthDispatcher(statusCode = 500)
|
||||
clockMillis.set(EndpointResolver.CACHE_TTL_MS + 1_000L)
|
||||
resolver.resolve(listOf(lan))
|
||||
val failedOutcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)]
|
||||
assertNotNull(failedOutcome)
|
||||
assertTrue("failed probe must flip the outcome", !failedOutcome!!.reachable)
|
||||
assertEquals("HTTP 500 from /health", failedOutcome.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeOutcomes_recordsConnectionFailureDetail() = runTest {
|
||||
val dead = MockWebServer().apply { start() }
|
||||
val deadHost = dead.hostName
|
||||
val deadPort = dead.port
|
||||
dead.shutdown()
|
||||
|
||||
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
|
||||
val deadCandidate = EndpointCandidate(
|
||||
role = "tailscale", priority = 0,
|
||||
api = ApiEndpoint(deadHost, deadPort, tls = false),
|
||||
relay = RelayEndpoint("ws://$deadHost:$deadPort", transportHint = "ws"),
|
||||
)
|
||||
resolver.resolve(listOf(deadCandidate))
|
||||
val outcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(deadCandidate)]
|
||||
assertNotNull("dead-port probe must record an outcome", outcome)
|
||||
assertTrue(!outcome!!.reachable)
|
||||
assertNotNull("failure outcomes must carry a human detail", outcome.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeOutcomes_surviveClearCache() = runTest {
|
||||
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
|
||||
val lan = candidate("lan", priority = 0, server = reachableServer)
|
||||
resolver.resolve(listOf(lan))
|
||||
assertNotNull(resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)])
|
||||
|
||||
resolver.clearCache()
|
||||
|
||||
assertNotNull(
|
||||
"clearCache resets probe *caching*, not the UI-facing verdict history",
|
||||
resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markUnreachable_recordsOutcome() = runTest {
|
||||
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
|
||||
val lan = candidate("lan", priority = 0, server = reachableServer)
|
||||
|
||||
resolver.markUnreachable(lan)
|
||||
|
||||
val outcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)]
|
||||
assertNotNull(outcome)
|
||||
assertTrue(!outcome!!.reachable)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.SkillListResponse
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -171,6 +174,47 @@ class HermesApiClientTest {
|
||||
assertEquals(ChatMode.ENHANCED_HERMES, capabilities.toChatMode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCapabilitiesBody_prefersNativeUpstreamSessionFeatures() {
|
||||
val body = """
|
||||
{
|
||||
"object": "hermes.api_server.capabilities",
|
||||
"features": {
|
||||
"chat_completions": true,
|
||||
"run_events_sse": true,
|
||||
"session_resources": true,
|
||||
"session_chat_streaming": true,
|
||||
"skills_api": true
|
||||
},
|
||||
"endpoints": {
|
||||
"chat_completions": {"method": "POST", "path": "/v1/chat/completions"},
|
||||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||
"sessions": {"method": "GET", "path": "/api/sessions"},
|
||||
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
||||
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val capabilities = parseCapabilitiesBody(Json { ignoreUnknownKeys = true }, body)
|
||||
|
||||
assertEquals(true, capabilities?.sessionsApi)
|
||||
assertEquals(true, capabilities?.sessionsChatStream)
|
||||
assertEquals(true, capabilities?.portable)
|
||||
assertEquals(true, capabilities?.runs)
|
||||
assertEquals("sessions", capabilities?.preferredChatEndpoint())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
|
||||
val body = """{"status":"ok"}"""
|
||||
|
||||
val capabilities = parseCapabilitiesBody(Json { ignoreUnknownKeys = true }, body)
|
||||
|
||||
assertNull(capabilities)
|
||||
}
|
||||
|
||||
// --- URL construction patterns ---
|
||||
// These verify the string patterns used by authRequest() inside the client.
|
||||
|
||||
@@ -220,6 +264,46 @@ class HermesApiClientTest {
|
||||
assertEquals("http://localhost:8642/v1/models", url)
|
||||
}
|
||||
|
||||
// --- Skills endpoint compatibility ---
|
||||
|
||||
@Test
|
||||
fun skillEndpointOrder_prefersUpstreamV1ThenLegacyApiFallback() {
|
||||
assertEquals(listOf("/v1/skills", "/api/skills"), HERMES_SKILL_ENDPOINTS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skillListResponse_parsesUpstreamV1DataEnvelope() {
|
||||
val body = """
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"name": "android", "description": "Control phone", "category": "android"}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = Json { ignoreUnknownKeys = true }.decodeFromString<SkillListResponse>(body)
|
||||
|
||||
assertEquals(1, parsed.data?.size)
|
||||
assertEquals("android", parsed.data?.first()?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSkillListBody_acceptsUpstreamV1DataEnvelope() {
|
||||
val body = """
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"name": "android", "description": "Control phone", "category": "android"}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = parseSkillListBody(Json { ignoreUnknownKeys = true }, body)
|
||||
|
||||
assertEquals(listOf("android"), parsed?.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlConstruction_deleteSession() {
|
||||
val baseUrl = "http://localhost:8642"
|
||||
|
||||
@@ -134,6 +134,24 @@ class SessionModelsTest {
|
||||
assertEquals("s1", response.sessions!![0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionListResponse_withUpstreamDataField() {
|
||||
val jsonStr = """
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "s1", "title": "Session 1"}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<SessionListResponse>(jsonStr)
|
||||
|
||||
assertNotNull(response.data)
|
||||
assertEquals(1, response.data!!.size)
|
||||
assertEquals("s1", response.data!![0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionListResponse_bothFieldsNull_whenEmpty() {
|
||||
val jsonStr = """{}"""
|
||||
@@ -141,6 +159,7 @@ class SessionModelsTest {
|
||||
|
||||
assertNull(response.items)
|
||||
assertNull(response.sessions)
|
||||
assertNull(response.data)
|
||||
}
|
||||
|
||||
// --- SessionResponse ---
|
||||
@@ -279,6 +298,25 @@ class SessionModelsTest {
|
||||
assertEquals(1, response.messages!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageListResponse_withUpstreamDataField() {
|
||||
val jsonStr = """
|
||||
{
|
||||
"object": "list",
|
||||
"session_id": "s1",
|
||||
"data": [
|
||||
{"role": "user", "content": "Test"}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<MessageListResponse>(jsonStr)
|
||||
|
||||
assertNotNull(response.data)
|
||||
assertEquals(1, response.data!!.size)
|
||||
assertEquals("Test", response.data!![0].contentText)
|
||||
}
|
||||
|
||||
// --- HermesSseEvent ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -270,6 +270,44 @@ class HermesPairingPayloadTest {
|
||||
assertNull(ep.relay.transportHint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericApiUrlQr_buildsStandardPayload() {
|
||||
val payload = parseHermesPairingQr("https://hermes.example.com:8642")
|
||||
|
||||
assertNotNull(payload)
|
||||
val parsed = payload!!
|
||||
val endpoints = parsed.endpoints.orEmpty()
|
||||
assertEquals("hermes.example.com", parsed.host)
|
||||
assertEquals(8642, parsed.port)
|
||||
assertTrue(parsed.tls)
|
||||
assertEquals("", parsed.key)
|
||||
assertNull(parsed.relay)
|
||||
assertEquals("https://hermes.example.com:8642", parsed.serverUrl)
|
||||
assertEquals(1, endpoints.size)
|
||||
assertEquals("public", endpoints[0].role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericApiJsonQr_acceptsUrlAndApiKeyAliases() {
|
||||
val raw = """
|
||||
{
|
||||
"api_url": "http://192.168.1.50:8642",
|
||||
"api_key": "dev-key"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val payload = parseHermesPairingQr(raw)
|
||||
|
||||
assertNotNull(payload)
|
||||
val parsed = payload!!
|
||||
assertEquals("192.168.1.50", parsed.host)
|
||||
assertEquals(8642, parsed.port)
|
||||
assertFalse(parsed.tls)
|
||||
assertEquals("dev-key", parsed.key)
|
||||
assertNull(parsed.relay)
|
||||
assertEquals("lan", parsed.endpoints.orEmpty()[0].role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingHost_rejectsPayload() {
|
||||
// The parser's minimum contract: a payload without `host` is not
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PowerFeatureGateTest {
|
||||
|
||||
@Test
|
||||
fun fromRelayAuth_unpaired_requiresPairing() {
|
||||
assertEquals(
|
||||
PowerFeatureGateStatus.RequiresPairing,
|
||||
PowerFeatureGateStatus.fromRelayAuth(AuthState.Unpaired),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromRelayAuth_expiredFailure_mapsToPairingExpired() {
|
||||
assertEquals(
|
||||
PowerFeatureGateStatus.PairingExpired,
|
||||
PowerFeatureGateStatus.fromRelayAuth(AuthState.Failed("Your session expired")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromRelayAuth_tokenFailure_mapsToPairingExpired() {
|
||||
assertEquals(
|
||||
PowerFeatureGateStatus.PairingExpired,
|
||||
PowerFeatureGateStatus.fromRelayAuth(AuthState.Failed("server rejected token")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromRelayAuth_otherFailure_requiresPairing() {
|
||||
assertEquals(
|
||||
PowerFeatureGateStatus.RequiresPairing,
|
||||
PowerFeatureGateStatus.fromRelayAuth(AuthState.Failed("pairing code was invalid")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.0" apply false
|
||||
id("com.android.library") version "9.2.0" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.library") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false
|
||||
}
|
||||
|
||||
@@ -66,12 +66,34 @@ GET /v1/models
|
||||
-> { "object": "list", "data": [{ "id": "claude-opus-4-6", "object": "model" }, ...] }
|
||||
```
|
||||
|
||||
### Capabilities
|
||||
```
|
||||
GET /v1/capabilities
|
||||
-> {
|
||||
"features": {
|
||||
"session_resources": true,
|
||||
"session_chat_streaming": true,
|
||||
"skills_api": true
|
||||
},
|
||||
"endpoints": {
|
||||
"sessions": {"method": "GET", "path": "/api/sessions"},
|
||||
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
||||
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use this before assuming optional API-server surfaces exist. Older builds may
|
||||
not have `/v1/capabilities`; in that case Hermes-Relay falls back to targeted
|
||||
route probes.
|
||||
|
||||
### Sessions
|
||||
|
||||
```
|
||||
# List sessions
|
||||
GET /api/sessions?limit=50&offset=0
|
||||
-> { "items": [...], "total": N }
|
||||
-> { "object": "list", "data": [...], "total": N }
|
||||
|
||||
# Create session
|
||||
POST /api/sessions
|
||||
@@ -92,7 +114,7 @@ DELETE /api/sessions/{session_id}
|
||||
|
||||
# Get messages
|
||||
GET /api/sessions/{session_id}/messages
|
||||
-> { "items": [...], "total": N }
|
||||
-> { "object": "list", "data": [...], "total": N }
|
||||
|
||||
# Search sessions
|
||||
GET /api/sessions/search?q=keyword&limit=20
|
||||
@@ -103,6 +125,10 @@ POST /api/sessions/{session_id}/fork
|
||||
-> { "session": { ... }, "forked_from": "..." }
|
||||
```
|
||||
|
||||
The native upstream list envelope is `{"object":"list","data":[...]}`.
|
||||
Older fork/bootstrap builds may return `items`, `sessions`, or `messages`;
|
||||
clients should continue accepting those as compatibility shapes.
|
||||
|
||||
### Chat (Non-Streaming)
|
||||
```
|
||||
POST /api/sessions/{session_id}/chat
|
||||
@@ -259,7 +285,9 @@ GET /api/memory?target=memory // or target=user
|
||||
|
||||
### Skills
|
||||
```
|
||||
GET /api/skills # optional ?category= filter
|
||||
GET /v1/skills # native upstream read-only list
|
||||
GET /v1/toolsets # native upstream toolset inventory
|
||||
GET /api/skills # legacy compatibility list, optional ?category= filter
|
||||
GET /api/skills/{name}
|
||||
```
|
||||
> `/api/skills/categories` was removed from upstream as dead code (commit 8d023e43) and is not re-injected by the bootstrap.
|
||||
@@ -270,14 +298,17 @@ Probe endpoints to detect what's available:
|
||||
|
||||
```
|
||||
GET /health -> basic connectivity
|
||||
GET /api/sessions -> enhanced Hermes session API
|
||||
GET /v1/capabilities -> native feature and endpoint map
|
||||
GET /api/sessions -> session API fallback probe
|
||||
GET /v1/models -> model listing (OpenAI-compatible)
|
||||
GET /api/skills -> skills support
|
||||
GET /v1/skills -> native read-only skills support
|
||||
GET /v1/toolsets -> native read-only toolsets support
|
||||
GET /api/skills -> legacy skills compatibility
|
||||
GET /api/memory -> memory support
|
||||
GET /api/config -> config API
|
||||
|
||||
Chat modes:
|
||||
"enhanced-hermes" -> sessions API available (use /api/sessions/*/chat/stream)
|
||||
"enhanced-hermes" -> sessions API available (prefer /api/sessions/*/chat/stream)
|
||||
"portable" -> only /v1/chat/completions available (OpenAI-compatible)
|
||||
"disconnected" -> nothing works
|
||||
```
|
||||
@@ -292,4 +323,4 @@ Chat modes:
|
||||
| Streaming format | OpenAI delta format | Custom SSE events (see above) |
|
||||
| Tool visibility | Hidden | Exposed via events (pending/started/completed/failed) |
|
||||
| Thinking/Reasoning | Not exposed | Exposed via `tool.progress` events |
|
||||
| Memory/Skills | Not applicable | Full API access |
|
||||
| Memory/Skills | Not applicable | Native read-only skills/toolsets; memory and skill detail/toggle remain compatibility surfaces |
|
||||
|
||||
+230
-40
@@ -80,15 +80,16 @@ The app supports two streaming endpoints, selectable in Settings:
|
||||
| **Sessions** (`/api/sessions/{id}/chat/stream`) | Inline text annotations (`` `💻 terminal` ``) — client parses from markdown | Hermes-native SSE (assistant.delta, tool.progress, etc.) or OpenAI-format (delta.content) |
|
||||
| **Runs** (`/v1/runs` + `/v1/runs/{run_id}/events`) | **Structured events** (tool.started, tool.completed) — real-time tool cards | Hermes lifecycle events (message.delta, tool.started, tool.completed, run.completed) |
|
||||
|
||||
**Important upstream note:** The `/api/sessions` CRUD endpoints are moving
|
||||
toward upstream Hermes core through focused PR
|
||||
[#29302](https://github.com/NousResearch/hermes-agent/pull/29302), which covers
|
||||
session list/create/read/update/delete, messages, fork, chat, and chat stream.
|
||||
Until that reaches a released core build, `hermes_relay_bootstrap/` still ships
|
||||
with the plugin and runs at Python interpreter startup via `.pth`. The bootstrap
|
||||
now composes with partial upstream support: native routes win per method/path,
|
||||
and the relay only injects missing compatibility gaps such as config, skills, or
|
||||
memory when core does not provide them.
|
||||
**Important upstream note:** The `/api/sessions` CRUD/chat endpoints are now in
|
||||
upstream Hermes core via focused PR
|
||||
[#33134](https://github.com/NousResearch/hermes-agent/pull/33134), which
|
||||
salvaged the useful session-control portion of #29302 and covers session
|
||||
list/create/read/update/delete, messages, fork, chat, and chat stream. Read-only
|
||||
skill/toolset discovery is also native via
|
||||
[#33016](https://github.com/NousResearch/hermes-agent/pull/33016). The bootstrap
|
||||
still ships for older core builds and for surfaces that remain compatibility-only
|
||||
(config, memory, legacy skill detail/toggle, available-models, slash middleware),
|
||||
but sessions and read-only skill lists should now be upstream-first.
|
||||
|
||||
The app's `probeCapabilities()` returns a per-endpoint snapshot, and `ConnectionViewModel.resolveStreamingEndpoint()` collapses `streamingEndpoint = "auto"` (the default for new installs) to a concrete `"sessions"` or `"runs"` choice based on what the server actually exposes.
|
||||
|
||||
@@ -132,12 +133,12 @@ Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
|
||||
#### 6a. QR Carries Both API and Relay Credentials (updated 2026-05-03)
|
||||
|
||||
**Decision:** The Hermes pairing QR payload bundles the API server credentials AND the relay URL + pairing code into a single scan. The pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim, both backed by `plugin/pair.py`) runs on the Hermes host; if a relay is reachable at `localhost:RELAY_PORT`, the command mints a fresh 6-char code, pre-registers it with the relay via a new loopback-only `POST /pairing/register` endpoint, and embeds `{url, code}` under a nullable `relay` key alongside the existing `host`/`port`/`key`/`tls` fields. The dashboard pairing flow uses the relay's loopback-only `POST /pairing/mint` endpoint instead; when the dashboard omits `api_key`, the relay reads the same host-local Hermes API key config as `hermes-pair` and places it in top-level `key`.
|
||||
**Decision:** The Hermes pairing QR payload bundles the API server credentials AND the relay URL + pairing code into a single scan. The pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim, all backed by `plugin/pair.py`) runs on the Hermes host; if a relay is reachable at `localhost:RELAY_PORT`, the command mints a fresh 6-char code, pre-registers it with the relay via a new loopback-only `POST /pairing/register` endpoint, and embeds `{url, code}` under a nullable `relay` key alongside the existing `host`/`port`/`key`/`tls` fields. The dashboard pairing flow uses the relay's loopback-only `POST /pairing/mint` endpoint instead; when the dashboard omits `api_key`, the relay reads the same host-local Hermes API key config as `hermes pair` and places it in top-level `key`.
|
||||
|
||||
**Trust anchor:** the operator with shell access on the host. Only a process running on the same machine as the relay can hit `/pairing/register` — the handler rejects any non-loopback `request.remote` with HTTP 403. A LAN attacker cannot inject codes. This matches the model we already rely on for reading `~/.hermes/.env` and `~/.hermes/config.yaml`: if you have shell access to the host, you have enough privilege to authorize a device.
|
||||
|
||||
**Why the change was necessary:**
|
||||
- Previously the phone generated its own 6-char pairing code locally via `AuthManager.generatePairingCode()` and sent it to the relay on WSS connect. The relay had no way to know what code to accept, so relay pairing was effectively broken — only API-direct-chat pairing worked via the QR.
|
||||
- Previously the phone generated its own 6-char pairing code locally via `AuthManager.generatePairingCode()` and sent it to the relay on WSS connect. The relay had no way to know what code to accept, so relay pairing was effectively broken — only direct API chat pairing worked via the QR.
|
||||
- Pushing the code flow through the host means the operator always has the source of truth, and a single scan configures both chat and terminal/bridge with no manual steps.
|
||||
|
||||
**Schema evolution:**
|
||||
@@ -341,7 +342,7 @@ Key data classes: `MessageEvent` (inbound), `SendResult` (outbound), `SessionSou
|
||||
|
||||
**Why the old `skills/hermes-pairing-qr/` was deleted:** It was the pre-plugin bash script era — `hermes-pair` as a shell script + a flat-file `SKILL.md`. The plugin now owns the QR generation (`plugin/pair.py`, pure Python, no `qrencode` dependency), the skill at `skills/devops/hermes-relay-pair/` owns the slash-command surface, and the shell shim at `~/.local/bin/hermes-pair` covers the script-friendly CLI entry point. Keeping the deprecated skill around would have been two sources of truth for the same operation.
|
||||
|
||||
**Upstream CLI gap (documented for posterity):** hermes-agent v0.8.0's `PluginContext.register_cli_command()` is wired up on the plugin side, and `plugin/cli.py` calls it correctly. However, `hermes_cli/main.py:5236` only reads `plugins.memory.discover_plugin_cli_commands()` (memory-plugin-specific) and never consults the generic `_cli_commands` dict. Third-party plugin CLI commands never reach the top-level argparser. Documented in DEVLOG as an upstream fix target. Until it lands, `hermes pair` (with a space) is **not** a working entry point — docs point users at `/hermes-relay-pair` (skill-driven slash command) and `hermes-pair` (dashed shell shim) instead.
|
||||
**Plugin CLI status (updated 2026-06-07):** hermes-agent v0.8.0 had a top-level argparse gap where third-party `PluginContext.register_cli_command()` entries did not reach `hermes <subcommand>`. Current upstream now discovers plugin CLI registrations in `hermes_cli/main.py`, so `hermes pair` and `hermes relay` are the preferred shell entry points when the plugin is enabled. `/hermes-relay-pair` and the dashed `hermes-pair` shim stay as older-build and script compatibility paths until our supported baseline includes the upstream fix.
|
||||
|
||||
**References:**
|
||||
- `install.sh` — canonical installer
|
||||
@@ -453,7 +454,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
|
||||
|
||||
- **Grants on a single token (not multiple tokens)** — one WSS connection, one auth envelope, one session lookup. Per-channel expiry is checked at channel message dispatch time via `Session.channel_is_expired(name)`. Simpler to reason about than multiple parallel tokens, and the phone only needs one storage slot.
|
||||
- **`math.inf` for never-expire** — represents "truly unbounded" in code, serializes to `null` on the wire (JSON doesn't have an infinity literal, and null maps cleanly to Kotlin's nullable `Long?`). `canonicalize()` uses `allow_nan=False` so accidentally trying to sign a payload with a raw `math.inf` crashes loudly — callers must explicitly emit `None`/`0`. Prevents silent serialization bugs.
|
||||
- **Metadata on pairing entries, host wins over phone** — when the host operator runs `hermes-pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. Operator policy is authoritative. If the host didn't specify anything, the phone's value applies.
|
||||
- **Metadata on pairing entries, host wins over phone** — when the host operator runs `hermes pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. Operator policy is authoritative. If the host didn't specify anything, the phone's value applies.
|
||||
- **Token prefix (not full token) in `/sessions` responses** — a caller already holds their own full token; they should never see another session's full token. First 8 chars are enough to identify devices in a practical deployment (one operator, 1-3 phones) and enough entropy to avoid collisions. Collisions return 409 with the match count.
|
||||
- **Always open the TTL picker (no skip)** — even when the QR carries an operator-chosen TTL, the dialog opens with that value preselected. The user is always in the loop for the trust decision. A future "don't ask again if QR specifies a TTL" toggle is a plausible refinement but not in this cut.
|
||||
|
||||
@@ -499,13 +500,17 @@ Adopting from ARC's workflow patterns:
|
||||
### 16. Runtime API Server Patch via .pth Bootstrap (2026-04-12)
|
||||
|
||||
**Context:** The Android app depends on API-server routes for session history,
|
||||
profile/config metadata, skills, and memory-backed UI. Upstream core is now
|
||||
moving in focused pieces rather than one large frontend API patch: PR
|
||||
[#29302](https://github.com/NousResearch/hermes-agent/pull/29302) covers the
|
||||
canonical `/api/sessions/*` surface, while config/skills/memory still remain
|
||||
compatibility routes in this repo until core exposes stable equivalents. Without
|
||||
the bootstrap, users on older vanilla upstream builds lose session browsing,
|
||||
metadata-backed settings, and history-on-restart behavior.
|
||||
profile/config metadata, skills, and memory-backed UI. Upstream core moved in
|
||||
focused pieces rather than one large frontend API patch: PR
|
||||
[#33134](https://github.com/NousResearch/hermes-agent/pull/33134) now covers the
|
||||
canonical `/api/sessions/*` surface, and PR
|
||||
[#33016](https://github.com/NousResearch/hermes-agent/pull/33016) covers
|
||||
read-only `/v1/skills` + `/v1/toolsets`. Config, memory, legacy skill
|
||||
detail/toggle, available-models, and slash-command preprocessing still remain
|
||||
compatibility routes in this repo until core exposes stable equivalents or the
|
||||
local UI no longer depends on them. Without the bootstrap, users on older
|
||||
vanilla upstream builds lose session browsing, metadata-backed settings, and
|
||||
history-on-restart behavior.
|
||||
|
||||
We considered four options:
|
||||
- **A. Stay fork-only.** Reject vanilla upstream users until the relevant core API surfaces land. Penalises onboarding.
|
||||
@@ -520,19 +525,24 @@ We considered four options:
|
||||
1. **Zero modifications to hermes-agent's filesystem.** `git pull` / `hermes update` see no local changes, so they always work cleanly. The patch lives entirely in `hermes_relay_bootstrap/` inside our own repo.
|
||||
2. **Single-file containment of all ported logic.** `_handlers.py` is 500 lines of straight-line aiohttp handler code with explicit `adapter` parameters (closures, not bound methods). Easy to audit, easy to delete.
|
||||
3. **Feature detection by method/path, not broad route family.** Native upstream
|
||||
routes win one method/path at a time. This matters because PR #29302 can land
|
||||
`/api/sessions/*` before core has stable config/skills/memory APIs; the
|
||||
routes win one method/path at a time. This matters because #33134 landed
|
||||
`/api/sessions/*` before core had stable config/memory/legacy skill APIs; the
|
||||
bootstrap must not skip those remaining compatibility routes just because a
|
||||
sessions route exists.
|
||||
4. **Trust model already established.** The user installed our plugin into their hermes-agent venv. They've already consented to having the plugin import hermes-agent internals (it does this for relay tools, voice endpoints, media registry). Monkey-patching `aiohttp.web.Application` is in the same trust bucket.
|
||||
5. **Surface-by-surface removal.** When PR #29302 or equivalent reaches a
|
||||
released hermes-agent version, the sessions compatibility routes should go
|
||||
quiet automatically. Config, skills, memory, and command preprocessing remain
|
||||
until their native replacements exist. Full bootstrap deletion happens only
|
||||
after every compatibility route group has a stable core equivalent.
|
||||
6. **`/v1/runs` is genuinely better for chat than `/api/sessions/{id}/chat/stream`.** It's standard upstream, supports `X-Hermes-Session-Id` for continuation, and emits live structured tool events. The fork's chat handler exists because upstream didn't HAVE this clean structured-event runs API at the time the fork was cut — but upstream does now.
|
||||
5. **Surface-by-surface removal.** With #33134/#33016 merged, sessions and
|
||||
read-only skill lists should go quiet automatically on current upstream.
|
||||
Config, memory, legacy skill detail/toggle, available-models, and command
|
||||
preprocessing remain until their native replacements exist or the local UI
|
||||
stops depending on them. Full bootstrap deletion happens only after every
|
||||
compatibility route group has a stable core equivalent or a deliberate local
|
||||
removal.
|
||||
6. **`/v1/runs` remains the fallback run-control path.** Native
|
||||
`/api/sessions/{id}/chat/stream` is now the preferred session-persisted chat
|
||||
path when advertised. `/v1/runs` still matters for async run lifecycle/control
|
||||
and for older builds without native sessions chat.
|
||||
|
||||
**The Android client adapts via `streamingEndpoint = "auto"`.** New `ServerCapabilities` data class returned by `HermesApiClient.probeCapabilities()` captures per-endpoint presence (`sessionsApi`, `sessionsChatStream`, `runs`, `portable`, `healthy`). `ConnectionViewModel.resolveStreamingEndpoint()` collapses `"auto"` to `"sessions"` (when chat-stream handler is present, i.e. fork or upstream-merged) or `"runs"` (otherwise, i.e. bootstrap-injected vanilla upstream). The setting still supports manual `"sessions"` / `"runs"` overrides for debugging.
|
||||
**The Android client adapts via `streamingEndpoint = "auto"`.** `ServerCapabilities` returned by `HermesApiClient.probeCapabilities()` captures per-endpoint presence (`sessionsApi`, `sessionsChatStream`, `runs`, `portable`, `healthy`). `ConnectionViewModel.resolveStreamingEndpoint()` collapses `"auto"` to `"sessions"` when native session chat is present, then falls back to OpenAI-compatible completions or runs according to the probe. The setting still supports manual `"sessions"` / `"completions"` / `"runs"` overrides for debugging.
|
||||
|
||||
**Risks accepted:**
|
||||
- **Plugin load order** — verified: `.pth` files are processed by Python's `site` module BEFORE any application code runs, so our import hook is in place before hermes-agent imports `aiohttp.web`.
|
||||
@@ -548,15 +558,19 @@ We considered four options:
|
||||
- `install.sh` step 2 — copies the `.pth` into the venv site-packages
|
||||
|
||||
**Removal path** is now per surface:
|
||||
1. Sessions: after PR #29302 or equivalent ships in released core, keep the
|
||||
bootstrap installed but verify it skips native `/api/sessions/*` routes.
|
||||
2. Config/skills/memory: remove those compatibility handlers only after stable
|
||||
core APIs exist and Android probes prefer them.
|
||||
3. Slash middleware: remove after native API-server slash preprocessing exists.
|
||||
4. Full cleanup: delete `hermes_relay_bootstrap/`, delete
|
||||
1. Sessions: once the supported Hermes baseline includes #33134, remove the
|
||||
sessions compatibility handlers and any docs that require bootstrap for
|
||||
history/chat. Until then, verify native `/api/sessions/*` routes win.
|
||||
2. Read-only skills/toolsets: clients should prefer native `/v1/skills` and
|
||||
`/v1/toolsets` from #33016. Retire `/api/skills` list dependence; keep legacy
|
||||
detail/toggle only if the UI still needs it.
|
||||
3. Config/memory/available-models: remove those compatibility handlers only
|
||||
after stable core APIs exist or the dependent Android surfaces are redesigned.
|
||||
4. Slash middleware: remove after native API-server slash preprocessing exists.
|
||||
5. Full cleanup: delete `hermes_relay_bootstrap/`, delete
|
||||
`hermes_relay_bootstrap.pth`, remove the `.pth` install block, and update
|
||||
local agent docs only after all compatibility groups have native replacements.
|
||||
5. The Android client `probeCapabilities()` and `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
|
||||
6. The Android client `probeCapabilities()` and `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
|
||||
|
||||
---
|
||||
|
||||
@@ -928,7 +942,7 @@ The plan called for adding a `// VOICE HOOK` callback to `ChatViewModel` so `Voi
|
||||
- `plugin/relay/server.py` — route registration alongside `/media/*`
|
||||
- `plugin/tests/test_voice_routes.py` — 14 unit tests, `unittest`-based (pytest conftest issue documented in `CLAUDE.md`)
|
||||
- `app/src/main/kotlin/.../audio/VoiceRecorder.kt` — MediaRecorder amplitude StateFlow
|
||||
- `app/src/main/kotlin/.../audio/VoicePlayer.kt` — MediaPlayer + Visualizer amplitude StateFlow with OEM fallback
|
||||
- `app/src/main/kotlin/.../audio/VoicePlayer.kt` — Media3 ExoPlayer (gapless TTS queue) + Visualizer amplitude StateFlow with OEM fallback; `audioSessionId` served from a thread-safe `@Volatile` cache (read off-main by barge-in)
|
||||
- `app/src/main/kotlin/.../network/RelayVoiceClient.kt` — OkHttp multipart + JSON clients
|
||||
- `app/src/main/kotlin/.../viewmodel/VoiceViewModel.kt` — turn state machine, sentence detection, TTS queue consumer, `ChatViewModel` observation pattern
|
||||
- `app/src/main/kotlin/.../ui/components/VoiceModeOverlay.kt` — full-screen overlay, VoiceState→SphereState mapping, three interaction modes
|
||||
@@ -1151,9 +1165,9 @@ session-API endpoints.
|
||||
All shell out to `tailscale` CLI and return structured dicts; no new
|
||||
daemon, no new state.
|
||||
- `scripts/hermes-relay-tailscale` — shell shim mirroring `hermes-pair`
|
||||
pattern (see `project_hermes_plugin_cli_gap.md` memory — plugin
|
||||
`register_cli_command` doesn't reach `main.py` argparse on v0.8.0,
|
||||
so shell shim is the working path).
|
||||
pattern for scriptability and older Hermes builds. Current upstream supports
|
||||
generic plugin CLI command dispatch, so native `hermes <subcommand>` should
|
||||
be preferred when available.
|
||||
- `install.sh` gets an optional step [7/7]: detect `tailscale` binary;
|
||||
if present and the operator hasn't declined, offer to run
|
||||
`tailscale serve --bg --https=8767 http://127.0.0.1:8767`. Skipped
|
||||
@@ -1525,3 +1539,179 @@ old STT -> Hermes -> TTS pipeline instead of a native realtime agent.
|
||||
- `plugin/relay/realtime_agent/broker.py`
|
||||
- `plugin/relay/realtime_agent/providers/xai.py`
|
||||
- `plugin/relay/realtime_agent/providers/openai.py`
|
||||
|
||||
---
|
||||
|
||||
## ADR 33 - Realtime Agent foregrounds short Hermes turns and promotes long ones to background tasks
|
||||
|
||||
**Status:** Accepted, phased (2026-05-24). Default-on at feature GA, gated by a
|
||||
prerequisite provider-idle-tolerance spike (Phase 0). Supersedes the
|
||||
blocking-broker assumption inside ADR 32's sequence; ADR 32's preamble ->
|
||||
forced-Hermes -> provider-summary shape is preserved for the foreground tier.
|
||||
|
||||
**Context.** Today a Realtime Agent turn runs Hermes *synchronously inside the
|
||||
provider event pump*: `_pump_provider_events` awaits `_handle_provider_tool_call`
|
||||
-> `_run_brokered_tool`, which streams the entire Hermes SSE run to completion
|
||||
before the pump consumes the next provider event (`broker.py`). This is correct
|
||||
and lowest-latency for short Q&A, but it has two costs that grow with task
|
||||
length:
|
||||
|
||||
- The provider realtime socket sits attached-but-idle for the whole run (a live,
|
||||
billed, audio-clocked WebSocket parked for tens of seconds during research,
|
||||
multi-tool, or desktop/build tasks).
|
||||
- The tool surface already advertises a background vocabulary
|
||||
(`hermes_run_task`, `hermes_get_status`, `hermes_cancel`, `hermes_confirm`)
|
||||
and a `hermes_run_status` state machine, but `hermes_get_status` /
|
||||
`hermes_cancel` as *provider* tool calls are unreachable mid-run because the
|
||||
pump is parked. Only the client->relay `response.cancel` path can interrupt.
|
||||
|
||||
The blocking `await` is also an *implicit mutex*: it serializes the three audio
|
||||
sources that can produce `voice.output_audio.delta` (see "Who speaks" below) so
|
||||
they never overlap. Removing it requires replacing that mutex with an explicit
|
||||
floor owner.
|
||||
|
||||
**Who speaks (confirmed against both supported providers).** Up to three mouths
|
||||
exist; only one is active per phase today because of the blocking await:
|
||||
|
||||
1. **Realtime provider** - xAI `grok-voice-latest`, OpenAI `gpt-realtime-2`.
|
||||
Both run with `turn_detection: None` (relay owns turn boundaries; no server
|
||||
VAD) and audio output modality. Speaks the pre-Hermes acknowledgement and the
|
||||
final post-result summary.
|
||||
2. **Relay TTS render** - `xai_tts`/`openai_tts` via `_render_provider_audio`.
|
||||
A separate, non-realtime synthesizer used as the forced-summary fallback and
|
||||
the legacy render path. Emits the *same* `voice.output_audio.delta` wire event
|
||||
as the provider, so Android cannot distinguish them.
|
||||
3. **Android local TTS** - the `should_speak` long-wait filler, driven by
|
||||
`hermes.run.progress`. Client-side, not the provider.
|
||||
|
||||
Because all three converge on one Android `AudioTrack`, **floor arbitration must
|
||||
happen relay-side, before bytes reach the socket.**
|
||||
|
||||
**Decision.** Keep three turn classes; make promotion automatic and default-on,
|
||||
with the relay as the single explicit floor owner.
|
||||
|
||||
- **Tier A - Foreground (short Q&A).** Unchanged from ADR 32: provider preamble
|
||||
-> relay-forced Hermes (still awaited) -> provider summary. Lowest latency,
|
||||
trivial floor. This remains the path for any turn that completes inside the
|
||||
promotion grace window.
|
||||
- **Tier B - Promoted (long task detected late).** Start in Tier A. If the
|
||||
Hermes run has not produced a final result within a grace window
|
||||
(`promote_after_ms`, default ~6000ms, tunable), the relay *detaches* the run
|
||||
from the pump: the run continues as a tracked `asyncio.Task`, the pump resumes
|
||||
consuming provider events, and the provider speaks a short "I've started that -
|
||||
I'll let you know" handoff. Progress continues via `hermes.run.progress`. When
|
||||
the background run completes, the relay injects the result as a tool result and
|
||||
requests a provider summary at the next floor-idle moment.
|
||||
- **Tier C - Explicitly durable.** `hermes_run_task(mode="background")` returns a
|
||||
run handle immediately (no grace window). For tasks the model/profile knows up
|
||||
front are long (research, builds via desktop tools, multi-step). Same
|
||||
completion-injection path as Tier B.
|
||||
|
||||
Promotion is the default behavior, not a flag. Grace-period promotion preserves
|
||||
Tier A latency for the common case and only forks when a run actually proves
|
||||
long, so the user never has to pick a mode.
|
||||
|
||||
**The relay is the single floor owner.** A per-session floor state
|
||||
(`idle | provider_speaking | hermes_filler | result_pending`) gates every audio
|
||||
source:
|
||||
|
||||
- Only one mouth may hold the floor. The provider holds it by default.
|
||||
- A completed background result does **not** barge in. It is queued as
|
||||
`result_pending` and spoken only when the floor returns to `idle` (provider
|
||||
finished, user not mid-utterance). Provider VAD being off means the relay
|
||||
controls `response.create`, so it can withhold the summary until the floor is
|
||||
clear.
|
||||
- Android local filler (`should_speak`) is suppressed whenever the provider holds
|
||||
the floor; it is a Tier B/C long-wait affordance only.
|
||||
- Relay TTS render (mouth 2) may only fire when it owns the floor and the
|
||||
provider has drained, exactly as the forced-summary fallback does today.
|
||||
|
||||
**Settings (ample, per ADR intent).** Surface in Voice Settings -> Realtime
|
||||
Agent, with relay-side `realtime_voice` config as source of truth and per-profile
|
||||
override:
|
||||
|
||||
- `promotion_enabled` (default true) - master switch; false pins Tier A blocking.
|
||||
- `promote_after_ms` (default ~6000) - grace window before Tier B handoff.
|
||||
- `background_default_mode` - whether ambiguous long turns prefer promote vs.
|
||||
stay-foreground.
|
||||
- `spoken_handoff` (default true) - speak the "I've started that" line on
|
||||
promotion vs. silent + visual only.
|
||||
- `progress_spoken_after_ms` / `progress_repeat_ms` - reuse existing
|
||||
`_HERMES_SPOKEN_PROGRESS_*` knobs, now configurable.
|
||||
- `result_delivery` - `speak_when_idle` (default) vs. `notify_then_speak`
|
||||
(chime/visual, speak on user re-engage) vs. `visual_only`.
|
||||
- `max_background_runs` - concurrent background runs per session (default 1 for
|
||||
the MVP; the existing single-`hermes_task` field assumes 1).
|
||||
|
||||
**Protocol additions (relay <-> Android, additive).**
|
||||
|
||||
- `hermes.run.promoted` - run moved to background; carries `run_id`,
|
||||
`promote_after_ms`, `spoken_handoff`.
|
||||
- `hermes.run.background_completed` - background run finished; precedes the
|
||||
provider/relay summary.
|
||||
- Extend `hermes.run.progress` with `tier` and `floor` so the client can render
|
||||
background state distinctly (e.g. a persistent "working on: ..." chip).
|
||||
- `hermes_get_status` / `hermes_cancel` become genuinely reachable as provider
|
||||
tool calls in Tier B/C because the pump is no longer parked; no schema change.
|
||||
|
||||
**Prerequisite (Phase 0 spike) — RESOLVED 2026-05-24.** The spike asked how xAI
|
||||
and OpenAI realtime sessions behave when held open and idle. Verdicts (see
|
||||
`docs/realtime-voice-poc.md`): **OpenAI `hold-floor-ok` (empirical** — survived
|
||||
10/20/30s idle with clean post-idle audio); **xAI `hold-floor-ok`** (shipping
|
||||
Realtime Agent already holds `xai_realtime` sessions open across between-turn
|
||||
idle with `turn_detection: None` + resume TTL; relay-host probe retained as a
|
||||
regression check, not a precondition). The premise was also superseded in
|
||||
implementation: Tier B closes the pending provider call with an interim ack
|
||||
rather than holding an open response, so the socket only sees the normal
|
||||
between-turns idle gap — no provider needs the `must-reopen` fallback today, and
|
||||
default-on is unblocked.
|
||||
|
||||
**Rules.**
|
||||
|
||||
- Hermes remains the only path for tools, memory, current data, research, side
|
||||
effects, durable context, and confirmations (unchanged from ADR 29/32).
|
||||
- Exactly one audio source may hold the floor at a time; the relay enforces this
|
||||
before audio reaches Android. Background results never barge in.
|
||||
- Android must not read raw Hermes output aloud; spoken output is always a
|
||||
provider (or relay-fallback) summary of the compact Hermes result.
|
||||
- Promotion must be cancel-safe: a promoted/background run is cancelable via both
|
||||
the provider `hermes_cancel` tool and the client `response.cancel` path, reusing
|
||||
`_cancel_active_hermes`.
|
||||
- A turn must never strand: if a background run completes while the WS is
|
||||
detached, the result is replayed through the existing event-ring/resume path on
|
||||
reattach.
|
||||
|
||||
**Open questions / risks.**
|
||||
|
||||
- Floor arbitration is the hard part and the existing `native_forced_*` /
|
||||
`_should_forward_provider_response_event` suppression machinery is already the
|
||||
most fragile code in `broker.py`. Concurrency stresses it most; the floor-owner
|
||||
state machine should *replace* ad-hoc suppression, not stack on top of it.
|
||||
- Concurrent background runs (`max_background_runs > 1`) are out of scope for the
|
||||
MVP; the session model assumes a single `hermes_task`.
|
||||
- "Notify then speak" result delivery needs an Android affordance (chime +
|
||||
chip + tap-to-hear) that does not yet exist.
|
||||
|
||||
**Phased rollout.**
|
||||
|
||||
1. **Phase 0** - provider-idle-tolerance spike (above). Gate for default-on.
|
||||
2. **Phase 1** - introduce the relay floor-owner state machine under the current
|
||||
blocking behavior (no functional change), with tests that prove single-floor
|
||||
invariants.
|
||||
3. **Phase 2** - Tier B grace-period promotion behind `promotion_enabled`,
|
||||
default **off**, validated on long-task transcripts.
|
||||
4. **Phase 3** - flip `promotion_enabled` default **on**; add Tier C
|
||||
`mode="background"`; ship the Voice Settings surface.
|
||||
|
||||
**Key Files:**
|
||||
- `docs/decisions.md` (this ADR; supersedes ADR 32's blocking assumption)
|
||||
- `docs/plans/2026-05-24-realtime-background-hermes-runs.md` (companion plan)
|
||||
- `plugin/relay/realtime_agent/broker.py`
|
||||
- `plugin/relay/realtime_agent/hermes_tool_broker.py`
|
||||
- `plugin/relay/realtime_agent/models.py`
|
||||
- `plugin/relay/realtime_agent/providers/xai.py`
|
||||
- `plugin/relay/realtime_agent/providers/openai.py`
|
||||
- `plugin/relay/profile_voice.py`
|
||||
- `docs/relay-protocol.md`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/VoiceSettingsScreen.kt`
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Hermes Relay UI Refresh Mockups
|
||||
|
||||
Created as a design-only pass. These files do not change the Android app.
|
||||
|
||||
## Preview
|
||||
|
||||
Open `index.html` in a browser:
|
||||
|
||||
```powershell
|
||||
Start-Process "C:\Users\Bailey\Desktop\Open-Projects\hermes-relay\docs\mockups\hermes-relay-ui-refresh\index.html"
|
||||
```
|
||||
|
||||
## Direction
|
||||
|
||||
The proposed layout is a "Relay cockpit":
|
||||
|
||||
- Chat remains the default surface and keeps the sessions drawer.
|
||||
- Manage remains primary for the standard install path.
|
||||
- Bridge becomes a first-class mode instead of being buried in Settings.
|
||||
- Terminal is always one tap away from Chat, Manage, and Bridge.
|
||||
- Settings moves behind the agent/profile area and system menu instead of occupying a fat bottom tab.
|
||||
- The bottom of the app becomes a thin live status strip, closer to official Hermes Desktop, rather than a large navigation bar.
|
||||
|
||||
## Inputs
|
||||
|
||||
Local app observations:
|
||||
|
||||
- `RelayApp.kt` currently registers Chat, Manage, Settings as bottom nav items, while Terminal and Bridge are routed but reached from secondary surfaces.
|
||||
- `ChatScreen.kt` already has the useful pieces: session drawer, agent header, endpoint chip, command palette, voice, and attachment composer.
|
||||
- `SettingsScreen.kt` owns profile inspection, connections, Hermes management, chat/voice/media/appearance, and a "Power tools" section containing Terminal and Bridge.
|
||||
- `BridgeCoreScreen.kt` already has the right Bridge grouping: Connections, Terminal, Voice, Notification companion, Media, and Relay sessions.
|
||||
- `AgentInfoSheet` already consolidates Profile, Personality, and Connection. The mockup turns that into the main profile-management affordance.
|
||||
|
||||
Official Hermes references:
|
||||
|
||||
- [Hermes Agent home](https://hermes-agent.nousresearch.com/) for the stark black/white/electric-blue and ASCII/glyph language.
|
||||
- [Hermes Desktop page](https://hermes-agent.nousresearch.com/desktop) for the electric-blue duotone visual system and feature ordering.
|
||||
- [Desktop App docs](https://hermes-agent.nousresearch.com/docs/user-guide/desktop) for the chat-first layout, left sidebar, bottom status bar, right preview rail, management panes, command palette, sessions, and profile concepts.
|
||||
- [NousResearch/hermes-agent desktop routes](https://github.com/NousResearch/hermes-agent/blob/main/apps/desktop/src/app/routes.ts) for the official top-level management surfaces: settings, command center, skills, messaging, artifacts, cron, profiles, and agents.
|
||||
|
||||
## Proposed Implementation Shape
|
||||
|
||||
1. Replace the large `NavigationBar` in `RelayApp.kt` with:
|
||||
- a compact top `RelayModeStrip` for Chat, Manage, Bridge;
|
||||
- a persistent Terminal icon shortcut in the app chrome;
|
||||
- a thin bottom `RelayStatusStrip` for connection, model/profile, and safety state.
|
||||
|
||||
2. Make Bridge primary:
|
||||
- keep `BridgeCoreScreen.kt` as the standard-install Bridge landing;
|
||||
- for sideload builds, keep Device Control under Bridge as a protected advanced section;
|
||||
- keep Settings links for discoverability, but stop making Settings the only path.
|
||||
|
||||
3. Promote profile management:
|
||||
- use `AgentInfoSheet` as the profile switcher and connection switcher;
|
||||
- add a clear route from that sheet to `ProfileInspectorScreen`;
|
||||
- surface default/profile-isolated API state in the header and status strip.
|
||||
|
||||
4. Improve sessions:
|
||||
- add search, pinned/archived groupings, and a profile filter to `SessionDrawerContent`;
|
||||
- preserve the current drawer model so Chat remains uncluttered.
|
||||
|
||||
5. Official-Hermes styling translation:
|
||||
- keep Hermes Relay's navy/purple base;
|
||||
- introduce electric blue as a hard accent, not a full repaint;
|
||||
- add sparse ASCII/glyph texture and sharper dividers;
|
||||
- use compact mono metadata for connection/session/model state.
|
||||
|
||||
## Files to Touch Later
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ChatScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/BridgeCoreScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/SessionDrawer.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionInfoSheet.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/theme/Theme.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/theme/Type.kt`
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 434 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@@ -0,0 +1,330 @@
|
||||
# Plan: Realtime Agent background Hermes runs (foreground -> promote -> durable)
|
||||
|
||||
> **Purpose.** Stop the Realtime Agent's provider event pump from blocking on
|
||||
> the full Hermes run. Keep short turns synchronous and low-latency, but let
|
||||
> genuinely long Hermes runs detach to a tracked background task so the provider
|
||||
> session stays responsive and the run can be monitored, summarized on
|
||||
> completion, and cancelled.
|
||||
>
|
||||
> **Decision of record.** ADR 33 (`docs/decisions.md`). This plan is the
|
||||
> phased, file-level execution of that ADR. Read ADR 33 first — it defines the
|
||||
> three tiers, the relay-as-single-floor-owner rule, the settings surface, and
|
||||
> the Phase 0 prerequisite spike.
|
||||
>
|
||||
> **Non-negotiable.** Hermes remains the only authority for tools, memory,
|
||||
> current data, research, side effects, durable context, and confirmations
|
||||
> (ADR 29/32). This plan changes *when and how* a Hermes run is awaited and
|
||||
> spoken — never whether Hermes governs it.
|
||||
|
||||
## Current State (grounded in code)
|
||||
|
||||
The realtime-agent broker already runs a persistent provider event pump; the
|
||||
gap is that tool execution blocks it.
|
||||
|
||||
- `plugin/relay/realtime_agent/broker.py`
|
||||
- `_pump_provider_events` (~`broker.py:1124`) is the long-lived
|
||||
`native_provider_task` — the persistent loop. It survives WS detach/resume.
|
||||
- `FUNCTION_CALL_COMPLETED` -> `_handle_provider_tool_call` (~`broker.py:1628`)
|
||||
-> `_run_brokered_tool` (~`broker.py:1930`, `return await task`) ->
|
||||
`_execute_brokered_tool` (~`broker.py:1956`) `async for`s the entire Hermes
|
||||
SSE stream before the pump consumes the next provider event. **This is the
|
||||
blocking await to remove for long runs.**
|
||||
- `_send_hermes_run_progress` (~`broker.py:2245`) already runs concurrently as
|
||||
`progress_task`, emitting `hermes.run.progress` every
|
||||
`_HERMES_PROGRESS_INTERVAL_SECONDS` and spoken filler after
|
||||
`_HERMES_SPOKEN_PROGRESS_AFTER_SECONDS`. Reuse this for background progress.
|
||||
- `_cancel_active_hermes` (~`broker.py:2295`) already cancels `hermes_task`
|
||||
cross-coroutine. Promotion must keep this working.
|
||||
- `RealtimeAgentSession` (~`broker.py:114`) holds `hermes_task`,
|
||||
`hermes_run_status`, and the `native_forced_*` suppression flags. Floor state
|
||||
will live here.
|
||||
- `_render_provider_audio` (~`broker.py:2509`) is the relay TTS render mouth
|
||||
(forced-summary fallback). It must obey the floor owner.
|
||||
- `_request_playback_drain` / `_send_provider_tool_result` /
|
||||
`_request_provider_response` (~`broker.py:1700`-`1810`) are the existing
|
||||
result-injection primitives the background-completion path will reuse.
|
||||
- `plugin/relay/realtime_agent/hermes_tool_broker.py`
|
||||
- `_TOOL_SURFACE = ("hermes_run_task", "hermes_get_status", "hermes_cancel",
|
||||
"hermes_confirm")` (~`:189`) — background vocabulary already advertised.
|
||||
- `stream_task` yields the run; unchanged by this plan.
|
||||
- `plugin/relay/realtime_agent/models.py` — `SERVER_EVT_*`, `CLIENT_MSG_*`,
|
||||
`HERMES_TOOL_SCHEMAS`. New events/fields added here.
|
||||
- Providers (`providers/xai.py`, `providers/openai.py`) — both run
|
||||
`turn_detection: None` (relay owns `response.create`) and emit audio. This is
|
||||
what makes "withhold the summary until the floor is idle" possible.
|
||||
- Settings: `plugin/relay/profile_voice.py` (`realtime_voice_settings`,
|
||||
`save_profile_voice_section`) + `plugin/relay/config.py`.
|
||||
- Android: `viewmodel/VoiceViewModel.kt`, `network/RelayVoiceClient.kt`,
|
||||
`data/VoicePreferences.kt`, `data/RealtimeConversationContext.kt`,
|
||||
`voice/RealtimeTurnSyncBuilder.kt`, `ui/screens/VoiceSettingsScreen.kt`.
|
||||
- Tests: `plugin/tests/test_realtime_agent_routes.py`,
|
||||
`test_realtime_agent_xai_provider.py`, `test_realtime_agent_openai_provider.py`.
|
||||
|
||||
### Three audio sources ("who speaks")
|
||||
|
||||
All three converge on Android's single `AudioTrack` via `voice.output_audio.delta`:
|
||||
|
||||
1. Realtime provider (xAI/OpenAI) — primary.
|
||||
2. Relay TTS render (`_render_provider_audio`) — fallback; same wire event.
|
||||
3. Android local TTS — `should_speak` filler on `hermes.run.progress`.
|
||||
|
||||
Today the blocking await serializes them. This plan replaces that implicit mutex
|
||||
with an **explicit relay-side floor owner**.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
```text
|
||||
provider event pump (never blocks on a long run)
|
||||
-> short run: await inline (Tier A, unchanged ADR 32 shape)
|
||||
-> long run: detach to tracked task, resume pump, speak handoff (Tier B)
|
||||
-> explicit durable: return handle immediately (Tier C)
|
||||
|
|
||||
v
|
||||
FloorOwner(idle | provider_speaking | hermes_filler | result_pending)
|
||||
|
|
||||
background run completes -> queue result_pending -> speak when floor idle
|
||||
```
|
||||
|
||||
**FloorOwner contract (relay-side, per session):**
|
||||
- Exactly one mouth holds the floor; provider holds it by default.
|
||||
- A completed background result is queued `result_pending`, never barges in. It
|
||||
is spoken (provider summary, or relay-TTS fallback) only on transition to
|
||||
`idle`.
|
||||
- Android `should_speak` filler is suppressed while `provider_speaking`.
|
||||
- `_render_provider_audio` may only fire when it owns the floor and the provider
|
||||
has drained.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change Tier A latency or the ADR 32 preamble -> forced-Hermes ->
|
||||
provider-summary shape for short turns.
|
||||
- Do not give the provider authority over tools/memory/confirmations.
|
||||
- Do not support `max_background_runs > 1` in this plan (single `hermes_task`).
|
||||
- Do not require adb/device testing for verification unless Bailey asks. JVM/py
|
||||
unit tests plus the lab smoke gate cover acceptance.
|
||||
- Do not depend on Hermes upstream changes; the broker already owns the loop.
|
||||
|
||||
## Protocol Additions (additive only)
|
||||
|
||||
Relay -> client (add to `models.py`):
|
||||
- `hermes.run.promoted` — `{run_id, promote_after_ms, spoken_handoff, tier}`.
|
||||
- `hermes.run.background_completed` — `{run_id, ok, tool_count}` (precedes summary).
|
||||
- Extend `hermes.run.progress` with `tier` (`foreground|promoted|durable`) and
|
||||
`floor` (`idle|provider_speaking|hermes_filler|result_pending`).
|
||||
|
||||
No new client->relay messages required — `response.cancel`, `hermes_cancel`, and
|
||||
`hermes_get_status` already exist; the latter two become reachable mid-run once
|
||||
the pump no longer blocks.
|
||||
|
||||
## Settings Surface (per ADR 33)
|
||||
|
||||
Relay-side `realtime_voice` config (source of truth, per-profile override via
|
||||
`profile_voice.py`), mirrored into Android `VoicePreferences`:
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `promotion_enabled` | `true` (Phase 3) | Master switch; `false` pins Tier A blocking |
|
||||
| `promote_after_ms` | `6000` | Grace window before Tier B handoff |
|
||||
| `background_default_mode` | `promote` | Ambiguous long turn: `promote` vs `foreground` |
|
||||
| `spoken_handoff` | `true` | Speak "I've started that" on promotion |
|
||||
| `progress_spoken_after_ms` | `15000` | Reuse `_HERMES_SPOKEN_PROGRESS_AFTER_SECONDS` |
|
||||
| `progress_repeat_ms` | `30000` | Reuse `_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS` |
|
||||
| `result_delivery` | `speak_when_idle` | vs `notify_then_speak` / `visual_only` |
|
||||
| `max_background_runs` | `1` | Fixed at 1 this plan |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Provider idle-tolerance spike (GATES default-on)
|
||||
|
||||
**Goal.** Determine empirically whether xAI and OpenAI realtime sessions tolerate
|
||||
being held open and quiescent (no `response.create`, no input audio) for
|
||||
30–120s, since Tier B holds the floor while a background run completes.
|
||||
|
||||
**Tasks.**
|
||||
1. Add a throwaway script under `plugin/tools/` or a `scripts/` probe (not
|
||||
shipped) that opens each provider realtime socket via the existing adapters,
|
||||
sends `session.update`, then idles 30/60/120s and logs: socket survival, any
|
||||
server-side timeout/close codes, VAD/turn artifacts on the first post-idle
|
||||
`response.create`, and any keep-alive requirement.
|
||||
2. Record findings in `docs/realtime-voice-poc.md` under a new "Idle tolerance"
|
||||
section, per provider.
|
||||
|
||||
**Acceptance.**
|
||||
- A documented per-provider verdict: `hold-floor-ok` | `needs-keepalive` |
|
||||
`must-reopen`. This verdict selects the Tier B fallback strategy per provider.
|
||||
|
||||
**Note.** If a provider is `must-reopen`, Tier B for that provider detaches the
|
||||
run but closes/reopens the provider socket (or keeps a minimal keep-alive)
|
||||
rather than holding it conversational. Capture that in the ADR's Phase 0 line.
|
||||
|
||||
**Status: DONE (2026-05-24).** Verdicts recorded in
|
||||
`docs/realtime-voice-poc.md` → Idle tolerance:
|
||||
- **OpenAI `gpt-realtime-2` — `hold-floor-ok` (empirical).** Probe ran live
|
||||
(10s/20s/30s idle windows survived; clean post-idle audio each time).
|
||||
- **xAI `grok-voice-latest` — `hold-floor-ok`.** No xAI creds on the dev box, but
|
||||
the verdict is not conditional: the shipping Realtime Agent already holds
|
||||
`xai_realtime` sessions open across between-turn idle gaps
|
||||
(`turn_detection: None` + resume TTL) with no idle-close reports. A relay-host
|
||||
probe run is retained as a regression check, not a precondition.
|
||||
|
||||
**Premise superseded.** Phase 2/3 implemented Tier B by *closing the pending
|
||||
provider call with an interim ack* rather than holding an open response, so the
|
||||
"hold the floor conversational while a run completes" worst case this spike
|
||||
guarded against does not occur — the socket only sees the normal between-turns
|
||||
idle gap. Both providers are `hold-floor-ok`, so the default-on gate is satisfied
|
||||
(no provider needs the `must-reopen` fallback today).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Introduce FloorOwner under current blocking behavior (no functional change)
|
||||
|
||||
**Goal.** Add the explicit floor state machine and route all three mouths through
|
||||
it, while preserving today's exact audible behavior. This is the de-risking step.
|
||||
|
||||
**Tasks.**
|
||||
1. Add `RealtimeFloor` (new dataclass/enum) to `broker.py` or a new
|
||||
`realtime_agent/floor.py`: state `idle|provider_speaking|hermes_filler|
|
||||
result_pending`, with `acquire(mouth)`, `release(mouth)`, and
|
||||
`can_speak(mouth) -> bool`. Pure, unit-testable, no I/O.
|
||||
2. Hold a `floor: RealtimeFloor` on `RealtimeAgentSession`.
|
||||
3. Gate the three emit paths through `floor.can_speak(...)`:
|
||||
- provider audio in `_pump_provider_events` (`AUDIO_DELTA` -> `provider`),
|
||||
- `_render_provider_audio` (-> `relay_tts`),
|
||||
- `should_speak` progress events (-> `android_filler`; suppress while
|
||||
`provider_speaking`).
|
||||
4. Transition floor on `RESPONSE_STARTED`/`AUDIO_DONE`/`RESPONSE_DONE` and on
|
||||
playback-drain completion.
|
||||
5. Stamp `floor` onto `hermes.run.progress` (additive field).
|
||||
|
||||
**Acceptance.**
|
||||
- All existing `plugin/tests/test_realtime_agent_*` pass unchanged.
|
||||
- New `test_realtime_floor.py` proves single-mouth invariants:
|
||||
background-result-never-barges, filler-suppressed-while-provider-speaks,
|
||||
relay-TTS-only-when-owned.
|
||||
- Manually/log-verified: a short turn sounds identical to pre-change.
|
||||
|
||||
**Test gate.** `python -m unittest plugin.tests.test_realtime_floor` +
|
||||
existing realtime agent tests green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Tier B grace-period promotion (default OFF)
|
||||
|
||||
**Goal.** Detach a long Hermes run from the pump after `promote_after_ms`; resume
|
||||
the pump; speak a handoff; deliver the result on completion via the floor owner.
|
||||
|
||||
**Tasks.**
|
||||
1. `models.py`: add `hermes.run.promoted`, `hermes.run.background_completed`,
|
||||
`tier` field; add settings keys to the config schema.
|
||||
2. `profile_voice.py` / `config.py`: read/write the new `realtime_voice` keys
|
||||
with defaults above (`promotion_enabled=false` this phase).
|
||||
3. `broker.py` — split `_run_brokered_tool` for `hermes_run_task`:
|
||||
- Start the run task as today, but `await asyncio.wait({task}, timeout=
|
||||
promote_after_ms)`.
|
||||
- If done in time: Tier A path, unchanged.
|
||||
- If not: emit `hermes.run.promoted`, optionally speak handoff (gated by
|
||||
`spoken_handoff` + floor), set `tier="promoted"`, **return control to the
|
||||
pump** without awaiting the task. Keep `session.hermes_task` set.
|
||||
4. Add a pump-side drain point: between provider events (and on a small timer),
|
||||
check for a finished background task; when finished, emit
|
||||
`hermes.run.background_completed`, then inject via the existing
|
||||
`_send_provider_tool_result` -> `_request_provider_response` path **only when
|
||||
`floor` is `idle`** (else mark `result_pending` and inject on next `idle`).
|
||||
5. Preserve cancellation: `hermes_cancel` (now reachable) and `response.cancel`
|
||||
both route to `_cancel_active_hermes`.
|
||||
6. Resume safety: if WS detaches mid-background-run, the completion event must
|
||||
replay through the existing event-ring/resume path on reattach.
|
||||
|
||||
**Acceptance.**
|
||||
- With `promotion_enabled=true` in test config, a run that exceeds
|
||||
`promote_after_ms` emits `hermes.run.promoted`, the pump processes a subsequent
|
||||
provider event before the run finishes (proves non-blocking), and the result is
|
||||
spoken exactly once after completion.
|
||||
- A run under the window behaves as Tier A (no `promoted` event).
|
||||
- Cancel during a promoted run stops it and emits `hermes.run.cancelled`.
|
||||
- Detach+resume during a promoted run replays `background_completed`.
|
||||
|
||||
**Test gate.** New `test_realtime_promotion.py` (fake provider connection + fake
|
||||
Hermes broker with controllable latency) covering: under-window, over-window,
|
||||
cancel-promoted, detach-resume-completes, result-waits-for-idle-floor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Default-on + Tier C durable + Android settings UI
|
||||
|
||||
**Goal.** Flip `promotion_enabled` default to `true` (gated on Phase 0 verdict),
|
||||
add explicit `mode="background"`, and expose settings on Android.
|
||||
|
||||
**Tasks.**
|
||||
1. `models.py` `HERMES_TOOL_SCHEMAS`: document/validate `hermes_run_task.mode`
|
||||
(`run|background`); `background` returns a handle immediately (skip grace
|
||||
window, go straight to Tier B detach).
|
||||
2. Default `promotion_enabled=true`; per-provider Tier B strategy from Phase 0.
|
||||
3. Android `VoicePreferences.kt` + `RelayVoiceClient.kt`: read/write the new
|
||||
settings; surface in `VoiceSettingsScreen.kt` under Realtime Agent (promotion
|
||||
toggle, grace slider, handoff toggle, result-delivery picker).
|
||||
4. Android `VoiceViewModel.kt` + `RealtimeTurnSyncBuilder.kt`: handle
|
||||
`hermes.run.promoted` / `hermes.run.background_completed`; render a persistent
|
||||
"working on: …" chip while `tier=promoted/durable`; implement
|
||||
`notify_then_speak` affordance (chime + tap-to-hear) if that mode is selected.
|
||||
5. Docs: `docs/relay-protocol.md` (new events/fields), `user-docs/features/
|
||||
voice.md` (settings + behavior), `CHANGELOG.md` `[Unreleased]`.
|
||||
|
||||
**Acceptance.**
|
||||
- Default config promotes long runs without user action; short runs unaffected.
|
||||
- `hermes_run_task(mode="background")` returns immediately and completes via the
|
||||
same path.
|
||||
- Android shows promoted state and speaks the result per `result_delivery`.
|
||||
- `./gradlew lint` clean; py tests green; lab smoke
|
||||
(`scripts/realtime-voice-lab-smoke.ps1`) still under threshold for short turns.
|
||||
|
||||
**Test gate.** Full `plugin.tests.test_realtime_agent_*` + new floor/promotion
|
||||
suites; Android unit tests for the new `VoicePreferences`/sync mapping;
|
||||
`./gradlew lint`.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Floor logic stacking on `native_forced_*` suppression.** The forced-summary
|
||||
state machine is the most fragile code in `broker.py`. Mitigation: Phase 1
|
||||
introduces FloorOwner *as the serializer*; migrate suppression decisions to ask
|
||||
the floor rather than adding parallel flags.
|
||||
- **Double-speak (provider + relay TTS).** Mitigation: both gated by
|
||||
`floor.can_speak`; result injection only on `idle`.
|
||||
- **Provider idle close.** Mitigation: Phase 0 verdict picks per-provider Tier B
|
||||
strategy; `must-reopen` providers don't hold the floor.
|
||||
- **Stranded background result on disconnect.** Mitigation: reuse event-ring +
|
||||
resume replay; covered by Phase 2 acceptance.
|
||||
|
||||
## Test Strategy Summary
|
||||
|
||||
- Pure unit: `RealtimeFloor` invariants (Phase 1).
|
||||
- Broker integration with fakes: promotion timing, cancel, resume, idle-gated
|
||||
delivery (Phase 2).
|
||||
- Android: settings round-trip + event handling (Phase 3).
|
||||
- Regression: existing realtime agent suites unchanged throughout; lab smoke for
|
||||
short-turn latency.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `docs/decisions.md` (ADR 33)
|
||||
- `plugin/relay/realtime_agent/broker.py`
|
||||
- `plugin/relay/realtime_agent/floor.py` (new)
|
||||
- `plugin/relay/realtime_agent/hermes_tool_broker.py`
|
||||
- `plugin/relay/realtime_agent/models.py`
|
||||
- `plugin/relay/realtime_agent/providers/xai.py`
|
||||
- `plugin/relay/realtime_agent/providers/openai.py`
|
||||
- `plugin/relay/profile_voice.py`
|
||||
- `plugin/relay/config.py`
|
||||
- `plugin/tests/test_realtime_floor.py` (new)
|
||||
- `plugin/tests/test_realtime_promotion.py` (new)
|
||||
- `docs/relay-protocol.md`
|
||||
- `docs/realtime-voice-poc.md` (Phase 0 findings)
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/voice/RealtimeTurnSyncBuilder.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/data/VoicePreferences.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayVoiceClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/VoiceSettingsScreen.kt`
|
||||
- `user-docs/features/voice.md`
|
||||
- `CHANGELOG.md`
|
||||
@@ -0,0 +1,83 @@
|
||||
# Plan: Persistent realtime-agent session (one socket across turns)
|
||||
|
||||
> **Purpose.** Make Realtime Agent voice a *persistent conversation* instead of a
|
||||
> new session per utterance. Today each turn calls
|
||||
> `RelayVoiceClient.runRealtimeAgent()` which `createRealtimeAgentSession()`s a
|
||||
> fresh relay session + provider socket and closes it on `voice.response.done`.
|
||||
> The provider therefore has no live memory of prior turns (only relay-seeded
|
||||
> context snippets), and every turn pays session-setup latency.
|
||||
>
|
||||
> **Key finding.** This is a **client-only** change. The relay already supports
|
||||
> many turns on one socket — `_handle_provider_native_ws` loops over
|
||||
> `input_audio.append` / `input_audio.commit` / `response.create` and only tears
|
||||
> the session down when the WebSocket disconnects. `voice.response.done` is a
|
||||
> *turn* boundary, not a *session* boundary. The client is what closes the socket
|
||||
> on `voice.response.done` (`RelayVoiceClient.kt:1410`).
|
||||
|
||||
## Design
|
||||
|
||||
Keep one relay session + provider socket open for the lifetime of a Realtime
|
||||
Agent voice-mode session; feed each utterance as a new turn on that socket.
|
||||
|
||||
### RelayVoiceClient
|
||||
- Add an opt-in **persistent mode** to `runRealtimeAgent` via two optional params
|
||||
(one-shot path is byte-for-byte unchanged when they're null):
|
||||
- `turnInputs: ReceiveChannel<RealtimeTurnInput>?` — when non-null, the call is
|
||||
a long-lived session: after the first turn it reads further turns off the
|
||||
channel and sends their `input_audio.append`+`commit` on the open socket.
|
||||
- `onTurnComplete: (RealtimeVoiceSummary) -> Unit` — invoked at each
|
||||
`voice.response.done` instead of completing+closing.
|
||||
- In persistent mode:
|
||||
- `voice.response.done` → call `onTurnComplete`, **do not** close the socket or
|
||||
complete `finished`.
|
||||
- A reader coroutine drains `turnInputs` and sends each turn's PCM chunks.
|
||||
- `finished` completes only when the channel closes (voice-mode exit) or on a
|
||||
fatal socket/provider error.
|
||||
- The per-turn idle/turn-limit guards in `awaitRealtimeAgentCompletion` are
|
||||
scoped to an *active* turn only — between-turn idle is expected and must not
|
||||
trip `REALTIME_AGENT_IDLE_TIMEOUT_MS`.
|
||||
- `RealtimeTurnInput(inputPcm, sampleRate, prompt)` data class.
|
||||
|
||||
### VoiceViewModel
|
||||
- Hold a `realtimeLiveSession` (the persistent call's `Job` + the
|
||||
`SendChannel<RealtimeTurnInput>`), opened lazily on the first Realtime Agent
|
||||
turn and reused for subsequent turns.
|
||||
- Per utterance: instead of a fresh `runRealtimeAgentTurn`, do the per-turn UI
|
||||
setup (state reset, watchdog, `chatVm.startRealtimeAgentTurn`) and
|
||||
`realtimeLiveSession.submit(RealtimeTurnInput(...))`.
|
||||
- Close the session (`channel.close()` + cancel job) on `exitVoiceMode`, engine
|
||||
switch away from Realtime Agent, and `onCleared`.
|
||||
- The continuous event callback (the `when(event.type)` block) is registered once
|
||||
for the session and handles every turn's events.
|
||||
|
||||
### Fallback flag (risk control)
|
||||
- `VoicePreferences.realtimePersistentSession` (default **true**). When false,
|
||||
fall back to the current one-shot `runRealtimeAgentTurn` path. Lets the user
|
||||
flip back on-device without a rebuild if the persistent path misbehaves.
|
||||
|
||||
## Non-Goals
|
||||
- No relay changes (the relay already supports multi-turn sockets).
|
||||
- No change to the one-shot path used by the Voice Lab / stable engine.
|
||||
- Not changing barge-in semantics beyond keeping them working per turn.
|
||||
|
||||
## Risks / must-validate-on-device
|
||||
- Feeding new input while a prior turn's audio is still draining (barge-in vs.
|
||||
new turn). Mitigation: reuse existing barge-in/cancel before submitting a turn.
|
||||
- Per-turn UI state resets must not tear down the shared session.
|
||||
- Between-turn idle must not trip the stall timeout.
|
||||
- Provider/relay session longevity across long pauses (the socket stays open, so
|
||||
no resume-TTL dependency — but confirm providers don't idle-close; see ADR 33
|
||||
Phase 0 `hold-floor-ok`).
|
||||
|
||||
## Test strategy
|
||||
- Unit-test the pure pieces: `RealtimeTurnInput` chunking, the persistent-vs-
|
||||
one-shot branch decision, idle-guard gating by active-turn.
|
||||
- On-device (post-merge, by Bailey): multi-turn conversation with follow-up
|
||||
references ("what did you just say?"), background promotion mid-conversation,
|
||||
barge-in, exit/re-enter voice mode, engine switch.
|
||||
|
||||
## Key Files
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayVoiceClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/data/VoicePreferences.kt`
|
||||
- `docs/relay-protocol.md` (note: turn vs. session boundary)
|
||||
@@ -0,0 +1,430 @@
|
||||
# Standard Dashboard + Relay Power Mode Plan
|
||||
|
||||
**Status:** Ready for agent-team implementation
|
||||
**Date:** 2026-06-07
|
||||
**Owner surface:** Android app, relay compatibility layer, user docs
|
||||
**Goal path:** `docs/plans/2026-06-07-standard-dashboard-relay-power-mode.md`
|
||||
**Goal:** Make the default Android experience feel like a standard Hermes dashboard/client while keeping relay-only capabilities as power-user features that clearly require pairing.
|
||||
|
||||
---
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The app should have a standard default path and a power-user relay path:
|
||||
|
||||
- Standard users can connect with the Hermes API server and dashboard auth, then use chat, skills, cron, MCP, profiles, models, and basic settings.
|
||||
- Relay-specific features remain supported, but are no longer part of the default mental model.
|
||||
- Terminal, Bridge, relay sessions, route/grant management, media file inspection, and profile memory file editing are power-user features.
|
||||
- Power-user features must clearly show "Requires pairing" when the current connection is not paired.
|
||||
- Pairing remains the source of truth for relay grants, terminal/bridge access, TUI/desktop bridge flows, and device-control features.
|
||||
- Avoid visible tier labels such as Easy, Standard, or Advanced. Use plain product language: "Power tools", "Requires pairing", "Pair to unlock".
|
||||
|
||||
This is a UI/data-plane realignment. It is not a relay protocol removal.
|
||||
|
||||
---
|
||||
|
||||
## Verified Context
|
||||
|
||||
### Upstream Hermes Direction
|
||||
|
||||
Latest upstream Hermes has moved much of the admin surface into dashboard APIs:
|
||||
|
||||
- Dashboard/admin server on `:9119` includes `/api/status`, `/api/config`, `/api/skills`, `/api/cron/jobs`, `/api/mcp/servers`, `/api/profiles`, `/api/memory`, toolsets, env, and plugin routes.
|
||||
- Dashboard auth supports remote gated sessions using cookies, plus loopback/session-token behavior for the web dashboard.
|
||||
- API server on `:8642` exposes standard external surfaces such as `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets`.
|
||||
- The lightweight `rusty4444/hermes-android` model is API/dashboard oriented and does not cover relay pairing, grants, bridge, or terminal behavior.
|
||||
|
||||
### Local Current State
|
||||
|
||||
Relevant local files:
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ConnectionsSettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionWizard.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/data/ConnectionData.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/HermesApiClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayProfileInspectorClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProfileInspectorScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ProfileInspectorViewModel.kt`
|
||||
- `hermes_relay_bootstrap/_handlers.py`
|
||||
- `hermes_relay_bootstrap/_patch.py`
|
||||
- `docs/android-ui-design-reference.md`
|
||||
- `user-docs/reference/configuration.md`
|
||||
|
||||
Current gaps:
|
||||
|
||||
- Bottom navigation currently makes Terminal and Bridge first-class default destinations.
|
||||
- Standard dashboard/admin capabilities are not first-class native Android screens.
|
||||
- Profile inspector and memory file editing are relay-session oriented.
|
||||
- Skill toggle still has compatibility paths around relay/bootstrap behavior.
|
||||
- Connection state does not separately model API auth, dashboard auth, and relay pairing.
|
||||
|
||||
---
|
||||
|
||||
## Product Rules
|
||||
|
||||
### Standard Default
|
||||
|
||||
Default visible areas should prioritize:
|
||||
|
||||
- Chat
|
||||
- Sessions or conversation history
|
||||
- Skills
|
||||
- Cron jobs
|
||||
- MCP servers/catalog
|
||||
- Profiles and SOUL basics
|
||||
- Models/config basics
|
||||
- Connection health
|
||||
- Appearance and normal app settings
|
||||
|
||||
MCP can appear in the standard management surface, but raw server command editing, env/key reveal, and risky install details belong behind power-user disclosure.
|
||||
|
||||
### Power Tools
|
||||
|
||||
Power tools include:
|
||||
|
||||
- Terminal
|
||||
- Bridge
|
||||
- Relay sessions
|
||||
- Route/grant inspection
|
||||
- Media token/file inspector
|
||||
- Profile memory file editor
|
||||
- Raw config editor
|
||||
- Env reveal and secret management
|
||||
- Diagnostics and developer options
|
||||
|
||||
These features stay supported. They should be quieter in default navigation and clearly gated when pairing is missing.
|
||||
|
||||
### Pairing Gate Pattern
|
||||
|
||||
Use one shared UI pattern for unpaired power features:
|
||||
|
||||
- Show the feature name and short capability summary.
|
||||
- Show a stable status label: `Requires pairing`.
|
||||
- Show a primary action: `Pair to unlock`.
|
||||
- Provide one sentence of explanation: "This feature uses relay grants and requires a paired device session."
|
||||
- Do not expose raw URL/token forms as the first recovery path.
|
||||
- Do not present disabled empty screens without a clear pairing CTA.
|
||||
|
||||
If the device was previously paired but the relay session expired, use:
|
||||
|
||||
- Status: `Pairing expired`
|
||||
- Primary action: `Pair again`
|
||||
- Secondary action where appropriate: `View connection`
|
||||
|
||||
### Auth Model
|
||||
|
||||
Model three separate auth contexts:
|
||||
|
||||
| Context | Typical port | Purpose | User-facing language |
|
||||
| --- | --- | --- | --- |
|
||||
| API server | `8642` | Chat, runs, read-only standard capabilities | API connection |
|
||||
| Dashboard | `9119` | Skills, cron, MCP, profiles, config, model/admin APIs | Dashboard sign-in |
|
||||
| Relay | `8767` | Terminal, Bridge, relay sessions, device grants, media relay | Pairing |
|
||||
|
||||
Never imply that dashboard sign-in unlocks relay-grant features. Never imply that relay pairing replaces API/dashboard auth.
|
||||
|
||||
---
|
||||
|
||||
## Feature Placement Matrix
|
||||
|
||||
| Feature | Default placement | Required auth | Pairing required? |
|
||||
| --- | --- | --- | --- |
|
||||
| Chat | Main nav | API key/session | No |
|
||||
| Conversation sessions | Main nav or Chat subview | API key/session | No |
|
||||
| Skills browse/toggle | Standard management | Dashboard auth | No |
|
||||
| Cron jobs | Standard management | Dashboard auth | No |
|
||||
| MCP servers/catalog | Standard management | Dashboard auth | No |
|
||||
| Profiles/SOUL basics | Standard management | Dashboard auth | No |
|
||||
| Model/config basics | Standard settings | Dashboard auth | No |
|
||||
| Memory provider status/reset | Standard settings | Dashboard auth | No |
|
||||
| Profile memory file editor | Power tools | Relay session | Yes |
|
||||
| Terminal | Power tools | Relay session/grants | Yes |
|
||||
| Bridge | Power tools | Relay session/grants | Yes |
|
||||
| Relay sessions/routes/grants | Power tools | Relay session | Yes |
|
||||
| Media inspector | Power tools | Relay session | Yes |
|
||||
| Raw config/env reveal | Power tools | Dashboard auth | No, but confirm/reveal required |
|
||||
| Diagnostics/developer options | Power tools | Varies | Varies |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
## Wave 1 - Dashboard Client and Connection Model
|
||||
|
||||
**Summary.** Add first-class dashboard connectivity alongside existing API and relay connection state.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Add `DashboardApiClient` under `app/src/main/kotlin/com/hermesandroid/relay/network/`.
|
||||
- Support:
|
||||
- `GET /api/status`
|
||||
- `POST /auth/password-login`
|
||||
- `GET /api/auth/me`
|
||||
- Dashboard cookie persistence through the existing secure storage pattern or an explicit encrypted cookie store.
|
||||
- Add typed models for dashboard auth status and feature capabilities.
|
||||
- Extend `ConnectionData.kt` with:
|
||||
- `dashboardUrl`
|
||||
- `dashboardAuthRequired`
|
||||
- `dashboardAuthProviders`
|
||||
- `dashboardLastStatus`
|
||||
- migration/default derivation from API host to `:9119`.
|
||||
- Keep API key, dashboard auth, and relay token storage separate.
|
||||
- Unit tests cover URL derivation, auth state parsing, missing dashboard handling, and migration defaults.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/data/ConnectionData.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/HermesApiClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/DashboardApiClient.kt` new
|
||||
- `app/src/test/kotlin/com/hermesandroid/relay/network/DashboardApiClientTest.kt` new
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 1 from `docs/plans/2026-06-07-standard-dashboard-relay-power-mode.md`. Add a dashboard client and connection metadata without changing relay pairing behavior. Keep API, dashboard, and relay auth as separate states. Add focused tests for dashboard status/auth parsing and connection migration.
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 - Shared Feature Gate UX
|
||||
|
||||
**Summary.** Create reusable UI for power features that require pairing.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Add a reusable component for gated power features, for example `RequiresPairingCard` or `PowerFeatureGate`.
|
||||
- Component supports:
|
||||
- `Requires pairing`
|
||||
- `Pairing expired`
|
||||
- `Unavailable on this server`
|
||||
- `Dashboard sign-in required`
|
||||
- Component always provides a clear primary action.
|
||||
- Terminal, Bridge, relay sessions, and profile memory file editor routes use this gate when unpaired.
|
||||
- No power feature falls through to an empty/error-heavy screen when pairing is missing.
|
||||
- Compose previews or screenshot tests cover at least unpaired, expired, and paired states.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProfileInspectorScreen.kt`
|
||||
- terminal and bridge screen files discovered during implementation
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 2. Build the shared pairing gate component and wire it into every relay-only route. The UX copy must use "Requires pairing" and "Pair to unlock", not tier labels. Preserve existing paired behavior.
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 - Standard Management Screens
|
||||
|
||||
**Summary.** Add native standard screens backed by upstream dashboard APIs.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Add native screens/viewmodels for:
|
||||
- Skills list/toggle using dashboard `/api/skills` and `/api/skills/toggle`.
|
||||
- Cron jobs list/detail/actions using `/api/cron/jobs`, runs, pause/resume/trigger/delete.
|
||||
- MCP servers/catalog using `/api/mcp/servers` and catalog/install endpoints.
|
||||
- Profiles/SOUL basics using `/api/profiles` and profile SOUL/model/description endpoints.
|
||||
- Config/model basics using dashboard config/model endpoints where available.
|
||||
- Each screen handles:
|
||||
- Dashboard unavailable
|
||||
- Dashboard sign-in required
|
||||
- API unsupported on older Hermes
|
||||
- Empty state
|
||||
- Loading/error/retry
|
||||
- Do not expose raw env reveal by default.
|
||||
- Keep existing chat command palette behavior working while moving management UX to dashboard APIs.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/DashboardApiClient.kt`
|
||||
- new screen/viewmodel/model files under `ui/screens`, `viewmodel`, `data`, and/or `network`
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 3. Add standard native management screens backed by the dashboard APIs. Keep screens compact and operational, following `docs/android-ui-design-reference.md`. Use dashboard sign-in and unsupported-server states instead of raw stack traces or relay pairing prompts.
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 - Navigation and Settings Realignment
|
||||
|
||||
**Summary.** Make standard management the default path and move relay capabilities behind power tools.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Adjust bottom navigation so Terminal and Bridge are not default-first for new standard users.
|
||||
- Add a standard management destination or settings group for Skills, Cron, MCP, Profiles, and Config.
|
||||
- Add a Power Tools section in Settings for Terminal, Bridge, Relay sessions, Media inspector, raw config/env, diagnostics, and developer options.
|
||||
- Preserve deep links/routes for existing paired users.
|
||||
- Do not remove Terminal or Bridge.
|
||||
- Remove or avoid visible Easy/Standard/Advanced tier copy.
|
||||
- Existing Settings categories stay compact and use disclosure for advanced controls.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ConnectionsSettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DeveloperSettingsScreen.kt`
|
||||
- `user-docs/reference/configuration.md`
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 4. Rebalance navigation toward standard Hermes management while keeping relay tools accessible under Power Tools. Existing paired users must not lose routes; unpaired users should see pairing gates for relay-only features.
|
||||
|
||||
---
|
||||
|
||||
## Wave 5 - Pairing Flow Integration
|
||||
|
||||
**Summary.** Keep pairing strong, but position it as the unlock path for relay power features.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Pairing remains a first-class full-screen flow.
|
||||
- After successful pairing, probe/store dashboard URL if derivable and not already set.
|
||||
- From any gated power feature, `Pair to unlock` opens the existing pairing flow and returns to the requested feature after success when practical.
|
||||
- Manual connection remains available, but under Advanced/manual setup.
|
||||
- Pairing confirmation continues to show route, grants, API server, relay endpoint, TTL, and warnings.
|
||||
- Expired pairing state is distinct from never paired.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionWizard.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/PairScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ConnectionsSettingsScreen.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt`
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 5. Keep the existing pairing security model and use it as the unlock path for power tools. Do not flatten relay tokens into API/dashboard auth. Add return-to-feature behavior where the navigation model supports it cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Wave 6 - Relay Compatibility and Bootstrap Cleanup
|
||||
|
||||
**Summary.** Keep relay/profile-memory power paths, but stop treating bootstrap compatibility endpoints as the primary standard admin API.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Keep relay profile memory file editing available as a paired power feature.
|
||||
- Replace standard skills/config management calls with dashboard APIs where supported.
|
||||
- Keep compatibility fallback for older Hermes versions, but mark it as fallback in code comments/docs.
|
||||
- Audit `hermes_relay_bootstrap/_handlers.py` and `_patch.py` for routes now covered by upstream dashboard/API server.
|
||||
- Update install/docs to reflect latest `API_SERVER_KEY` requirement for networked API server use.
|
||||
- Decide whether to propose an upstream profile-memory file API. If not, document that this remains a relay power feature.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayProfileInspectorClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ProfileInspectorViewModel.kt`
|
||||
- `hermes_relay_bootstrap/_handlers.py`
|
||||
- `hermes_relay_bootstrap/_patch.py`
|
||||
- `install.sh`
|
||||
- `README.md`
|
||||
- `user-docs/reference/configuration.md`
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 6. Keep profile memory file editing and relay-specific surfaces as paired power features. Prefer upstream dashboard APIs for standard features and narrow bootstrap compatibility to older-server fallback behavior.
|
||||
|
||||
---
|
||||
|
||||
## Wave 7 - Tests, Docs, and Release Notes
|
||||
|
||||
**Summary.** Verify the user-facing split and document the new connection model.
|
||||
|
||||
**Scope / Acceptance criteria.**
|
||||
|
||||
- Android unit tests cover:
|
||||
- dashboard URL derivation
|
||||
- dashboard auth status
|
||||
- pairing gate state mapping
|
||||
- unpaired vs expired vs paired feature availability
|
||||
- fallback behavior for older servers
|
||||
- Compose/UI tests or screenshot checks cover:
|
||||
- Standard default navigation
|
||||
- Power feature requiring pairing
|
||||
- Dashboard sign-in required
|
||||
- Paired power feature available
|
||||
- Docs explain:
|
||||
- API connection
|
||||
- Dashboard sign-in
|
||||
- Pairing for relay power tools
|
||||
- Which features require pairing
|
||||
- Release notes call out that Terminal/Bridge are still supported but now presented as power tools.
|
||||
|
||||
**Files likely to touch.**
|
||||
|
||||
- `app/src/test/`
|
||||
- `app/src/androidTest/` if UI tests already exist
|
||||
- `docs/android-ui-design-reference.md`
|
||||
- `user-docs/reference/configuration.md`
|
||||
- `user-docs/features/dashboard.md`
|
||||
- `README.md`
|
||||
- `RELEASE_NOTES.md`
|
||||
|
||||
**Agent brief.**
|
||||
|
||||
> Implement Wave 7. Add focused tests and docs for the standard-vs-power split. The docs must be explicit that pairing is required for terminal/bridge/relay power features but not for normal API/dashboard use.
|
||||
|
||||
---
|
||||
|
||||
## Agent Team Execution Model
|
||||
|
||||
Recommended parallelization:
|
||||
|
||||
| Wave | Parallelism | Notes |
|
||||
| --- | --- | --- |
|
||||
| Wave 1 | Serial first | Establishes shared connection/auth models. |
|
||||
| Wave 2 | Parallel after Wave 1 model shape is known | UI component work can run while API clients progress if contracts are stable. |
|
||||
| Wave 3 | Parallel by surface | Skills, Cron, MCP, Profiles can be split across agents once `DashboardApiClient` contracts exist. |
|
||||
| Wave 4 | Serial | Navigation/settings touches shared files. |
|
||||
| Wave 5 | Serial or paired with Wave 4 | Pairing return paths depend on navigation shape. |
|
||||
| Wave 6 | Parallel with Wave 7 docs after API decisions | Bootstrap cleanup and docs can proceed once standard APIs are wired. |
|
||||
| Wave 7 | Final serial verification | Runs after surfaces land. |
|
||||
|
||||
Shared-file hot spots:
|
||||
|
||||
- `RelayApp.kt`
|
||||
- `SettingsScreen.kt`
|
||||
- `ConnectionData.kt`
|
||||
- `DashboardApiClient.kt`
|
||||
- `ProfileInspectorScreen.kt`
|
||||
- `ProfileInspectorViewModel.kt`
|
||||
|
||||
Assign only one active agent at a time to each hot-spot file.
|
||||
|
||||
---
|
||||
|
||||
## Definition Of Complete
|
||||
|
||||
The goal is complete when all of the following are true:
|
||||
|
||||
- A fresh standard user can connect with API/dashboard credentials and use chat plus standard management screens without relay pairing.
|
||||
- Terminal, Bridge, relay sessions, media inspector, and profile memory file editor are still available.
|
||||
- Every relay-only feature clearly shows `Requires pairing` with a `Pair to unlock` path when unpaired.
|
||||
- Existing paired users keep their relay workflows.
|
||||
- API auth, dashboard auth, and relay pairing are modeled and displayed as separate connection states.
|
||||
- Standard screens use upstream dashboard/API server endpoints where available.
|
||||
- Bootstrap compatibility routes are fallback only, not the primary standard path.
|
||||
- Docs and release notes explain the new standard/power split.
|
||||
- Tests cover dashboard connection, pairing gates, and compatibility fallback.
|
||||
|
||||
---
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- Do not remove pairing.
|
||||
- Do not remove Terminal or Bridge.
|
||||
- Do not force every standard user through relay pairing.
|
||||
- Do not use visible Easy/Standard/Advanced tier language in the app UI.
|
||||
- Do not expose env/secret reveal in the default settings path.
|
||||
- Do not replace the native Android app with a dashboard WebView.
|
||||
- Do not break older paired sessions without a migration path.
|
||||
|
||||
+31
-20
@@ -4,17 +4,25 @@
|
||||
|
||||
## Short Description (≤80 chars)
|
||||
|
||||
Self-hosted Hermes chat, voice, terminal, and notification companion
|
||||
Your self-hosted Hermes AI agent, in your pocket — chat, voice, and control.
|
||||
|
||||
## Full Description
|
||||
|
||||
**Hermes-Relay** is a native Android client for the Hermes agent platform. Connect to your self-hosted Hermes AI agent and chat, speak, pair, and manage relay sessions directly from your phone.
|
||||
**Hermes-Relay** is the native Android client for the Hermes agent platform. Point it at your own self-hosted Hermes server and chat with your agent, talk to it hands-free, and manage it — models, keys, skills, profiles — from anywhere.
|
||||
|
||||
Built for developers and AI enthusiasts who run their own Hermes agent instance. This is not a hosted AI service — it is a companion app for your own infrastructure.
|
||||
Built for developers and AI enthusiasts who run their own Hermes agent instance. This is not a hosted AI service — it is a companion app for your own infrastructure, and it talks only to servers you configure.
|
||||
|
||||
━━━ QUICK START ━━━
|
||||
|
||||
1. Run hermes-agent with its API server enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server's address (for example http://192.168.1.100:8642).
|
||||
3. The setup wizard probes what your server supports and shows you a readiness card — then you're talking.
|
||||
|
||||
A plain Hermes install is enough: chat, management, and voice all work without any plugin or extra services.
|
||||
|
||||
━━━ HOW IT WORKS ━━━
|
||||
|
||||
Hermes-Relay connects directly to your Hermes API Server over HTTP/SSE for real-time streaming chat. If you also run the Hermes relay service, the app can pair by QR code and use Bridge Core features such as terminal access, voice routes, notification companion, media handoff, and relay-session management.
|
||||
Hermes-Relay connects directly to your Hermes API Server for real-time streaming chat. The Manage tab and voice use your Hermes dashboard with one sign-in. If you also run the optional Hermes relay service, the app can pair by QR code and add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
|
||||
|
||||
━━━ GOOGLE PLAY BUILD ━━━
|
||||
|
||||
@@ -24,23 +32,26 @@ Device Control is reserved for sideload builds distributed outside Google Play.
|
||||
|
||||
━━━ FEATURES ━━━
|
||||
|
||||
◆ Direct API Streaming
|
||||
Chat with your Hermes agent over SSE. Responses stream token-by-token with animated typing indicators.
|
||||
◆ Streaming Chat
|
||||
Chat with your Hermes agent in real time. Responses stream token-by-token with markdown rendering, tool-call visibility, file attachments, and a searchable command palette.
|
||||
|
||||
◆ Session Management
|
||||
Create, switch, rename, and delete chat sessions. Message history is loaded from your Hermes server on demand.
|
||||
|
||||
◆ Profiles and Personalities
|
||||
Switch between configured Hermes profiles and personalities. The active profile context stays visible in chat and voice surfaces.
|
||||
◆ Manage Your Agent
|
||||
The full Hermes dashboard, native on your phone: switch models from your provider catalog, manage provider keys (write-only and masked), create and edit agent profiles, and browse, install, and update skills.
|
||||
|
||||
◆ Voice Mode
|
||||
Use optional microphone capture for Hermes Chat + Voice Output, where Hermes owns the chat/tool turn and the relay renders assistant speech. An experimental Realtime Agent engine can use provider-native realtime speech while Hermes remains the tool, profile, confirmation, and memory authority.
|
||||
Talk to your agent hands-free. Voice works on a plain Hermes install using the speech providers your server is configured with — no plugin needed. Relay-paired setups add per-profile voice providers and an experimental realtime conversation engine.
|
||||
|
||||
◆ Bridge Core Pairing
|
||||
Scan a relay QR code to configure API and relay endpoints, then manage relay sessions and per-feature grants from the app.
|
||||
◆ Works Away From Home
|
||||
Add your server's Tailscale or public URL and the app switches routes automatically — local network at home, your secure fallback everywhere else. Routes are editable any time, and when a server is unreachable the status tells you what to fix instead of just going red.
|
||||
|
||||
◆ Terminal and TUI Relay
|
||||
Use paired relay sessions for terminal/TUI access to your Hermes host when your relay grants allow it.
|
||||
◆ Sessions
|
||||
Create, switch, rename, and delete chat sessions. Message history loads from your Hermes server on demand.
|
||||
|
||||
◆ Multiple Servers, Profiles, and Personalities
|
||||
Connect to more than one Hermes server (home and work, dev and prod) and switch in one tap. Overlay an agent profile or personality per conversation.
|
||||
|
||||
◆ Relay Power Tools (optional)
|
||||
Scan a relay QR code to pair, then open a remote terminal to your Hermes host, manage relay sessions, and control per-feature grants from the app.
|
||||
|
||||
◆ Notification Companion
|
||||
Optional Android notification access lets Hermes-Relay forward posted-notification metadata to your paired relay so your assistant can summarize recent notifications. You enable or revoke this in Android system settings at any time.
|
||||
@@ -67,8 +78,8 @@ Full Material 3 with dynamic color theming, light/dark/system modes, animated sp
|
||||
━━━ REQUIREMENTS ━━━
|
||||
|
||||
• Android 8.0 or later (API 26+)
|
||||
• A running Hermes agent instance
|
||||
• Optional Hermes relay service for Bridge Core features
|
||||
• A running Hermes agent instance (chat, management, and voice need nothing else)
|
||||
• Optional Hermes relay service for power tools (terminal, notifications, media)
|
||||
• Network access to your server (local network, VPN, or internet)
|
||||
|
||||
━━━ OPEN SOURCE ━━━
|
||||
@@ -79,7 +90,7 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
|
||||
## Release Notes
|
||||
|
||||
v0.8.0 improves the Google Play-safe Bridge Core build, voice settings, and realtime voice path. Play remains free of AccessibilityService Device Control while keeping chat, profiles, voice, terminal/TUI relay, media, notification companion, relay sessions, QR pairing, diagnostics, and connection health. Voice Settings now clearly separates Hermes Chat + Voice Output from the experimental Realtime Agent, keeps fallback TTS visible as a global safety net, and Realtime Agent supports provider-native Hermes-brokered speech for xAI and OpenAI.
|
||||
v0.8.1 makes the standard no-plugin setup first-class. Voice now works on a plain Hermes install — one dashboard sign-in unlocks it. The Manage tab reaches parity with the desktop dashboard: models, provider keys, profiles, and a skills hub with install and update. Connection routes are editable on the phone, and remote access is built into the whole journey — a setup field, a readiness card, a smarter "unreachable" diagnosis, and a one-tap Tailscale shortcut.
|
||||
|
||||
## Category
|
||||
|
||||
@@ -92,4 +103,4 @@ Not designed for children.
|
||||
|
||||
## Tags
|
||||
|
||||
ai, agent, hermes, developer tools, chat, voice, terminal, self-hosted, open source
|
||||
ai, agent, hermes, developer tools, chat, voice, self-hosted, remote, open source
|
||||
|
||||
+5
-2
@@ -67,8 +67,11 @@ Notification companion is opt-in. The app only forwards notification metadata af
|
||||
|
||||
From Settings, users can:
|
||||
|
||||
- **Export** settings such as server URLs and preferences; secrets are excluded
|
||||
- **Import** a previously exported configuration
|
||||
- **Export** a full connection backup. The file includes server URLs,
|
||||
preferences, API keys, relay session tokens, device IDs, and dashboard
|
||||
cookies so restored connections can work without manual re-entry. Keep it
|
||||
private.
|
||||
- **Import** a previously exported backup
|
||||
- **Full reset** to wipe local data including encrypted credentials
|
||||
|
||||
## Stats for Nerds
|
||||
|
||||
@@ -1126,3 +1126,71 @@ Pass criteria:
|
||||
`voice.*.session.detached` followed by `voice.session.resumed`.
|
||||
- Replayed events are marked `replayed=true`, and the session does not start a
|
||||
duplicate Hermes run inside the resume TTL.
|
||||
|
||||
## Idle tolerance (ADR 33 Phase 0)
|
||||
|
||||
Background-Hermes-run promotion (ADR 33, `docs/plans/2026-05-24-realtime-background-hermes-runs.md`)
|
||||
detaches a long Hermes run from the provider event pump and keeps the provider
|
||||
session open while the run completes. Whether a provider can hold the floor while
|
||||
**quiescent** (no `response.create`, no input audio) for tens of seconds is the
|
||||
factual unknown that gates default-on promotion.
|
||||
|
||||
Run the probe on the relay host (provider credentials configured) with the repo
|
||||
root on `PYTHONPATH`:
|
||||
|
||||
```bash
|
||||
python scripts/realtime-provider-idle-probe.py --provider xai
|
||||
python scripts/realtime-provider-idle-probe.py --provider openai --windows 30,60,120
|
||||
```
|
||||
|
||||
The probe holds each socket idle across the windows, then issues one short
|
||||
post-idle turn to check for VAD/turn artifacts, and prints a verdict.
|
||||
|
||||
Record the verdict per provider. The verdict selects that provider's Tier B
|
||||
strategy:
|
||||
|
||||
| Verdict | Meaning | Tier B strategy |
|
||||
|---|---|---|
|
||||
| `hold-floor-ok` | Socket survives idle; post-idle turn clean | Hold the provider session open during the background run (default) |
|
||||
| `needs-keepalive` | Survives but post-idle turn degraded | Hold open + send a minimal keep-alive; revalidate the first post-idle turn |
|
||||
| `must-reopen` | Socket closes/errors while idle | Detach the run but close+reopen (or resume) the provider socket on completion |
|
||||
|
||||
### Findings
|
||||
|
||||
Both verdicts are **`hold-floor-ok`** and Phase 0 is closed. The probe's original
|
||||
worst case — a provider holding an *open response* idle for the whole run — does
|
||||
not occur: the promotion path closes the pending call with an interim ack
|
||||
(`broker.py:_begin_background_delivery`), so the socket only experiences the
|
||||
normal between-turns idle gap. That gap is already exercised in production by
|
||||
every Realtime Agent turn (the session stays open between the user finishing
|
||||
speaking and the next `response.create`, across the resume TTL).
|
||||
|
||||
| Provider | Date | Basis | Windows | Post-idle turn | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| OpenAI (`gpt-realtime-2`) | 2026-05-24 | empirical (probe) | 10s, 20s, 30s | audio returned, no error | **`hold-floor-ok`** |
|
||||
| xAI (`grok-voice-latest`) | 2026-05-24 | existing production behavior + protocol parity | between-turn idle in daily use | clean (no idle-close reports) | **`hold-floor-ok`** |
|
||||
|
||||
**OpenAI — empirical.** Ran `realtime-provider-idle-probe.py --provider openai
|
||||
--windows 10,20,30` against the live API. The session stayed open across all
|
||||
three quiescent windows and produced clean audio on every post-idle
|
||||
`response.create`. (Incidental observation, not an idle finding: the live API
|
||||
emitted one `Missing required parameter: 'session.audio.output.format.rate'`
|
||||
error at `session.update` time — a minor schema drift in
|
||||
`providers/openai.py:_session_update` worth a follow-up; the session still
|
||||
functioned and returned audio.)
|
||||
|
||||
**xAI — production behavior + parity.** No xAI key/OAuth store is present on the
|
||||
dev box, so a fresh probe run is deferred to the relay host. The verdict is not
|
||||
conditional, however: the *shipping* Realtime Agent already holds `xai_realtime`
|
||||
sessions open across between-turn idle gaps with `turn_detection: None`
|
||||
(relay-driven turns) and a resume TTL, and there are no reports of xAI closing or
|
||||
degrading on those idle gaps. Background promotion does not lengthen the
|
||||
*open-response* duration (the call is closed with an interim ack), so it does not
|
||||
introduce a new idle condition beyond what xAI already tolerates today. Running
|
||||
the probe on the relay host is retained as a **regression check**, not a
|
||||
precondition. If it ever returns `needs-keepalive`/`must-reopen`, set that
|
||||
provider's `realtime_voice` override accordingly; the per-provider setting
|
||||
surface already supports it.
|
||||
|
||||
**Conclusion.** Both verdicts are `hold-floor-ok`, so `promotion_enabled`
|
||||
defaults **on**. Phase 0's gate is satisfied; default-on is no longer blocked.
|
||||
|
||||
@@ -591,6 +591,47 @@ times, currency, percentages, versions, measurements, counts, paths, URLs, IDs,
|
||||
JSON, logs, stack traces, tables, and dense numeric strings should be spoken as
|
||||
human-readable summaries rather than raw character-by-character dumps.
|
||||
|
||||
#### Background Hermes runs (ADR 33)
|
||||
|
||||
Short Hermes runs answer in-line (the provider speaks the summary as soon as the
|
||||
brokered result returns). A run that exceeds `promote_after_ms` is **promoted**
|
||||
to a tracked background task so the provider event pump stays responsive instead
|
||||
of blocking on the run:
|
||||
|
||||
```json
|
||||
{"type":"hermes.run.promoted","event_id":20,"source":"hermes","run_id":"...","tier":"promoted","promote_after_ms":6000,"spoken_handoff":true,"result_delivery":"speak_when_idle","call_id":"call-1"}
|
||||
{"type":"hermes.run.background_completed","event_id":41,"source":"hermes","run_id":"...","ok":true,"tool_count":2}
|
||||
```
|
||||
|
||||
- On promotion the relay closes the pending provider function call with an
|
||||
interim `{"status":"running_in_background"}` output (so the provider socket is
|
||||
not left awaiting a tool result) and, when `spoken_handoff` is on, has the
|
||||
provider speak a brief "I'm on it" line.
|
||||
- When the background run finishes, the relay emits
|
||||
`hermes.run.background_completed`, waits for the audio **floor** to be idle,
|
||||
then injects the result through the same forced-summary path so the provider
|
||||
speaks the answer exactly once. `result_delivery` selects `speak_when_idle`
|
||||
(default), `notify_then_speak`, or `visual_only`.
|
||||
- `hermes_run_task(mode="background")` skips the grace window and detaches
|
||||
immediately (`tier:"durable"`), even when grace-period promotion is disabled.
|
||||
- `hermes.run.progress` carries two extra fields while a run is in flight:
|
||||
`tier` (`foreground` | `promoted` | `durable`) and `floor`
|
||||
(`idle` | `provider_speaking` | `hermes_filler` | `result_pending`). The relay
|
||||
is the single floor owner — a completed background result never barges in, and
|
||||
Android local filler is suppressed while the provider holds the floor.
|
||||
- A promoted run is cancellable via `response.cancel` (client) or the
|
||||
`hermes_cancel` provider tool; if the WebSocket detaches mid-run, the
|
||||
completion events replay through the resume event ring.
|
||||
|
||||
Promotion is configured per profile under `realtime_voice` (relay) and exposed
|
||||
on `GET/PATCH /voice/realtime-agent/config` as a `promotion` block:
|
||||
`enabled` (default on), `promote_after_ms`, `background_default_mode`,
|
||||
`spoken_handoff`, `progress_spoken_after_ms`, `progress_repeat_ms`,
|
||||
`result_delivery`, and `max_background_runs`. The default-on path is safe because
|
||||
it closes the pending call rather than holding an open provider response; the
|
||||
`scripts/realtime-provider-idle-probe.py` verdict (see `docs/realtime-voice-poc.md`)
|
||||
confirms per-provider socket survival across the between-turns idle gap.
|
||||
|
||||
If a provider-native realtime turn answers directly without Hermes, Android
|
||||
keeps the local user/assistant bubbles and marks that provider-only assistant
|
||||
turn as unsynced. The next normal Hermes chat/run request includes those
|
||||
|
||||
@@ -16,7 +16,7 @@ The relay server is a lightweight Python service that bridges the Hermes-Relay A
|
||||
|
||||
If you only use chat, you do **not** need the relay server. The app connects directly to the Hermes API Server for chat, sessions, profiles, and skills. Voice endpoints live on the relay but can authenticate with the same Hermes API server key used for chat; remote-control features such as terminal, bridge, TUI, media/session management, and Android control still require relay pairing.
|
||||
|
||||
When using the dashboard's pair/repair flow, the QR still needs both credential families: top-level `key` for direct Hermes API chat/sessions, and `relay.code` for the relay session token used by voice/bridge/terminal surfaces. The relay's loopback-only `/pairing/mint` endpoint reads `API_SERVER_KEY` from the same host-local config chain as `hermes-pair` when the dashboard does not explicitly pass `api_key`.
|
||||
When using the dashboard's pair/repair flow, the QR still needs both credential families: top-level `key` for direct Hermes API chat/sessions, and `relay.code` for the relay session token used by voice/bridge/terminal surfaces. The relay's loopback-only `/pairing/mint` endpoint reads `API_SERVER_KEY` from the same host-local config chain as `hermes pair` when the dashboard does not explicitly pass `api_key`.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -193,7 +193,7 @@ Or use a reverse proxy (nginx/Caddy) to terminate TLS in front of the relay. Ful
|
||||
|
||||
The relay uses a QR-driven two-step auth flow:
|
||||
|
||||
1. **Pairing** — the pair command runs on the Hermes host (either the `/hermes-relay-pair` slash command invoked from any Hermes chat surface, or the `hermes-pair` shell shim), mints a fresh 6-char code (`A-Z / 0-9`), pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, and embeds the relay URL + code in the scanned QR payload. The same payload is also printed as a paste-friendly `hermes-relay://pair?payload=...` invite URL for desktop GUI/CLI setup. The phone sends the code in its first `system/auth` envelope; the relay consumes it and issues a session token. Codes are one-shot and expire 10 minutes after registration. Android clears a failed scanned code after `auth.fail` so a stale QR cannot keep reconnecting into the rate limiter.
|
||||
1. **Pairing** — the pair command runs on the Hermes host (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim), mints a fresh 6-char code (`A-Z / 0-9`), pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, and embeds the relay URL + code in the scanned QR payload. The same payload is also printed as a paste-friendly `hermes-relay://pair?payload=...` invite URL for desktop GUI/CLI setup. The phone sends the code in its first `system/auth` envelope; the relay consumes it and issues a session token. Codes are one-shot and expire 10 minutes after registration. Android clears a failed scanned code after `auth.fail` so a stale QR cannot keep reconnecting into the rate limiter.
|
||||
2. **Session token** — Stored in Android's EncryptedSharedPreferences. Used for subsequent relay connections and Relay-protected HTTP routes. Expires after 30 days by default and carries per-channel grants, including `voice:config`, `voice:stt`, `voice:tts`, and `voice:realtime`.
|
||||
|
||||
Voice endpoints also accept the existing Hermes API bearer token used by API-server clients such as the Obsidian Hermes Client. That API bearer path is limited to `/voice/config`, `/voice/transcribe`, `/voice/synthesize`, `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*`; it is not accepted for sessions, media, clipboard, terminal, TUI, bridge, profile writes, or Android control routes. Android derives the conventional Relay URL from the configured API URL (`http(s)://host:8642` to `ws(s)://host:8767`) and probes the voice routes, with a manual Relay URL override for custom routing. For non-loopback callers, Hermes API bearer auth requires HTTPS by default, either direct TLS or trusted `X-Forwarded-Proto: https` from an explicitly trusted proxy.
|
||||
@@ -285,7 +285,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/ws`, `/` | GET (upgrade) | Main WebSocket endpoint. Phone connects, sends `system/auth`, then multiplexes `chat`/`terminal`/`bridge` envelopes. |
|
||||
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code. Returns `{"code": "ABC123"}`. Unrestricted (intended for host-local callers). |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `/hermes-relay-pair` / `hermes-pair`. |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. |
|
||||
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
|
||||
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
|
||||
|
||||
@@ -202,7 +202,7 @@ QR still embeds all detected candidates; only the probe order changes.
|
||||
|
||||
```bash
|
||||
# All three modes detected, but Tailscale probed first
|
||||
hermes-pair --mode auto --public-url https://hermes.example.com/relay --prefer tailscale
|
||||
hermes pair --mode auto --public-url https://hermes.example.com/relay --prefer tailscale
|
||||
```
|
||||
|
||||
Result: `[(0, tailscale), (1, lan), (2, public)]` — phone tries the
|
||||
@@ -216,7 +216,7 @@ order. **Role already at priority 0** → no-op.
|
||||
|
||||
Works identically from three surfaces:
|
||||
|
||||
- **CLI:** `hermes-pair --prefer tailscale`
|
||||
- **CLI:** `hermes pair --prefer tailscale`
|
||||
- **Skill:** `/hermes-relay-pair` documented in
|
||||
[`skills/devops/hermes-relay-pair/SKILL.md`](../skills/devops/hermes-relay-pair/SKILL.md)
|
||||
- **Dashboard:** Remote Access tab → Endpoint preview card →
|
||||
@@ -294,7 +294,8 @@ that returns true (PR #9295 has landed in your hermes-agent install),
|
||||
the helper still works but the canonical path
|
||||
(`hermes gateway run --tailscale`) is preferred and the helper will
|
||||
be removed in a future release. Same retirement pattern as
|
||||
`hermes_relay_bootstrap/` after PR #8556.
|
||||
`hermes_relay_bootstrap/`: retire compatibility per surface once the
|
||||
supported upstream baseline covers it.
|
||||
|
||||
### Forward-auth gateways (Authelia, Cloudflare Access) in front of the API server
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Hermes-Relay gives a remote AI agent full control of an Android device via Acces
|
||||
## Current Security Model
|
||||
|
||||
### Authentication
|
||||
- **Pairing code**: A random 6-character alphanumeric code. For the QR-driven flow, the pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim) generates the code on the Hermes host and pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint; the phone-side `AuthManager.generatePairingCode()` generator is retained for the Phase 3 bridge flow.
|
||||
- **Pairing code**: A random 6-character alphanumeric code. For the QR-driven flow, the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) generates the code on the Hermes host and pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint; the phone-side `AuthManager.generatePairingCode()` generator is retained for the Phase 3 bridge flow.
|
||||
- The phone and server must share this code to establish a connection.
|
||||
- Codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambiguous 0/O/1/I" restriction was dropped when the pairing flow moved from "human retypes code from display" to "code flows phone ↔ server via QR + HTTP" (see `docs/decisions.md` §6a).
|
||||
- `POST /pairing/register` is gated to loopback callers only (`127.0.0.1` / `::1`) — only a process with host shell access on the relay machine can inject pairing codes. A LAN attacker cannot.
|
||||
|
||||
+5
-5
@@ -161,12 +161,12 @@ Phone control — mirrors upstream relay protocol.
|
||||
|
||||
### 3.3 Auth Flow
|
||||
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — either `/hermes-relay-pair` from any Hermes chat surface (backed by the `devops/hermes-relay-pair` skill) or the `hermes-pair` shell shim (a thin wrapper around `python -m plugin.pair`). Both share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (and the API server credentials) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (and the API server credentials) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
|
||||
As of **v3 (ADR 24)**, the QR can also carry an ordered list of **endpoint candidates** (`lan` / `tailscale` / `public` / operator-defined roles). A single pairing covers every network the phone might be on — the phone picks the highest-priority reachable candidate at connect time and re-probes on network change. The single-URL top-level fields still appear in v3 QRs for backward compatibility; old phones ignore `endpoints` via `ignoreUnknownKeys = true`, new phones prefer `endpoints` and fall back to the top-level URL when the array is absent. See [`docs/remote-access.md`](remote-access.md) for the operator-facing setup per mode.
|
||||
|
||||
```
|
||||
1. Operator runs /hermes-relay-pair (or hermes-pair) on the Hermes host,
|
||||
1. Operator runs `hermes pair` (or `/hermes-relay-pair`) on the Hermes host,
|
||||
optionally with --ttl <duration>, --grants terminal=7d,bridge=1d,
|
||||
--mode {auto,lan,tailscale,public} (default auto), --public-url <url>.
|
||||
2. The pair command reads the API server config (host/port/key) from
|
||||
@@ -259,7 +259,7 @@ Biometric gate on the app side for terminal access (fingerprint/face) remains pl
|
||||
|
||||
- `hermes` — payload version. `1` is the legacy shape (no new fields); `2` is set when any v2-only field (`ttl_seconds`, `grants`, `transport_hint`) is present in the `relay` block; `3` is set when `endpoints` is present (ADR 24). All three versions parse on the current Android client.
|
||||
- `endpoints` — **optional** ordered list of endpoint candidates. When present, the phone uses these in strict-priority order (0 = highest) and re-probes reachability on network change. When absent, the phone synthesizes a single priority-0 candidate from the top-level `host`/`port`/`tls` + `relay.url`/`transport_hint` fields. `role` is an open string (known values `lan` / `tailscale` / `public` get styled UI; anything else renders as "Custom VPN (<role>)"). Per-endpoint entries intentionally carry **only** `api` + `relay` — the pairing code, TTL, and grants stay at the top level because they're per-pair artifacts, not per-endpoint. Full schema in ADR 24.
|
||||
- Top-level fields (`host`/`port`/`key`/`tls`) configure the direct-chat Hermes API Server. Unchanged since v1.
|
||||
- Top-level fields (`host`/`port`/`key`/`tls`) configure the direct Hermes API Server. Unchanged since v1.
|
||||
- `relay` — **optional** and nullable. Present only when the pair command found a running relay and successfully pre-registered a pairing code with it.
|
||||
- `relay.url` — full WebSocket URL (`ws://` for dev, `wss://` for production).
|
||||
- `relay.code` — 6-char one-shot pairing code from `A-Z / 0-9`. Expires 10 minutes after registration.
|
||||
@@ -287,7 +287,7 @@ Implementation references:
|
||||
| Transport (default) | WSS / TLS 1.3 (**preferred**) |
|
||||
| Transport (opt-in) | Plain `ws://` — gated on `InsecureConnectionAckDialog` consent + reason picker (LAN-only / Tailscale or VPN / Local dev). Reason is displayed, not enforced — operator intent is the trust model. |
|
||||
| Transport indicator | `TransportSecurityBadge` in Settings + Session sheet + Paired Devices card. Three states: 🔒 secure / 🔓 insecure with reason / 🔓 insecure unknown. |
|
||||
| Pairing (host → phone) | `hermes-pair` / `/hermes-relay-pair` → `POST /pairing/register` (loopback-only) → QR embedded in operator's terminal or chat. |
|
||||
| Pairing (host → phone) | `hermes pair` / `/hermes-relay-pair` → `POST /pairing/register` (loopback-only) → QR embedded in operator's terminal or chat. |
|
||||
| Pairing (phone → host, Phase 3) | Stubbed at `POST /pairing/approve` — same wire shape, same loopback gate. Real UX pending bridge work. |
|
||||
| Session lifetime | User-selected at pair: 1d / 7d / 30d / 90d / 1y / **never**. Never is always selectable; operator intent is the trust model. |
|
||||
| Per-channel grants | One session token carries per-channel expiries for `chat`, `terminal`, `bridge`, `tui`, and split voice grants (`voice:config`, `voice:stt`, `voice:tts`). Grants are clamped to session lifetime. |
|
||||
@@ -431,7 +431,7 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
|
||||
| `/ws`, `/` | GET (upgrade) | WebSocket handler — main multiplexed channel |
|
||||
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`/hermes-relay-pair` skill or `hermes-pair` shim) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard, desktop GUI, and CLI pair/repair flows. |
|
||||
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}` — `config` is the parsed `config.yaml` for `~/.hermes/` (when `name == "default"`) or `~/.hermes/profiles/<name>/`. Loopback callers skip bearer; remote callers require the relay session bearer. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
|
||||
| `/api/profiles/{name}/skills` | GET | Profile-scoped skill enumeration. Walks `<profile>/skills/<category>/<skill>/SKILL.md` recursively; returns `{profile, skills: [{name, category, description, path, enabled: true}], total}`. Same auth model as `/config`. `name`/`description` come from YAML frontmatter when present, else directory basename. All skills report `enabled: true` today — see §22 for the toggle stub. |
|
||||
|
||||
@@ -4,7 +4,8 @@ Improvements that would benefit hermes-relay (and other frontends) if added to [
|
||||
|
||||
## Current Upstream PR Alignment
|
||||
|
||||
- PR #29302 (`feat: add API server session controls`) is the canonical upstream path for `/api/sessions/*`, message history, fork, chat, and chat stream. Hermes-Relay should prefer these native routes when present and keep the bootstrap as a per-route compatibility overlay only for older or partial core builds.
|
||||
- PR #33134 (`feat(api-server): session control API — sessions/chat/fork/SSE-stream`) merged the canonical upstream path for `/api/sessions/*`, message history, fork, chat, and chat stream. It salvaged the useful portion of PR #29302, which superseded the older broad PR #8556. Hermes-Relay should prefer these native routes and keep the bootstrap only as an older-build compatibility overlay.
|
||||
- PR #33016 (`feat(api-server): add GET /v1/skills and /v1/toolsets`) merged the canonical read-only skill/toolset discovery path. Hermes-Relay should prefer `/v1/skills` and `/v1/toolsets` over legacy `/api/skills` list shapes.
|
||||
- PR #8199 (`feat(api): add native audio transcription and speech endpoints`) is the canonical upstream path for core STT/TTS execution through `/v1/audio/transcriptions` and `/v1/audio/speech`. Hermes-Relay should keep `/voice/*` as the paired-device facade but eventually call those native core endpoints internally before falling back to private helper imports.
|
||||
- PR #29364 (`feat: add API server audio endpoints`) should not become a competing `/api/audio/*` API if #8199 remains the accepted audio base. Rework it as a discovery/compatibility follow-up or close it after confirming the upstream maintainer preference.
|
||||
|
||||
@@ -57,35 +58,13 @@ Improvements that would benefit hermes-relay (and other frontends) if added to [
|
||||
|
||||
**Workaround (current):** App fetches `config.agent.personalities` map, sends the system prompt as `system_message`.
|
||||
|
||||
## 3. Wire Third-Party Plugin CLI Commands into Top-Level Argparser
|
||||
## 3. Third-Party Plugin CLI Commands (Resolved Upstream)
|
||||
|
||||
**Current state (hermes-agent v0.8.0):** `PluginContext.register_cli_command(name, help, setup_fn, handler_fn, description)` is implemented in `hermes_cli/plugins.py:192` and plugins can call it during `register(ctx)`. The resulting registrations are stored in `PluginManager._cli_commands`, and a module-level getter `get_plugin_cli_commands()` exists at line 592. But `hermes_cli/main.py:5236` only consults `plugins.memory.discover_plugin_cli_commands()` (memory-subsystem-specific) when building the top-level argparser — it never iterates the generic `_cli_commands` dict.
|
||||
**Current state (2026-06-07 source check):** current upstream discovers plugins before top-level CLI parser finalization and iterates `get_plugin_manager()._cli_commands.values()` in `hermes_cli/main.py`. Third-party plugins that call `ctx.register_cli_command(...)` now reach plugin-provided commands such as `hermes pair` and `hermes relay` through the plugin-native path.
|
||||
|
||||
**Result:** third-party plugins (like ours) correctly register sub-commands via the documented API, Hermes reports them loaded successfully in `hermes plugins list`, but typing `hermes <subcommand>` at the shell returns `argument command: invalid choice`. The plugin CLI path is effectively dead for anything outside the memory plugin subsystem.
|
||||
**Impact:** no new upstream patch is needed for generic plugin CLI command dispatch. Hermes-Relay should prefer plugin-registered `hermes pair` / `hermes relay` on current upstream installs once the Hermes-Relay plugin is installed and enabled. These are not built-in Hermes core commands.
|
||||
|
||||
**Proposed patch:** immediately after the existing memory discovery loop in `main.py`, add a parallel loop over `get_plugin_cli_commands()` and wire each entry into the subparsers the same way. Something like:
|
||||
|
||||
```python
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_cli_commands
|
||||
for cmd_name, cmd_info in get_plugin_cli_commands().items():
|
||||
if cmd_name in subparsers.choices:
|
||||
continue # memory loop already handled it
|
||||
plugin_parser = subparsers.add_parser(
|
||||
cmd_name,
|
||||
help=cmd_info["help"],
|
||||
description=cmd_info.get("description", ""),
|
||||
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
|
||||
)
|
||||
cmd_info["setup_fn"](plugin_parser)
|
||||
except Exception as _exc:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).debug("Generic plugin CLI discovery failed: %s", _exc)
|
||||
```
|
||||
|
||||
**Impact:** any plugin declaring `ctx.register_cli_command(...)` in `register()` would instantly get a working `hermes <name>` sub-command. Our hermes-relay plugin would unlock `hermes pair` and `hermes relay start` without shell shims. All other third-party plugins would benefit too.
|
||||
|
||||
**Workaround (current):** ship a `hermes-pair` shell shim at `~/.local/bin/hermes-pair` that execs `<venv-python> -m plugin.pair "$@"`, plus a `/hermes-relay-pair` skill that auto-registers as a slash command in any Hermes chat session. Both work but are plumbing around the gap rather than using the intended API.
|
||||
**Compatibility fallback:** keep the dashed `hermes-pair` shell shim and `/hermes-relay-pair` slash command while we support older Hermes builds and existing scripts. They call the same implementation and can be retired only after the supported baseline includes the upstream CLI discovery fix and docs/install examples no longer depend on the shim.
|
||||
|
||||
## 4. Follow Symlinks in Skill Discovery
|
||||
|
||||
@@ -103,22 +82,22 @@ except Exception as _exc:
|
||||
|
||||
**Proposed — a two-stage arc, each stage a small, independently reviewable PR:**
|
||||
|
||||
**Stage 1 — stateless preprocessor (sibling follow-up to PR #29302).** A lightweight preprocessor in `api_server.py`'s `/v1/runs` + `/v1/chat/completions` handlers that detects a leading `/` in the user text, matches the first token against `GATEWAY_KNOWN_COMMANDS`, and splits on command type:
|
||||
**Stage 1 — stateless preprocessor (follow-up to the API-server chat work).** A lightweight preprocessor in `api_server.py`'s `/v1/runs` + `/v1/chat/completions` handlers that detects a leading `/` in the user text, matches the first token against `GATEWAY_KNOWN_COMMANDS`, and splits on command type:
|
||||
|
||||
- **Stateless commands** (`/help`, `/commands`, and any others that can execute without touching router-owned state) are dispatched via existing helpers (`gateway_help_lines()` at `hermes_cli/commands.py:340`) and returned as a synthetic SSE stream matching the handlers' existing event shape.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, and most of the registry) return a deterministic, helpful SSE notice along the lines of *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (from PR #29302) or a channel with session state (Discord, CLI, Telegram)."*
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, and most of the registry) return a deterministic, helpful SSE notice along the lines of *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (native since PR #33134) or a channel with session state (Discord, CLI, Telegram)."*
|
||||
- **Unknown** and **cli-only** commands fall through to the LLM path unchanged.
|
||||
- **Preprocessor exceptions** fall through to the LLM path unchanged — a preprocessor bug must never take down a normal chat request.
|
||||
|
||||
This respects upstream's intentional design (api_server stays stateless, no router coupling) while fixing the hallucination symptom and unlocking the commands that *can* run statelessly.
|
||||
|
||||
**Stage 2 — stateful dispatch on `/api/sessions/{id}/chat/stream` (after PR #29302 lands).** Once session management primitives ship, a separate PR adds a preprocessor **scoped to the session chat stream endpoint only**, using the URL's `session_id` as the persistence handle. Stateful commands become session-scoped dict writes (`session.model_override = new_model`) without refactoring `GatewayRouter` or plumbing api_server into the router. This matches upstream's partition cleanly: `/v1/*` remains stateless and OpenAI-compatible; statefulness lives on `/api/sessions/*`.
|
||||
**Stage 2 — stateful dispatch on `/api/sessions/{id}/chat/stream` (now unblocked by PR #33134).** A separate PR can add a preprocessor **scoped to the session chat stream endpoint only**, using the URL's `session_id` as the persistence handle. Stateful commands become session-scoped dict writes (`session.model_override = new_model`) without refactoring `GatewayRouter` or plumbing api_server into the router. This matches upstream's partition cleanly: `/v1/*` remains stateless and OpenAI-compatible; statefulness lives on `/api/sessions/*`.
|
||||
|
||||
**Why not one big PR:** a full GatewayRouter refactor plus api_server plumbing was considered and rejected. It would touch 10+ files across subsystems normally owned separately, fight the documented "api_server is excluded from router notification" design decision, and review as a much larger change than the value added. The two-stage arc ships faster, reviews cleaner, and matches the upstream partition better.
|
||||
|
||||
**Impact:** frontends that speak the API server (hermes-relay, hermes-workspace, ClawPort, and any OpenAI-compatible client that points at the Hermes base URL) get the same built-in command surface as Discord/Telegram/CLI, in two predictable stages.
|
||||
|
||||
**Workaround (current / near-term):** `hermes_relay_bootstrap/_command_middleware.py` (planned for v0.4.1) mirrors Stage 1 as an aiohttp middleware injected at bootstrap time, so vanilla upstream installs that ship with the relay get the hallucination fix and the stateless commands without waiting for an upstream release. The bootstrap middleware fork-detects the same way the existing route injection does — it no-ops once Stage 1 lands upstream.
|
||||
**Workaround (current / near-term):** `hermes_relay_bootstrap/_command_middleware.py` mirrors Stage 1 as an aiohttp middleware injected at bootstrap time, so older upstream installs that ship with the relay get the hallucination fix and the stateless commands without waiting for an upstream release. The bootstrap middleware feature-detects native support and should no-op once Stage 1 lands upstream.
|
||||
|
||||
## 6. API Server Audio Endpoints for Relay-Compatible STT/TTS
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Upstream Hermes Integration Sync
|
||||
|
||||
Last reviewed: 2026-05-20
|
||||
Last reviewed: 2026-06-07
|
||||
|
||||
This document tracks how Hermes-Relay integrates with Hermes upstream surfaces, which
|
||||
parts use supported extension points, and which parts are compatibility layers that
|
||||
@@ -18,6 +18,9 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
|
||||
- Local upstream gap tracker: `docs/upstream-contributions.md`
|
||||
- Local relay reference: `docs/relay-server.md`
|
||||
- Local wire protocol reference: `docs/relay-protocol.md`
|
||||
- Source check: `gateway/platforms/api_server.py` on NousResearch/hermes-agent `main`
|
||||
- Merged session API: https://github.com/NousResearch/hermes-agent/pull/33134
|
||||
- Merged skills/toolsets API: https://github.com/NousResearch/hermes-agent/pull/33016
|
||||
|
||||
## Supported-First Policy
|
||||
|
||||
@@ -36,9 +39,10 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
|
||||
| Plugin metadata and discovery | `plugin.yaml`, plugin directory discovery, `plugins.enabled`, and `register(ctx)` | `plugin/plugin.yaml`, `plugin/__init__.py` | Aligned | Keep server-owned version metadata in sync with `python scripts/check-server-version-sync.py`. |
|
||||
| Agent tools | Tool Gateway tools registered through plugin context | `ctx.register_tool(...)` in `plugin/__init__.py`; schemas and handlers in `plugin/tools/*` | Aligned with custom transports | Tool registration should stay in `register(ctx)`; transport details stay behind handlers. |
|
||||
| Dashboard tab and plugin API | Dashboard plugin manifest plus plugin API routes under the Hermes dashboard plugin mount | `plugin/dashboard/manifest.json`, `plugin/dashboard/plugin_api.py` | Aligned wrapper | Dashboard routes may proxy relay state, but discovery and mounting should stay upstream-native. |
|
||||
| Chat and model API | OpenAI-compatible API server routes such as `/v1/chat/completions`, `/v1/models`, `/health`, and supported streaming routes | Android `HermesApiClient`, relay docs, Web API docs | Mixed | Prefer standard API routes first; use `/api/sessions` only when capability probes find it. |
|
||||
| Sessions API | Proposed upstream API-server session controls in NousResearch/hermes-agent PR #29302 (`/api/sessions`, messages, fork, chat, chat stream) | Android `HermesApiClient`; compatibility overlay in `hermes_relay_bootstrap/*` | Upstream-pending with fallback | Prefer native `/api/sessions/*` when present. Bootstrap must skip native routes per method/path and only inject missing compatibility routes. |
|
||||
| Config, skills, memory APIs | Not documented as stable upstream API-server routes in current public docs | `hermes_relay_bootstrap/*`, `docs/HERMES-WEBAPI-REFERENCE.md` | Compatibility layer | Keep separate from the sessions retirement path. Do not skip these just because native `/api/sessions` exists. |
|
||||
| Chat and model API | OpenAI-compatible API server routes such as `/v1/chat/completions`, `/v1/models`, `/v1/capabilities`, `/health`, and supported streaming routes | Android `HermesApiClient`, relay docs, Web API docs | Mixed | Prefer `/v1/capabilities` when present, then targeted probes for mixed-version fallback. |
|
||||
| Sessions API | Native API-server session controls merged in NousResearch/hermes-agent PR #33134 (`/api/sessions`, messages, fork, chat, chat stream) | Android `HermesApiClient`; older-build compatibility overlay in `hermes_relay_bootstrap/*` | Native upstream with fallback | Prefer native `/api/sessions/*`. Bootstrap must skip native routes per method/path and only inject missing compatibility routes for old core builds. |
|
||||
| Skills and toolsets discovery | Native read-only `/v1/skills` and `/v1/toolsets` merged in NousResearch/hermes-agent PR #33016 | Android `HermesApiClient.getSkills()` prefers `/v1/skills`; desktop/CLI tool surfaces should prefer `/v1/toolsets` where applicable | Native upstream with legacy fallback | Retire `/api/skills` list dependence from clients; keep legacy detail/toggle only where no native equivalent exists. |
|
||||
| Config, memory, legacy skills, available-models APIs | Not stable current upstream API-server routes as of the 2026-06-07 source check | `hermes_relay_bootstrap/*`, `docs/HERMES-WEBAPI-REFERENCE.md` | Compatibility layer | Keep separate from the sessions/skills retirement path. Do not keep the bootstrap solely for sessions or read-only skill lists once supported baselines include #33134/#33016. |
|
||||
| Mobile, desktop, and terminal relay transport | No general upstream plugin WSS transport for persistent remote clients in current public docs | `plugin/relay/server.py`, `plugin/relay/channels/*` | Custom | Keep the relay protocol documented and avoid leaking relay-only assumptions into upstream API clients. |
|
||||
| Pairing QR and relay session minting | No upstream pairing or device-registration method for remote mobile clients in current public docs | `plugin/pair.py`, relay `/pairing/*`, Android QR parser | Custom | QR payloads should keep API credentials (`key`) separate from relay credentials (`relay.code`). |
|
||||
| Basic STT/TTS over HTTP | Proposed upstream API-server audio endpoints in PR #8199 (`/v1/audio/transcriptions`, `/v1/audio/speech`) | Relay `/voice/config`, `/voice/transcribe`, `/voice/synthesize`; Android `RelayVoiceClient`; `plugin/relay/upstream_voice.py` | Custom wrapper pending upstream replacement | Keep `/voice/*` as the relay auth/session compatibility facade. Once core audio endpoints land, prefer proxying to native `/v1/audio/*` for STT/TTS work before falling back to private helper imports. |
|
||||
@@ -50,8 +54,8 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
|
||||
|
||||
| Deviation | Owner files | Why it exists | Guard or fallback | Retirement condition |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| API bootstrap route and middleware injection | `hermes_relay_bootstrap/*` | Native installs need session/config/skills/memory endpoints and slash-command preprocessing before upstream exposes stable equivalents. | Method/path feature detection skips native upstream routes and injects only missing compatibility gaps; upstream-module checks skip middleware when native slash preprocessing exists. | Retire per surface: sessions after PR #29302 or equivalent ships in a released core; config/skills/memory after stable core APIs exist; slash middleware after native preprocessing exists. |
|
||||
| Plugin CLI shim fallback | `plugin/__init__.py`, `plugin/cli.py`, install scripts | Some Hermes versions do not wire third-party plugin CLI commands into the top-level parser. | `ctx.register_cli_command` is attempted first; standalone shims fill the gap. | Remove shims once upstream plugin CLI discovery is stable for native installs. |
|
||||
| API bootstrap route and middleware injection | `hermes_relay_bootstrap/*` | Older native installs need session/config/skills/memory endpoints and slash-command preprocessing before upstream exposes stable equivalents. Current upstream already covers sessions plus read-only skills/toolsets. | Method/path feature detection skips native upstream routes and injects only missing compatibility gaps; upstream-module checks skip middleware when native slash preprocessing exists. | Retire per surface: sessions once the supported Hermes baseline includes #33134; read-only skill lists once clients use `/v1/skills`; config/memory/legacy skill detail/toggle/available-models after stable core replacements or local UX removal; slash middleware after native preprocessing exists. |
|
||||
| Plugin CLI shim fallback | `plugin/__init__.py`, `plugin/cli.py`, install scripts | Current upstream wires third-party plugin CLI commands into the top-level parser, but older supported Hermes builds and scripts may still call the dashed shims. | Prefer `ctx.register_cli_command` / plugin-provided `hermes pair` on current upstream after Hermes-Relay is installed and enabled; standalone shims stay as compatibility wrappers. | Remove shims only after the supported Hermes baseline includes the upstream CLI discovery fix and release/install docs have switched away from the dashed names. |
|
||||
| Relay HTTP and WSS server | `plugin/relay/server.py`, `plugin/relay/channels/*` | Mobile, desktop, terminal, media, push, and bridge features need persistent client channels and relay-owned session state. | Keep upstream API calls separate from relay session calls and document the protocol in `docs/relay-protocol.md`. | Replace pieces only when upstream provides equivalent remote-client transport or platform adapters. |
|
||||
| Pairing schema with `relay.code` | `plugin/pair.py`, Android pairing parser, relay `/pairing/*` | An API bearer key authenticates Hermes API calls but does not create relay sessions or describe WSS endpoints. | QR payloads carry direct API credentials and relay credentials as separate families. | Remove custom pairing when upstream offers native remote-device registration and relay discovery. |
|
||||
| Voice `/voice/*` endpoints | `plugin/relay/voice.py`, `plugin/relay/upstream_voice.py`, `plugin/relay/voice_auth.py`, Android voice client | Relay clients need paired-session auth, profile labels, transport guards, and stable `/voice/*` shapes even while core audio APIs evolve. | Use native `/v1/audio/*` once available for STT/TTS execution, with helper imports as fallback; pass selected Hermes profile context; require relay session or valid Hermes API bearer auth. | Keep `/voice/*` as a compatibility facade until mobile clients can safely target core audio directly without losing relay auth/grants/profile behavior. |
|
||||
@@ -65,8 +69,9 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
|
||||
|
||||
- A vanilla Hermes install plus the Hermes-Relay plugin should be able to use
|
||||
standard chat/model/health API paths without a fork-only requirement.
|
||||
- Enhanced management features may require the bootstrap compatibility package until
|
||||
upstream exposes equivalent routes. Those features must be probed before use.
|
||||
- Enhanced management features may require the bootstrap compatibility package only
|
||||
for surfaces that still lack upstream equivalents. Sessions and read-only skill
|
||||
lists should be treated as native-upstream-first.
|
||||
- The bootstrap must compose with partially-upgraded Hermes core builds. Native
|
||||
routes win per method/path; missing compatibility routes may still be injected.
|
||||
- Relay-specific features must authenticate through relay sessions or explicitly
|
||||
@@ -99,9 +104,9 @@ upgrading the supported Hermes baseline.
|
||||
- `plugin/dashboard/plugin_api.py`
|
||||
- `hermes_relay_bootstrap/*`
|
||||
- `plugin/relay/server.py`
|
||||
- `plugin/relay/voice.py`
|
||||
- `plugin/relay/realtime_voice.py`
|
||||
- `plugin/relay/upstream_voice.py`
|
||||
- `plugin/relay/voice.py`
|
||||
- `plugin/relay/realtime_voice.py`
|
||||
- `plugin/relay/upstream_voice.py`
|
||||
- `plugin/pair.py`
|
||||
- Android `HermesApiClient` and pairing/voice clients
|
||||
- Desktop TUI transport files under `desktop/src`
|
||||
@@ -115,10 +120,12 @@ upgrading the supported Hermes baseline.
|
||||
- `GET /health`
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions` or the supported streaming route for the target version
|
||||
- `GET /api/sessions?limit=1` only as an enhanced-management capability probe
|
||||
- `GET /v1/capabilities` and confirm `features.session_chat_streaming`, `features.skills_api`, `endpoints.session_chat_stream`, `endpoints.skills`, and `endpoints.toolsets`
|
||||
- `GET /api/sessions?limit=1` and `GET /api/sessions/{id}/messages` using the upstream `{"object":"list","data":[...]}` envelope
|
||||
- `GET /v1/skills` and `GET /v1/toolsets` using the upstream `{"object":"list","data":[...]}` envelope
|
||||
- Relay health and info endpoints from `docs/relay-server.md`
|
||||
- Dashboard plugin overview under the Hermes plugin API mount
|
||||
- `GET /v1/capabilities` and the native `/api/sessions/*` route set when testing a core build with PR #29302 or equivalent
|
||||
- Native `/api/sessions/*` route set when testing a core build with PR #33134 or equivalent
|
||||
- `POST /v1/audio/transcriptions` and `POST /v1/audio/speech` when testing a core build with PR #8199 or equivalent
|
||||
- Voice config, transcription, synthesis, and realtime routes only with relay session auth or a valid Hermes API bearer
|
||||
6. Update this file when upstream adds a supported replacement for a custom layer.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
appVersionName = "0.8.0"
|
||||
appVersionCode = "10"
|
||||
agp = "8.13.2"
|
||||
appVersionName = "0.8.1"
|
||||
appVersionCode = "11"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.20"
|
||||
compose-bom = "2026.03.01"
|
||||
navigation-compose = "2.9.7"
|
||||
@@ -117,7 +117,6 @@ compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-mani
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
play-publisher = { id = "com.github.triplet.play", version.ref = "play-publisher" }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user