feat(android): apply relay cockpit refresh
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/logs`, `/api/analytics/usage`.
|
||||
|
||||
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
|
||||
@@ -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` |
|
||||
|
||||
@@ -109,23 +109,44 @@ hermes-relay update # self-update via GitHub Releases
|
||||
- **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)
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
### 2. Connect Android to standard Hermes first
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
For normal Android use, run upstream Hermes with its API server and dashboard enabled, then choose **Standard Hermes** in onboarding:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
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
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642"
|
||||
echo "Android API key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
Android Chat uses the direct Hermes API server (`:8642`) and the API key above. Android Manage uses the Hermes dashboard (`:9119`) and signs in separately with dashboard cookies, including Nous/OIDC when the dashboard advertises it. Relay pairing is not required for Chat or Manage. If the phone also has a Tailscale route, enter it in the optional Tailscale API URL field; the app will use LAN at home and Tailscale when LAN is not reachable.
|
||||
|
||||
For the full copy/paste setup, Windows commands, dashboard auth notes, and upstream Hermes links, see the [Getting Started guide](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
### 3. Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin only when you want Terminal, Bridge, Relay sessions, media/device-control routes, or relay-backed voice paths:
|
||||
|
||||
```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 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:
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/`, registers the skill/plugin paths, installs compatibility shims, and can install a systemd user service for the WSS relay. Current upstream Hermes supports plugin-registered CLI commands; after the Hermes-Relay plugin is installed and enabled, prefer the plugin-provided `hermes pair`. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility. If you cannot scan a QR, use `hermes pair --register-code ABCD12` with the manual code shown in Android **Settings -> Connections -> Advanced**.
|
||||
|
||||
- **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`.
|
||||
|
||||
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.
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System, 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.
|
||||
|
||||
@@ -146,7 +167,7 @@ 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.
|
||||
@@ -214,13 +235,13 @@ See the [changelog](CHANGELOG.md) for the full list.
|
||||
**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`
|
||||
2. **Choose Standard Hermes** during onboarding and enter your API URL/key (e.g. `http://192.168.1.100:8642`)
|
||||
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)
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via plugin-provided `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/)**.
|
||||
@@ -333,7 +354,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
|
||||
|
||||
|
||||
+57
-161
@@ -3,12 +3,9 @@ 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
|
||||
@@ -16,10 +13,7 @@ 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 {
|
||||
|
||||
@@ -39,261 +33,163 @@ class OnboardingFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,9 @@ data class DashboardConnectionStatus(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -39,9 +42,9 @@ data class DashboardConnectionStatus(
|
||||
*
|
||||
* **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(
|
||||
@@ -59,6 +62,15 @@ data class Connection(
|
||||
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,
|
||||
@@ -135,5 +147,126 @@ data class Connection(
|
||||
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 })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +401,7 @@ class ConnectionStore private constructor(
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
|
||||
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
@@ -405,8 +462,22 @@ class ConnectionStore private constructor(
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
return if (dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) {
|
||||
copy(dashboardUrl = derivedDashboardUrl)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,6 +24,7 @@ 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,
|
||||
@@ -48,12 +49,24 @@ 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")
|
||||
@@ -63,6 +76,7 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
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
|
||||
@@ -77,6 +91,9 @@ 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,
|
||||
@@ -93,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 }
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -331,21 +340,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
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -34,10 +35,20 @@ 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,
|
||||
@@ -78,11 +89,26 @@ class DashboardApiClient(
|
||||
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()),
|
||||
@@ -188,7 +214,7 @@ class DashboardApiClient(
|
||||
deleteJsonObject("/api/profiles/${pathSegment(name)}")
|
||||
|
||||
suspend fun loginPassword(
|
||||
provider: String = "password",
|
||||
provider: String = "basic",
|
||||
username: String,
|
||||
password: String,
|
||||
next: String = "/",
|
||||
@@ -248,6 +274,12 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -274,12 +306,63 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
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)}"
|
||||
@@ -308,11 +391,13 @@ class DashboardApiClient(
|
||||
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 = parseProviderIds(providersElement),
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
)
|
||||
@@ -321,12 +406,22 @@ class DashboardApiClient(
|
||||
fun parseAuthSession(root: JsonObject): DashboardAuthSession {
|
||||
val user = root["user"] as? JsonObject
|
||||
val session = root["session"] as? JsonObject
|
||||
val authenticated = root.booleanField("authenticated")
|
||||
val explicitAuthenticated = root.booleanField("authenticated")
|
||||
?: root.booleanField("ok")
|
||||
?: (user != null || session != null)
|
||||
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"),
|
||||
@@ -336,26 +431,53 @@ class DashboardApiClient(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseProviderIds(element: JsonElement?): List<String> {
|
||||
fun parseProviders(element: JsonElement?): List<DashboardAuthProvider> {
|
||||
return when (element) {
|
||||
is JsonArray -> element.mapNotNull { providerId(it) }
|
||||
is JsonArray -> element.mapNotNull { provider(it) }
|
||||
is JsonObject -> element.entries.mapNotNull { (key, value) ->
|
||||
providerId(value) ?: key.takeIf { it.isNotBlank() }
|
||||
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()
|
||||
}.distinct()
|
||||
}.distinctBy { it.name }
|
||||
}
|
||||
|
||||
private fun providerId(element: JsonElement?): String? {
|
||||
private fun provider(element: JsonElement?): DashboardAuthProvider? {
|
||||
return when (element) {
|
||||
is JsonPrimitive -> element.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
is JsonObject -> element.stringField("id")
|
||||
?: element.stringField("name")
|
||||
?: element.stringField("type")
|
||||
?: element.stringField("provider")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +563,51 @@ class DashboardCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -501,6 +668,12 @@ private fun Response.readJsonObject(json: Json): JsonObject {
|
||||
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 }
|
||||
|
||||
@@ -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,44 @@ 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>? {
|
||||
@@ -260,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}")
|
||||
@@ -349,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}")
|
||||
@@ -1141,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
|
||||
@@ -1180,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
|
||||
@@ -1226,6 +1280,25 @@ class HermesApiClient(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun probeAudioApi(): Boolean = withContext(Dispatchers.IO) {
|
||||
val healthy = try {
|
||||
val req = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext false
|
||||
|
||||
fun routeExists(path: String): Boolean = try {
|
||||
val req = authRequest("$baseUrl$path").head().build()
|
||||
client.newCall(req).execute().use { response -> response.code != 404 }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
routeExists("/api/audio/transcribe") && routeExists("/api/audio/speak")
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
fun shutdown() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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("Hermes API voice is not available"))
|
||||
} 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 standardFailure: Result<T>? = null
|
||||
if (standardReadyProvider()) {
|
||||
val result = block(standardClient)
|
||||
if (result.isSuccess || !relayReadyProvider()) return result
|
||||
standardFailure = result
|
||||
}
|
||||
if (relayReadyProvider()) {
|
||||
return block(relayClient)
|
||||
}
|
||||
return standardFailure ?: Result.failure(
|
||||
IllegalStateException("Voice needs a reachable Hermes API or Relay voice route"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val apiUrlProvider: () -> String?,
|
||||
private val apiBearerTokenProvider: suspend () -> String? = { null },
|
||||
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 = apiBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes API 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 = authRequest("$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 = apiBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes API 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 = authRequest("$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 apiBaseUrl(): String? =
|
||||
apiUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private suspend fun authRequest(url: String): Request.Builder {
|
||||
val builder = Request.Builder().url(url)
|
||||
val token = apiBearerTokenProvider()?.trim().orEmpty()
|
||||
if (token.isNotBlank()) builder.header("Authorization", "Bearer $token")
|
||||
return builder
|
||||
}
|
||||
|
||||
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 unauthorized - check your API key"
|
||||
404 -> "$operation unavailable on this Hermes server"
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -55,10 +51,8 @@ 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
|
||||
@@ -71,6 +65,7 @@ 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
|
||||
@@ -80,6 +75,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
|
||||
@@ -108,7 +107,11 @@ 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.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.network.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.ProfileApiUrlResolver
|
||||
import com.hermesandroid.relay.network.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
@@ -119,6 +122,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
|
||||
|
||||
@@ -187,11 +191,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"
|
||||
@@ -200,8 +204,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 {
|
||||
@@ -282,12 +286,6 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
private val bottomNavScreens = listOf(
|
||||
Screen.Chat,
|
||||
Screen.Manage,
|
||||
Screen.Settings
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val connectionViewModel: ConnectionViewModel = viewModel()
|
||||
@@ -367,6 +365,14 @@ 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 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
|
||||
@@ -395,6 +401,34 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
val standardVoiceClient = remember {
|
||||
StandardHermesVoiceClient(
|
||||
context = mediaContext,
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
apiUrlProvider = {
|
||||
val baseApiUrl = ProfileApiUrlResolver.normalize(
|
||||
connectionViewModel.effectiveApiServerUrl.value,
|
||||
)
|
||||
ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = connectionViewModel.selectedProfile.value?.apiServerUrl,
|
||||
baseApiUrl = baseApiUrl,
|
||||
) ?: connectionViewModel.effectiveApiServerUrl.value
|
||||
},
|
||||
apiBearerTokenProvider = { connectionViewModel.getApiKey() },
|
||||
)
|
||||
}
|
||||
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
|
||||
@@ -422,6 +456,7 @@ fun RelayApp() {
|
||||
val player = VoicePlayer(mediaContext)
|
||||
voiceViewModel.initialize(
|
||||
voiceClient = voiceClient,
|
||||
voiceAudioClient = voiceAudioClient,
|
||||
chatViewModel = chatViewModel,
|
||||
recorder = recorder,
|
||||
player = player,
|
||||
@@ -454,7 +489,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),
|
||||
@@ -619,6 +654,7 @@ fun RelayApp() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -636,6 +672,17 @@ 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
|
||||
@@ -643,7 +690,6 @@ fun RelayApp() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
val imeBottom = WindowInsets.ime.getBottom(density)
|
||||
val isKeyboardVisible = imeBottom > 0
|
||||
@@ -653,6 +699,11 @@ 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 relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
|
||||
// Single snackbar host for the whole app — exposed via LocalSnackbarHost
|
||||
// so voice/chat/settings screens can call showHumanError from their
|
||||
@@ -713,6 +764,17 @@ fun RelayApp() {
|
||||
globalConnectionStatus != null &&
|
||||
!isOnboarding &&
|
||||
!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
|
||||
@@ -782,6 +844,7 @@ fun RelayApp() {
|
||||
ConnectionStatusBanner(
|
||||
status = globalConnectionStatus,
|
||||
includeStatusBarPadding = !showUnattendedBanner && availableUpdate == null,
|
||||
onClick = onConnectionStatusBannerClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -824,73 +887,31 @@ fun RelayApp() {
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
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 ->
|
||||
@@ -927,6 +948,10 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onManageSignIn = {
|
||||
postOnboardingRoute = Screen.Manage.route
|
||||
connectionViewModel.completeOnboarding()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -976,6 +1001,44 @@ 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 = {
|
||||
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) {
|
||||
@@ -984,6 +1047,34 @@ 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
|
||||
}
|
||||
},
|
||||
onNavigateToBridge = {
|
||||
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) {
|
||||
@@ -1020,6 +1111,29 @@ fun RelayApp() {
|
||||
onNavigateToBridgeSafety = {
|
||||
navController.navigate(Screen.BridgeSafetySettings.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
|
||||
}
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BridgeCoreScreen(
|
||||
@@ -1027,6 +1141,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) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -1048,6 +1180,11 @@ fun RelayApp() {
|
||||
onNavigateToRelaySessions = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1140,6 +1277,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)
|
||||
},
|
||||
@@ -1155,6 +1310,11 @@ fun RelayApp() {
|
||||
onNavigateToRelaySessions = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1253,18 +1413,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)
|
||||
},
|
||||
@@ -1311,6 +1476,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
|
||||
|
||||
+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
@@ -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,340 @@
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.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,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(26.dp)
|
||||
.relayPanel(
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
background = RelayRefresh.Background.copy(alpha = 0.94f),
|
||||
borderColor = RelayRefresh.Line,
|
||||
)
|
||||
.padding(horizontal = 12.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 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,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.relayPanel()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
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
|
||||
|
||||
@@ -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,64 +348,96 @@ 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 = "Talk to any Hermes profile with real-time streaming responses, tool progress, and full markdown."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalPage() {
|
||||
private fun ManagePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = "Manage",
|
||||
description = "Use dashboard-backed Skills, Cron, MCP, Profiles, Models, Config, and Settings without Relay pairing."
|
||||
)
|
||||
}
|
||||
|
||||
@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 = "Terminal, Bridge, Relay sessions, grants, and relay-backed device features require Relay pairing. Sideload builds can unlock device control."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -394,6 +445,7 @@ private fun BridgePage() {
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -406,6 +458,7 @@ private fun ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onCancel = onSkip,
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -22,6 +23,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 +42,17 @@ 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.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 +67,38 @@ 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 = {},
|
||||
) {
|
||||
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 +107,43 @@ 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),
|
||||
)
|
||||
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 +224,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,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.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -62,9 +64,16 @@ 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.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 +114,9 @@ fun BridgeScreen(
|
||||
// === PHASE3-safety-rails: safety summary card ===
|
||||
onNavigateToBridgeSafety: () -> Unit = {},
|
||||
// === END PHASE3-safety-rails ===
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
) {
|
||||
val masterToggle by viewModel.masterToggle.collectAsState()
|
||||
val permissionStatus by viewModel.permissionStatus.collectAsState()
|
||||
@@ -197,8 +209,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 +227,36 @@ 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
|
||||
}
|
||||
},
|
||||
)
|
||||
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 +297,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,
|
||||
|
||||
@@ -36,11 +36,14 @@ 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.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 +131,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 +142,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 +195,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,10 +263,8 @@ 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 official Hermes API audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
@@ -835,7 +847,8 @@ fun ChatScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.radialNavyBackground(isDarkTheme = isDarkTheme)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.14f)
|
||||
.imePadding()
|
||||
.alpha(chatAlpha)
|
||||
) {
|
||||
@@ -992,7 +1005,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 +1013,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,6 +1021,18 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
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 toggle (show/hide sphere visualization).
|
||||
// Profile + personality pickers moved into the agent sheet
|
||||
// that opens on title tap — the top bar no longer owns
|
||||
@@ -1024,9 +1049,19 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
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) {
|
||||
@@ -1129,42 +1164,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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1556,10 +1610,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 +1617,7 @@ fun ChatScreen(
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
"Voice needs API key or pairing, plus a reachable relay route",
|
||||
"Voice needs a reachable Hermes API or Relay voice route",
|
||||
android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
@@ -1734,6 +1784,7 @@ fun ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
onDismiss = { showAgentInfo = false },
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
onNavigateToProfileInspector = onNavigateToProfileInspector,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+211
-18
@@ -53,12 +53,14 @@ 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.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.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
@@ -108,6 +110,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 +129,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 +199,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 +228,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 +240,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 +312,7 @@ private fun ConnectionCard(
|
||||
liveState: RelayUiState?,
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayEnabled: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
@@ -304,6 +320,7 @@ private fun ConnectionCard(
|
||||
onRevoke: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -378,9 +395,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 +427,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 +478,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 +496,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
|
||||
@@ -643,11 +684,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 +780,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,
|
||||
|
||||
+851
-158
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 = {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -396,6 +396,7 @@ fun SettingsScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
onDismiss = { showAgentSheet = false },
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
onNavigateToProfileInspector = onNavigateToProfileInspector,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -106,6 +107,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
|
||||
@@ -410,6 +412,62 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
SectionCard(title = "Stable STT/TTS Route") {
|
||||
listOf(
|
||||
VoiceAudioRoute.Auto to Triple(
|
||||
"Auto",
|
||||
"Use standard Hermes audio first, then Relay when needed.",
|
||||
false,
|
||||
),
|
||||
VoiceAudioRoute.Standard to Triple(
|
||||
"Standard Hermes",
|
||||
"Use the upstream API audio path used by Hermes Desktop.",
|
||||
false,
|
||||
),
|
||||
VoiceAudioRoute.Relay to Triple(
|
||||
"Relay",
|
||||
"Use Relay voice providers and streaming voice output.",
|
||||
true,
|
||||
),
|
||||
).forEach { (route, copy) ->
|
||||
val (label, detail, relayOnly) = copy
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentAudioRoute == route,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setAudioRoute(route) }
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = currentAudioRoute == route,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
if (relayOnly) ExperimentalBadge("Optional")
|
||||
}
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Global Voice Controls ---
|
||||
SectionCard(title = "Global Voice Controls") {
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -26,11 +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
|
||||
@@ -66,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
|
||||
@@ -256,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
|
||||
@@ -283,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."
|
||||
|
||||
@@ -391,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
|
||||
@@ -735,6 +739,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
fun initialize(
|
||||
voiceClient: RelayVoiceClient,
|
||||
voiceAudioClient: VoiceAudioClient? = null,
|
||||
chatViewModel: ChatViewModel,
|
||||
recorder: VoiceRecorder,
|
||||
player: VoicePlayer,
|
||||
@@ -772,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
|
||||
@@ -1563,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")
|
||||
@@ -1573,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()
|
||||
@@ -1582,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()
|
||||
@@ -1754,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
|
||||
}
|
||||
@@ -1773,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,
|
||||
@@ -1829,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()
|
||||
@@ -2817,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) {
|
||||
@@ -2888,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()
|
||||
@@ -3227,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)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -75,6 +75,31 @@ class ConnectionDashboardFieldsTest {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -35,7 +37,7 @@ class DashboardApiClientTest {
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"auth_required": true,
|
||||
"auth_providers": ["password", "nous"]
|
||||
"auth_providers": ["basic", "nous"]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
@@ -47,7 +49,8 @@ class DashboardApiClientTest {
|
||||
|
||||
assertEquals("/api/status", request.path)
|
||||
assertTrue(status.authRequired)
|
||||
assertEquals(listOf("password", "nous"), status.authProviders)
|
||||
assertEquals(listOf("basic", "nous"), status.authProviders)
|
||||
assertEquals("basic", status.authProviderDetails.first().name)
|
||||
assertEquals("0.16.0", status.version)
|
||||
}
|
||||
|
||||
@@ -62,7 +65,7 @@ class DashboardApiClientTest {
|
||||
"auth": {
|
||||
"required": true,
|
||||
"providers": [
|
||||
{"id": "password", "label": "Password"},
|
||||
{"id": "basic", "label": "Username & Password"},
|
||||
{"type": "oauth", "name": "nous"}
|
||||
]
|
||||
}
|
||||
@@ -75,7 +78,135 @@ class DashboardApiClientTest {
|
||||
val status = client.getStatus().getOrThrow()
|
||||
|
||||
assertTrue(status.authRequired)
|
||||
assertEquals(listOf("password", "nous"), status.authProviders)
|
||||
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
|
||||
@@ -107,12 +238,11 @@ class DashboardApiClientTest {
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"authenticated": true, "username": "bailey", "provider": "password"}"""),
|
||||
.setBody("""{"authenticated": true, "username": "bailey", "provider": "basic"}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val login = client.loginPassword(
|
||||
provider = "password",
|
||||
username = "bailey",
|
||||
password = "secret",
|
||||
).getOrThrow()
|
||||
@@ -123,7 +253,7 @@ class DashboardApiClientTest {
|
||||
|
||||
assertEquals("/auth/password-login", loginRequest.path)
|
||||
val body = loginRequest.body.readUtf8()
|
||||
assertTrue(body.contains(""""provider":"password""""))
|
||||
assertTrue(body.contains(""""provider":"basic""""))
|
||||
assertTrue(body.contains(""""username":"bailey""""))
|
||||
assertTrue(body.contains(""""password":"secret""""))
|
||||
assertTrue(login.ok)
|
||||
@@ -133,7 +263,7 @@ class DashboardApiClientTest {
|
||||
assertEquals("hermes_session=abc123", sessionRequest.getHeader("Cookie"))
|
||||
assertTrue(session.authenticated)
|
||||
assertEquals("bailey", session.username)
|
||||
assertEquals("password", session.provider)
|
||||
assertEquals("basic", session.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -146,6 +276,33 @@ class DashboardApiClientTest {
|
||||
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(
|
||||
@@ -161,6 +318,22 @@ class DashboardApiClientTest {
|
||||
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(
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
|
||||
@@ -173,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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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 |
|
||||
|
||||
+53
-39
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
+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
|
||||
|
||||
@@ -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.1"
|
||||
appVersionCode = "11"
|
||||
agp = "8.13.2"
|
||||
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" }
|
||||
|
||||
@@ -6,10 +6,11 @@ that waits for `aiohttp.web` to be imported, then replaces `web.Application`
|
||||
with a thin subclass that detects when hermes-agent's `APIServerAdapter`
|
||||
attaches itself to a fresh app and:
|
||||
|
||||
1. **Injects extra routes** — `/api/sessions/*`, `/api/memory`, `/api/skills`,
|
||||
`/api/config`, and `/api/available-models` — so a vanilla upstream
|
||||
hermes-agent install serves the management endpoints the Hermes-Relay
|
||||
Android app expects.
|
||||
1. **Injects missing compatibility routes** — older hermes-agent builds may
|
||||
lack `/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, and
|
||||
`/api/available-models`. Current upstream already has the session API and
|
||||
read-only `/v1/skills` + `/v1/toolsets`, so native routes win per method/path
|
||||
and the bootstrap only fills gaps.
|
||||
|
||||
2. **Installs slash-command middleware** — an aiohttp middleware that intercepts
|
||||
`/v1/chat/completions` and `/v1/runs` to handle gateway slash commands
|
||||
@@ -18,14 +19,16 @@ attaches itself to a fresh app and:
|
||||
LLM from hallucinating responses for them. This mirrors the upstream
|
||||
Stage 1 preprocessor from `gateway/platforms/api_server_slash.py`.
|
||||
|
||||
Chat streaming continues to use upstream's standard `/v1/runs` endpoint, which
|
||||
already emits structured tool events.
|
||||
Chat streaming prefers upstream's native
|
||||
`/api/sessions/{session_id}/chat/stream` endpoint when it is advertised. Older
|
||||
builds that only get bootstrap-provided session CRUD fall back to standard
|
||||
`/v1/chat/completions` or `/v1/runs` paths.
|
||||
|
||||
This module is removed in its entirety once upstream PR
|
||||
https://github.com/NousResearch/hermes-agent/pull/8556 lands and reaches a
|
||||
released hermes-agent version. The bootstrap feature-detects on route paths
|
||||
and module presence and silently no-ops when the upstream-merged or fork-built
|
||||
endpoints are already present, so it stays harmless during the rollout window.
|
||||
This module retires per surface, not as one broad PR cleanup. Sessions can go
|
||||
once the supported hermes-agent baseline includes PR #33134, read-only skills
|
||||
should use PR #33016's `/v1/skills`, and the remaining config/memory/legacy
|
||||
skill/available-model/slash-command surfaces need stable replacements or local
|
||||
UX removal before the package and `.pth` hook can disappear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Ported handlers from the `feat/session-api` branch on Codename-11/hermes-agent
|
||||
(submitted upstream as PR #8556).
|
||||
"""Compatibility handlers from the pre-upstream Hermes-Relay API branch.
|
||||
|
||||
The original broad branch was superseded upstream. Current Hermes main has
|
||||
native session controls via PR #33134 and read-only skills/toolsets via PR
|
||||
#33016; these handlers remain for older core builds and for compatibility-only
|
||||
surfaces that do not yet have stable API-server replacements.
|
||||
|
||||
This file mirrors the management endpoints from the fork branch, adapted to
|
||||
take the `APIServerAdapter` instance as an explicit parameter rather than
|
||||
@@ -40,11 +44,12 @@ Endpoints injected (all bearer-auth gated via `adapter._check_auth`):
|
||||
|
||||
NOT injected:
|
||||
|
||||
- `POST /api/sessions/{session_id}/chat/stream` — chat streaming intentionally
|
||||
goes through upstream's standard `/v1/runs`, which emits structured
|
||||
`tool.started`/`tool.completed` SSE events in real time. Injecting the
|
||||
sessions chat handler would require coordinating with `_create_agent` /
|
||||
`run_conversation` — the fork's riskiest cross-cutting dependencies.
|
||||
- `POST /api/sessions/{session_id}/chat/stream` — native upstream provides
|
||||
this in PR #33134. The bootstrap does not inject a chat-stream handler for
|
||||
older builds because that path requires coordinating with `_create_agent` /
|
||||
`run_conversation` — the fork's riskiest cross-cutting dependencies. Clients
|
||||
should fall back to `/v1/chat/completions` or `/v1/runs` when chat streaming
|
||||
is not advertised.
|
||||
|
||||
- `GET /api/skills/categories` — removed from upstream as dead code in commit
|
||||
8d023e43 ("refactor: remove dead code — 1,784 lines across 77 files"). The
|
||||
@@ -55,8 +60,10 @@ Removal note: upstream is moving toward focused native surfaces rather than one
|
||||
large frontend API patch. As each method/path lands in hermes-agent, route
|
||||
registration below skips that native route and keeps only the missing
|
||||
compatibility gaps. Cleanup should therefore happen per surface: sessions can
|
||||
retire after native session controls are released, while config/skills/memory
|
||||
remain until core exposes stable equivalents.
|
||||
retire once the supported core baseline includes PR #33134, read-only skill
|
||||
lists should use `/v1/skills` from PR #33016, while config/memory/legacy skill
|
||||
detail/toggle/available-models remain until core exposes stable equivalents or
|
||||
Hermes-Relay stops depending on them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+17
-13
@@ -13,11 +13,14 @@
|
||||
# directly into the venv's site-packages so Python's `site` module
|
||||
# auto-loads it at every interpreter startup. The bootstrap monkey-
|
||||
# patches `aiohttp.web.Application` so when the gateway builds its app,
|
||||
# our extra `/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`
|
||||
# and `/api/available-models` routes get injected onto the same router
|
||||
# the gateway is in the middle of populating. Feature-detected by route
|
||||
# path — if your hermes-agent build already has these endpoints natively,
|
||||
# the bootstrap no-ops cleanly so it's safe to ship across all versions.
|
||||
# missing compatibility routes can be injected onto the same router the
|
||||
# gateway is in the middle of populating. Current upstream already serves
|
||||
# `/api/sessions/*` and read-only `/v1/skills` + `/v1/toolsets`; the
|
||||
# bootstrap now mainly protects older core builds and remaining gaps such
|
||||
# as `/api/memory`, legacy `/api/skills`, `/api/config`, and
|
||||
# `/api/available-models`. Feature-detected by route path — if your
|
||||
# hermes-agent build already has a route natively, the bootstrap leaves it
|
||||
# alone.
|
||||
# 3. A symlink at ~/.hermes/plugins/hermes-relay → the clone's plugin/ dir
|
||||
# so Hermes's plugin loader discovers + enables the plugin
|
||||
# 4. The skill(s) under skills/ into ~/.hermes/config.yaml as a scanned
|
||||
@@ -27,10 +30,11 @@
|
||||
# - ~/.local/bin/hermes-pair → `<venv>/python -m plugin.pair "$@"`
|
||||
# - ~/.local/bin/hermes-status → `<venv>/python -m plugin.status "$@"`
|
||||
# - ~/.local/bin/hermes-relay-update → curl-pipe re-runs install.sh
|
||||
# The pair/status shims exist because the upstream `hermes pair` plugin
|
||||
# CLI path is blocked. The update shim is just a discoverable name for
|
||||
# "re-run the canonical curl-pipe installer" — convenience UX, not a
|
||||
# separate code path.
|
||||
# Current upstream exposes plugin CLI commands, so `hermes pair` should
|
||||
# work when the plugin is enabled. The pair/status shims remain
|
||||
# script-friendly older-build fallbacks. The update shim is just a
|
||||
# discoverable name for "re-run the canonical curl-pipe installer" —
|
||||
# convenience UX, not a separate code path.
|
||||
# 6. A systemd user unit at ~/.config/systemd/user/hermes-relay.service
|
||||
# (optional — only on hosts with a systemd user session; skipped on
|
||||
# macOS, WSL-without-systemd, bare chroots, etc.). When installed,
|
||||
@@ -314,8 +318,8 @@ ok "Installed $("$VENV_PY" -m pip show hermes-relay 2>/dev/null | awk '/^Name:/{
|
||||
|
||||
# Drop the bootstrap .pth into the venv's site-packages so Python loads
|
||||
# `hermes_relay_bootstrap` at interpreter startup. This is what allows the
|
||||
# plugin to inject `/api/sessions/*` (and friends) onto the gateway's
|
||||
# aiohttp app at startup. The .pth has to live directly in site-packages —
|
||||
# plugin to inject missing compatibility routes onto the gateway's aiohttp app
|
||||
# at startup. The .pth has to live directly in site-packages —
|
||||
# setuptools' editable install does NOT ship data-files there, so we drop
|
||||
# it manually here. Idempotent: a second run overwrites the same file.
|
||||
#
|
||||
@@ -330,8 +334,8 @@ if [ -n "$SITE_PKGS" ] && [ -d "$SITE_PKGS" ] && [ -f "$PTH_SRC" ]; then
|
||||
ok "Installed bootstrap .pth → $SITE_PKGS/hermes_relay_bootstrap.pth"
|
||||
else
|
||||
info " Could not determine venv site-packages — bootstrap .pth NOT installed"
|
||||
info " This means /api/sessions/* won't be injected onto vanilla upstream"
|
||||
info " hermes-agent. Manually copy $PTH_SRC into your venv's site-packages."
|
||||
info " This means older hermes-agent builds won't get relay compatibility"
|
||||
info " routes. Manually copy $PTH_SRC into your venv's site-packages."
|
||||
fi
|
||||
|
||||
# ── 3/6 Symlink plugin into Hermes plugin dir ─────────────────────────────
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ def register(ctx):
|
||||
check_fn=_make_desktop_check(tool_name),
|
||||
)
|
||||
|
||||
# Register CLI sub-commands: hermes pair + hermes relay (v0.8.0+)
|
||||
# Register plugin-native CLI sub-commands: hermes pair + hermes relay.
|
||||
# Wrapped in try/except so the plugin still works on older hermes-agent
|
||||
# versions that do not expose register_cli_command.
|
||||
try:
|
||||
|
||||
@@ -134,7 +134,10 @@ class HermesToolBroker:
|
||||
except Exception as exc:
|
||||
logger.debug("Hermes context fetch failed for %s: %s", session_id, exc)
|
||||
return ()
|
||||
items = payload.get("items") or payload.get("messages") if isinstance(payload, dict) else None
|
||||
if isinstance(payload, dict):
|
||||
items = payload.get("data") or payload.get("items") or payload.get("messages")
|
||||
else:
|
||||
items = None
|
||||
if not isinstance(items, list):
|
||||
return ()
|
||||
messages: list[dict[str, str]] = []
|
||||
|
||||
+21
-3
@@ -1,11 +1,29 @@
|
||||
import com.meta.spatial.plugin.SpatialAppExtension
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
// Meta Spatial 0.12.0 ships older AGP/Kotlin compiler artifacts on its
|
||||
// plugin classpath; AGP 9.2 provides the Android/Kotlin tooling here.
|
||||
classpath("com.meta.spatial:spatial-gradle-plugin-impl:0.12.0") {
|
||||
exclude(group = "com.android.tools.build", module = "gradle")
|
||||
exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-embeddable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.meta.spatial.plugin")
|
||||
}
|
||||
|
||||
apply(plugin = "com.meta.spatial.plugin")
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesquest"
|
||||
compileSdk = 36
|
||||
@@ -101,7 +119,7 @@ val exportSpatialScenes = providers.gradleProperty("quest.exportScenes")
|
||||
.map(String::toBoolean)
|
||||
.orElse(false)
|
||||
|
||||
spatial {
|
||||
extensions.configure<SpatialAppExtension>("spatial") {
|
||||
allowUsageDataCollection.set(false)
|
||||
if (exportSpatialScenes.get()) {
|
||||
scenes {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
appVersionName = "0.6.0"
|
||||
appVersionCode = "7"
|
||||
agp = "8.9.1"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.0.20"
|
||||
spatialsdk = "0.12.0"
|
||||
compose-bom = "2024.09.03"
|
||||
@@ -56,7 +56,5 @@ meta-spatial-sdk-mruk = { group = "com.meta.spatial", name = "meta-spatial-sdk-m
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", 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" }
|
||||
meta-spatial-plugin = { id = "com.meta.spatial.plugin", version.ref = "spatialsdk" }
|
||||
|
||||
@@ -5,12 +5,10 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application") version "8.9.1"
|
||||
id("com.android.library") version "8.9.1"
|
||||
id("org.jetbrains.kotlin.android") version "2.0.20"
|
||||
id("com.android.application") version "9.2.1"
|
||||
id("com.android.library") version "9.2.1"
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.20"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20"
|
||||
id("com.meta.spatial.plugin") version "0.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +27,3 @@ include(":relay-ui")
|
||||
|
||||
project(":relay-core").projectDir = file("../relay-core")
|
||||
project(":relay-ui").projectDir = file("../relay-ui")
|
||||
|
||||
gradle.beforeProject {
|
||||
plugins.withId("com.android.library") {
|
||||
pluginManager.apply("org.jetbrains.kotlin.android")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ Auth uses optional Bearer token (`API_SERVER_KEY`). Most local setups run withou
|
||||
|
||||
## ADR-7: Pairing Code Auth for Relay (QR-driven, updated 2026-04-11)
|
||||
|
||||
**Decision:** Initial pairing via 6-char code generated by the pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim) on the Hermes host, pre-registered with the relay via a loopback-only `/pairing/register` endpoint, and embedded in the same QR payload that carries the API server credentials. One scan configures both chat and the relay. Session tokens handle all subsequent reconnects.
|
||||
**Decision:** Initial Relay pairing via 6-char code generated by the pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host, pre-registered with the relay via a loopback-only `/pairing/register` endpoint, and embedded in the same QR payload that can also carry API server credentials. Standard API/dashboard setup can be saved without Relay pairing. Session tokens handle all subsequent relay reconnects.
|
||||
|
||||
### Rationale
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ The relay connection (bridge/terminal) uses a pairing code for initial setup, th
|
||||
|
||||
<HermesFlow diagram="auth-flow" height="200px" />
|
||||
|
||||
Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim) on the Hermes host mints the code and pre-registers it with the relay via a loopback-only `/pairing/register` endpoint before embedding it in the QR — so the phone never types a code by hand. Session tokens are stored in EncryptedSharedPreferences backed by Android Keystore.
|
||||
Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host mints the code and pre-registers it with the relay via a loopback-only `/pairing/register` endpoint before embedding it in the QR — so the phone never types a code by hand. Session tokens are stored in EncryptedSharedPreferences backed by Android Keystore.
|
||||
|
||||
## Direct API vs Relay
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ The Google Play build can request camera for QR pairing, microphone for Voice mo
|
||||
|
||||
## Data export and reset
|
||||
|
||||
From **Settings**, you can export your configuration (secrets excluded), import a backup, or perform a full reset that wipes local data including encrypted credentials.
|
||||
From **Settings**, you can export a full connection backup, import a backup, or perform a full reset that wipes local data including encrypted credentials. Full backups include sensitive connection material such as API keys, relay session tokens, device IDs, and dashboard cookies so restored connections can work without manual re-entry. Keep exported backup files private.
|
||||
|
||||
## Open source
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
API keys are stored using Android's `EncryptedSharedPreferences`:
|
||||
- Encryption: AES-256-GCM
|
||||
- Key management: Android Keystore (hardware-backed when available)
|
||||
- Keys are never included in backups or exports
|
||||
- Full backups include API keys and other connection secrets. Treat exported
|
||||
backup files like credentials and store them somewhere private.
|
||||
|
||||
## Network Security
|
||||
|
||||
@@ -38,7 +39,7 @@ The Hermes API bearer token is accepted only for `/voice/config`, `/voice/transc
|
||||
|
||||
## Relay Auth Flow
|
||||
|
||||
1. Operator runs `/hermes-relay-pair` (from any Hermes chat surface) or `hermes-pair` (shell shim) on the Hermes host
|
||||
1. Operator runs `hermes pair` (shell), `/hermes-relay-pair` (from any Hermes chat surface), or the compatibility `hermes-pair` shim on the Hermes host
|
||||
2. The pair command probes `localhost:RELAY_PORT/health`; if the relay is up, it mints a fresh 6-char code
|
||||
3. It pre-registers the code with the relay via the loopback-only `POST /pairing/register` endpoint
|
||||
4. The relay URL + code are embedded in the QR payload alongside the API server credentials
|
||||
@@ -62,7 +63,9 @@ Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambi
|
||||
|
||||
- Session tokens encrypted in EncryptedSharedPreferences
|
||||
- API keys never logged or included in error messages
|
||||
- Backup exports exclude tokens and API keys
|
||||
- Backup exports include API keys, relay session tokens, device IDs, and
|
||||
dashboard cookies so connections can be restored. The export dialog warns
|
||||
before writing the file.
|
||||
- DataStore preferences are app-private (standard Android sandbox)
|
||||
|
||||
## Bridge Security — Five-Stage Safety Gate
|
||||
|
||||
@@ -101,7 +101,7 @@ The third command (`hermes-relay` with no args) drops you into `shell` mode —
|
||||
|
||||
See **[Installation](./installation.md)** for the full walkthrough (Bun-compiled binaries, version-aware install, `hermes` alias, self-update flow) and **[Pairing](./pairing.md)** for minting a 6-char code on the server.
|
||||
|
||||
The tray app follows the same rule as the CLI for relay-backed desktop control: daemon, devices, grants, and TUI controls unlock only after a paired session token exists in `~/.hermes/remote-sessions.json`. A raw Advanced relay URL is only an override hint, not a pairing, so fresh or signed-out installs show "Pair first" for those controls until pairing completes. The Chat tab is allowed to run in a lighter chat-only mode against a direct Hermes WebAPI URL (`http://host:8642`) with an optional API key kept only for the current tray session. The TUI tab runs the experimental embedded terminal: xterm.js renders inside the dashboard while the Rust tray process owns the local PTY and launches the same tmux-backed `hermes-relay` session path. The Plugins tab uses the same embedded terminal host for installable dashboard surfaces such as Herm. Open in an external terminal remains the fallback for PTY focus, resize, and shortcut testing.
|
||||
The tray app follows the same rule as the CLI for relay-backed desktop control: daemon, devices, grants, and TUI controls unlock only after a paired session token exists in `~/.hermes/remote-sessions.json`. A raw Advanced relay URL is only an override hint, not a pairing, so fresh or signed-out installs show "Pair first" for those controls until pairing completes. The Chat tab can run in a lighter chat-only mode against a direct Hermes WebAPI URL (`http://host:8642`) with the API key from `API_SERVER_KEY` kept only for the current tray session. The TUI tab runs the experimental embedded terminal: xterm.js renders inside the dashboard while the Rust tray process owns the local PTY and launches the same tmux-backed `hermes-relay` session path. The Plugins tab uses the same embedded terminal host for installable dashboard surfaces such as Herm. Open in an external terminal remains the fallback for PTY focus, resize, and shortcut testing.
|
||||
|
||||
## Why both shell AND chat modes?
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Pairing exchanges a one-time 6-character code for a long-lived session token, st
|
||||
SSH into your Hermes host (or use any terminal already on it):
|
||||
|
||||
```bash
|
||||
hermes-pair --ttl 600
|
||||
hermes pair --ttl 600
|
||||
```
|
||||
|
||||
Output:
|
||||
@@ -38,7 +38,7 @@ The CLI prompts:
|
||||
|
||||
```
|
||||
Relay: ws://<host>:8767
|
||||
Need a pairing code — run `/hermes-relay-pair` (or `hermes-pair`) on the relay host.
|
||||
Need a pairing code — run `hermes pair` (or `/hermes-relay-pair`) on the relay host.
|
||||
(Paste works; cleaned code shown before submit.)
|
||||
|
||||
Pairing code (6 chars): _
|
||||
@@ -75,12 +75,12 @@ On the server:
|
||||
|
||||
```bash
|
||||
# All three routes
|
||||
hermes-pair --mode auto --public-url https://hermes.example.com
|
||||
hermes pair --mode auto --public-url https://hermes.example.com
|
||||
|
||||
# Or specific:
|
||||
hermes-pair --mode lan
|
||||
hermes-pair --mode tailscale
|
||||
hermes-pair --mode public --public-url https://hermes.example.com
|
||||
hermes pair --mode lan
|
||||
hermes pair --mode tailscale
|
||||
hermes pair --mode public --public-url https://hermes.example.com
|
||||
```
|
||||
|
||||
The output is a JSON blob (printed alongside the QR). Copy it verbatim and paste to the CLI:
|
||||
|
||||
@@ -50,7 +50,7 @@ rm ~/.hermes/remote-sessions.json # or delete just this URL's entry
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
Mint a fresh code on the server first: `hermes-pair --ttl 600`.
|
||||
Mint a fresh code on the server first: `hermes pair --ttl 600`.
|
||||
|
||||
## `disconnected before auth`
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ A **connection** in Hermes-Relay is a saved link to a Hermes server. Add multipl
|
||||
|
||||
Each connection stores everything needed to talk to one Hermes install:
|
||||
|
||||
- API server URL (`http(s)://host:8642`) and auto-derived relay URL (`ws(s)://host:8767`), unless you set a manual relay override
|
||||
- Its own pairing record — session token, device ID, optional API key
|
||||
- API server URL (`http(s)://host:8642`) and API key for Chat/session API calls
|
||||
- Auto-derived dashboard URL (`http(s)://host:9119`) and dashboard cookies for Manage
|
||||
- Auto-derived relay URL (`ws(s)://host:8767`), unless you set a manual relay override
|
||||
- Its own Relay pairing record — session token and device ID for Terminal, Bridge, and relay-only power tools
|
||||
- Its own sessions, memory, personalities, and skill list (fetched from that server)
|
||||
- Last-active session ID and explicit profile pick, so switching back takes you where you left off
|
||||
|
||||
@@ -36,7 +38,7 @@ Open **Settings → Connections**. Each card shows the connection's label, hostn
|
||||
- **Revoke** — server-side logout. The token is invalidated on the server; the connection stays in the app but is marked unpaired.
|
||||
- **Remove** — deletes the connection and its stored auth material. The TOFU cert pin for the server's host survives, so if you re-add the same server later, it's still trusted without a re-verify.
|
||||
|
||||
Tap **Add connection** to create a new one. This launches the standard QR pairing flow (same as first-time setup). After pairing, the connection is saved with the server's hostname as its default label.
|
||||
Tap **Add connection** to create a new one. This launches the same connection wizard used during first-time setup. Choose **Standard Hermes** for the normal API/dashboard path, or scan a QR when your host already printed one. Relay pairing is optional and can be added later from the connection card.
|
||||
|
||||
## Live status and diagnostics
|
||||
|
||||
@@ -65,7 +67,7 @@ For Tailscale, run this on the host before pairing:
|
||||
|
||||
```bash
|
||||
hermes-relay-tailscale enable
|
||||
hermes-pair --mode auto --prefer tailscale
|
||||
hermes pair --mode auto --prefer tailscale
|
||||
```
|
||||
|
||||
The helper publishes relay `:8767` and API `:8642`; both must be reachable for the full app to work away from LAN. The route menu in Settings lets you prefer a route for the current session without changing the stored connection.
|
||||
|
||||
@@ -12,7 +12,7 @@ The plugin is a thin observer — it never modifies state, never writes to your
|
||||
|
||||
**On your server:**
|
||||
|
||||
- hermes-agent with the Dashboard Plugin System (upstream commit `01214a7f` on `axiom`, or any later `main` once [PR #8556](https://github.com/NousResearch/hermes-agent/pull/8556) and its dashboard followups merge). `hermes dashboard start` must already work for you.
|
||||
- hermes-agent with the Dashboard Plugin System. `hermes dashboard start` must already work for you; the Relay tab uses the dashboard plugin mount and does not depend on the legacy session API branch.
|
||||
- The canonical Hermes-Relay install — if you ran the one-liner on the [Quick Start](/guide/getting-started), you're done. The installer symlinks `~/.hermes/plugins/hermes-relay` → the plugin subtree and the dashboard scanner picks up `plugin/dashboard/manifest.json` automatically.
|
||||
- A gateway restart after install: `systemctl --user restart hermes-gateway`.
|
||||
|
||||
@@ -30,7 +30,11 @@ The plugin's header shows the relay version, overall health (green / red dot), a
|
||||
|
||||
The Android app also uses the Hermes dashboard/admin API as its standard management data plane. The **Manage** tab derives the dashboard URL from the active API server URL by default (`:8642` → `:9119`) and reads Skills, Cron, MCP, MCP catalog, Profiles, Models, and Config from dashboard endpoints when the server supports them. The native tab supports skill toggles, cron pause/resume/run/delete plus recent runs, MCP enable/test/remove, catalog installs that do not require inline credentials, profile activation/delete, and read-only profile SOUL details.
|
||||
|
||||
This is separate from relay pairing. A standard user can connect with API/dashboard credentials and use the Manage tab without pairing the relay. Relay-only capabilities — Terminal, Bridge, Relay sessions, Media inspector, and profile memory file editing — stay under **Settings → Power tools** and show **Requires pairing** until the phone has a paired relay session. Editing profile SOUL or memory files remains in the paired profile inspector.
|
||||
Dashboard sign-in is the upstream-preferred remote auth path. Android supports the bundled `basic` username/password provider and redirect providers such as `nous` or self-hosted OIDC through the dashboard's `/auth/login?provider=...` flow. Successful sign-in stores dashboard cookies, verifies the flat upstream `/api/auth/me` session response, and probes `/api/auth/ws-ticket`. This matches the Hermes Desktop remote-gateway model: sign in once to the dashboard, then reuse that dashboard session for `/api/ws` with a short-lived ticket.
|
||||
|
||||
This is separate from relay pairing and from `API_SERVER_KEY`. A dashboard session does not become an API bearer token; Android Chat still uses the API key fallback until its dashboard JSON-RPC chat adapter is enabled. Relay-only capabilities — Terminal, Bridge, Relay sessions, Media inspector, and profile memory file editing — stay under **Settings → Power tools** and show **Requires pairing** until the phone has a paired relay session. Editing profile SOUL or memory files remains in the paired profile inspector.
|
||||
|
||||
Server-side dashboard auth is owned by upstream Hermes. For current provider registration, Nous OAuth, username/password, and remote dashboard guidance, use the Hermes [Web Dashboard docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard).
|
||||
|
||||
## The Four Tabs
|
||||
|
||||
@@ -40,14 +44,14 @@ The landing tab. Shows:
|
||||
|
||||
- **Relay version + uptime + health** — served by the relay's `/relay/info` endpoint. Green dot = reachable, red = `relay unreachable at 127.0.0.1:8767` (the gateway can't see your relay process; check `systemctl --user status hermes-relay`).
|
||||
- **Paired devices list** — one row per active session. Columns: device name (from the phone's `PairedDeviceInfo`), token prefix (first 8 chars — full tokens are never sent), created-at, last-seen, expires-at, labeled per-channel grants (chat / bridge / terminal / TUI / voice), transport hint (`wss` / `ws`).
|
||||
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Relay sessions or running `hermes-pair --revoke <prefix>` on the server.
|
||||
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Relay sessions or running `hermes pair --revoke <prefix>` on the server.
|
||||
- **Pair new device** — button in the card header opens the [PairDialog](#pairing-a-new-device) described below.
|
||||
|
||||
<!-- TODO: replace with real screenshot — dashboard Relay Management tab with a paired device row -->
|
||||
|
||||
#### Pairing a new device
|
||||
|
||||
The **Pair new device** button on the Relay Management tab is an alternative to `/hermes-relay-pair` and the `hermes-pair` CLI — same underlying pairing flow, just driven from a browser on your laptop instead of a chat or shell. Useful when you're already in the dashboard reviewing session state and want to onboard a phone without bouncing out to a terminal.
|
||||
The **Pair new device** button on the Relay Management tab is an alternative to `/hermes-relay-pair` and the `hermes pair` CLI — same underlying pairing flow, just driven from a browser on your laptop instead of a chat or shell. Useful when you're already in the dashboard reviewing session state and want to onboard a phone without bouncing out to a terminal.
|
||||
|
||||
**Click the button to open a PairDialog with:**
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@ The app uses the Hermes `/api/sessions` REST API:
|
||||
## Authentication
|
||||
|
||||
If the Hermes server is configured with `API_SERVER_KEY`, the app sends:
|
||||
|
||||
```
|
||||
Authorization: Bearer <API_SERVER_KEY>
|
||||
```
|
||||
|
||||
Most local Hermes setups don't require a key. The API key field in Settings is optional.
|
||||
The API key field in Settings is technically optional because Hermes can run an open local API server. For phone-reachable LAN, VPN, or public deployments, set `API_SERVER_KEY` and enter the same value in Android.
|
||||
|
||||
When provided, the key is stored in Android's `EncryptedSharedPreferences` using AES-256-GCM encryption backed by the Android Keystore.
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@ A **<span class="track-badge track-badge--sideload">Sideload only</span>** badge
|
||||
| [Voice Mode](/features/voice) | Real-time voice conversation — sphere listens, agent speaks back via your server's configured TTS/STT providers |
|
||||
| [Markdown Rendering](/features/markdown) | Full markdown with syntax-highlighted code blocks |
|
||||
| [Reasoning Display](/features/reasoning) | Collapsible extended-thinking blocks |
|
||||
| [Connections](/features/connections) | Pair with multiple Hermes servers — one-tap switch from the top-bar chip |
|
||||
| [Connections](/features/connections) | Save multiple Hermes servers — one-tap switch from the top-bar chip |
|
||||
| [Profiles](/features/profiles) | Auto-discovered upstream agent directories — overlay model + SOUL on chat turns |
|
||||
| [Personalities](/features/personalities) | Dynamic from `GET /api/config` — picker, agent name on bubbles |
|
||||
| [Command Palette](/guide/chat#command-palette) | Searchable command browser — 29 gateway commands, personalities, 90+ skills |
|
||||
| [Slash Commands](/guide/chat#inline-autocomplete) | Inline autocomplete as you type `/` |
|
||||
| [QR Code Pairing](/guide/getting-started#qr-code-pairing-recommended) | Scan `hermes-pair` QR to auto-configure connection |
|
||||
| [Standard Setup](/guide/getting-started#connect-android-to-hermes) | Connect by API URL/key first; scan a QR only when you want Relay pairing |
|
||||
| [Token Tracking](/features/tokens) | Per-message usage and cost |
|
||||
| [Tool Progress](/features/tools) | Configurable display — Off, Compact, or Detailed |
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ script automates a quick end-to-end check of the lab routes.
|
||||
|
||||
**"Relay returned 413" on synthesize** — you're trying to synthesize more than 5000 characters at once. This is a safety cap on the relay side to avoid runaway TTS costs. Client-side sentence chunking should normally keep individual requests well under this, so a 413 usually means the agent returned one enormous uninterrupted sentence.
|
||||
|
||||
**"That pairing code was already used"** — Relay pairing codes are one-shot. Generate a fresh QR from the dashboard Relay tab or `hermes-pair` and scan again. If you only need chat plus voice, skip the Relay pairing path and save the Hermes API URL/key instead; the app will derive the conventional Relay voice URL and probe `/voice/config`.
|
||||
**"That pairing code was already used"** — Relay pairing codes are one-shot. Generate a fresh QR from the dashboard Relay tab or `hermes pair` and scan again. If you only need chat plus voice, skip the Relay pairing path and save the Hermes API URL/key instead; the app will derive the conventional Relay voice URL and probe `/voice/config`.
|
||||
|
||||
## Privacy Note
|
||||
|
||||
|
||||
+128
-170
@@ -8,11 +8,11 @@ import { withBase } from 'vitepress'
|
||||
|
||||
- Android device or emulator (API 26+ / Android 8.0+)
|
||||
- A running [Hermes Agent](https://hermes-agent.nousresearch.com) instance (v0.8.0+ recommended) with the API server enabled
|
||||
- Python 3.11+ on the server (for the pairing plugin)
|
||||
- Python 3.11+ on the server only if you plan to install the optional Relay power-user plugin
|
||||
|
||||
## Quick Start
|
||||
|
||||
Three commands get you from zero to connected:
|
||||
The default path is standard Hermes first: connect Android to the Hermes API/dashboard, then add Relay pairing only when you need power-user features.
|
||||
|
||||
### 1. Install the Android app
|
||||
|
||||
@@ -25,176 +25,134 @@ The two builds use different application IDs, so you can install both side-by-si
|
||||
|
||||
Once you've decided: install from the [Play Store listing](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay), or grab 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) and follow the [Sideload APK](#sideload-apk) section below for step-by-step install and integrity-verification instructions.
|
||||
|
||||
### 2. Install the server plugin
|
||||
### 2. Prepare Hermes on your computer or server
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
Hermes-Relay for Android uses two upstream Hermes surfaces:
|
||||
|
||||
- **API server** on `:8642` for Chat and sessions
|
||||
- **Dashboard** on `:9119` for Manage sign-in and admin screens
|
||||
|
||||
If Hermes is already installed, start at `hermes setup --portal`. If you already have a model/provider configured, you can skip that line.
|
||||
|
||||
**macOS / Linux / WSL2 / Termux:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
hermes setup --portal
|
||||
|
||||
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
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642"
|
||||
echo "Android API key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
**Windows PowerShell:**
|
||||
|
||||
```powershell
|
||||
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
hermes setup --portal
|
||||
|
||||
$HermesDir = Join-Path $HOME ".hermes"
|
||||
New-Item -ItemType Directory -Force $HermesDir | Out-Null
|
||||
$ApiKey = ([guid]::NewGuid().ToString("N") + [guid]::NewGuid().ToString("N"))
|
||||
@"
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$ApiKey
|
||||
"@ | Add-Content (Join-Path $HermesDir ".env")
|
||||
|
||||
Write-Host "Android API URL: http://<this-computer-ip>:8642"
|
||||
Write-Host "Android API key: $ApiKey"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
Replace `<this-computer-ip>` with the address your phone can reach, such as a LAN IP, Tailscale name, or HTTPS reverse-proxy host. Do not use `127.0.0.1` from Android unless Hermes is running on the phone itself.
|
||||
|
||||
For **Manage**, also run the Hermes dashboard on a phone-reachable URL. For a trusted LAN or VPN, the quick path is username/password auth:
|
||||
|
||||
```bash
|
||||
# Run in a second terminal on the Hermes host.
|
||||
DASHBOARD_SECRET="$(openssl rand -base64 32)"
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=choose-a-strong-password
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET=$DASHBOARD_SECRET
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
hermes dashboard --no-open --host 0.0.0.0 --port 9119
|
||||
```
|
||||
|
||||
On Windows, set the same `HERMES_DASHBOARD_*` values in `$HOME\.hermes\.env`, then run the same `hermes dashboard --no-open --host 0.0.0.0 --port 9119` command in a second PowerShell window.
|
||||
|
||||
For a public or hosted dashboard, use upstream Hermes dashboard auth with Nous OAuth/OIDC instead of a simple password.
|
||||
|
||||
For the upstream details, see the Hermes [Installation](https://hermes-agent.nousresearch.com/docs/getting-started/installation), [Nous Portal](https://hermes-agent.nousresearch.com/docs/integrations/nous-portal), [API Server](https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server), and [Web Dashboard](https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard) docs.
|
||||
|
||||
::: warning Dashboard auth and API bearer auth are different
|
||||
The API key above is for Android Chat on `:8642`. Dashboard sign-in on `:9119` uses dashboard cookies plus short-lived `/api/ws` tickets. Android supports dashboard username/password and Nous/OIDC sign-in for Manage, but dashboard login does not create an API key.
|
||||
:::
|
||||
|
||||
### 3. Connect Android to Hermes
|
||||
|
||||
On first launch:
|
||||
|
||||
1. Tap through the standard onboarding pages.
|
||||
2. On **Connect**, choose **Standard Hermes**.
|
||||
3. Enter the API URL, for example `http://192.168.1.100:8642`, or tap **Scan for Hermes on LAN**.
|
||||
4. Optional: scan a generic setup QR that contains an API URL, or JSON with `api_url` and optional `api_key`.
|
||||
5. Enter the same value you set in `API_SERVER_KEY` for Android Chat fallback when the QR did not include it.
|
||||
6. Optional: enter a Tailscale API URL such as `https://your-host.ts.net:8642`.
|
||||
7. Tap **Connect**.
|
||||
|
||||
This enables Chat plus Manage surfaces such as Skills, Cron, MCP, Profiles, Models/Config, and Settings. Manage may ask you to sign in to the dashboard; choose **Sign in with Nous Research** when the dashboard advertises the `nous` provider, or use the username/password provider on trusted LAN/VPN deployments. Relay pairing is not required for this standard path.
|
||||
|
||||
When both a LAN URL and a Tailscale URL are saved, Android probes the saved routes and uses the highest-priority reachable one. Chat and Manage move together: LAN at home, Tailscale when you leave the local network.
|
||||
|
||||
### 4. Optional: add Relay power tools
|
||||
|
||||
Skip this unless you want Terminal, Bridge, Relay sessions, channel grants, or relay-backed device-control features.
|
||||
|
||||
On the Hermes host:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
The installer follows Hermes's canonical skill-distribution pattern:
|
||||
`hermes pair` is provided by the Hermes-Relay plugin through upstream Hermes' plugin CLI support; it is not a built-in Hermes core command. Then scan the QR in Android from **Settings -> Connections -> Pair Relay** or from onboarding's **Scan setup QR** path. If the relay is not running, the plugin can still print an API-only QR, so Chat works and Relay can be paired later.
|
||||
|
||||
1. Clones the repo to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`)
|
||||
2. `pip install -e ~/.hermes/hermes-relay/` into the hermes-agent venv — editable, so `git pull` is all that's needed to update the plugin
|
||||
3. Adds `~/.hermes/hermes-relay/skills` to `skills.external_dirs` in `~/.hermes/config.yaml` (idempotent YAML edit) so the `hermes-relay-pair` skill is picked up on every hermes-agent load
|
||||
4. Symlinks `~/.hermes/plugins/hermes-relay` → the clone's `plugin/` subdir
|
||||
5. Installs a thin `~/.local/bin/hermes-pair` shim that execs `python -m plugin.pair` inside the hermes-agent venv
|
||||
6. Installs a systemd user unit at `~/.config/systemd/user/hermes-relay.service` (optional — skipped on macOS, WSL-without-systemd, bare chroots)
|
||||
More detail:
|
||||
|
||||
Restart hermes-agent after install.
|
||||
|
||||
::: tip What you get
|
||||
- **Full Hermes-Relay Android app features** — sessions browser, conversation history on app restart, personality picker, command palette, memory management. Just install the plugin and it works.
|
||||
- **Full `android_*` bridge toolset for sideload phones** (tap, type, read screen, screenshot, open apps, send SMS, call, search contacts, share files/MMS attachments, etc.) — registered by the plugin only when `/bridge/status` reports a sideload Device Control phone
|
||||
- **`/hermes-relay-pair` slash command** — backed by the `devops/hermes-relay-pair` skill and usable from any Hermes chat surface
|
||||
- **`hermes-pair` shell shim** — for scripts and power-user flows
|
||||
- **Voice mode endpoints** on relay HTTP routes (transcribe, synthesize, voice config, streaming voice output, and Realtime Agent), with paired relay-session auth first and Hermes API-key fallback for chat+voice-only installs
|
||||
|
||||
No separate skill install, no `qrencode` binary needed.
|
||||
:::
|
||||
|
||||
::: info Updating
|
||||
Because the installer uses `pip install -e` for the plugin and `external_dirs` for the skill, updates are a single command:
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/hermes-relay && git pull && bash install.sh
|
||||
systemctl --user restart hermes-gateway hermes-relay
|
||||
```
|
||||
|
||||
`bash install.sh` is idempotent — safe to re-run as often as you like. It re-applies every step against the existing install, picks up any new files, and rebuilds the systemd unit from the latest template.
|
||||
:::
|
||||
|
||||
::: info Uninstalling
|
||||
A clean uninstaller ships in the same repo:
|
||||
|
||||
```bash
|
||||
bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
```
|
||||
|
||||
Or if you don't have the clone any more:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash
|
||||
```
|
||||
|
||||
The uninstaller reverses every install step in the opposite order, is idempotent, and never touches state shared with other Hermes tools (`~/.hermes/.env`, the gateway's `state.db`, the `hermes-agent` venv core). Useful flags:
|
||||
|
||||
```bash
|
||||
bash uninstall.sh --dry-run # preview without changing anything
|
||||
bash uninstall.sh --keep-clone # leave ~/.hermes/hermes-relay in place
|
||||
bash uninstall.sh --remove-secret # also wipe the QR signing identity
|
||||
```
|
||||
|
||||
By default the QR signing secret at `~/.hermes/hermes-relay-qr-secret` is preserved, so re-installing keeps the same identity and any phones still holding their session tokens stay valid.
|
||||
:::
|
||||
|
||||
### 3. Pair your phone
|
||||
|
||||
You have two equivalent entry points — pick whichever fits where you already are:
|
||||
|
||||
**From an active Hermes session** (shortest path if you're already chatting with the agent): type `/hermes-relay-pair` in any chat surface — CLI, Discord, Telegram, anywhere Hermes is listening. The `hermes-relay-pair` skill generates the QR and renders it inline for you. No shell required.
|
||||
|
||||
**From a shell** (power-user / scriptable): on the server, run
|
||||
|
||||
```bash
|
||||
hermes-pair
|
||||
```
|
||||
|
||||
The dashed `hermes-pair` is a thin shim that execs `python -m plugin.pair` in the hermes-agent venv. Both routes share the same implementation and produce the same QR + plain-text output.
|
||||
|
||||
::: warning `hermes pair` (with a space) is not currently exposed
|
||||
A top-level `hermes pair` sub-command would be nice, but hermes-agent v0.8.0's top-level argparser doesn't forward to third-party plugins' `register_cli_command()` dict yet. Use `/hermes-relay-pair` or the dashed `hermes-pair` shim in the meantime — both work today and will keep working once the upstream gap is closed.
|
||||
:::
|
||||
|
||||
This prints a QR code **and** the plain-text connection details (server URL, API key). Scan the QR from the app's onboarding screen — or type the values in manually if your terminal can't render QR blocks. The text fallback is always shown, so this works inside Hermes's Rich TUI panel and over SSH with limited charsets.
|
||||
|
||||
**One scan configures chat *and* the relay.** If you've already started the Hermes-Relay WSS server on the same host (see [Relay Server](#relay-server-optional) below), `hermes-pair` automatically detects it at `localhost:8767`, mints a fresh 6-char pairing code, pre-registers the code with the relay via its loopback-only `/pairing/register` endpoint, and embeds the relay URL and code in the same QR. The phone scans once and is ready for chat, terminal, and bridge.
|
||||
|
||||
If the relay isn't running, `hermes-pair` prints an `[info]` line pointing at `hermes relay start` and renders an API-only QR — chat still works, and you can pair with the relay later once it's up. Voice can use the saved Hermes API key too, so chat+voice does not require a paired Relay session. In manual setup, enter the API URL and API key first; the app derives the Relay URL from the same host on port `8767` and only asks for a manual override if `/voice/config` cannot be reached. Bridge Core, terminal/TUI, media, and sideload Device Control routes still require pairing. Plain-LAN voice testing with an API key requires HTTPS or a local runtime opt-in on the relay host: `hermes relay insecure-api-key on` while testing, then `hermes relay insecure-api-key off`. You can also force API-only mode explicitly:
|
||||
|
||||
```bash
|
||||
hermes-pair --no-relay
|
||||
```
|
||||
|
||||
#### Choosing session lifetime + channel grants
|
||||
|
||||
By default the phone prompts you to pick a session TTL when you scan the QR (1 day / 7 days / 30 days / 90 days / 1 year / never expire). You can also **pre-set** the TTL and per-channel grants on the host side so the phone's picker dialog opens with your chosen values already selected:
|
||||
|
||||
```bash
|
||||
# Pair for 7 days
|
||||
hermes-pair --ttl 7d
|
||||
|
||||
# Pair indefinitely, limit terminal to 30 days and bridge to 1 day
|
||||
hermes-pair --ttl never --grants terminal=30d,bridge=1d
|
||||
|
||||
# Short-lived dev session
|
||||
hermes-pair --ttl 1d
|
||||
```
|
||||
|
||||
Supported duration formats: `1d`, `7d`, `30d`, `90d`, `1y`, `never` (or any `<number><unit>` combo where unit is `s`/`m`/`h`/`d`/`w`/`y`). Grants can be pre-set for `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, and `voice:tts` and are automatically clamped to the overall session TTL — a grant cannot outlive its session. If you omit voice grants, new sessions get them by default, and older sessions inherit voice from the `chat` grant.
|
||||
|
||||
::: tip Camera unavailable? Use manual pairing
|
||||
If you can't scan a QR — for example you're SSH'd into the host from the same phone you want to pair, the host has no display attached, or there's no second camera-equipped device handy — Hermes-Relay ships a manual fallback flow. Open the app's **Settings → Connections → [active card] → Advanced → Manual pairing code (fallback)** section to read its locally-generated 6-char code, then on the host run:
|
||||
|
||||
```bash
|
||||
hermes-pair --register-code ABCD12 # default 30d session
|
||||
hermes-pair --register-code ABCD12 --ttl 7d # composes with --ttl / --grants
|
||||
```
|
||||
|
||||
The command pre-registers your code with the local relay over loopback and prints a confirmation. Tap **Connect** in the same card and you're paired. Same 10-minute single-use expiry as QR codes; same TTL/grant rules — `--ttl` and `--grants` flags compose with `--register-code` exactly the same way they compose with the default QR flow.
|
||||
:::
|
||||
|
||||
The phone's TTL picker dialog always opens on scan, preselected with your chosen values, so you have one final chance to confirm or override before the session is created. The selection you make is persisted as the new default for future pairs.
|
||||
|
||||
::: tip Never expire
|
||||
`Never expire` is always available in the picker regardless of transport. The phone treats your intent as the trust model rather than gating on secure-transport detection — if you explicitly pick it, the session stays active until you revoke it from **Relay sessions**.
|
||||
:::
|
||||
|
||||
#### Transport security — plain connections and pairing consent
|
||||
|
||||
The app renders a **Transport Security** badge inside the active connection card's Security section:
|
||||
|
||||
- 🔒 **Secure (TLS)** — paired over `wss://` / `https://`
|
||||
- 🔓 **Plain (on LAN / Tailscale / public URL)** — paired over `ws://` / `http://`; the label reflects the **currently active route** so a Tailscale fallback reads honestly even if you originally paired over LAN
|
||||
- 🔓 **Plain (no TLS)** — plain transport with no active-route information yet (cold start, or manual URL config before the first probe)
|
||||
|
||||
Amber, not red — the trust model on `ws://` is the network perimeter, not TLS. A home/office LAN and a private Tailscale network are both legitimate trust domains for plain transport; the badge is factual, not alarming.
|
||||
|
||||
**Three different consent gates** exist for plain transport, each firing at the moment that actually changes the threat model:
|
||||
|
||||
1. **Scanning an all-plain QR** (no secure route in the candidate list) — one-time per install. The pairing confirm step renders a checkbox: *"I understand this pairing sends traffic in plain text — visible to anyone on the network."* Tick it once per install; the Pair button activates. Subsequent all-plain pairs don't re-prompt. Mixed QRs (LAN + Tailscale) are ungated — the secure fallback is your safety net.
|
||||
2. **First toggle of "Allow plain (unencrypted) connections"** in the active card's Advanced section — opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason displays on the badge afterward (though the role-aware label above usually overrides it).
|
||||
3. **Changing a paired TTL to "Never expire"** on a plain connection — inline warning, no forced confirm. The trust model is already established at pair time.
|
||||
|
||||
The app also runs a **Trust On First Use** (TOFU) cert pinning check on `wss://` connections: on the first successful handshake it records the server's certificate fingerprint, and every subsequent connect verifies against it. If the cert changes (because the relay was rebuilt, the Let's Encrypt cert rolled over, or an MITM is happening), the connection fails loudly. Re-pairing via QR is taken as explicit consent to pin a new certificate.
|
||||
|
||||
#### Relay sessions management
|
||||
|
||||
**Settings → Connections → [active card] → Security → Relay sessions** (or simply **Settings → Relay sessions**) lists every phone currently paired with the relay — device name, transport badge, route list, session expiry, per-channel grant chips (tap the info icon next to *Channel grants* for an explanation of what each channel does), and a **Revoke** button per row. Revoking the current device wipes local state and redirects to the pair flow. Any paired phone can revoke any other; for single-operator setups this is intentional (so you can manage everything from one phone), multi-user deployments will need a role model later.
|
||||
- [Relay Server](#relay-server-optional) for persistent service setup
|
||||
- [Remote access](/guide/remote-access) for Tailscale, VPN, and public URL recipes
|
||||
- [Connections](/features/connections) for multiple servers and route switching
|
||||
|
||||
::: tip Multiple Hermes servers
|
||||
The app supports pairing with more than one Hermes server (home + work, dev + prod, etc.) and switching with a single tap. Once you've paired the first server, open **Settings → Connections** to add a second — it launches the same QR flow. When you have two or more, a **Connection** radio list appears inside the agent sheet (tap the agent name in the Chat top bar), letting you switch without re-pairing. See [Connections](/features/connections) for the full model.
|
||||
The app can save more than one Hermes server, such as Home and Work. Add or switch servers later in **Settings -> Connections**.
|
||||
:::
|
||||
|
||||
::: warning Security
|
||||
The QR contains credentials — your API key if one is set, and the relay pairing code if a relay block was embedded. The pairing QR is now also signed with HMAC-SHA256 using a host-local secret (auto-created at `~/.hermes/hermes-relay-qr-secret`, mode 0o600). Don't screenshot or share it. The relay code is one-shot and expires in 10 minutes, but the API key is long-lived.
|
||||
:::
|
||||
## Dashboard Login From Android
|
||||
|
||||
## Hermes Server Setup
|
||||
Manage uses the Hermes dashboard/admin server and stores dashboard cookies separately from Relay pairing credentials.
|
||||
|
||||
Enable the API server in your Hermes configuration (`~/.hermes/.env`):
|
||||
- **Dashboard auth disabled/open dashboard:** Manage should work as long as Android can reach the dashboard URL.
|
||||
- **Basic username/password login enabled:** supported. Android posts to `/auth/password-login` with the upstream `basic` provider, stores the dashboard cookies, and checks `/api/auth/me`.
|
||||
- **Nous OAuth / OIDC redirect login enabled:** supported for dashboard auth. Android opens the dashboard's `/auth/login?provider=...` flow in an in-app WebView, imports the resulting dashboard cookies, checks `/api/auth/me`, and probes `/api/auth/ws-ticket`.
|
||||
- **Custom password providers:** supported when `/api/auth/providers` advertises `supports_password: true`.
|
||||
|
||||
```bash
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_KEY=your-secret-key-here
|
||||
API_SERVER_HOST=0.0.0.0 # Allow network access (default is localhost only)
|
||||
API_SERVER_PORT=8642
|
||||
```
|
||||
|
||||
::: tip API key is optional for local setups
|
||||
If you're running Hermes on the same machine (or connecting via `localhost`), you can leave `API_SERVER_KEY` unset. The key is only needed when exposing the API server over the network. If you do set one, `hermes-pair` reads it automatically, and the dashboard's pair/repair QR flow now reads the same key through the relay so chat sessions and voice pairing stay in sync.
|
||||
:::
|
||||
Relay pairing does not replace dashboard login. Dashboard login also does not mint an API key: it matches the Hermes Desktop remote-gateway path by authenticating `/api/ws` and `/api/pty` with dashboard cookies plus a single-use ticket from `/api/auth/ws-ticket`. Android now supports that login and ticket probe; API-key chat remains the fallback until Android's native dashboard-gateway chat adapter is wired in.
|
||||
|
||||
## Sideload APK
|
||||
|
||||
@@ -224,7 +182,7 @@ The exact wording varies by OEM (Samsung calls it "Install unknown apps", Pixel
|
||||
|
||||
### 3. Install it
|
||||
|
||||
Open the downloaded APK from your Downloads notification or the Files app, then tap **Install**. The first launch will walk you through onboarding and pairing.
|
||||
Open the downloaded APK from your Downloads notification or the Files app, then tap **Install**. The first launch will walk you through standard Hermes connection; Relay pairing is optional for power tools.
|
||||
|
||||
### 4. Verify integrity (optional but recommended)
|
||||
|
||||
@@ -273,22 +231,22 @@ scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat run # Build + install + launch (requires connected device)
|
||||
```
|
||||
|
||||
## Manual Pairing
|
||||
## Manual Setup
|
||||
|
||||
If you don't want to use QR pairing, you can enter connection details by hand — either during the app's onboarding flow or later from Settings.
|
||||
If you don't want to scan a QR, enter standard connection details by hand during onboarding or later from Settings.
|
||||
|
||||
**During onboarding:**
|
||||
|
||||
1. The app opens with an onboarding flow
|
||||
2. On the **Connect** page, tap **Enter manually**
|
||||
3. Type your API Server URL (e.g., `http://192.168.1.100:8642`) and API Key
|
||||
4. Tap **Test Connection** to verify
|
||||
5. Optionally enter a **Relay URL** for Terminal/Bridge features
|
||||
6. Tap **Get Started**
|
||||
2. On the **Connect** page, tap **Standard Hermes**
|
||||
3. Type your API server URL, for example `http://192.168.1.100:8642`, scan for Hermes on LAN, or scan a generic QR containing the API URL/key
|
||||
4. Enter the same value you set in `API_SERVER_KEY` for Android Chat fallback when it was not included by the QR
|
||||
5. Optional: enter a Tailscale API URL such as `https://your-host.ts.net:8642`
|
||||
6. Tap **Connect**
|
||||
|
||||
**After onboarding:** open **Settings → Connections**. Each paired server is a card in the list; the currently-active card expands inline to show status rows, endpoint details, and an **Advanced** section with manual URL config, insecure-mode toggle, and the manual pairing-code fallback flow. The per-card **Re-pair** button is the one-tap entry point for scanning a new QR. API Server URL, API Key, Relay URL, and Insecure Mode all live under the active card's **Advanced** expander, with **Save & Test** for each.
|
||||
**After onboarding:** open **Settings → Connections**. Each Hermes host is a card in the list; the currently-active card expands inline to show status rows, route details, and an **Advanced** section with manual API URL/API key config, Relay URL override, insecure-mode toggle, and the manual Relay pairing-code fallback flow. The per-card **Pair Relay** / **Re-pair** button is the entry point for scanning a Relay QR when you need power tools.
|
||||
|
||||
The `hermes-pair` command always prints these same values as plain text alongside the QR code, so you can copy them directly.
|
||||
For Standard setup there is no built-in upstream mobile pairing command yet, so use LAN scan, copy/paste, or a generic QR containing the API URL/key. If a QR includes a Relay block from the Hermes-Relay plugin, Android will show the Relay pairing confirmation and TTL/grants picker; if it is API-only, Android saves the standard API/dashboard connection.
|
||||
|
||||
## Relay Server (Optional)
|
||||
|
||||
@@ -302,18 +260,18 @@ hermes relay start --no-ssl
|
||||
# Or directly from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
Run this on the same machine as hermes-agent. If the relay is running when you execute `hermes-pair` (or `/hermes-relay-pair`), its URL and a freshly-registered pairing code are automatically embedded in the QR — you don't need to enter anything in the app.
|
||||
Run this on the same machine as hermes-agent. On current upstream Hermes installs with the Hermes-Relay plugin enabled, the plugin-provided `hermes pair` command is available. If the relay is running when you execute `hermes pair` (or `/hermes-relay-pair`), its URL and a freshly-registered pairing code are automatically embedded in the QR — you don't need to enter anything in the app.
|
||||
:::
|
||||
|
||||
For persistent deployment, Docker, systemd, and TLS options, see the [Relay Server docs](/reference/relay-server).
|
||||
|
||||
If you only saw an API-only QR earlier (because the relay wasn't running), just start the relay and re-run `hermes-pair` — the new QR will include the relay block.
|
||||
If you only saw an API-only QR earlier (because the relay wasn't running), just start the relay and re-run the plugin-provided `hermes pair` — the new QR will include the relay block.
|
||||
|
||||
## Connecting from Anywhere (Tailscale, VPN, Public URL)
|
||||
|
||||
Hermes-Relay supports **multi-endpoint pairing**: one QR carries every network path your server is reachable on, and the phone auto-picks whichever is reachable at the moment. Works across LAN / cell / tailnet / public reverse proxy without re-pairing when you change networks.
|
||||
|
||||
**Default — `--mode auto`.** `hermes-pair --mode auto` (run on the server) probes the LAN, detects Tailscale if it's running, and emits an ordered candidate list in the QR. To include an external reverse-proxy or Cloudflare Tunnel URL, add `--public-url https://hermes.example.com`.
|
||||
**Default — `--mode auto`.** `hermes pair --mode auto` (run on the server) probes the LAN, detects Tailscale if it's running, and emits an ordered candidate list in the QR. To include an external reverse-proxy or Cloudflare Tunnel URL, add `--public-url https://hermes.example.com`.
|
||||
|
||||
**Enable Tailscale on the server** with `hermes-relay-tailscale enable` — this fronts the loopback-bound relay port `8767` and Hermes API port `8642` with `tailscale serve`, using Tailscale's managed TLS + tailnet ACLs. Both ports matter: relay pairing covers terminal/bridge/control features, while chat and API-key voice use the Hermes API server. Skip this if you prefer a reverse proxy + Let's Encrypt, or a self-hosted VPN — both work identically as long as the phone can reach both services.
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
# Hermes-Relay — Android
|
||||
|
||||
This section covers the **Android client** for [Hermes Agent](https://hermes-agent.nousresearch.com): chat, voice, relay pairing, terminal/TUI relay, notifications, and optional sideload Device Control.
|
||||
This section covers the **Android client** for [Hermes Agent](https://hermes-agent.nousresearch.com): standard API/dashboard setup, chat, Manage, voice, optional Relay pairing, terminal/TUI relay, notifications, and optional sideload Device Control.
|
||||
|
||||
::: tip Looking for the desktop CLI?
|
||||
The desktop terminal client (Windows / macOS / Linux) lives in its own section: **[Desktop CLI →](/desktop/)**. Both clients pair against the same Hermes-Relay server and share `~/.hermes/remote-sessions.json` — pair once from either, both work.
|
||||
The desktop terminal client (Windows / macOS / Linux) lives in its own section: **[Desktop CLI →](/desktop/)**. Desktop Relay pairing and Android Relay pairing use the same Hermes-Relay server and `~/.hermes/remote-sessions.json`.
|
||||
:::
|
||||
|
||||
Hermes-Relay is a native Android app for [Hermes Agent](https://hermes-agent.nousresearch.com). Chat with your agent, manage sessions, use voice, pair to the relay, and access remote terminal/TUI surfaces. The Google Play build ships Bridge Core only; sideload builds add AccessibilityService-backed Device Control.
|
||||
Hermes-Relay is a native Android app for [Hermes Agent](https://hermes-agent.nousresearch.com). Chat with your agent through the Hermes API server, manage Skills/Cron/MCP/Profile surfaces through the dashboard, use voice, and optionally pair Relay for terminal/TUI and bridge power tools. The Google Play build ships Bridge Core only; sideload builds add AccessibilityService-backed Device Control.
|
||||
|
||||
## Quick Install
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
1. Install Hermes and run the API server/dashboard on your host.
|
||||
2. Install the Android app.
|
||||
3. Choose **Standard Hermes** and enter the API URL/key.
|
||||
4. Add Relay pairing later only if you want Terminal, Bridge, Relay sessions, or device-control power tools.
|
||||
|
||||
This installs the server-side plugin. One command, full features — sessions browser, conversation history, personality picker, command palette, memory management, relay WSS for terminal/TUI and Bridge Core, sideload Device Control routes, and relay HTTP voice routes all work out of the box on any standard `hermes-agent` install. Grab the Android app from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases), then either type `/hermes-relay-pair` in any Hermes chat surface or run `hermes-pair` from a shell to generate a pairing QR. See [Installation & Setup](/guide/getting-started) for the full walkthrough.
|
||||
See [Installation & Setup](/guide/getting-started) for copy/paste host commands and upstream Hermes links.
|
||||
|
||||
To uninstall later:
|
||||
If you installed the optional Relay plugin and want to uninstall it later:
|
||||
|
||||
```bash
|
||||
bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
|
||||
@@ -4,12 +4,15 @@ Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailsc
|
||||
|
||||
## What Uses Which Connection
|
||||
|
||||
One pairing QR can configure both parts of the app:
|
||||
Standard setup saves the API server URL and API key directly. If you also enter
|
||||
the host's Tailscale API URL in Standard setup, Android stores both routes and
|
||||
uses the highest-priority reachable one. A Relay pairing QR can also carry both
|
||||
parts of the app when you enable the optional relay:
|
||||
|
||||
- **Chat and API-backed voice** use the Hermes API server URL and the Hermes API bearer key when one is configured.
|
||||
- **Terminal, bridge, TUI, media/session management, clipboard, profile writes, Android control, and relay-token voice fallback** use the relay URL and require a paired relay session token.
|
||||
|
||||
The app stores your base API URL and relay URL on the connection, then uses the active route selected from the QR's endpoint list at runtime. That means a single scan can stay valid when LAN is reachable at home and Tailscale is the reachable route away from home.
|
||||
The app stores your base API URL, optional Tailscale API URL, and relay URL on the connection, then uses the active route selected from saved route candidates at runtime. That means one saved connection can use LAN at home and Tailscale away from home.
|
||||
|
||||
## Recommended: Tailscale
|
||||
|
||||
@@ -17,7 +20,7 @@ On the Hermes host:
|
||||
|
||||
```bash
|
||||
hermes-relay-tailscale enable
|
||||
hermes-pair --mode auto --prefer tailscale
|
||||
hermes pair --mode auto --prefer tailscale
|
||||
```
|
||||
|
||||
The Tailscale helper publishes both required loopback services:
|
||||
@@ -40,19 +43,19 @@ hermes-relay-tailscale status
|
||||
Use `--mode auto` for the normal multi-endpoint QR:
|
||||
|
||||
```bash
|
||||
hermes-pair --mode auto
|
||||
hermes pair --mode auto
|
||||
```
|
||||
|
||||
It emits LAN when available, adds Tailscale when the helper detects a tailnet hostname, and adds a public route when you pass `--public-url`:
|
||||
|
||||
```bash
|
||||
hermes-pair --mode auto --public-url https://hermes.example.com/relay
|
||||
hermes pair --mode auto --public-url https://hermes.example.com/relay
|
||||
```
|
||||
|
||||
Use `--prefer tailscale` when you want the phone to try Tailscale first but still keep LAN as a fallback:
|
||||
|
||||
```bash
|
||||
hermes-pair --mode auto --prefer tailscale
|
||||
hermes pair --mode auto --prefer tailscale
|
||||
```
|
||||
|
||||
You can also override from the phone: **Settings -> Connections -> active connection -> Routes -> Prefer this route**.
|
||||
|
||||
@@ -70,7 +70,9 @@ Your Hermes server may connect to AI providers such as OpenAI or Anthropic serve
|
||||
|
||||
From the app's Settings screen, you can:
|
||||
|
||||
- **Export** your configuration (server URLs and preferences; secrets excluded)
|
||||
- **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 saved configuration
|
||||
- **Full reset** to permanently delete local data including encrypted credentials
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hermes API Reference
|
||||
|
||||
Hermes-Relay communicates with the Hermes API Server using the following endpoints. If the server is configured with `API_SERVER_KEY`, requests must include a Bearer token in the `Authorization` header. Most local setups don't require a key.
|
||||
Hermes-Relay communicates with the Hermes API Server using the following endpoints. If the server is configured with `API_SERVER_KEY`, requests must include a Bearer token in the `Authorization` header. For phone-reachable LAN, VPN, or public setups, configure `API_SERVER_KEY` and enter the same value in Android.
|
||||
|
||||
## Base URL
|
||||
|
||||
@@ -18,9 +18,9 @@ Hermes-Relay voice endpoints can reuse this same Bearer token. Relay validates i
|
||||
|
||||
## How endpoints get served
|
||||
|
||||
Installing the plugin via `install.sh` is enough to make all of the endpoints below work — including the management ones (`/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models`). The plugin wires the gateway up at install time so these are served on the same `:8642` host as the standard `/v1/*` endpoints, with the same `Authorization: Bearer …` auth.
|
||||
Current upstream Hermes serves the session API natively on `/api/sessions/*`, advertises it through `/v1/capabilities`, and exposes read-only skill/toolset discovery through `/v1/skills` and `/v1/toolsets`. Installing Hermes-Relay via `install.sh` still adds a bootstrap compatibility hook for older hermes-agent builds and for remaining management surfaces that are not current API-server routes (`/api/memory`, legacy `/api/skills`, `/api/config`, `/api/available-models`).
|
||||
|
||||
**Chat streaming uses standard `/v1/runs`** by default — it emits structured `tool.started`/`tool.completed` SSE events for live tool progress cards in the Android app. The app's `Settings → Chat → Streaming endpoint = "Auto"` (default) probes per-endpoint capability and picks the best chat path automatically; you can manually force `Sessions` or `Runs` mode for debugging.
|
||||
**Chat streaming uses `Auto` by default.** The app probes `/v1/capabilities` first, then legacy route probes, and prefers native `/api/sessions/{id}/chat/stream` when available. Older builds fall back to `/v1/chat/completions` or `/v1/runs`; you can manually force `Sessions`, `Completions`, or `Runs` mode for debugging.
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -36,6 +36,17 @@ Returns server health status. Used by the app to verify connectivity (green/red
|
||||
|
||||
---
|
||||
|
||||
### Capabilities
|
||||
|
||||
```
|
||||
GET /v1/capabilities
|
||||
```
|
||||
|
||||
Returns the API-server feature and endpoint map. The app uses it before falling
|
||||
back to legacy route probes.
|
||||
|
||||
---
|
||||
|
||||
### List Sessions
|
||||
|
||||
```
|
||||
@@ -47,7 +58,8 @@ Returns all chat sessions.
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Session title",
|
||||
@@ -59,6 +71,8 @@ Returns all chat sessions.
|
||||
}
|
||||
```
|
||||
|
||||
Older compatibility builds may return `items` or `sessions`; the app accepts all three shapes.
|
||||
|
||||
---
|
||||
|
||||
### Create Session
|
||||
@@ -91,7 +105,8 @@ Returns message history for a session.
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"role": "user | assistant",
|
||||
|
||||
@@ -4,12 +4,12 @@ Hermes-Relay stores its settings using Android's DataStore, Android Keystore (fo
|
||||
|
||||
## Connection Settings
|
||||
|
||||
These are configured during onboarding or from the **Settings → Connections** screen. That screen is the single authoritative home for everything connection-related (as of the 2026-04-21 unification — the legacy singular *Settings → Connection* subpage was folded in here). Each paired server appears as its own card in the list; the currently-active card expands inline to surface all deep-configuration UI.
|
||||
These are configured during onboarding or from the **Settings → Connections** screen. That screen is the single authoritative home for everything connection-related (as of the 2026-04-21 unification — the legacy singular *Settings → Connection* subpage was folded in here). Each saved Hermes server appears as its own card in the list; the currently-active card expands inline to surface all deep-configuration UI.
|
||||
|
||||
Hermes-Relay now treats connection auth as three related but separate contexts:
|
||||
|
||||
- **API connection** (`:8642`) — chat, sessions, and portable API calls. Networked servers should set `API_SERVER_KEY`; the Android app stores that key separately from relay pairing.
|
||||
- **Dashboard sign-in** (`:9119`) — standard management surfaces such as Skills, Cron, MCP, Profiles, Models, and Config. Android derives the dashboard URL from the API host by default and keeps dashboard cookies separate from relay tokens.
|
||||
- **Dashboard sign-in** (`:9119`) — upstream-preferred remote identity for the standard dashboard/desktop path. Manage uses dashboard cookies; dashboard-gateway chat uses short-lived `/api/ws` tickets minted from those cookies. Android supports username/password and redirect providers such as Nous/OIDC for this surface.
|
||||
- **API connection** (`:8642`) — OpenAI-compatible chat, sessions, and portable API calls. If the Hermes API server is configured with `API_SERVER_KEY`, Android stores that bearer key and uses it for Chat while dashboard-gateway JSON-RPC chat is being wired in.
|
||||
- **Pairing** (`:8767`) — relay grants for Terminal, Bridge, relay sessions, media relay inspection, and profile memory file editing. Pairing is not required for standard dashboard/API use, but it is required for relay power tools.
|
||||
|
||||
The default bottom navigation is **Chat**, **Manage**, and **Settings**. Terminal and Bridge are still available from **Settings → Power tools** and deep links, but unpaired devices see a clear **Requires pairing** / **Pair to unlock** gate before those relay-only screens load.
|
||||
@@ -23,20 +23,20 @@ The **Manage** tab uses the dashboard session, not relay pairing. It covers Skil
|
||||
**On the active card — a deep body below the action row.** Each section is headed by a labelMedium header and a one-line caption so the card self-narrates:
|
||||
|
||||
- **Connection health** (always visible) — *"Tap any row for details."* Three tappable rows (API / Relay / Session) open detail sheets with token, endpoint, health, and session info.
|
||||
- **Routes (N)** (when the pairing carries multi-endpoint candidates) — *"The app picks the fastest reachable network automatically and switches when you change networks."* Expander reveals one row per route with role chip (LAN / Tailscale / Public / Custom VPN), per-row Secure/Plain security chip, state chip (Active / Fallback), probe-now button, per-candidate *Prefer this route* override, and per-candidate TOFU pin inspection.
|
||||
- **Advanced** (collapsible) — *"Manual setup — most people don't need this after QR pairing."* Holds:
|
||||
- **Manual URL config** — API Server URL, API Key, Relay URL, each with **Save & Test**.
|
||||
- **Routes (N)** (when Standard setup saved a LAN/Tailscale URL or pairing carried multi-endpoint candidates) — *"The app picks the fastest reachable network automatically and switches when you change networks."* Expander reveals one row per route with role chip (LAN / Tailscale / Public / Custom VPN), per-row Secure/Plain security chip, state chip (Active / Fallback), probe-now button, per-candidate *Prefer this route* override, and per-candidate TOFU pin inspection.
|
||||
- **Advanced** (collapsible) — *"Manual setup — most people don't need this after Standard Hermes setup."* Holds:
|
||||
- **Manual URL config** — API Server URL, API Key, Relay URL, each with **Save & Test**. This preserves the direct host/API-key path for custom networks, proxies, and compatibility installs.
|
||||
- **Allow plain (unencrypted) connections** toggle — first enable opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason is stored for later but the Transport Security badge usually derives a more accurate label from the live active-route role. Operator intent is the trust model — the toggle gates the UI's ability to save `ws://` / `http://` URLs, nothing server-side.
|
||||
- **Disconnect** button — drops the active WSS without clearing the session token.
|
||||
- **Manual pairing code (fallback)** — the 3-step flow for when you can't use QR scanning. (1) Copy the locally-generated 6-char code; (2) on the host, run `hermes-pair --register-code <code>`; (3) tap **Connect** here. Canonical flow is still the QR from `/hermes-relay-pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
|
||||
- **Manual pairing code (fallback)** — the 3-step flow for when you can't use QR scanning. (1) Copy the locally-generated 6-char code; (2) on the host, run `hermes pair --register-code <code>`; (3) tap **Connect** here. Canonical flow is still the QR from `/hermes-relay-pair` or `hermes pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
|
||||
- **Security** (always visible) — Transport Security badge (🔒 secure / 🔓 plain with reason / 🔓 unknown), Tailscale-detected chip, Hardware-keystore badge, and a **Relay sessions** row that navigates to the full list of phones paired with this server.
|
||||
|
||||
| Setting | Storage | Description |
|
||||
|---------|---------|-------------|
|
||||
| API Server URL | EncryptedSharedPreferences | Base URL of the Hermes API Server (e.g., `http://192.168.1.100:8642`) |
|
||||
| API Key | EncryptedSharedPreferences | Bearer token for API authentication. Latest Hermes deployments should set `API_SERVER_KEY` when exposed beyond loopback. |
|
||||
| API Key | EncryptedSharedPreferences | Bearer token for API-server authentication. Used by Android Chat fallback, not by dashboard login. |
|
||||
| Dashboard URL | DataStore | Hermes dashboard/admin URL, conventionally the same host as the API server on port `9119`. Derived automatically from the API URL unless explicitly overridden. |
|
||||
| Dashboard session cookies | EncryptedSharedPreferences | Auth cookies for the dashboard/admin server. Stored separately from API keys and relay session tokens. |
|
||||
| Dashboard session cookies | EncryptedSharedPreferences | Auth cookies for the dashboard/admin server. Stored separately from API keys and relay session tokens. OAuth/NouS WebView sign-in imports these cookies into the native dashboard client. |
|
||||
| Relay URL | EncryptedSharedPreferences | WebSocket URL for the Relay Server (optional, for bridge/terminal) |
|
||||
| Relay Session Token | **Keystore** (StrongBox when available), with fallback to EncryptedSharedPreferences | Persistent token from relay pairing flow. Migrated automatically from the legacy EncryptedSharedPreferences file on first launch post-upgrade. |
|
||||
| TOFU Cert Pins | DataStore (`tofu_pins`) | SHA-256 SPKI fingerprints per `host:port`. Recorded on the first successful `wss://` connect, verified on subsequent connects via OkHttp `CertificatePinner`. Wiped explicitly when the user re-pairs via QR (taken as consent to new cert material). |
|
||||
@@ -46,6 +46,10 @@ The **Manage** tab uses the dashboard session, not relay pairing. It covers Skil
|
||||
| All-plain pairing ack | DataStore (`all_insecure_pair_ack_seen`) | Whether the user has acknowledged the one-time pairing-consent checkbox that appears on step 2 when every route in the scanned QR is plain `ws://` / `http://` (no secure sibling). Per-install. Mixed QRs (LAN + Tailscale) are ungated because the secure route is a safety net. |
|
||||
| Trusted bridge actions | DataStore (`bridge_trusted_destructive_verbs`) | Set of destructive bridge verbs (e.g. `send_sms`, `call`) that bypass the confirmation overlay because the user ticked "Don't ask again" in a prior confirm. The master-disable toggle and the blocklist still override — trust is for eliminating confirmation fatigue on approved verbs, not a kill-switch bypass. Reset from Bridge → Trusted actions → **Reset**. |
|
||||
|
||||
### Backups
|
||||
|
||||
**Settings → Developer options → Export Settings** writes a full connection backup. It includes saved connection records, route candidates, preferred route, API keys, relay session tokens, device IDs, paired-session metadata, and dashboard cookies. Treat the exported JSON as sensitive credential material. Importing a backup replaces the saved connection list and restores those connection secrets into the app's encrypted stores.
|
||||
|
||||
### Pair Flow — TTL Picker
|
||||
|
||||
When you scan a pairing QR (or enter a code manually), a **Session TTL Picker** dialog opens before the phone connects to the relay. Options:
|
||||
@@ -56,9 +60,9 @@ When you scan a pairing QR (or enter a code manually), a **Session TTL Picker**
|
||||
- **90 days** / **1 year** — longer-lived operator devices.
|
||||
- **Never expire** — the device stays paired until you revoke it manually from Relay sessions. Always selectable — the phone treats user intent as the trust model and doesn't gate on transport security. A warning is shown inline.
|
||||
|
||||
The default pre-selection depends on the QR's operator-chosen TTL (if any, via `hermes-pair --ttl <duration>`), falling back to 30d on secure/Tailscale transports or 7d on plain ws. Your last pick persists as the new default for future pairs.
|
||||
The default pre-selection depends on the QR's operator-chosen TTL (if any, via `hermes pair --ttl <duration>`), falling back to 30d on secure/Tailscale transports or 7d on plain ws. Your last pick persists as the new default for future pairs.
|
||||
|
||||
Per-channel grants (`chat`, `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, `voice:tts`) can be pre-set by the operator via `hermes-pair --grants terminal=7d,bridge=1d,voice:stt=7d`. The phone displays them on the Relay sessions card as labeled chips with a tap-for-info icon explaining that each grant is a per-feature permission with an independent expiry. Grants cannot outlive the session — they're clamped to the session TTL server-side.
|
||||
Per-channel grants (`chat`, `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, `voice:tts`) can be pre-set by the operator via `hermes pair --grants terminal=7d,bridge=1d,voice:stt=7d`. The phone displays them on the Relay sessions card as labeled chips with a tap-for-info icon explaining that each grant is a per-feature permission with an independent expiry. Grants cannot outlive the session — they're clamped to the session TTL server-side.
|
||||
|
||||
### Relay sessions
|
||||
|
||||
@@ -148,7 +152,7 @@ The API server is part of `hermes gateway` and configured via `~/.hermes/.env`:
|
||||
```bash
|
||||
# Required for Hermes-Relay
|
||||
API_SERVER_ENABLED=true
|
||||
# API_SERVER_KEY=your-secret-key # Optional — only set if exposing to network
|
||||
API_SERVER_KEY=your-secret-key
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
```
|
||||
@@ -167,7 +171,7 @@ hermes relay start --no-ssl
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
|
||||
`RELAY_HOST` and `RELAY_PORT` are read by **both** the relay server itself and the pair command (`hermes-pair` / `/hermes-relay-pair`) — the pair command uses them to locate the local relay when pre-registering a pairing code, so if you run the relay on a non-default port, make sure the same values are in the environment when you invoke pairing.
|
||||
`RELAY_HOST` and `RELAY_PORT` are read by **both** the relay server itself and the pair command (`hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim) — the pair command uses them to locate the local relay when pre-registering a pairing code, so if you run the relay on a non-default port, make sure the same values are in the environment when you invoke pairing.
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
|
||||
@@ -134,8 +134,8 @@ hermes-relay insecure-api-key [status|on|off]
|
||||
| `/ws`, `/` | GET (upgrade) | WebSocket endpoint — phone connects here |
|
||||
| `/health` | GET | `{status, version, clients, sessions}` JSON |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach pairing metadata that applies to the session when the phone consumes the code — operator policy wins over phone-sent values. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `/hermes-relay-pair` / `hermes-pair` on the same host. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/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, desktop GUI, and CLI pair/repair flows. Reads the API key from the same host-local config chain as `hermes-pair` when not supplied explicitly. |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach pairing metadata that applies to the session when the phone consumes the code — operator policy wins over phone-sent values. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `hermes pair` / `/hermes-relay-pair` on the same host; `hermes-pair` remains a compatibility shim. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/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, desktop GUI, and CLI pair/repair flows. Reads the API key from the same host-local config chain as `hermes pair` when not supplied explicitly. |
|
||||
| `/pairing/approve` | POST | **Loopback only, reserved for future use.** Same wire shape as `/pairing/register`. Placeholder for a future phone-generates-code / host-approves flow that would complement the existing QR pairing direction. |
|
||||
| `/sessions` | GET | Bearer-auth'd (same token the WSS channel uses). Returns all active paired devices with metadata — device name, token prefix (first 8 chars, full token never exposed), created/last-seen timestamps, session expiry, per-channel grants, transport hint, and `is_current` for the device matching the bearer. `math.inf` expiries serialize as `null` (never expire). |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by token-prefix (≥ 4 chars). 200 on exact match, 404 on zero, 409 on ambiguous matches. Self-revoke is allowed and flagged via `revoked_self: true`. |
|
||||
@@ -214,7 +214,7 @@ As of v0.4 the Device Control surface is **34 routes** (33 excluding the legacy
|
||||
|
||||
## Pairing Model
|
||||
|
||||
The phone does **not** enter a pairing code by hand. Instead, the pair command (the `/hermes-relay-pair` slash command or the `hermes-pair` shell shim, both running on the Hermes host) drives the whole handshake:
|
||||
The phone does **not** enter a pairing code by hand. Instead, the pair command (`hermes pair`, the `/hermes-relay-pair` slash command, or the compatibility `hermes-pair` shell shim, all running on the Hermes host) drives the whole handshake:
|
||||
|
||||
1. The pair command mints a fresh 6-character code from `A-Z / 0-9`
|
||||
2. It POSTs the code to `/pairing/register` on the local relay (blocked for any caller outside `127.0.0.1` / `::1`)
|
||||
@@ -235,8 +235,8 @@ curl http://localhost:8767/health
|
||||
- **Connection refused** — Is the relay running? `systemctl --user status hermes-relay` (installed via `install.sh`) or `docker logs hermes-relay` (container) or `pgrep -af "python -m plugin.relay"` (manual launch).
|
||||
- **Voice endpoints 500 with "no API key available"** — The relay process doesn't have the right keys. The Python bootstrap loads `~/.hermes/.env` automatically, so this almost always means the key just isn't in `.env` yet. Double-check with `grep VOICE_TOOLS_OPENAI_KEY ~/.hermes/.env` (for STT) or `grep ELEVENLABS_API_KEY ~/.hermes/.env` (for TTS). If you just edited `.env`, restart the service so Python re-imports: `systemctl --user restart hermes-relay`.
|
||||
- **Service starts but port bind fails** — Check for an orphan manual launch: `pgrep -f "python -m plugin.relay"`. Kill it with `pkill -f "python -m plugin.relay"` then `systemctl --user restart hermes-relay`.
|
||||
- **Auth failure** — Pairing codes expire 10 minutes after registration and are one-shot. Re-run `hermes-pair` (or `/hermes-relay-pair`) to mint a fresh code and get a new QR.
|
||||
- **QR has no relay block** — the pair command only embeds relay details if it can reach `localhost:RELAY_PORT/health` when it runs. Start the relay first, then re-run `hermes-pair`.
|
||||
- **Auth failure** — Pairing codes expire 10 minutes after registration and are one-shot. Re-run `hermes pair` (or `/hermes-relay-pair`) to mint a fresh code and get a new QR.
|
||||
- **QR has no relay block** — the pair command only embeds relay details if it can reach `localhost:RELAY_PORT/health` when it runs. Start the relay first, then re-run `hermes pair`.
|
||||
- **TLS errors** — Use `--no-ssl` for local dev. Ensure cert paths are correct for production.
|
||||
- **Phone can't reach relay** — Check firewall rules for port 8767. Verify with `curl http://server-ip:8767/health` from another machine.
|
||||
- **Remote chat or API-key voice fails but relay pairs** — Verify the Hermes API route too: `curl http://server-ip:8642/health`, or `https://<tailnet-host>.ts.net:8642/health` when using Tailscale. Pairing can succeed through `:8767` while chat and API-key voice fail if `:8642` is not published.
|
||||
|
||||
Reference in New Issue
Block a user