Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cb30c927 | ||
|
|
ed41be3390 | ||
|
|
08816cfe63 | ||
|
|
01a0cde589 | ||
|
|
ed6742afe4 | ||
|
|
a6264df910 | ||
|
|
64e2e2eca6 | ||
|
|
1ccaf2c4f1 | ||
|
|
7686bb41e7 | ||
|
|
a940b4b8ea | ||
|
|
46afdeab59 | ||
|
|
d4a8aad050 | ||
|
|
0a6e95ae74 | ||
|
|
a6fc53e5cf | ||
|
|
c013daacda | ||
|
|
f5b1d377a4 | ||
|
|
bb1e406f3f | ||
|
|
10213ca8ed | ||
|
|
cbfccd8ccf | ||
|
|
c3c98caa31 | ||
|
|
ed60abd57c | ||
|
|
cab0d90530 | ||
|
|
d977600f9d | ||
|
|
c902c00101 | ||
|
|
aa6b48a068 | ||
|
|
50297d1496 | ||
|
|
d80f36a087 | ||
|
|
b6117c2d41 | ||
|
|
b0ee6935fe | ||
|
|
33538fde0c | ||
|
|
603919c8ff | ||
|
|
52df3adbf6 | ||
|
|
3eab11c639 | ||
|
|
2673f228bb | ||
|
|
53b8f6a418 | ||
|
|
c452c25148 | ||
|
|
0db5c02722 | ||
|
|
4630695c17 | ||
|
|
f4ee440015 |
@@ -4,7 +4,7 @@ contact_links:
|
||||
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
|
||||
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
|
||||
- name: User documentation
|
||||
url: https://codename-11.github.io/hermes-relay/
|
||||
url: https://hermes-relay.dev/docs/
|
||||
about: Read setup, pairing, remote access, and troubleshooting docs.
|
||||
- name: Contributing guide
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
@@ -42,13 +44,11 @@ jobs:
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const paths = files.map((file) => file.filename);
|
||||
const { stdout } = await exec.getExecOutput(
|
||||
'git',
|
||||
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
|
||||
);
|
||||
const paths = stdout.split(/\r?\n/).filter(Boolean);
|
||||
const { classifyCiPaths } = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Hermes-Relay — Docs Deployment
|
||||
#
|
||||
# Builds VitePress docs and deploys to GitHub Pages.
|
||||
# Triggers on pushes to main that change user-docs/ content,
|
||||
# or manually via workflow_dispatch.
|
||||
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'user-docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
# Node 24 ships npm 11, matching the npm that generates
|
||||
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
|
||||
# the lock over the optional `search-insights` peer dep of bundled
|
||||
# docsearch. Keep this aligned with the npm used to write the lock.
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
run: npm run build
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Deploy legacy docs redirects
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: legacy-docs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build redirect artifact
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build redirect-only site
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_file="legacy-pages-redirect/redirect.html"
|
||||
output_dir="legacy-pages-redirect/_site"
|
||||
rm -rf "$output_dir"
|
||||
mkdir -p \
|
||||
"$output_dir/guide/getting-started" \
|
||||
"$output_dir/reference/relay-server" \
|
||||
"$output_dir/architecture"
|
||||
for target in \
|
||||
index.html \
|
||||
404.html \
|
||||
guide/getting-started.html \
|
||||
guide/getting-started/index.html \
|
||||
reference/relay-server.html \
|
||||
reference/relay-server/index.html \
|
||||
architecture/connection-security.html; do
|
||||
cp "$source_file" "$output_dir/$target"
|
||||
done
|
||||
touch "$output_dir/.nojekyll"
|
||||
test "$(find "$output_dir" -type f | wc -l)" -eq 8
|
||||
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
|
||||
echo "Full documentation content must not be deployed by this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload redirect artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: legacy-pages-redirect/_site
|
||||
|
||||
deploy:
|
||||
name: Deploy redirect shim
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -93,3 +93,5 @@ keystore.properties
|
||||
|
||||
# Legacy generated desktop tray assets may remain after upgrading a worktree.
|
||||
desktop/tray/ui/vendor/
|
||||
# Generated from assets/screenshots/02_chat.png before docs dev/build.
|
||||
/user-docs/public/chat-demo.png
|
||||
|
||||
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
|
||||
## [Android 1.4.6] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -60,4 +60,4 @@ hermes-relay daemon status
|
||||
|
||||
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
|
||||
|
||||
See the [CLI and systray guide](https://codename-11.github.io/hermes-relay/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
|
||||
@@ -1,5 +1,147 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-17 — Smooth streamed-reply rendering and finalization
|
||||
|
||||
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
|
||||
tool events as authoritative instead of immediately republishing the transcript
|
||||
through a full history read. Rejoined sockets, Sessions SSE, detached turns,
|
||||
profile-aware resume, errors, and missing-data recovery retain their required
|
||||
authoritative reconciliation paths.
|
||||
|
||||
Bursty provider deltas now enter a main-thread frame pacer that publishes adaptive
|
||||
UTF-16-safe slices at a display-sized cadence. The visible live tail uses one stable
|
||||
plain-text node, ignores leading blank transport lines, and expands inside a short
|
||||
clipped size animation. Tool, thinking, completion, cancellation, and error
|
||||
boundaries still flush buffered content immediately and preserve event order.
|
||||
|
||||
Bottom-following is driven by stable row identity, real drag interactions, measured
|
||||
positive tail growth, and structural anchors. The active response retains its live
|
||||
renderer through completion, settles the exact footer for two frames, and releases
|
||||
to full Markdown after another row becomes the tail or the session is revisited.
|
||||
This prevents both the completion-time top snap and the transient scroll-to-bottom
|
||||
button without interrupting readers who intentionally move into history.
|
||||
|
||||
Focused stream-pacing, Unicode-boundary, leading-whitespace, Gateway reconnect,
|
||||
completion-policy, and scroll regressions passed. Repeated sideload builds and live
|
||||
phone tests verified smooth following, stable completion, exact-bottom settling,
|
||||
frame-paced text insertion, and clipped bubble growth.
|
||||
|
||||
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
|
||||
|
||||
Android chat now separates the stable Compose identity of a visible message row
|
||||
from its authoritative server message ID. The post-turn history reconcile can
|
||||
adopt persisted IDs and rebuild message boundaries without making LazyColumn
|
||||
remove and reinsert the long answer currently anchoring the viewport.
|
||||
|
||||
Regression coverage exercises both the same-count user/assistant ID adoption
|
||||
and a list-expansion reconcile that inserts persisted rows around a matched live
|
||||
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
|
||||
|
||||
## 2026-07-16 — Stable chat position after stream completion
|
||||
|
||||
Android chat now observes the assistant message identity and the streaming-to-final
|
||||
transition as conversation-tail changes. When a reader is already following the
|
||||
response, completion performs an instant multi-frame bottom settle after the
|
||||
streaming renderer is replaced by the final Markdown layout. The existing
|
||||
user-scroll gate remains authoritative, so reading older messages is not
|
||||
interrupted.
|
||||
|
||||
Focused snapshot regression coverage verifies completion detection, ordinary
|
||||
stream growth, stream startup, and server message-ID reconciliation.
|
||||
|
||||
## 2026-07-16 — Critical Relay authorization hardening
|
||||
|
||||
Relay privileged interfaces now enforce host-authorized policy at every shared
|
||||
dispatch boundary. Anonymous pairing-code minting was removed; pairing clients
|
||||
can no longer choose session lifetime or grants; Android bridge HTTP routes and
|
||||
terminal messages require live sessions with active route grants; ordinary
|
||||
session bearers can only reduce their own lifetime and existing grants; remote
|
||||
profile config reads expose an explicit public schema without host paths; and
|
||||
voice requests cannot override credential-bearing provider origins.
|
||||
|
||||
Six bounded exploit harnesses stopped at the restored boundaries. The combined
|
||||
security regression set passed 96 tests, Python compilation passed, Ruff passed
|
||||
for changed modules and tests apart from the pre-existing unused `signal`
|
||||
import in `server.py`, and `git diff --check` passed. The broad plugin
|
||||
discovery run progressed through unrelated suites but was interrupted by the
|
||||
existing Windows async-suite `KeyboardInterrupt` behavior, so the focused
|
||||
security and neighboring route suites remain the authoritative local result.
|
||||
The required-check path classifier now reads changed paths from the checked-out
|
||||
PR merge commit instead of GitHub's PR-files API, so an API outage cannot skip
|
||||
every surface check.
|
||||
|
||||
## 2026-07-16 — Fix production docs asset context
|
||||
|
||||
Updated the production docs Docker stage to build from the same full repository
|
||||
checkout used by CI rather than a hand-maintained file allowlist. This supplies
|
||||
the canonical screenshot manifest, localization registry and validators, route
|
||||
contract source, version metadata, and preview renderer without creating
|
||||
Docker-only `ENOENT` failures when those build inputs grow. Both Node build
|
||||
stages now install Python 3 so the localized website and docs validators run
|
||||
inside the production image build as they do in CI.
|
||||
|
||||
## 2026-07-16 — Localized marketing site
|
||||
|
||||
The Astro product site now publishes German, Spanish, Japanese, Brazilian
|
||||
Portuguese, and Simplified Chinese routes from one typed copy contract. Each
|
||||
route localizes marketing copy, navigation, accessibility labels, metadata, and
|
||||
links into the matching first-run documentation while retaining canonical
|
||||
screenshots, command examples, and UI recreations as shipped-product evidence.
|
||||
|
||||
Locale-aware canonical URLs, alternate-language links, Open Graph locale data,
|
||||
structured-data language, sitemap entries, and a responsive language selector
|
||||
were added. The localization registry records English-source freshness, and the
|
||||
website development and build commands reject missing or stale translations.
|
||||
Astro diagnostics, deterministic asset checks, the six-page production build,
|
||||
built-site validation, desktop/mobile browser checks, and `git diff --check`
|
||||
passed.
|
||||
|
||||
## 2026-07-16 — Temporary redirect shim for pre-migration Android builds
|
||||
|
||||
Restored GitHub Pages only as a redirect-only compatibility endpoint for app
|
||||
versions that still open `https://codename-11.github.io/hermes-relay/`. The
|
||||
shim preserves known paths, query strings, and fragments while forwarding to
|
||||
`https://hermes-relay.dev/docs/`; it does not publish the VitePress site.
|
||||
The production Nginx configuration resolves VitePress clean URLs to their
|
||||
`.html` artifacts so both legacy and corrected in-app links reach real pages.
|
||||
Removal criteria and the operator review date are tracked in `TODO.md`.
|
||||
|
||||
## 2026-07-15 — German, Brazilian Portuguese, and Japanese localization
|
||||
|
||||
Android now includes complete German, Brazilian Portuguese, and Japanese
|
||||
catalogs across the Google Play and sideload flavors. German and Brazilian
|
||||
Portuguese were recovered from unfinished translation drafts and refreshed
|
||||
against the current English resource contract; Japanese was generated through
|
||||
the same deterministic translation harness. The in-app picker, Android locale
|
||||
configuration, localization registry, contributor references, and user-facing
|
||||
language lists now describe the expanded set consistently.
|
||||
|
||||
The catalogs remain marked as AI-translated until fluent review is recorded.
|
||||
Structural validation covers resource parity, placeholders, plurals, arrays,
|
||||
formatting flags, XML parsing, and canonical source hashes. The 10-catalog
|
||||
validator, five focused `AppLanguageTest` cases, both flavor Kotlin/resource
|
||||
compilations, sideload debug lint, and `git diff --check` passed.
|
||||
|
||||
## 2026-07-15 — Retire GitHub Pages and move docs to hermes-relay.dev
|
||||
|
||||
Disabled and removed the GitHub Pages deployment path, changed the repository
|
||||
homepage to `https://hermes-relay.dev`, and moved the existing VitePress guide
|
||||
to `https://hermes-relay.dev/docs/` inside the production Coolify image. Active
|
||||
README, website, Android, release-note, and pet-schema links now target the new
|
||||
docs origin while historical DEVLOG entries remain unchanged.
|
||||
|
||||
## 2026-07-15 — Coolify root-context website deployment hotfix
|
||||
|
||||
Added a repository-owned multi-stage Dockerfile for the Astro marketing site
|
||||
and corrected its Coolify instructions. Production builds now keep the
|
||||
repository root as Docker context, run the existing `build:production` gate
|
||||
from `website/`, and serve the generated static output with Nginx. This keeps
|
||||
the site's canonical screenshot comparison against `docs/media/` intact while
|
||||
avoiding Nixpacks' incorrect Android/Gradle provider selection at monorepo root.
|
||||
|
||||
Verification: local website checks, production build, link validation, Docker
|
||||
image build, and Nginx-served smoke checks passed before deployment.
|
||||
|
||||
## 2026-07-15 — Android 1.4.6 and Plugin 1.4.2 release preparation
|
||||
|
||||
The profile-continuity and profile-image work was prepared as a two-surface
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
|
||||
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
@@ -50,13 +50,13 @@ Install → connect → talk, in about two minutes.
|
||||
### 1 · Install the app
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2 · Have Hermes running
|
||||
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
@@ -77,7 +77,7 @@ hermes gateway
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://hermes-relay.dev/docs/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://hermes-relay.dev/docs/guide/getting-started).
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
@@ -99,7 +99,7 @@ The wizard probes everything and finishes with a capability card:
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
|
||||
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
@@ -162,12 +162,12 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The Android app also ships a complete AI-assisted Spanish catalog. Choose
|
||||
**Español** from **Settings → Appearance → Language**; translation status and
|
||||
fluent review are tracked independently so community corrections remain easy
|
||||
to contribute.
|
||||
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
|
||||
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
|
||||
**Settings → Appearance → Language**; translation status and fluent review are
|
||||
tracked independently so community corrections remain easy to contribute.
|
||||
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -183,7 +183,7 @@ to contribute.
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
@@ -205,7 +205,7 @@ It pairs against the **same relay and credential store** as the Android app —
|
||||
|
||||
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## How It Works
|
||||
@@ -229,11 +229,11 @@ configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>简体中文</strong> · <a href="README.md">English</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
|
||||
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
|
||||
<a href="CHANGELOG.md">更新日志</a>
|
||||
</p>
|
||||
@@ -76,7 +76,7 @@ hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/sideload) for installation help.
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -6,6 +6,36 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
`https://hermes-relay.dev/docs/`, but Android 1.4.0 and earlier releases still
|
||||
contain hardcoded `https://codename-11.github.io/hermes-relay/` links. GitHub
|
||||
Pages therefore serves a redirect-only compatibility shim from
|
||||
`legacy-pages-redirect/`; it must never regain full documentation content.
|
||||
|
||||
Retire the shim only after the first Android release containing merge commit
|
||||
`52df3adbf6d61d0ddbfb69671546f7c4953f956a` has been available for at least
|
||||
90 days **and** at least two Android releases containing the corrected links
|
||||
have shipped. If either condition is unmet at review time, retain it and set a
|
||||
new review date.
|
||||
|
||||
Removal checklist:
|
||||
|
||||
- Remove `.github/workflows/legacy-docs-redirect.yml` and
|
||||
`legacy-pages-redirect/` through a reviewed PR.
|
||||
- Delete/disable the repository Pages site after that PR merges.
|
||||
- Verify `https://codename-11.github.io/hermes-relay/` no longer serves the
|
||||
shim and `https://hermes-relay.dev/docs/` plus representative deep links
|
||||
still return HTTP 200.
|
||||
- Update `DEVLOG.md` and the canonical Obsidian Hermes-Relay project note.
|
||||
|
||||
A one-shot operator reminder is scheduled for **2026-10-15 at 09:00 ET** to
|
||||
review these gates; it is a review trigger, not authorization for automatic
|
||||
removal.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||
|
||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||
@@ -615,13 +645,12 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
gated to pre-first-token. Deferred:
|
||||
|
||||
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
|
||||
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
|
||||
the final Markdown renderer during generation while the active tail stays raw.
|
||||
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
|
||||
final parse because partial CommonMark containers can re-parent earlier blocks.
|
||||
Verify that the chosen boundary removes the common heading/prose pop without
|
||||
introducing partial-fence or list flicker.
|
||||
- **Streaming↔final render parity — live reflow check remains.** Blank-terminated,
|
||||
unambiguous top-level prose/headings use the final Markdown renderer during
|
||||
generation while the active tail stays lightweight. Completion intentionally
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
@@ -643,13 +672,12 @@ gated to pre-first-token. Deferred:
|
||||
kebab). Needs on-device confirmation of the current conflict first.
|
||||
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
|
||||
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
|
||||
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
|
||||
new ids → every visible bubble replays its enter animation (gateway transport,
|
||||
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
|
||||
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
|
||||
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
|
||||
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
|
||||
isn't scrolled away. Largely neutralized once render parity removes the height delta.
|
||||
- **Sessions per-turn reconciliation.** Current upstream includes assistant/tool
|
||||
rows in `run.completed.messages`, but Android still uses a full profile-aware
|
||||
history read for successful Sessions turns so older servers and persisted message
|
||||
boundaries remain safe. Replace it only with a bounded partial-turn merge that
|
||||
preserves the prior transcript and client-only fields, with a full-history fallback
|
||||
when the completion payload is absent or incomplete.
|
||||
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
|
||||
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
|
||||
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.util.Locale
|
||||
enum class AppLanguage(val languageTag: String) {
|
||||
SYSTEM_DEFAULT(""),
|
||||
ENGLISH("en"),
|
||||
GERMAN("de"),
|
||||
BRAZILIAN_PORTUGUESE("pt-BR"),
|
||||
JAPANESE("ja"),
|
||||
SIMPLIFIED_CHINESE("zh-Hans"),
|
||||
SPANISH("es"),
|
||||
;
|
||||
@@ -27,8 +30,11 @@ enum class AppLanguage(val languageTag: String) {
|
||||
val locale = Locale.forLanguageTag(primaryTag)
|
||||
|
||||
return when (locale.language.lowercase(Locale.ROOT)) {
|
||||
"de" -> GERMAN
|
||||
"en" -> ENGLISH
|
||||
"es" -> SPANISH
|
||||
"ja" -> JAPANESE
|
||||
"pt" -> BRAZILIAN_PORTUGUESE
|
||||
"zh" -> {
|
||||
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
|
||||
locale.script.isEmpty() ||
|
||||
|
||||
@@ -129,6 +129,19 @@ data class ChatMessage(
|
||||
* the live message can be matched to its server row.
|
||||
*/
|
||||
val backgroundTask: BackgroundTaskState? = null,
|
||||
/**
|
||||
* Stable identity for Compose list rendering.
|
||||
*
|
||||
* Gateway/user rows start with client UUIDs, then post-turn history
|
||||
* reconciliation adopts the server message id into [id]. That server-id
|
||||
* adoption must not make a visible bubble look removed and reinserted to
|
||||
* LazyColumn: doing so discards its scroll anchor, which is especially
|
||||
* disruptive when the row is a long answer occupying the viewport.
|
||||
*
|
||||
* New rows default to their current [id]. Reconciled rows retain this key
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
|
||||
@@ -1315,7 +1315,9 @@ class ChatHandler {
|
||||
// `id = messageId` adopts the server id: for an id-matched (SSE)
|
||||
// row it's a no-op, but for a positionally reconciled (gateway /
|
||||
// user) row whose `prior` still carries a client UUID it swaps in
|
||||
// the server id so EVERY future reload matches by id.
|
||||
// the server id so EVERY future reload matches by id. `uiKey` is
|
||||
// deliberately not overwritten: Compose must continue treating
|
||||
// this as the same visible row across the post-turn reload.
|
||||
prior.copy(
|
||||
id = messageId,
|
||||
role = role,
|
||||
|
||||
@@ -2103,9 +2103,19 @@ class GatewayChatClient(
|
||||
|
||||
private val rejoinAttempts = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
|
||||
/** True if this socket loss should be answered with a rejoin attempt. */
|
||||
fun beginRejoin(): Boolean =
|
||||
!ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
@Volatile
|
||||
private var reconcileRequired = false
|
||||
|
||||
/**
|
||||
* True if this socket loss should be answered with a rejoin attempt.
|
||||
* Mark reconciliation before reconnecting so a terminal event arriving
|
||||
* immediately after `gateway.ready` cannot race ahead of the signal.
|
||||
*/
|
||||
fun beginRejoin(): Boolean {
|
||||
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
if (shouldRejoin) reconcileRequired = true
|
||||
return shouldRejoin
|
||||
}
|
||||
|
||||
private var watchdog: Job? = null
|
||||
|
||||
@@ -2132,6 +2142,12 @@ class GatewayChatClient(
|
||||
// Ask requests block with no further events, so they arm with
|
||||
// their own (longer) duration via watchdogTimeoutFor.
|
||||
armWatchdog(watchdogTimeoutFor(type))
|
||||
// Queue this immediately before the terminal callbacks. Both are
|
||||
// marshalled through the same dispatcher, preserving callback order
|
||||
// even when the WebSocket reader and reconnect coroutine differ.
|
||||
if (type == "message.complete" && reconcileRequired) {
|
||||
callbacks.onReconcileRequired()
|
||||
}
|
||||
mapper.onEvent(type, payload)
|
||||
if (mapper.turnEnded) {
|
||||
disarmWatchdog()
|
||||
@@ -2305,6 +2321,7 @@ class GatewayChatClient(
|
||||
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
|
||||
onComplete = { callbackDispatcher { callbacks.onComplete() } },
|
||||
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
|
||||
@@ -335,6 +335,12 @@ class GatewayTurnCallbacks(
|
||||
/** Attach deterministic output-risk metadata to the matching tool card. */
|
||||
val onToolOutputRisk: (GatewayToolOutputRisk) -> Unit = { _ -> },
|
||||
val onTurnComplete: () -> Unit,
|
||||
/**
|
||||
* Fired before [onComplete] when this turn rejoined after a socket gap.
|
||||
* Events emitted while the socket was unavailable are not replayed, so
|
||||
* the caller must reconcile the durable transcript after completion.
|
||||
*/
|
||||
val onReconcileRequired: () -> Unit,
|
||||
val onComplete: () -> Unit,
|
||||
val onUsage: (UsageInfo?) -> Unit,
|
||||
val onError: (String) -> Unit,
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurity
|
||||
|
||||
private const val LEARN_MORE_URL =
|
||||
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
|
||||
"https://hermes-relay.dev/docs/architecture/connection-security.html"
|
||||
|
||||
/**
|
||||
* Per-surface "Connection security" detail sheet — the tap target for the
|
||||
|
||||
@@ -882,8 +882,8 @@ private data class StandardConnectionDraft(
|
||||
val routeCandidates: List<EndpointCandidate>? = null,
|
||||
)
|
||||
|
||||
private const val SetupGuideUrl = "https://codename-11.github.io/hermes-relay/guide/getting-started"
|
||||
private const val RelaySetupDocsUrl = "https://codename-11.github.io/hermes-relay/reference/relay-server"
|
||||
private const val SetupGuideUrl = "https://hermes-relay.dev/docs/guide/getting-started"
|
||||
private const val RelaySetupDocsUrl = "https://hermes-relay.dev/docs/reference/relay-server"
|
||||
private const val HermesApiDocsUrl = "https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server"
|
||||
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
|
||||
@@ -11,34 +11,21 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
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.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.semantics.CollectionInfo
|
||||
import androidx.compose.ui.semantics.collectionInfo
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -69,7 +56,6 @@ import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.delay
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
|
||||
@@ -303,285 +289,42 @@ fun StreamingMarkdownContent(
|
||||
isStreaming: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blocks = remember(content, isStreaming) {
|
||||
if (isStreaming) {
|
||||
parseStreamingMarkdownBlocks(content)
|
||||
} else {
|
||||
listOf(StreamingMarkdownBlock.Markdown(content))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
// Match the final renderer's block spacer so moving the active tail
|
||||
// into the settled Markdown prefix does not add a second layout jump.
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
|
||||
content = block.content,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Text -> Text(
|
||||
text = block.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
|
||||
// Discord-like fenced block: a contrasting inset surface with a thin header
|
||||
// (language label + copy), and a horizontally-scrollable monospace body.
|
||||
// Header is shown whenever there's a language to label or code to copy, so
|
||||
// even a bare ``` fence gets the copy affordance once it has content.
|
||||
val hasHeader = block.language.isNotBlank() || block.code.isNotBlank()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
if (hasHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = block.language.ifBlank { "code" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
CodeCopyButton(code = block.code)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = block.code.ifEmpty { " " },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small copy affordance for a code block — copies [code] to the clipboard and
|
||||
* briefly flips to a check for feedback. No-op while [code] is blank.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeCopyButton(code: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (code.isNotBlank()) {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copied = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
if (isStreaming) {
|
||||
// Keep one stable layout node for the entire live turn. Promoting each
|
||||
// blank-terminated paragraph into Markdown replaced the Text subtree
|
||||
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
|
||||
// which looked like the whole chat reloaded. Updating this Text value
|
||||
// only remeasures the growing bubble. Full Markdown is parsed once the
|
||||
// owning row releases its stable live-tail layout.
|
||||
Text(
|
||||
// CommonMark ignores blank lines before the first block. The live
|
||||
// Text renderer must do the same or a response whose transport
|
||||
// prefix contains newlines appears to start several lines down.
|
||||
// Preserve indentation on the first non-blank line so indented
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface StreamingMarkdownBlock {
|
||||
data class Markdown(val content: String) : StreamingMarkdownBlock
|
||||
data class Text(val text: String) : StreamingMarkdownBlock
|
||||
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an in-flight response into stable Markdown and one structurally
|
||||
* incomplete tail.
|
||||
*
|
||||
* Only conservative, blank-terminated top-level prose/heading blocks promote
|
||||
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
|
||||
* remain on the lightweight streaming surface until the message settles; those
|
||||
* containers can legally absorb later lines, so promoting them early causes a
|
||||
* visible re-parenting jump when the final CommonMark tree is parsed.
|
||||
*/
|
||||
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
|
||||
if (content.isBlank()) return emptyList()
|
||||
|
||||
val normalized = content
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
|
||||
|
||||
if (settledEnd > 0) {
|
||||
normalized.substring(0, settledEnd).trimEnd().let { stable ->
|
||||
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
|
||||
}
|
||||
}
|
||||
|
||||
val activeTail = normalized.substring(settledEnd)
|
||||
if (activeTail.isNotBlank()) {
|
||||
blocks += parseActiveStreamingTail(activeTail)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
|
||||
private fun findStableMarkdownBoundary(content: String): Int {
|
||||
var offset = 0
|
||||
var blockStart = 0
|
||||
var activeFence: StreamingFence? = null
|
||||
var lastStableBoundary = 0
|
||||
|
||||
while (offset < content.length) {
|
||||
val newline = content.indexOf('\n', offset)
|
||||
val lineEnd = if (newline >= 0) newline else content.length
|
||||
val line = content.substring(offset, lineEnd)
|
||||
|
||||
activeFence = when (val current = activeFence) {
|
||||
null -> streamingFence(line)
|
||||
else -> if (isClosingFence(line, current)) null else current
|
||||
}
|
||||
|
||||
// Whitespace-only lines can be meaningful indentation inside a list.
|
||||
// Require a truly empty delimiter and stop at the first ambiguous
|
||||
// container so every promoted prefix remains structurally final.
|
||||
if (activeFence == null && newline >= 0 && line.isEmpty()) {
|
||||
val candidate = content.substring(blockStart, offset).trimEnd()
|
||||
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
|
||||
lastStableBoundary = newline + 1
|
||||
blockStart = lastStableBoundary
|
||||
}
|
||||
|
||||
if (newline < 0) break
|
||||
offset = newline + 1
|
||||
}
|
||||
|
||||
return lastStableBoundary
|
||||
}
|
||||
|
||||
private fun isConservativeStableBlock(block: String): Boolean = block
|
||||
.lineSequence()
|
||||
.filter { it.isNotEmpty() }
|
||||
.none { line ->
|
||||
line.firstOrNull()?.isWhitespace() == true ||
|
||||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
|
||||
}
|
||||
|
||||
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val paragraph = StringBuilder()
|
||||
val code = StringBuilder()
|
||||
var activeFence: StreamingFence? = null
|
||||
var language = ""
|
||||
|
||||
fun flushParagraph() {
|
||||
val text = paragraph.toString().trim('\n').trimEnd()
|
||||
if (text.isNotBlank()) {
|
||||
blocks += StreamingMarkdownBlock.Text(text)
|
||||
}
|
||||
paragraph.clear()
|
||||
}
|
||||
|
||||
fun flushCode() {
|
||||
blocks += StreamingMarkdownBlock.Code(
|
||||
language = language,
|
||||
code = code.toString().trimEnd('\n'),
|
||||
)
|
||||
code.clear()
|
||||
}
|
||||
|
||||
val lines = content.split('\n')
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
|
||||
|
||||
if (activeFence == null) {
|
||||
val fence = streamingFence(line)
|
||||
if (fence != null) {
|
||||
flushParagraph()
|
||||
activeFence = fence
|
||||
language = streamingFenceLanguage(line, fence)
|
||||
} else {
|
||||
paragraph.append(lineWithBreak)
|
||||
}
|
||||
} else if (isClosingFence(line, activeFence)) {
|
||||
flushCode()
|
||||
activeFence = null
|
||||
language = ""
|
||||
} else {
|
||||
code.append(lineWithBreak)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeFence != null) {
|
||||
flushCode()
|
||||
} else {
|
||||
flushParagraph()
|
||||
MarkdownContent(
|
||||
content = content,
|
||||
textColor = textColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private data class StreamingFence(
|
||||
val marker: Char,
|
||||
val length: Int,
|
||||
)
|
||||
internal fun String.withoutLeadingBlankLines(): String {
|
||||
var contentStart = 0
|
||||
while (contentStart < length) {
|
||||
val lineEnd = indexOf('\n', startIndex = contentStart)
|
||||
if (lineEnd < 0) break
|
||||
|
||||
private fun streamingFence(line: String): StreamingFence? {
|
||||
val trimmed = line.trimStart()
|
||||
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
|
||||
val length = trimmed.takeWhile { it == marker }.length
|
||||
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
|
||||
val line = substring(contentStart, lineEnd).removeSuffix("\r")
|
||||
if (line.isNotBlank()) break
|
||||
contentStart = lineEnd + 1
|
||||
}
|
||||
return substring(contentStart)
|
||||
}
|
||||
|
||||
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
|
||||
val trimmed = line.trimStart()
|
||||
if (trimmed.firstOrNull() != activeFence.marker) return false
|
||||
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
|
||||
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
|
||||
}
|
||||
|
||||
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
|
||||
val tail = line.trimStart().drop(fence.length).trim()
|
||||
return tail
|
||||
.takeWhile { !it.isWhitespace() && it != fence.marker }
|
||||
.take(32)
|
||||
}
|
||||
|
||||
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
|
||||
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
@@ -83,6 +85,12 @@ fun MessageBubble(
|
||||
showThinking: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
/**
|
||||
* Keeps a just-completed live tail on its stable Text layout. The owning
|
||||
* list releases this once another row becomes the tail, allowing full
|
||||
* Markdown to render without disturbing the visible bottom anchor.
|
||||
*/
|
||||
retainStreamingLayout: Boolean = false,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Quote this message into the input field. Null hides the Quote entry in
|
||||
@@ -361,6 +369,26 @@ fun MessageBubble(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isUser && !isSystem &&
|
||||
(message.isStreaming || retainStreamingLayout)
|
||||
) {
|
||||
// The frame-paced text node is already measured at its
|
||||
// new size. Animate and clip the owning surface so a
|
||||
// newly wrapped line is revealed inside the expanding
|
||||
// bubble instead of drawing below the previous bounds
|
||||
// for one frame. TopStart keeps existing prose fixed.
|
||||
Modifier.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 72,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
alignment = Alignment.TopStart,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
Modifier.leftEdgeGlow(
|
||||
@@ -396,15 +424,16 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Settled blocks keep the real Markdown renderer while
|
||||
// only the structurally incomplete tail stays raw. The
|
||||
// same composable settles the final tail so retained
|
||||
// parser state survives the streaming -> final handoff.
|
||||
// Keep one plain Text node stable while content grows
|
||||
// and while this completed response remains the visible
|
||||
// tail. Full Markdown renders once another row becomes
|
||||
// the tail (or the session is revisited), where its
|
||||
// remeasure cannot reset the active viewport.
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor,
|
||||
isStreaming = message.isStreaming,
|
||||
isStreaming = message.isStreaming || retainStreamingLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -538,7 +567,11 @@ fun MessageBubble(
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
if (isLastInGroup) {
|
||||
// Keep the live bubble's footer structurally quiet. Showing a
|
||||
// timestamp while text is still growing makes it chase every
|
||||
// token and exaggerates any single-frame layout lag. Reveal it
|
||||
// once the message settles into its final layout.
|
||||
if (isLastInGroup && !message.isStreaming) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
|
||||
@@ -359,7 +359,7 @@ private fun WelcomePage() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/guide/getting-started"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
|
||||
@@ -394,7 +394,7 @@ fun AboutScreen(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
|
||||
@@ -244,6 +244,9 @@ fun AppearanceSettingsScreen(
|
||||
val languageLabels = mapOf(
|
||||
AppLanguage.SYSTEM_DEFAULT to stringResource(R.string.appearance_language_system),
|
||||
AppLanguage.ENGLISH to stringResource(R.string.appearance_language_english),
|
||||
AppLanguage.GERMAN to stringResource(R.string.appearance_language_german),
|
||||
AppLanguage.BRAZILIAN_PORTUGUESE to stringResource(R.string.appearance_language_brazilian_portuguese),
|
||||
AppLanguage.JAPANESE to stringResource(R.string.appearance_language_japanese),
|
||||
AppLanguage.SIMPLIFIED_CHINESE to stringResource(R.string.appearance_language_simplified_chinese),
|
||||
AppLanguage.SPANISH to stringResource(R.string.appearance_language_spanish),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -37,6 +38,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.DragInteraction
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -76,6 +78,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -85,7 +88,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -218,7 +220,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* A same-author run breaks into a new visual group once the gap to the
|
||||
* neighboring message exceeds this — so a conversation resumed after a pause
|
||||
@@ -233,19 +234,38 @@ private const val GROUP_GAP_MS = 5 * 60_000L
|
||||
*
|
||||
* Captured inside a `snapshotFlow { ... }` so distinctUntilChanged can
|
||||
* detect any meaningful change (new message, longer text, longer reasoning,
|
||||
* new tool card, streaming on/off) and re-trigger an auto-follow scroll.
|
||||
* new tool card, message-id reconciliation, streaming on/off) and re-trigger
|
||||
* an auto-follow scroll.
|
||||
*
|
||||
* `equals` is auto-generated by `data class`, which gives field-wise
|
||||
* comparison — exactly the behavior distinctUntilChanged needs.
|
||||
*/
|
||||
private data class ChatScrollSnapshot(
|
||||
internal data class ChatScrollSnapshot(
|
||||
val messageCount: Int,
|
||||
val lastMessageId: String?,
|
||||
val lastMessageUiKey: String?,
|
||||
val lastContentLength: Int,
|
||||
val lastThinkingLength: Int,
|
||||
val lastToolCallCount: Int,
|
||||
val isStreaming: Boolean
|
||||
)
|
||||
|
||||
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
|
||||
previous?.isStreaming == true &&
|
||||
!isStreaming &&
|
||||
previous.messageCount == messageCount &&
|
||||
previous.lastMessageUiKey == lastMessageUiKey
|
||||
|
||||
private class ChatTailTransitionRef(
|
||||
var snapshot: ChatScrollSnapshot? = null,
|
||||
)
|
||||
|
||||
private data class ChatTailLayoutSnapshot(
|
||||
val uiKey: String?,
|
||||
val measuredSizePx: Int?,
|
||||
val shouldFollowGrowth: Boolean,
|
||||
)
|
||||
|
||||
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
|
||||
val layout = layoutInfo
|
||||
if (layout.totalItemsCount == 0) return true
|
||||
@@ -267,9 +287,9 @@ private suspend fun LazyListState.scrollToConversationBottom(
|
||||
if (attempt == 0 || !isAtConversationBottom(slopPx)) {
|
||||
if (animateNext) {
|
||||
animateNext = false
|
||||
animateScrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
animateScrollToItem(lastIndex)
|
||||
} else {
|
||||
scrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
scrollToItem(lastIndex)
|
||||
}
|
||||
}
|
||||
withFrameNanos { }
|
||||
@@ -991,7 +1011,10 @@ fun ChatScreen(
|
||||
// user back to the latest token while they are reading history.
|
||||
// Reset to false the moment the user returns to the bottom.
|
||||
var userScrolledAway by remember(currentSessionId) { mutableStateOf(false) }
|
||||
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
|
||||
var programmaticBottomScroll by remember { mutableStateOf(false) }
|
||||
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
|
||||
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
|
||||
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
|
||||
var lastReadSnapshot by remember(currentSessionId) {
|
||||
mutableStateOf(currentUnreadSnapshot)
|
||||
@@ -1024,24 +1047,27 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Decide "is the user reading history" from GENUINE scroll gestures only.
|
||||
// A bare isAtBottom flip — the streaming bubble grew and pushed content
|
||||
// below the fold — must NOT count as the user scrolling away. That false
|
||||
// signal was the bounce: it popped the scroll-to-bottom FAB and (worse)
|
||||
// aborted auto-follow even though the user never touched the screen.
|
||||
// Content growth never sets isScrollInProgress, so keying off the scroll's
|
||||
// falling edge ignores it; only a real drag/fling that ENDS above the
|
||||
// bottom flips userScrolledAway. (Programmatic animated scrolls also set
|
||||
// isScrollInProgress, so they're excluded via programmaticBottomScroll.)
|
||||
// Decide "is the user reading history" from actual touch drags only.
|
||||
// isScrollInProgress also becomes true for our own animated bottom scroll;
|
||||
// if that animation is cancelled by the next stream batch, its falling
|
||||
// edge can race the programmatic flag and falsely disable auto-follow for
|
||||
// the rest of the turn. LazyListState's interaction source emits only real
|
||||
// drag gestures, so it cleanly separates user intent from app scrolling.
|
||||
// Pause follow at drag start so a new token cannot fight the finger, but do
|
||||
// not classify the user as reading history until the gesture actually ends
|
||||
// above the bottom. A tiny/cancelled touch must not poison the next turn.
|
||||
LaunchedEffect(listState) {
|
||||
var wasScrolling = false
|
||||
snapshotFlow { listState.isScrollInProgress }
|
||||
.collect { scrolling ->
|
||||
if (wasScrolling && !scrolling && !programmaticBottomScroll) {
|
||||
userScrolledAway = !isAtBottom
|
||||
listState.interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is DragInteraction.Start -> {
|
||||
isUserDragging = true
|
||||
}
|
||||
is DragInteraction.Stop, is DragInteraction.Cancel -> {
|
||||
isUserDragging = false
|
||||
userScrolledAway = !listState.isAtConversationBottom(atBottomSlopPx)
|
||||
}
|
||||
wasScrolling = scrolling
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reaching the bottom by any means (user, follow-pin, content shrank)
|
||||
// always re-arms auto-follow.
|
||||
@@ -1061,10 +1087,14 @@ fun ChatScreen(
|
||||
// suppression lifts and the button appears.
|
||||
val showScrollToBottom by remember {
|
||||
derivedStateOf {
|
||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||
messages.isNotEmpty() &&
|
||||
!isAtBottom &&
|
||||
!programmaticBottomScroll &&
|
||||
!(isStreaming && smoothAutoScroll && !userScrolledAway)
|
||||
!((isStreaming || retainingVisibleTail) &&
|
||||
smoothAutoScroll &&
|
||||
!userScrolledAway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,124 +1226,148 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom while streaming.
|
||||
//
|
||||
// Bugs the previous versions had:
|
||||
// 1. Keys only watched messages.size and content.length — so growth of
|
||||
// the thinking block (thinkingContent) and tool-card additions
|
||||
// (toolCalls) silently froze the auto-follow during long reasoning
|
||||
// and tool execution phases.
|
||||
// 2. animateScrollToItem(lastIndex) defaults scrollOffset = 0, which
|
||||
// aligns the TOP of the item with the top of the viewport. For a
|
||||
// tall streaming bubble (reasoning + tool cards + text) that means
|
||||
// the user gets snapped back to the start of the message instead
|
||||
// of staying at the latest token. Fix: scrollOffset = Int.MAX_VALUE
|
||||
// pins the bottom of the item to the bottom of the viewport.
|
||||
// 3. There was no userScrolledAway gate, so any delta would yank a
|
||||
// user reading history back to the bottom.
|
||||
// 4. The isStreaming flag was a snapshot key, so the stream-complete
|
||||
// transition (true → false) re-triggered animateScrollToItem even
|
||||
// when no content actually changed — producing a visible jiggle.
|
||||
// 5. Sessions endpoint reloads the entire message list on stream
|
||||
// complete via loadMessageHistory(), and the resulting animateItem()
|
||||
// placement animations on every bubble fought with our concurrent
|
||||
// animateScrollToItem — producing a flash where the viewport
|
||||
// visibly settled twice.
|
||||
//
|
||||
// The fix below uses snapshotFlow on a snapshot of every meaningful
|
||||
// streaming-state field. distinctUntilChanged debounces identical
|
||||
// emissions; collectLatest cancels any in-flight scroll animation when
|
||||
// a newer delta arrives, preventing animation pile-ups during rapid
|
||||
// SSE bursts. The pref `smoothAutoScroll` (default true) gates the
|
||||
// entire effect — when off, only manual scrolling occurs.
|
||||
//
|
||||
// The previous-snapshot var inside the LaunchedEffect coroutine lets us
|
||||
// distinguish "content arrived" from "state flipped" and "single
|
||||
// append" from "list rebuild", and pick the right scroll strategy.
|
||||
LaunchedEffect(listState, smoothAutoScroll) {
|
||||
if (!smoothAutoScroll) return@LaunchedEffect
|
||||
var previousSnapshot: ChatScrollSnapshot? = null
|
||||
val tailMessage = messages.lastOrNull()
|
||||
val tailTransition = ChatScrollSnapshot(
|
||||
messageCount = messages.size,
|
||||
lastMessageId = tailMessage?.id,
|
||||
lastMessageUiKey = tailMessage?.uiKey,
|
||||
lastContentLength = tailMessage?.content?.length ?: 0,
|
||||
lastThinkingLength = tailMessage?.thinkingContent?.length ?: 0,
|
||||
lastToolCallCount = tailMessage?.toolCalls?.size ?: 0,
|
||||
isStreaming = tailMessage?.isStreaming == true,
|
||||
)
|
||||
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
|
||||
|
||||
// New rows and streaming -> final Markdown are structural transitions.
|
||||
// Anchor their trailing spacer in SideEffect so the request participates in
|
||||
// the very next remeasure instead of correcting an already-drawn frame.
|
||||
SideEffect {
|
||||
val previous = tailTransitionRef.snapshot
|
||||
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
|
||||
val completed = tailTransition.isCompletionAfter(previous)
|
||||
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
|
||||
(previous == null ||
|
||||
previous.messageCount != tailTransition.messageCount ||
|
||||
previous.lastMessageUiKey != tailTransition.lastMessageUiKey)
|
||||
|
||||
if (streamStarted) {
|
||||
// Sending a turn means "follow my new answer" even if the idle
|
||||
// transcript had previously been left above the bottom. Do not
|
||||
// clear isUserDragging: a real finger keeps priority until release.
|
||||
userScrolledAway = false
|
||||
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
|
||||
} else if (completed) {
|
||||
completionSettlingUiKey = tailTransition.lastMessageUiKey
|
||||
} else if (
|
||||
tailStructureChanged &&
|
||||
retainedLiveTailUiKey != null &&
|
||||
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
|
||||
) {
|
||||
retainedLiveTailUiKey = null
|
||||
}
|
||||
|
||||
val shouldAnchor = smoothAutoScroll &&
|
||||
!isUserDragging &&
|
||||
(!userScrolledAway || streamStarted) &&
|
||||
(streamStarted || completed || tailStructureChanged)
|
||||
if (shouldAnchor) {
|
||||
listState.requestScrollToItem(tailTransition.messageCount + 1)
|
||||
}
|
||||
|
||||
tailTransitionRef.snapshot = tailTransition
|
||||
}
|
||||
|
||||
// Completion adds the timestamp/footer after the final token. Keep the
|
||||
// stable live renderer, then consume any small remaining forward range for
|
||||
// two settled frames. scrollBy preserves the current item anchor and is
|
||||
// visually inert when already at the exact bottom; unlike scrollToItem it
|
||||
// cannot align the top of a tall response with the viewport.
|
||||
LaunchedEffect(
|
||||
completionSettlingUiKey,
|
||||
smoothAutoScroll,
|
||||
userScrolledAway,
|
||||
isUserDragging,
|
||||
) {
|
||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var settledFrames = 0
|
||||
repeat(6) {
|
||||
withFrameNanos { }
|
||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settledFrames += 1
|
||||
if (settledFrames >= 2) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
completionSettlingUiKey = null
|
||||
}
|
||||
|
||||
// Ordinary streaming growth keeps the same row and Text node. Advance the
|
||||
// existing scroll position by exactly the measured positive height delta;
|
||||
// never replace the logical anchor with scrollToItem(). User input has a
|
||||
// higher mutation priority and cancels this work naturally.
|
||||
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
|
||||
|
||||
var previousLayout: ChatTailLayoutSnapshot? = null
|
||||
snapshotFlow {
|
||||
val last = messages.lastOrNull()
|
||||
// Snapshot every field that can grow during a single turn.
|
||||
// Any change here means "more content arrived, try to follow".
|
||||
ChatScrollSnapshot(
|
||||
messageCount = messages.size,
|
||||
lastContentLength = last?.content?.length ?: 0,
|
||||
lastThinkingLength = last?.thinkingContent?.length ?: 0,
|
||||
lastToolCallCount = last?.toolCalls?.size ?: 0,
|
||||
isStreaming = last?.isStreaming == true
|
||||
val tail = messages.lastOrNull()
|
||||
val tailSize = tail?.uiKey?.let { uiKey ->
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item -> item.key == uiKey }
|
||||
?.size
|
||||
}
|
||||
ChatTailLayoutSnapshot(
|
||||
uiKey = tail?.uiKey,
|
||||
measuredSizePx = tailSize,
|
||||
shouldFollowGrowth = tail?.isStreaming == true ||
|
||||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { snapshot ->
|
||||
val prev = previousSnapshot
|
||||
previousSnapshot = snapshot
|
||||
.collect { current ->
|
||||
val previous = previousLayout
|
||||
previousLayout = current
|
||||
val previousSize = previous?.measuredSizePx ?: return@collect
|
||||
val currentSize = current.measuredSizePx ?: return@collect
|
||||
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
|
||||
|
||||
if (messages.isEmpty()) return@collectLatest
|
||||
if (userScrolledAway) return@collectLatest
|
||||
|
||||
// Skip "state-only" snapshot deltas where the only thing
|
||||
// that changed is the isStreaming flag. The viewport is
|
||||
// already at the right position from the last content
|
||||
// delta — animating again on the state flip causes a
|
||||
// visible flash, especially in sessions mode where the
|
||||
// StreamingDots row vanishes when isStreaming flips false.
|
||||
val onlyStreamingFlagChanged = prev != null
|
||||
&& prev.messageCount == snapshot.messageCount
|
||||
&& prev.lastContentLength == snapshot.lastContentLength
|
||||
&& prev.lastThinkingLength == snapshot.lastThinkingLength
|
||||
&& prev.lastToolCallCount == snapshot.lastToolCallCount
|
||||
&& prev.isStreaming != snapshot.isStreaming
|
||||
if (onlyStreamingFlagChanged) return@collectLatest
|
||||
|
||||
// Sessions endpoint reloads the entire message list on
|
||||
// stream complete (one streaming message → multiple final
|
||||
// messages with proper boundaries + tool call cards).
|
||||
// animateScrollToItem during a list rebuild conflicts with
|
||||
// the items' animateItem() placement animations and produces
|
||||
// a visible flash. Use the instant scrollToItem path so the
|
||||
// viewport snaps to the new bottom while the items animate
|
||||
// into their final positions independently.
|
||||
val isListRebuild = prev != null
|
||||
&& snapshot.messageCount - prev.messageCount > 1
|
||||
|
||||
// Growth of the bubble we're already following (thinking /
|
||||
// content / tool cards on the same message) arrives at token
|
||||
// frequency on the gateway transport. animateScrollToItem
|
||||
// per delta is a cancel/restart storm — each collectLatest
|
||||
// cancellation strands the viewport mid-animation (showing
|
||||
// earlier content) before the next one yanks it back:
|
||||
// visible stutter when parked at the bottom during long
|
||||
// reasoning. Pin instantly instead; reserve the animation
|
||||
// for the discrete new-bubble event.
|
||||
val isSameTurnGrowth = prev != null
|
||||
&& snapshot.messageCount == prev.messageCount
|
||||
|
||||
if (isSameTurnGrowth) {
|
||||
// Tail-follow: a single atomic pin to the clamped bottom.
|
||||
// The helper's multi-frame settle loop gets cancelled by
|
||||
// collectLatest on the very next token (streaming arrives
|
||||
// ~every frame), stranding the viewport mid-settle → the
|
||||
// bounce. One withFrameNanos to let the grown content lay
|
||||
// out, then one scrollToItem — instant and cancellation-safe.
|
||||
withFrameNanos { }
|
||||
val lastIndex = listState.layoutInfo.totalItemsCount - 1
|
||||
if (lastIndex >= 0) listState.scrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
} else {
|
||||
// Discrete events (new bubble, list rebuild, history load):
|
||||
// scrollOffset = Int.MAX_VALUE clamps to the deepest offset;
|
||||
// the helper retries across frames so late markdown/code
|
||||
// measurement can't leave us anchored above the real bottom.
|
||||
// Instant for a rebuild (avoids fighting animateItem()).
|
||||
scrollConversationToBottom(animated = !isListRebuild)
|
||||
val growthPx = currentSize - previousSize
|
||||
if (growthPx > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(growthPx.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Haptic on stream complete
|
||||
// Completion haptic only; scroll ownership remains with the transition and
|
||||
// measured-growth paths above.
|
||||
var observedActiveStream by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isStreaming) {
|
||||
if (!isStreaming && messages.isNotEmpty()) {
|
||||
if (isStreaming) {
|
||||
observedActiveStream = true
|
||||
} else if (observedActiveStream) {
|
||||
observedActiveStream = false
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}
|
||||
@@ -2102,8 +2156,15 @@ fun ChatScreen(
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
|
||||
|
||||
items(messages.size, key = { messages[it].id }) { index ->
|
||||
// `id` can legitimately change once after a Gateway turn:
|
||||
// the history reconcile adopts the persisted server id.
|
||||
// Keep Compose identity stable across that data update so
|
||||
// LazyColumn retains the visible row and its scroll anchor.
|
||||
items(messages.size, key = { messages[it].uiKey }) { index ->
|
||||
val message = messages[index]
|
||||
val retainLiveLayout =
|
||||
index == messages.lastIndex &&
|
||||
message.uiKey == retainedLiveTailUiKey
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
@@ -2142,112 +2203,112 @@ fun ChatScreen(
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
val taskModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
)
|
||||
BackgroundTaskCard(
|
||||
task = task,
|
||||
toolCalls = message.toolCalls,
|
||||
showTimeline = toolDisplay != "off",
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
)
|
||||
.animateItem(),
|
||||
modifier = taskModifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (processNotification != null) {
|
||||
val notificationModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
)
|
||||
SyntheticProcessNotificationNotice(
|
||||
notification = processNotification,
|
||||
modifier = Modifier
|
||||
.padding(top = if (isFirstInGroup) 6.dp else 2.dp)
|
||||
.animateItem(),
|
||||
modifier = notificationModifier,
|
||||
)
|
||||
} else if (shouldRenderBubble) MessageBubble(
|
||||
message = message,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (hasBackgroundTask) 1.dp
|
||||
} else if (shouldRenderBubble) {
|
||||
val bubbleModifier = Modifier.padding(
|
||||
top = if (hasBackgroundTask) 1.dp
|
||||
else if (isFirstInGroup) 6.dp
|
||||
else 1.dp,
|
||||
)
|
||||
.animateItem(),
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = { msgId, cardKey, action ->
|
||||
// OPEN_URL is resolved at the UI layer
|
||||
// because launching ACTION_VIEW needs a
|
||||
// Context. We record the dispatch FIRST
|
||||
// via the ViewModel so the card collapses
|
||||
// even if the browser launch throws.
|
||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context, action
|
||||
)
|
||||
)
|
||||
MessageBubble(
|
||||
message = message,
|
||||
modifier = bubbleModifier,
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
retainStreamingLayout = retainLiveLayout,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = { msgId, cardKey, action ->
|
||||
// OPEN_URL is resolved at the UI layer
|
||||
// because launching ACTION_VIEW needs a
|
||||
// Context. Record the dispatch first so
|
||||
// the card collapses even if launch fails.
|
||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
!message.id.startsWith("voice-intent-") &&
|
||||
!message.id.startsWith("steer-")
|
||||
) {
|
||||
{ msg ->
|
||||
editingMessage = msg
|
||||
inputText = msg.content.take(charLimit)
|
||||
}
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
!message.id.startsWith("voice-intent-") &&
|
||||
!message.id.startsWith("steer-")
|
||||
) {
|
||||
{ msg ->
|
||||
editingMessage = msg
|
||||
inputText = msg.content.take(charLimit)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
.trim()
|
||||
.lines()
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
inputText = if (inputText.isBlank()) {
|
||||
"$quoted\n\n"
|
||||
} else {
|
||||
"$inputText\n$quoted\n\n"
|
||||
}
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
// We piggyback on the same scope.launch that posts
|
||||
// the snackbar — they are sequential anyway.
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
hermesMessageLabel,
|
||||
text
|
||||
null
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
.trim()
|
||||
.lines()
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
inputText = if (inputText.isBlank()) {
|
||||
"$quoted\n\n"
|
||||
} else {
|
||||
"$inputText\n$quoted\n\n"
|
||||
}
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
hermesMessageLabel,
|
||||
text,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
snackbarHostState.showSnackbar(
|
||||
message = copiedToClipboardMsg,
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
snackbarHostState.showSnackbar(
|
||||
message = copiedToClipboardMsg,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Steered sends live inside a server-side tool
|
||||
// result, not a user message — flag the local
|
||||
|
||||
@@ -120,6 +120,21 @@ data class ContextWindowUsage(
|
||||
get() = if (maxTokens > 0) (usedTokens.toFloat() / maxTokens).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful Sessions SSE turn still needs the server-authoritative transcript
|
||||
* because that transport does not stream every persisted message boundary.
|
||||
* Gateway turns already deliver the assistant, reasoning, and tool lifecycle
|
||||
* directly; reloading their full transcript only republishes a healthy live turn.
|
||||
* A successful socket rejoin is the exception because events emitted during the
|
||||
* gap cannot be replayed.
|
||||
*/
|
||||
internal fun shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport: String,
|
||||
gatewayReconcileRequired: Boolean,
|
||||
): Boolean =
|
||||
actualTransport == "sessions" ||
|
||||
(actualTransport == "gateway" && gatewayReconcileRequired)
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
@@ -132,6 +147,9 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private var activeStream: ActiveTurnHandle? = null
|
||||
|
||||
/** Token bursts awaiting their next UI-sized publication window. */
|
||||
private var activeStreamDeltas: StreamDeltaCoalescer? = null
|
||||
|
||||
/**
|
||||
* True when [activeStream] is a GATEWAY turn (vs an SSE EventSource). A
|
||||
* gateway turn runs on the gateway client, which survives a same-connection
|
||||
@@ -1254,6 +1272,9 @@ class ChatViewModel : ViewModel() {
|
||||
onTurnComplete = {
|
||||
if (acceptsEvent()) handler.onTurnComplete(messageId)
|
||||
},
|
||||
// Server-initiated turns already take the bounded durable-history
|
||||
// reconcile below on every completion.
|
||||
onReconcileRequired = { },
|
||||
onComplete = {
|
||||
val canWriteTranscript = acceptsEvent()
|
||||
val expectedText = handler.messages.value
|
||||
@@ -2255,6 +2276,10 @@ class ChatViewModel : ViewModel() {
|
||||
// same-connection route blip and reconnects its own socket, keeping the
|
||||
// live session), so cancelling here would needlessly kill a recoverable
|
||||
// turn. Leave it running; it completes on the gateway client.
|
||||
if (!activeStreamIsGateway) {
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
}
|
||||
val droppedSseCheckpoint = if (!activeStreamIsGateway) buildTurnCheckpoint() else null
|
||||
if (!activeStreamIsGateway) {
|
||||
activeStream?.cancel()
|
||||
@@ -2293,6 +2318,8 @@ class ChatViewModel : ViewModel() {
|
||||
historyLoadGeneration.incrementAndGet()
|
||||
sessionRefreshGeneration.incrementAndGet()
|
||||
intentionallyCancelled = true
|
||||
activeStreamDeltas?.discard()
|
||||
activeStreamDeltas = null
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
@@ -3339,7 +3366,8 @@ class ChatViewModel : ViewModel() {
|
||||
* message among role==USER messages (excluding phone-local traces the
|
||||
* server never saw), truncates the local list from it, and dispatches a
|
||||
* gateway turn carrying `truncate_before_user_ordinal`. Local/server
|
||||
* divergence self-heals via the post-turn history reload.
|
||||
* divergence self-heals through the gateway's authoritative truncate and
|
||||
* live turn events; recovery paths still perform a full history reconcile.
|
||||
*
|
||||
* @return false when the edit could not be dispatched (turn in flight,
|
||||
* non-gateway endpoint, missing client, ordinal failure) — the caller
|
||||
@@ -3827,6 +3855,8 @@ class ChatViewModel : ViewModel() {
|
||||
val gateway = gatewayClient
|
||||
val canBackground = streamingEndpoint == "gateway" &&
|
||||
activeStreamIsGateway && activeStream != null && gateway != null
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
val checkpoint = if (canBackground) buildTurnCheckpoint() else null
|
||||
if (canBackground && gateway.backgroundActiveTurn()) {
|
||||
if (checkpoint != null) {
|
||||
@@ -4036,6 +4066,8 @@ class ChatViewModel : ViewModel() {
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
},
|
||||
// Recovered turns already reload their authoritative history below.
|
||||
onReconcileRequired = { },
|
||||
onComplete = {
|
||||
if (owns()) {
|
||||
finalizeTurnSideEffects(handler, messageId)
|
||||
@@ -5394,6 +5426,8 @@ class ChatViewModel : ViewModel() {
|
||||
// finalizes the previous turn's leftover streaming placeholder so it
|
||||
// can't pulse forever next to this turn's fresh one.
|
||||
cancelAnswerRecovery()
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
|
||||
// A new turn is starting: clear any leftover cancellation flag so a
|
||||
// stale `true` from a PRIOR cancelled turn (the flag is sticky — a
|
||||
@@ -5414,6 +5448,7 @@ class ChatViewModel : ViewModel() {
|
||||
// stream answer recovery (issue #166) on "sessions": the other
|
||||
// endpoints keep their existing error behavior.
|
||||
var dispatchedSseEndpoint: String? = null
|
||||
var gatewayHistoryReconcileRequired = false
|
||||
|
||||
val assistantTimestamp = System.currentTimeMillis()
|
||||
beginTurnCheckpoint(
|
||||
@@ -5440,13 +5475,26 @@ class ChatViewModel : ViewModel() {
|
||||
// interfaceContextPrompt today, so it marks this turn as spoken.
|
||||
// Tag the reply with a "Voice" chip (parity with "Realtime
|
||||
// Agent"); ChatHandler.loadMessageHistory preserves it across
|
||||
// the post-turn history reload.
|
||||
// history and recovery reconciliation.
|
||||
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
|
||||
)
|
||||
)
|
||||
|
||||
val streamDeltas = StreamDeltaCoalescer(
|
||||
scope = viewModelScope,
|
||||
onTextDelta = { delta -> handler.onTextDelta(currentMessageId, delta) },
|
||||
onThinkingDelta = { delta -> handler.onThinkingDelta(currentMessageId, delta) },
|
||||
)
|
||||
activeStreamDeltas = streamDeltas
|
||||
|
||||
fun flushAndReleaseStreamDeltas() {
|
||||
streamDeltas.flushNow()
|
||||
if (activeStreamDeltas === streamDeltas) activeStreamDeltas = null
|
||||
}
|
||||
|
||||
// Shared callbacks for both endpoints
|
||||
val onMessageStartedCb = { serverMsgId: String ->
|
||||
streamDeltas.flushNow()
|
||||
// Replace the placeholder's ID so subsequent deltas/tool calls attach
|
||||
// to it instead of creating a duplicate orphan bubble with streaming dots.
|
||||
// Only replaces empty+streaming messages (the placeholder), not completed turns.
|
||||
@@ -5459,34 +5507,41 @@ class ChatViewModel : ViewModel() {
|
||||
firstTokenNotified = true
|
||||
AppAnalytics.onFirstTokenReceived()
|
||||
}
|
||||
handler.onTextDelta(currentMessageId, delta)
|
||||
streamDeltas.appendText(delta)
|
||||
}
|
||||
val onThinkingDeltaCb = { delta: String ->
|
||||
handler.onThinkingDelta(currentMessageId, delta)
|
||||
streamDeltas.appendThinking(delta)
|
||||
}
|
||||
val onToolCallStartCb = { toolCallId: String, toolName: String ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
// Turn complete — one assistant message finished, but the run may continue
|
||||
val onTurnCompleteCb = {
|
||||
streamDeltas.flushNow()
|
||||
handler.onTurnComplete(currentMessageId)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onCompleteCb = {
|
||||
flushAndReleaseStreamDeltas()
|
||||
// Double-finalize guard: if a straggler completion arrives while
|
||||
// the answer-recovery poller is running, the normal completion
|
||||
// wins — stop the poller before finalizing so the turn can't
|
||||
// finish twice.
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val completedTransport = dispatchedSseEndpoint
|
||||
?: if (activeStreamIsGateway) "gateway" else streamingEndpoint
|
||||
finalizeTurnSideEffects(handler, currentMessageId)
|
||||
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
|
||||
|
||||
@@ -5504,15 +5559,13 @@ class ChatViewModel : ViewModel() {
|
||||
refreshReasoningSettings()
|
||||
}
|
||||
|
||||
// Sessions endpoint doesn't emit structured tool events during streaming —
|
||||
// tool calls are only available as JSON on the stored messages. Reload the
|
||||
// server-authoritative history to get proper message boundaries + tool_calls.
|
||||
// Gateway turns reconcile the same way: live tool events are gated by the
|
||||
// server's display.tool_progress config, so a turn that ran tools silently
|
||||
// (config off, or events lost in a mid-turn rejoin gap) still gets its tool
|
||||
// cards + persisted reasoning right after the turn — not on the next app
|
||||
// restart. By message.complete the server has persisted the turn, so the
|
||||
// REST read is authoritative.
|
||||
// Sessions SSE does not stream every persisted message boundary, so it
|
||||
// still needs the server-authoritative transcript after success. A healthy
|
||||
// Gateway turn is already authoritative in memory through its structured
|
||||
// assistant/reasoning/tool events; reloading the full transcript here would
|
||||
// republish the entire visible list and cause a completion flash. A Gateway
|
||||
// socket rejoin explicitly flags this completion for the same profile-aware
|
||||
// history reconcile because events emitted during the gap may be missing.
|
||||
val sid = handler.currentSessionId.value
|
||||
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
|
||||
// has NO assistant message persisted server-side, so reconciling the
|
||||
@@ -5523,9 +5576,13 @@ class ChatViewModel : ViewModel() {
|
||||
val turnErrored = handler.messages.value
|
||||
.lastOrNull { it.id == currentMessageId }
|
||||
?.badges?.contains("Error") == true
|
||||
if (sid != null && (streamingEndpoint == "sessions" || streamingEndpoint == "gateway")) {
|
||||
if (sid != null && (completedTransport == "sessions" || completedTransport == "gateway")) {
|
||||
viewModelScope.launch {
|
||||
if (!turnErrored) {
|
||||
if (!turnErrored && shouldReloadHistoryAfterSuccessfulTurn(
|
||||
completedTransport,
|
||||
gatewayHistoryReconcileRequired,
|
||||
)
|
||||
) {
|
||||
// Profile-aware read: a gateway turn on a non-default profile
|
||||
// persists into THAT profile's own state.db, so the bare
|
||||
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
|
||||
@@ -5587,6 +5644,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
flushAndReleaseStreamDeltas()
|
||||
val errorSessionId = handler.currentSessionId.value
|
||||
if (intentionallyCancelled) {
|
||||
intentionallyCancelled = false
|
||||
@@ -5943,18 +6001,24 @@ class ChatViewModel : ViewModel() {
|
||||
onToolCallDone = onToolCallDoneCb,
|
||||
onToolCallFailed = onToolCallFailedCb,
|
||||
onToolOutputRisk = { risk ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolOutputRisk(currentMessageId, risk)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
onTurnComplete = onTurnCompleteCb,
|
||||
onReconcileRequired = {
|
||||
gatewayHistoryReconcileRequired = true
|
||||
},
|
||||
onComplete = onCompleteCb,
|
||||
onUsage = onUsageCb,
|
||||
onError = onErrorCb,
|
||||
onToolGenerating = { name ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolGenerating(currentMessageId, name)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
@@ -6076,6 +6140,8 @@ class ChatViewModel : ViewModel() {
|
||||
// false: the Stopped-badge block below finalizes the placeholder
|
||||
// itself (completing it here first would hide it from findLast).
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
@@ -6610,6 +6676,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
flushTurnCheckpointForTeardown()
|
||||
gatewayClient?.setUnsolicitedTurnProvider(null)
|
||||
gatewayClient?.setColdPrewarmSessionReadyListener(null)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Frame-paced publication cadence for streaming text. */
|
||||
internal const val STREAM_DELTA_FRAME_MS = 16L
|
||||
|
||||
private const val STREAM_DELTA_MIN_CHARS_PER_FRAME = 8
|
||||
private const val STREAM_DELTA_MAX_CHARS_PER_FRAME = 48
|
||||
private const val STREAM_DELTA_TARGET_DRAIN_FRAMES = 8
|
||||
|
||||
private enum class StreamDeltaKind {
|
||||
TEXT,
|
||||
THINKING,
|
||||
}
|
||||
|
||||
private data class PendingStreamDelta(
|
||||
val kind: StreamDeltaKind,
|
||||
val content: StringBuilder,
|
||||
)
|
||||
|
||||
/**
|
||||
* Main-thread-confined stream-delta frame pacer.
|
||||
*
|
||||
* Providers often deliver many token events in one scheduler burst, followed
|
||||
* by a network gap. Publishing that burst as one Compose state update makes
|
||||
* text appear in large steps even when rendering itself is fast. This buffer
|
||||
* drains a bounded slice every display-sized interval instead. The slice grows
|
||||
* with backlog, keeping presentation close to real time without returning to
|
||||
* hundreds of full-message republishes per second.
|
||||
*
|
||||
* Adjacent deltas of the same kind retain their bytes and their ordering
|
||||
* relative to thinking/text transitions. Lifecycle boundaries call [flushNow]
|
||||
* so no buffered content can arrive after a tool event, completion,
|
||||
* cancellation, or error has settled the message.
|
||||
*/
|
||||
internal class StreamDeltaCoalescer(
|
||||
private val scope: CoroutineScope,
|
||||
private val onTextDelta: (String) -> Unit,
|
||||
private val onThinkingDelta: (String) -> Unit,
|
||||
private val frameMs: Long = STREAM_DELTA_FRAME_MS,
|
||||
) {
|
||||
private val pending = mutableListOf<PendingStreamDelta>()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
init {
|
||||
require(frameMs >= 0L) { "frameMs must be non-negative" }
|
||||
}
|
||||
|
||||
fun appendText(delta: String) {
|
||||
enqueue(StreamDeltaKind.TEXT, delta)
|
||||
}
|
||||
|
||||
fun appendThinking(delta: String) {
|
||||
enqueue(StreamDeltaKind.THINKING, delta)
|
||||
}
|
||||
|
||||
fun flushNow() {
|
||||
flushJob?.cancel()
|
||||
flushJob = null
|
||||
flushPending()
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
flushJob?.cancel()
|
||||
flushJob = null
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
private fun enqueue(kind: StreamDeltaKind, delta: String) {
|
||||
if (delta.isEmpty()) return
|
||||
|
||||
val tail = pending.lastOrNull()
|
||||
if (tail?.kind == kind) {
|
||||
tail.content.append(delta)
|
||||
} else {
|
||||
pending += PendingStreamDelta(kind, StringBuilder(delta))
|
||||
}
|
||||
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
private fun scheduleFrame() {
|
||||
if (flushJob != null || pending.isEmpty()) return
|
||||
|
||||
flushJob = scope.launch {
|
||||
delay(frameMs)
|
||||
flushJob = null
|
||||
publishFrame()
|
||||
scheduleFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishFrame() {
|
||||
if (pending.isEmpty()) return
|
||||
|
||||
var remainingBudget = streamDeltaFrameBudget(
|
||||
pending.sumOf { it.content.length },
|
||||
)
|
||||
val frame = mutableListOf<Pair<StreamDeltaKind, String>>()
|
||||
|
||||
while (remainingBudget > 0 && pending.isNotEmpty()) {
|
||||
val head = pending.first()
|
||||
val requestedLength = minOf(remainingBudget, head.content.length)
|
||||
val safeLength = head.content.codePointSafePrefixLength(requestedLength)
|
||||
if (safeLength == 0) break
|
||||
|
||||
val content = head.content.substring(0, safeLength)
|
||||
head.content.delete(0, safeLength)
|
||||
remainingBudget -= safeLength
|
||||
if (head.content.isEmpty()) pending.removeAt(0)
|
||||
|
||||
val previous = frame.lastOrNull()
|
||||
if (previous?.first == head.kind) {
|
||||
frame[frame.lastIndex] = head.kind to (previous.second + content)
|
||||
} else {
|
||||
frame += head.kind to content
|
||||
}
|
||||
}
|
||||
|
||||
publish(frame)
|
||||
}
|
||||
|
||||
private fun flushPending() {
|
||||
if (pending.isEmpty()) return
|
||||
|
||||
// Copy before invoking callbacks so a callback that indirectly queues
|
||||
// more work starts a fresh window instead of mutating this drain.
|
||||
val batch = pending.map { it.kind to it.content.toString() }
|
||||
pending.clear()
|
||||
publish(batch)
|
||||
}
|
||||
|
||||
private fun publish(batch: List<Pair<StreamDeltaKind, String>>) {
|
||||
batch.forEach { (kind, content) ->
|
||||
when (kind) {
|
||||
StreamDeltaKind.TEXT -> onTextDelta(content)
|
||||
StreamDeltaKind.THINKING -> onThinkingDelta(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun streamDeltaFrameBudget(pendingChars: Int): Int {
|
||||
if (pendingChars <= 0) return 0
|
||||
val adaptiveBudget =
|
||||
(pendingChars + STREAM_DELTA_TARGET_DRAIN_FRAMES - 1) /
|
||||
STREAM_DELTA_TARGET_DRAIN_FRAMES
|
||||
return adaptiveBudget
|
||||
.coerceIn(STREAM_DELTA_MIN_CHARS_PER_FRAME, STREAM_DELTA_MAX_CHARS_PER_FRAME)
|
||||
.coerceAtMost(pendingChars)
|
||||
}
|
||||
|
||||
private fun StringBuilder.codePointSafePrefixLength(requestedLength: Int): Int {
|
||||
if (requestedLength <= 0) return 0
|
||||
if (requestedLength >= length) return length
|
||||
|
||||
return if (
|
||||
Character.isHighSurrogate(this[requestedLength - 1]) &&
|
||||
Character.isLowSurrogate(this[requestedLength])
|
||||
) {
|
||||
requestedLength - 1
|
||||
} else {
|
||||
requestedLength
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1054,6 +1054,9 @@
|
||||
<string name="appearance_language_desc">选择 Hermes-Relay 使用的语言。跟随系统会使用设备的语言设置。</string>
|
||||
<string name="appearance_language_system">跟随系统</string>
|
||||
<string name="appearance_language_english">English</string>
|
||||
<string name="appearance_language_german">Deutsch</string>
|
||||
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
|
||||
<string name="appearance_language_japanese">日本語</string>
|
||||
<string name="appearance_language_simplified_chinese">简体中文</string>
|
||||
<string name="appearance_language_spanish">Español</string>
|
||||
<string name="appearance_appearance">外观</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2812,6 +2812,9 @@
|
||||
<string name="appearance_language_desc">Elige el idioma de Hermes-Relay. La opción predeterminada del sistema sigue la configuración del dispositivo.</string>
|
||||
<string name="appearance_language_system">Predeterminado del sistema</string>
|
||||
<string name="appearance_language_english">English</string>
|
||||
<string name="appearance_language_german">Deutsch</string>
|
||||
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
|
||||
<string name="appearance_language_japanese">日本語</string>
|
||||
<string name="appearance_language_simplified_chinese">简体中文</string>
|
||||
<string name="appearance_language_spanish">Español</string>
|
||||
<string name="chat_profile_history_unavailable">No se pudo acceder al historial de conversaciones del perfil activo. Vuelve a conectarte e inténtalo de nuevo.</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1057,6 +1057,9 @@
|
||||
<string name="appearance_language_desc">Choose the language used by Hermes-Relay. System default follows your device setting.</string>
|
||||
<string name="appearance_language_system">System default</string>
|
||||
<string name="appearance_language_english">English</string>
|
||||
<string name="appearance_language_german">Deutsch</string>
|
||||
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
|
||||
<string name="appearance_language_japanese">日本語</string>
|
||||
<string name="appearance_language_simplified_chinese">简体中文</string>
|
||||
<string name="appearance_language_spanish">Español</string>
|
||||
<string name="appearance_appearance">Appearance</string>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="zh-Hans" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="pt-BR" />
|
||||
<locale android:name="zh-Hans" />
|
||||
</locale-config>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="a11y_service_label">Hermes-Bridge Dev</string>
|
||||
<string name="notification_companion_label">assistente de notificações do Hermes Dev</string>
|
||||
<string name="a11y_description_sideload">O Hermes Bridge concede ao agente acesso completo de leitura e gravação no celular para controle sem usar as mãos por voz e visão. Todas as ações são registradas no Registro de atividades, e as ações destrutivas exigem sua confirmação.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sideload flavor strings.
|
||||
|
||||
`a11y_description_sideload` is the full-surface description users see when
|
||||
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
|
||||
flavor we can be explicit about voice + vision + full device control here:
|
||||
the sideload user is assumed to be a power user who installed an APK by
|
||||
hand, not a Play Store customer.
|
||||
|
||||
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
|
||||
flag any mention of "full read/write access" or "hands-free control" as
|
||||
outside the declared use case.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="a11y_service_label">Hermes-Bridge Dev</string>
|
||||
<string name="notification_companion_label">Hermes Dev-Benachrichtigungsassistent</string>
|
||||
<string name="a11y_description_sideload">Hermes Bridge gewährt dem Agenten vollständigen Lese- und Schreibzugriff auf das Smartphone zur freihändigen Steuerung per Sprache und Bilderkennung. Alle Aktionen werden im Aktivitätsprotokoll erfasst; destruktive Aktionen erfordern deine Bestätigung.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sideload flavor strings.
|
||||
|
||||
`a11y_description_sideload` is the full-surface description users see when
|
||||
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
|
||||
flavor we can be explicit about voice + vision + full device control here:
|
||||
the sideload user is assumed to be a power user who installed an APK by
|
||||
hand, not a Play Store customer.
|
||||
|
||||
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
|
||||
flag any mention of "full read/write access" or "hands-free control" as
|
||||
outside the declared use case.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="a11y_service_label">Hermes-Bridge Dev</string>
|
||||
<string name="notification_companion_label">Hermes Dev 通知コンパニオン</string>
|
||||
<string name="a11y_description_sideload">Hermes Bridge は、エージェントに電話機への完全な読み取り/書き込みアクセス権を与え、音声と視覚によるハンズフリー制御を可能にします。すべてのアクションはアクティビティ ログに記録され、破壊的なアクションにはユーザーの確認が必要です。</string>
|
||||
</resources>
|
||||
@@ -26,6 +26,10 @@ class AppLanguageTest {
|
||||
|
||||
@Test
|
||||
fun addedLocaleTagsResolveToTheirPickerOptions() {
|
||||
assertEquals(AppLanguage.GERMAN, AppLanguage.fromLanguageTags("de-DE"))
|
||||
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-BR"))
|
||||
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-PT"))
|
||||
assertEquals(AppLanguage.JAPANESE, AppLanguage.fromLanguageTags("ja-JP"))
|
||||
assertEquals(AppLanguage.SPANISH, AppLanguage.fromLanguageTags("es-MX"))
|
||||
}
|
||||
|
||||
@@ -33,6 +37,9 @@ class AppLanguageTest {
|
||||
fun languageOptionsProduceExpectedLocaleLists() {
|
||||
assertTrue(AppLanguage.SYSTEM_DEFAULT.toLocaleList().isEmpty)
|
||||
assertEquals("en", AppLanguage.ENGLISH.toLocaleList().toLanguageTags())
|
||||
assertEquals("de", AppLanguage.GERMAN.toLocaleList().toLanguageTags())
|
||||
assertEquals("pt-BR", AppLanguage.BRAZILIAN_PORTUGUESE.toLocaleList().toLanguageTags())
|
||||
assertEquals("ja", AppLanguage.JAPANESE.toLocaleList().toLanguageTags())
|
||||
assertEquals("zh-Hans", AppLanguage.SIMPLIFIED_CHINESE.languageTag)
|
||||
assertEquals("es", AppLanguage.SPANISH.toLocaleList().toLanguageTags())
|
||||
assertEquals(
|
||||
|
||||
@@ -1046,6 +1046,11 @@ class ChatHandlerTest {
|
||||
// Server ids adopted onto the live rows.
|
||||
assertEquals("srv-1", user.id)
|
||||
assertEquals("srv-2", assistant.id)
|
||||
// Compose identity stays on the live rows. A same-count post-turn
|
||||
// reload must not remove/reinsert the two bubbles just because their
|
||||
// authoritative ids arrived.
|
||||
assertEquals("uuid-user", user.uiKey)
|
||||
assertEquals("uuid-assistant", assistant.uiKey)
|
||||
// State carried by id, in place.
|
||||
assertEquals(1, user.attachments.size)
|
||||
assertEquals("outb64", user.attachments[0].content)
|
||||
@@ -1056,6 +1061,39 @@ class ChatHandlerTest {
|
||||
assertFalse(assistant.isStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_listRebuildPreservesMatchedTailUiKey() {
|
||||
// Some persisted turns rebuild one live streaming bubble into several
|
||||
// server rows (for example, restored message boundaries/tool output).
|
||||
// The reconciled tail must retain its UI identity even while new rows
|
||||
// are inserted around it, otherwise LazyColumn loses the viewport
|
||||
// anchor on a long answer.
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "uuid-live-tail",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "final chunk",
|
||||
timestamp = 3L,
|
||||
isStreaming = false,
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "srv-user", role = "user", content = JsonPrimitive("question"), timestamp = 1.0),
|
||||
MessageItem(id = "srv-prefix", role = "assistant", content = JsonPrimitive("earlier chunk"), timestamp = 2.0),
|
||||
MessageItem(id = "srv-tail", role = "assistant", content = JsonPrimitive("final chunk"), timestamp = 3.0),
|
||||
)
|
||||
)
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(3, messages.size)
|
||||
assertEquals("uuid-live-tail", messages.single { it.id == "srv-tail" }.uiKey)
|
||||
assertEquals("srv-user", messages.single { it.id == "srv-user" }.uiKey)
|
||||
assertEquals("srv-prefix", messages.single { it.id == "srv-prefix" }.uiKey)
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
||||
// Once the first reload adopts the server id, subsequent reloads match by
|
||||
|
||||
@@ -382,6 +382,7 @@ class GatewayChatClientTest {
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||
val reconcileRequests = AtomicInteger(0)
|
||||
val completeLatch = CountDownLatch(1)
|
||||
val preflightFailures = ConcurrentLinkedQueue<String>()
|
||||
|
||||
@@ -394,6 +395,7 @@ class GatewayChatClientTest {
|
||||
onToolCallDone = { id, result -> toolDone += id to result },
|
||||
onToolCallFailed = { _, _ -> },
|
||||
onTurnComplete = { },
|
||||
onReconcileRequired = { reconcileRequests.incrementAndGet() },
|
||||
onComplete = { completeLatch.countDown() },
|
||||
onUsage = { it?.let(usages::add) },
|
||||
onError = { errors += it; completeLatch.countDown() },
|
||||
@@ -503,6 +505,7 @@ class GatewayChatClientTest {
|
||||
assertEquals(listOf("Hi!"), r.textDeltas.toList())
|
||||
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
|
||||
assertEquals(5, r.usages.firstOrNull()?.resolvedInputTokens)
|
||||
assertEquals(0, r.reconcileRequests.get())
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
@@ -978,6 +981,7 @@ class GatewayChatClientTest {
|
||||
|
||||
assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("after rejoin"), r.textDeltas.toList())
|
||||
assertEquals(1, r.reconcileRequests.get())
|
||||
assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty())
|
||||
// The fix's core invariant: a mid-turn rejoin must NEVER session.resume.
|
||||
assertTrue(
|
||||
|
||||
@@ -32,6 +32,7 @@ class GatewayEventMapperTest {
|
||||
val sessionIds = mutableListOf<String>()
|
||||
var starts = 0
|
||||
var turnCompletes = 0
|
||||
var reconcileRequests = 0
|
||||
var completes = 0
|
||||
var usage: UsageInfo? = null
|
||||
var usageCalls = 0
|
||||
@@ -47,6 +48,7 @@ class GatewayEventMapperTest {
|
||||
onToolCallFailed = { id, err -> toolFails += id to err },
|
||||
onToolOutputRisk = { toolOutputRisks += it },
|
||||
onTurnComplete = { turnCompletes++ },
|
||||
onReconcileRequired = { reconcileRequests++ },
|
||||
onComplete = { completes++ },
|
||||
onUsage = { usage = it; usageCalls++ },
|
||||
onError = { errors += it },
|
||||
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownStreamingParserTest {
|
||||
|
||||
@Test
|
||||
fun activeParagraph_remainsRawUntilAStableBlockBoundary() {
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text("A paragraph still arriving")),
|
||||
parseStreamingMarkdownBlocks("A paragraph still arriving"),
|
||||
)
|
||||
|
||||
// A single newline is a Markdown soft break, not a stable block split.
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text("line one\nline two")),
|
||||
parseStreamingMarkdownBlocks("line one\nline two"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLine_promotesSettledPrefixToRealMarkdown() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("## Stable heading"),
|
||||
StreamingMarkdownBlock.Text("- one\n- two\n\nTail still arriving"),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"## Stable heading\n\n- one\n- two\n\nTail still arriving",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openFence_keepsBlankLinesInsideTheActiveCodeBlock() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("Intro"),
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "kotlin",
|
||||
code = "val first = 1\n\nval second = 2",
|
||||
),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"Intro\n\n```kotlin\nval first = 1\n\nval second = 2",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closedFence_staysOnTheStreamingCodeSurfaceUntilFinal() {
|
||||
val blocks = parseStreamingMarkdownBlocks(
|
||||
"```kotlin\nval answer = 42\n```\n\nNext paragraph",
|
||||
)
|
||||
|
||||
assertEquals(2, blocks.size)
|
||||
assertEquals(
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "kotlin",
|
||||
code = "val answer = 42",
|
||||
),
|
||||
blocks[0],
|
||||
)
|
||||
assertEquals(StreamingMarkdownBlock.Text("Next paragraph"), blocks[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longerFence_isNotClosedByShorterFenceInsideCode() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "markdown",
|
||||
code = "```\ninside\n```",
|
||||
),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"````markdown\n```\ninside\n```\n````",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun table_staysRawUntilTheFinalCommonMarkParse() {
|
||||
val content = "| Name | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompleteTableDelimiter_doesNotPrematurelyPromoteTheTable() {
|
||||
val content = "| Name | Value |\n| --- | --"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapedHeaderPipe_doesNotMakeTheTableLookSettled() {
|
||||
val content = "| Name \\| alias | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listAndContinuation_stayTogetherUntilFinal() {
|
||||
val content = "- first paragraph\n\n continuation\n- second"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lazyBlockQuoteContinuation_isNeverSplitIntoASettledPrefix() {
|
||||
val content = "> quoted line\n\nlazy continuation"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crlfInput_isNormalizedWithoutLeakingCarriageReturns() {
|
||||
val blocks = parseStreamingMarkdownBlocks("First\r\n\r\nSecond")
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("First"),
|
||||
StreamingMarkdownBlock.Text("Second"),
|
||||
),
|
||||
blocks,
|
||||
)
|
||||
assertTrue(blocks.none { it.toString().contains('\r') })
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class StreamingMarkdownContentTest {
|
||||
@Test
|
||||
fun liveText_discardsOnlyLeadingBlankLines() {
|
||||
assertEquals(
|
||||
"First line\n\nSecond line",
|
||||
"\n\r\n \t\nFirst line\n\nSecond line".withoutLeadingBlankLines(),
|
||||
)
|
||||
assertEquals(
|
||||
" indented code",
|
||||
"\n\n indented code".withoutLeadingBlankLines(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatScrollSnapshotTest {
|
||||
@Test
|
||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val complete = snapshot(isStreaming = false)
|
||||
|
||||
assertTrue(complete.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tail replacement is not mistaken for stream completion`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val replaced = snapshot(isStreaming = false, lastMessageUiKey = "replacement-tail")
|
||||
|
||||
assertFalse(replaced.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message list rebuild is not mistaken for stream completion`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val rebuilt = snapshot(isStreaming = false, messageCount = 10)
|
||||
|
||||
assertFalse(rebuilt.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary streaming growth is not a completion`() {
|
||||
val before = snapshot(contentLength = 4_000, isStreaming = true)
|
||||
val after = snapshot(contentLength = 4_500, isStreaming = true)
|
||||
|
||||
assertFalse(after.isCompletionAfter(before))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting a stream is not a completion`() {
|
||||
val idle = snapshot(isStreaming = false)
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
|
||||
assertFalse(streaming.isCompletionAfter(idle))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server id adoption remains an observable tail change`() {
|
||||
val local = snapshot(isStreaming = false)
|
||||
val reconciled = local.copy(lastMessageId = "assistant-server-id")
|
||||
|
||||
assertNotEquals(local, reconciled)
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
contentLength: Int = 12_000,
|
||||
isStreaming: Boolean,
|
||||
messageCount: Int = 8,
|
||||
lastMessageUiKey: String = "assistant-ui-key",
|
||||
) = ChatScrollSnapshot(
|
||||
messageCount = messageCount,
|
||||
lastMessageId = "assistant-live-id",
|
||||
lastMessageUiKey = lastMessageUiKey,
|
||||
lastContentLength = contentLength,
|
||||
lastThinkingLength = 1_200,
|
||||
lastToolCallCount = 2,
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatTurnCompletionPolicyTest {
|
||||
@Test
|
||||
fun `successful Sessions turns reload persisted message boundaries`() {
|
||||
assertTrue(shouldReloadHistoryAfterSuccessfulTurn("sessions", gatewayReconcileRequired = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `healthy uninterrupted Gateway turns keep their live transcript`() {
|
||||
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("gateway", gatewayReconcileRequired = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Gateway turns with a socket gap reload potentially missed events`() {
|
||||
assertTrue(shouldReloadHistoryAfterSuccessfulTurn("gateway", gatewayReconcileRequired = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stateless structured transports do not reload session history`() {
|
||||
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("runs", gatewayReconcileRequired = false))
|
||||
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("completions", gatewayReconcileRequired = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StreamDeltaCoalescerTest {
|
||||
@Test
|
||||
fun `token burst drains across display paced frames`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
repeat(100) { coalescer.appendText("x") }
|
||||
runCurrent()
|
||||
|
||||
assertTrue(textBatches.isEmpty())
|
||||
advanceTimeBy(STREAM_DELTA_FRAME_MS - 1)
|
||||
runCurrent()
|
||||
assertTrue(textBatches.isEmpty())
|
||||
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(listOf("x".repeat(streamDeltaFrameBudget(100))), textBatches)
|
||||
assertTrue(textBatches.joinToString("").length < 100)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals("x".repeat(100), textBatches.joinToString(""))
|
||||
assertTrue(textBatches.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text and thinking transitions preserve stream order`() = runTest {
|
||||
val events = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = { events += "text:$it" },
|
||||
onThinkingDelta = { events += "thinking:$it" },
|
||||
)
|
||||
|
||||
coalescer.appendThinking("plan ")
|
||||
coalescer.appendThinking("first")
|
||||
coalescer.appendText("answer ")
|
||||
coalescer.appendText("next")
|
||||
coalescer.appendThinking("tail")
|
||||
coalescer.flushNow()
|
||||
|
||||
assertEquals(
|
||||
listOf("thinking:plan first", "text:answer next", "thinking:tail"),
|
||||
events,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal flush cancels the scheduled duplicate`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
coalescer.appendText("complete before timer")
|
||||
coalescer.flushNow()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("complete before timer"), textBatches)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frame pacing never separates a surrogate pair`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
coalescer.appendText("1234567🚀tail")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("1234567🚀tail", textBatches.joinToString(""))
|
||||
assertTrue(textBatches.none { it.endsWith('\uD83D') })
|
||||
assertTrue(textBatches.none { it.startsWith('\uDE80') })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard drops pending content`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
coalescer.appendText("old connection")
|
||||
coalescer.discard()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(textBatches.isEmpty())
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -369,7 +369,7 @@ Key data classes: `MessageEvent` (inbound), `SendResult` (outbound), `SessionSou
|
||||
- **Upstream doesn't solve this on the HTTP API surface.** Verification against `~/AppData/Local/Temp/hermes-agent/gateway/platforms/api_server.py` shows `APIServerAdapter.send()` is an explicit no-op with the comment `"API server uses HTTP request/response, not send()"`. `_write_sse_chat_completion` (api_server.py:651-757) streams raw `stream_q` deltas straight into SSE `content` chunks — it never invokes `extract_media()` and never routes deltas through `GatewayStreamConsumer` (which would at least strip `MEDIA:` tags via `_MEDIA_RE` at `stream_consumer.py:188`). The upstream `extract_media()` / `send_document()` calls at `gateway/run.py:4570`, `4747`, `4349` are only reachable from **non-streaming** paths (background tasks, cron, batch) and push-style platform adapters (Telegram, Feishu, WeChat, Slack), all of which override `send_document` with real platform APIs. The pull-based HTTP adapter inherits the base class default, which falls back to `self.send(chat_id, f"📎 File: {file_path}")` — which is the no-op. So `MEDIA:/tmp/...` has always passed through our chat stream as literal text.
|
||||
- **Inline base64 in tool output was the obvious alternative but blows up LLM context.** A 1280×720 JPEG is ~135 KB base64, and every subsequent turn's context window has to re-ingest the bytes. Scales badly for video or multiple attachments per turn. Opaque tokens are ~25 chars and add essentially zero context cost.
|
||||
- **No upstream PR in scope.** Fixing this properly upstream would mean implementing `send_document` on `APIServerAdapter` (likely via a side-channel SSE event or a new attachment field on the chat-completion chunk shape). That's a community-scoped API change, and user explicitly wanted an in-plugin workaround, not a fork.
|
||||
- **Our relay is already the right place.** The plugin's relay server (`plugin/relay/server.py`) is a service we already own, already has HTTP routes (`/health`, `/pairing`, `/pairing/register`), already uses `SessionManager` for bearer-auth'd channels, and already lives on the phone's trust boundary (paired via the same QR). Adding file-serving doesn't create a new security surface or a new credential store — it reuses both.
|
||||
- **Our relay is already the right place.** The plugin's relay server (`plugin/relay/server.py`) is a service we already own, already has HTTP routes (`/health`, `/pairing/register`), already uses `SessionManager` for bearer-auth'd channels, and already lives on the phone's trust boundary (paired via the same QR). Adding file-serving doesn't create a new security surface or a new credential store — it reuses both.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -465,7 +465,8 @@ 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 is host-authoritative** — 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. If host metadata is absent, the relay uses bounded server defaults; network clients never author session lifetime or grants. The legacy anonymous `POST /pairing` code-mint route is intentionally not registered, so every accepted code originates from a loopback-only operator flow.
|
||||
- **Bearer session-policy changes are monotonic and self-only** — a Relay bearer may use `PATCH /sessions/{token_prefix}` only for its own token and only to shorten its session or grants. It cannot add grant names, lengthen a grant, switch to never-expire, or modify another session. Those authority-increasing changes require a fresh operator-approved pairing flow.
|
||||
- **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.
|
||||
|
||||
@@ -846,9 +847,9 @@ First attempt parsed a top-level `profiles:` / `agents:` list from one YAML. Tha
|
||||
|
||||
The hermes-agent dashboard exposes `/api/config` and `/api/skills` via `hermes_cli/web_server.py` — a separate loopback-only web server from the chat API at `:8642`. Two problems if we proxied through the dashboard:
|
||||
1. **No profile scoping.** The dashboard's `/api/config` operates on the active profile only; there's no way to read another profile's config without switching first. Our relay already has the layout knowledge (`_load_profiles` scans the tree); duplicating that as "switch profile, read, switch back" is fragile and racy.
|
||||
2. **Secrets leakage risk.** `config.yaml` never holds credentials (those live in `~/.hermes/.env` + `~/.hermes/auth.json`), but proxying a general-purpose config endpoint invites future callers to pick up sensitive fields. A purpose-built read route keeps the attack surface small and the shape explicit — the response is `{profile, path, config, readonly: true}`, with the `readonly` flag part of the contract so clients can't silently assume write support.
|
||||
2. **Secrets leakage risk.** Credentials normally live in `~/.hermes/.env` or `~/.hermes/auth.json`, but Hermes also supports some credentials in `config.yaml` and extensions may add their own sensitive fields. A purpose-built read route therefore keeps the remote shape explicit: paired remote clients receive only `description` and `model.default`, while loopback operator callers can inspect the complete parsed file. The response remains `{profile, path, config, readonly: true}`, with `path: "config.yaml"` remotely so host layout is not disclosed.
|
||||
|
||||
Both endpoints trust the same boundary as every other phone-facing relay route: bearer-auth for remote callers, loopback for in-process dashboard proxy calls. `.env` and `auth.json` are **never** read or returned by these routes.
|
||||
Both endpoints trust the same boundary as every other phone-facing relay route: bearer-auth for remote callers, loopback for in-process dashboard proxy calls. The config endpoint additionally enforces an explicit remote response schema rather than key-name redaction, so new or nested extension sections cannot silently become public. `.env` and `auth.json` are **never** read or returned by these routes.
|
||||
|
||||
**Why read-only in v0.7:**
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"canonical_locale": "en",
|
||||
"verification_definitions": {
|
||||
"canonical": "English source text that defines product meaning.",
|
||||
@@ -8,6 +8,30 @@
|
||||
"verified": "Comprehensively reviewed in context by a fluent contributor across the shipped surface."
|
||||
},
|
||||
"locales": {
|
||||
"de": {
|
||||
"native_name": "Deutsch",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "de",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"en": {
|
||||
"native_name": "English",
|
||||
"verification": "canonical",
|
||||
@@ -15,7 +39,8 @@
|
||||
"surfaces": {
|
||||
"android": "canonical",
|
||||
"readme": "canonical",
|
||||
"user_docs": "canonical"
|
||||
"user_docs": "canonical",
|
||||
"website": "canonical"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
@@ -23,28 +48,96 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "fe2f099227cc377e298aac67ae402099e252e5474eeab230ecde119e117149ae",
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "english-fallback"
|
||||
}
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "es",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"ja": {
|
||||
"native_name": "日本語",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "ja",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"pt-BR": {
|
||||
"native_name": "Português (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "pt-BR",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"zh-Hans": {
|
||||
"native_name": "简体中文",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "fe2f099227cc377e298aac67ae402099e252e5474eeab230ecde119e117149ae",
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "maintained-summary",
|
||||
"user_docs": "core-pages"
|
||||
}
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "zh-CN",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-7
@@ -1,8 +1,8 @@
|
||||
# Localization
|
||||
|
||||
English is the canonical product language. Android ships Simplified Chinese and
|
||||
Spanish catalogs; additional languages can be added without changing the runtime
|
||||
architecture.
|
||||
English is the canonical product language. Android also ships Brazilian
|
||||
Portuguese, German, Japanese, Simplified Chinese, and Spanish catalogs;
|
||||
additional languages can be added without changing the runtime architecture.
|
||||
|
||||
Translation coverage and linguistic verification are separate. Shipped locale
|
||||
status is recorded in `docs/localization-status.json` as `ai-translated`,
|
||||
@@ -11,10 +11,10 @@ technical gates pass; the status must not imply human review that did not occur.
|
||||
See `docs/translation-playbook.md` for the required translation and critique
|
||||
workflow.
|
||||
|
||||
Users can switch between System default, English, Spanish, and Simplified Chinese from
|
||||
Settings → Appearance → Language. The picker stays synchronized with Android's
|
||||
per-app language setting; Android 12 and lower use AppCompat's automatic locale
|
||||
storage.
|
||||
Users can switch between System default, English, Brazilian Portuguese, German,
|
||||
Japanese, Spanish, and Simplified Chinese from Settings → Appearance → Language.
|
||||
The picker stays synchronized with Android's per-app language setting; Android
|
||||
12 and lower use AppCompat's automatic locale storage.
|
||||
|
||||
## Android resource contract
|
||||
|
||||
@@ -92,6 +92,62 @@ should link to the canonical English page rather than copying stale content.
|
||||
status. README and user-documentation translations may follow app translation;
|
||||
maintainer `docs/` and ADRs remain canonical English.
|
||||
|
||||
The public documentation currently localizes a deliberately bounded first-run
|
||||
set for every Android locale:
|
||||
|
||||
- documentation home;
|
||||
- Quick Start;
|
||||
- condensed Installation & Setup;
|
||||
- release-track choice;
|
||||
- symptom-first Troubleshooting.
|
||||
|
||||
Fast-moving API, architecture, security, CLI, and operator references remain
|
||||
canonical English and are linked from localized pages instead of copied. Each
|
||||
localized page declares `translation_status` and `canonical_source` in its
|
||||
frontmatter. `docs_source_sha256` in the status registry records the exact
|
||||
English page revision used for every locale.
|
||||
|
||||
Validate localized documentation and links with:
|
||||
|
||||
```bash
|
||||
python scripts/check-user-docs-locales.py
|
||||
```
|
||||
|
||||
After intentionally refreshing all five locale versions of a changed English
|
||||
core page, record the new canonical hashes with:
|
||||
|
||||
```bash
|
||||
python scripts/check-user-docs-locales.py --refresh
|
||||
```
|
||||
|
||||
The validator rejects stale source hashes, missing pages, broken internal
|
||||
links, unbalanced code fences, and translated or invented executable lines.
|
||||
VitePress runs this gate automatically before development and production builds.
|
||||
|
||||
## Marketing website
|
||||
|
||||
The product site ships the same locale set under `/de/`, `/es/`, `/ja/`,
|
||||
`/pt-BR/`, and `/zh-CN/`. Marketing copy, navigation, accessibility labels, and
|
||||
page metadata are localized. Product screenshots, command examples, and live UI
|
||||
recreations remain unchanged so they continue to represent the shipped product.
|
||||
|
||||
Validate the typed copy dictionaries and their English-source freshness with:
|
||||
|
||||
```bash
|
||||
python scripts/check-website-locales.py
|
||||
```
|
||||
|
||||
After reviewing every marketing translation against an intentional English copy
|
||||
change, record the new source hash with:
|
||||
|
||||
```bash
|
||||
python scripts/check-website-locales.py --refresh
|
||||
```
|
||||
|
||||
The Astro development, check, and production-build commands run this gate
|
||||
automatically. Locale routes publish their own canonical URL, language metadata,
|
||||
alternate-language links, and sitemap entry.
|
||||
|
||||
## Translation corrections and pull requests
|
||||
|
||||
Keep translation PRs scoped to one locale or one clearly described catalog
|
||||
|
||||
+2
-2
@@ -127,13 +127,13 @@ cell "identical").
|
||||
### Editor validation (JSON Schema)
|
||||
|
||||
A JSON Schema for this manifest is published at
|
||||
`https://codename-11.github.io/hermes-relay/pet.schema.json` (source of truth:
|
||||
`https://hermes-relay.dev/docs/pet.schema.json` (source of truth:
|
||||
`user-docs/public/pet.schema.json`). Add it as the first key of a `pet.json` for
|
||||
editor autocomplete and inline validation — and for an AI agent to lint its own
|
||||
output against:
|
||||
|
||||
```json
|
||||
{ "$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json", "id": "blob", "states": { "idle": { "frames": ["idle.png"], "fps": 6 } } }
|
||||
{ "$schema": "https://hermes-relay.dev/docs/pet.schema.json", "id": "blob", "states": { "idle": { "frames": ["idle.png"], "fps": 6 } } }
|
||||
```
|
||||
|
||||
The `$schema` key is an unknown field to the loader and is silently ignored
|
||||
|
||||
@@ -291,13 +291,12 @@ 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 pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
|
||||
| `/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 SessionManager's bounded defaults are used. Client-supplied policy in the WebSocket auth envelope is never authoritative. 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. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
|
||||
| `/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`. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired device can revoke any other — see ADR 15 for the trade-off rationale. |
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd. Update a paired device's session TTL and/or per-channel grants in place. Body: `{"ttl_seconds": 2592000}` (extend only) or `{"grants": {"terminal": 604800}}` (grants only) or both. `ttl_seconds = 0` means never-expire. **Semantics: TTL restarts the clock from now** — "extend by 30 days" = "30 days from now", not "old expiry + 30 days". If `grants` is omitted but `ttl_seconds` is provided and shorter than the existing expiry, existing grants are automatically clamped to the new session lifetime (no grant outlives its session). Returns 200 with the updated `{expires_at, grants}`; 400 on missing/invalid body; 404 on prefix miss; 409 on ambiguous prefix. Backs the Android Paired Devices "Extend" button. |
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{"ttl_seconds": 3600}`, `{"grants": {"terminal": 600}}`, or both may shorten the caller's current session policy. A bearer cannot target another session, extend its lifetime, add or lengthen grants, or change a finite expiry to never-expire; authority-increasing changes require a fresh operator-approved pairing flow. Omitted grants retain their existing absolute ceilings and are clamped if the parent session is shortened. Returns 200 with the reduced `{expires_at, grants}`; 400 on missing/invalid or unknown grants; 403 on cross-session targets or policy expansion; 404 on prefix miss; 409 on ambiguous prefix. |
|
||||
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
|
||||
| `/media/register` | POST | **Loopback only.** Register a file path with the in-memory `MediaRegistry` and receive an opaque token. Used by host-local tools (`android_screenshot` etc.) to make a file fetchable by the paired phone without leaking the filesystem path. Request body: `{"path": "/abs/path", "content_type": "image/jpeg", "file_name": "screenshot.jpg"}`. Response: `{"ok": true, "token": "<url-safe-16>", "expires_at": <unix>}`. Returns 403 for non-loopback callers, 400 on validation failure (relative path, missing file, oversized, outside allowed roots, etc). Path sandboxing is enforced server-side — see ADR 14. |
|
||||
| `/media/upload` | POST | Bearer-auth'd small upload endpoint for phone-originated media. Accepts JSON `{file_name, content_type, content}` where `content` is base64 and registers the decoded bytes with the media registry. |
|
||||
@@ -322,6 +321,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/voice/realtime-agent/session` | POST | Creates a brokered Realtime Agent session bound to the active profile, optional Hermes chat session id, provider/model/voice/sample-rate, auth principal, and event log path. |
|
||||
| `/voice/realtime-agent/{session_id}` | GET websocket | Experimental broker websocket. For provider-native xAI or OpenAI sessions, Android sends `session.start`, `input_audio.append`, `input_audio.commit` without transcript text, `playback.drained`, `response.cancel`, `hermes.confirm`, and `session.close`. The relay streams PCM to the provider, normalizes transcript/audio/function-call events, brokers only the approved Hermes functions, and sends input transcript events, Hermes session/tool/confirmation state, provider PCM as `voice.output_audio.delta`, and final `voice.response.done`. Hermes remains the owner of tools, memory, transcript persistence, Android bridge safety, confirmations, and cancellation. |
|
||||
| `/bridge/activity` | GET | **Loopback only.** Returns the `BridgeHandler.recent_commands` ring buffer (max 100 entries) as `{"activity": [ {request_id, method, path, params, sent_at, response_status, result_summary, error, decision}, ... ]}` — newest first. Query param: `?limit=N` (1–500, default 100) caps the response size. `params` is redacted for any key in `{password, token, secret, otp, bearer}`; `decision` is one of `pending` / `executed` / `blocked` / `confirmed` / `timeout` / `error`. 403 for non-loopback callers. Consumed by the dashboard plugin's Bridge Activity tab. |
|
||||
| Device Control routes (`/screen`, `/tap`, `/type`, and peers) | GET/POST | Require `Authorization: Bearer <session_token>` and an active `bridge` grant before any request data is forwarded to a connected Android client. Host tools supply the token through `ANDROID_BRIDGE_TOKEN`; loopback callers do not bypass this gate. |
|
||||
| `/media/inspect` | GET | **Loopback only.** Returns `{"media": [ {token, file_name, content_type, size, created_at, expires_at, last_accessed, is_expired}, ... ]}` — `MediaRegistry.list_all()` snapshot, newest first. Absolute file paths are **never** included — only `file_name` (basename). Query param: `?include_expired=true` includes evicted entries (default false, hides them). 403 for non-loopback callers. Consumed by the dashboard plugin's Media Inspector tab. |
|
||||
| `/relay/info` | GET | Aggregate status and capability contract. Loopback dashboard calls may omit auth; remote callers require a paired-device bearer. Returns backward-compatible `version` plus `plugin_version`, `protocol_version`, stable `capabilities`, per-profile `relay_state`, counters, and `health`. |
|
||||
| `/relay/security` | GET/PATCH | **Loopback only.** Runtime security toggles for local operators, `hermes relay insecure-api-key`, and `hermes-relay insecure-api-key`. `GET` returns `{"allow_insecure_api_bearer": false, "trust_proxy_headers": false, "scope": "runtime"}`. `PATCH {"allow_insecure_api_bearer": true}` enables plain-LAN API-key voice auth immediately for the running relay; `false` disables it. This is not persisted across restarts. |
|
||||
|
||||
+5
-5
@@ -88,7 +88,7 @@ Connection lifecycle, auth, keepalive.
|
||||
|
||||
| Type | Direction | Payload |
|
||||
|------|-----------|---------|
|
||||
| `auth` (pairing mode) | App → Server | `{ pairing_code, ttl_seconds?, grants?, device_name, device_id }` — `ttl_seconds` / `grants` come from the phone's TTL picker dialog; host metadata wins over phone metadata when both are present |
|
||||
| `auth` (pairing mode) | App → Server | `{ pairing_code, ttl_seconds?, grants?, device_name, device_id }` — `ttl_seconds` / `grants` remain in the wire shape for client compatibility, but only policy attached by a loopback-only host flow is authoritative; missing host metadata uses bounded server defaults |
|
||||
| `auth` (session mode) | App → Server | `{ session_token, device_name, device_id }` — ttl/grants are not re-sent; server keeps the grant table keyed on the original pair |
|
||||
| `auth.ok` | Server → App | `{ session_token, server_version, profiles[], expires_at, grants, transport_hint }` — see below |
|
||||
| `auth.fail` | Server → App | `{ reason }` |
|
||||
@@ -300,6 +300,7 @@ Implementation references:
|
||||
| Tailscale helper (first-class) | `plugin/relay/tailscale.py` + `hermes-relay-tailscale` CLI (ADR 25). Publishes the loopback relay over the tailnet via `tailscale serve --bg --https=<port>`; managed TLS + tailnet ACL identity. Optional, graceful-absent when the binary isn't installed. Auto-retires when upstream PR #9295 lands. See [`docs/remote-access.md`](remote-access.md). |
|
||||
| Multi-endpoint pairing | Single QR carries an ordered list of `role: lan/tailscale/public/...` candidates with strict-priority selection (ADR 24). Phone re-probes reachability on every network change. Per-candidate `transport_hint` drives the plaintext-`ws://` consent dialog. |
|
||||
| Device revocation | Paired Devices screen → `GET /sessions` (tokens masked to 8-char prefix) / `DELETE /sessions/{token_prefix}` (self-revoke allowed, wipes local state + redirects to pair flow). Any paired device can revoke any other — trade-off documented in ADR 15. |
|
||||
| Session policy updates | `PATCH /sessions/{token_prefix}` is self-targeted and reduction-only for normal Relay bearers. Extending a lifetime, adding or lengthening grants, or changing another session requires a fresh operator-approved pairing flow. |
|
||||
| Terminal gate | Biometric/PIN required before terminal access (planned). |
|
||||
|
||||
---
|
||||
@@ -432,10 +433,9 @@ 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 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 and CLI/tray pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
|
||||
| `/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}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}`. Loopback callers receive the parsed `config.yaml` and absolute path. Remote callers require a relay session bearer and receive only the explicitly public `description` and `model.default` fields with `path: "config.yaml"`; arbitrary provider, platform, integration, and extension sections never cross the remote boundary. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
|
||||
| `/api/profiles/{name}/avatar` | GET | Profile-scoped avatar discovery and image delivery. Searches direct children of the profile home for conventional names, preferring `avatar.*` then `profile.*` (`png`, `jpg`, `jpeg`, `webp`, `gif`; additional `profile-image`, `agent`, and `icon` stems are accepted). Synthetic `default` follows a valid sticky `active_profile` marker, matching its advertised identity. The resolved file must remain inside the profile home and satisfy the Relay media-size policy. Same loopback-or-session-bearer auth as the other profile reads. 404 when the profile or an image is absent. Android copies returned bytes into its existing device-local per-profile icon store. |
|
||||
| `/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. |
|
||||
| `/api/profiles/{name}/soul` | GET | Profile-scoped raw `SOUL.md` read. Returns `{profile, path, content, exists, size_bytes}` with optional `truncated: true` when content exceeds the 200KB inline cap. Absent SOUL.md returns 200 with `exists: false` and an empty content string so the Inspector can distinguish "no soul" from transport failure. Same auth model as `/config`. 404 on unknown profile; 500 `{error: "soul_read_failed"}` on decode error. See §22 in decisions.md. |
|
||||
@@ -629,7 +629,7 @@ Wraps the existing relay protocol. When the agent calls `android_*` tools, the t
|
||||
|
||||
#### 6.4.1 `android_*` tool surface
|
||||
|
||||
Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (plus `plugin/tools/android_notifications.py`, `plugin/tools/android_navigate.py`). The Python-side Device Control tools issue HTTP requests to the relay on loopback; the relay forwards them to the phone over WSS; the sideload phone executes them via the accessibility service and returns structured responses. Google Play phones report `bridge.device_control_supported=false` from `/bridge/status`, so these tools are hidden from the agent and direct command probes fail closed with `error_code: device_control_sideload_only`.
|
||||
Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (plus `plugin/tools/android_notifications.py`, `plugin/tools/android_navigate.py`). The Python-side Device Control tools issue bearer-authenticated HTTP requests to the relay on loopback using `ANDROID_BRIDGE_TOKEN`; the relay requires that session's active `bridge` grant before forwarding to the phone over WSS. The sideload phone executes commands via the accessibility service and returns structured responses. Google Play phones report `bridge.device_control_supported=false` from `/bridge/status`, so these tools are hidden from the agent and direct command probes fail closed with `error_code: device_control_sideload_only`.
|
||||
|
||||
**Baseline (pre-v0.4 — shipped in Phase 3 Wave 1):**
|
||||
|
||||
@@ -773,7 +773,7 @@ The `ActionResult.data` field indicates which tier succeeded (`"direct"` / `"par
|
||||
- [x] Android Keystore session token storage (`SessionTokenStore` — `KeystoreTokenStore` with StrongBox-preferred via `setRequestStrongBoxBacked`, `LegacyEncryptedPrefsTokenStore` TEE-backed fallback, one-shot lossless migration on first launch)
|
||||
- [x] User-chosen session TTL at pair time (`SessionTtlPickerDialog` — 1d / 7d / 30d / 90d / 1y / Never)
|
||||
- [x] Per-channel grants on one session token (`Session.grants` — chat / terminal / bridge / TUI / split voice grants (`voice:config`, `voice:stt`, `voice:tts`), clamped to session lifetime)
|
||||
- [x] Paired Devices screen (`PairedDevicesScreen` + `GET /sessions` + `DELETE /sessions/{prefix}` + `PATCH /sessions/{prefix}` for extend)
|
||||
- [x] Paired Devices screen (`PairedDevicesScreen` + `GET /sessions` + `DELETE /sessions/{prefix}`; bearer-authenticated `PATCH /sessions/{prefix}` is self-targeted and reduction-only)
|
||||
- [x] Transport security badge (`TransportSecurityBadge` — three states: secure / insecure-with-reason / insecure-unknown)
|
||||
- [x] First-time insecure-mode ack dialog with reason picker (`InsecureConnectionAckDialog`)
|
||||
- [x] Tailscale detection (`TailscaleDetector` — informational only)
|
||||
|
||||
@@ -53,6 +53,20 @@ After review:
|
||||
- add recurring terminology corrections to this glossary;
|
||||
- preserve translator credit and stale-PR lineage under `CONTRIBUTING.md`.
|
||||
|
||||
## Public documentation
|
||||
|
||||
Translate the first-run documentation as complete pages, preserving frontmatter,
|
||||
internal links, component tags, command names, route paths, configuration keys,
|
||||
and code samples exactly. Fast-moving API, relay-route, configuration, and
|
||||
architecture reference remains canonical English until a locale has a durable
|
||||
maintenance owner. Localized core pages should link to that reference rather
|
||||
than copying it.
|
||||
|
||||
Run `python scripts/check-user-docs-locales.py` before previewing or building the
|
||||
documentation. When a canonical English core page changes intentionally, review
|
||||
every localized counterpart, then refresh its recorded source hash with
|
||||
`python scripts/check-user-docs-locales.py --refresh`.
|
||||
|
||||
## Technical release gate
|
||||
|
||||
Run:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Legacy documentation redirect
|
||||
|
||||
This directory is a temporary compatibility shim for Android releases that
|
||||
hardcoded `https://codename-11.github.io/hermes-relay/` before PR #210.
|
||||
Documentation is hosted only at `https://hermes-relay.dev/docs/`; GitHub Pages
|
||||
serves redirect HTML and no documentation content.
|
||||
|
||||
The deployment workflow copies `redirect.html` to the project root, the Pages
|
||||
404 fallback, and the exact deep-link paths embedded in released clients. The
|
||||
JavaScript preserves the path, query string, and fragment while moving the
|
||||
request under `/docs/`. The meta refresh and visible link provide a no-script
|
||||
fallback to the guide root.
|
||||
|
||||
Removal is tracked in the repository root `TODO.md`. Do not delete this shim
|
||||
solely because the first fixed release has shipped; honor the documented
|
||||
compatibility window for older Play and sideload installations.
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta http-equiv="refresh" content="2;url=https://hermes-relay.dev/docs/" />
|
||||
<link rel="canonical" href="https://hermes-relay.dev/docs/" />
|
||||
<title>Hermes Relay documentation moved</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hermes Relay documentation moved</h1>
|
||||
<p>
|
||||
Redirecting to
|
||||
<a id="destination" href="https://hermes-relay.dev/docs/">hermes-relay.dev/docs</a>.
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const legacyBase = "/hermes-relay";
|
||||
const pathname = window.location.pathname;
|
||||
const suffix = pathname === legacyBase || pathname === `${legacyBase}/`
|
||||
? "/"
|
||||
: pathname.startsWith(`${legacyBase}/`)
|
||||
? pathname.slice(legacyBase.length)
|
||||
: "/";
|
||||
const destination = new URL("https://hermes-relay.dev/docs/");
|
||||
destination.pathname = `/docs/${suffix.replace(/^\/+/, "")}`;
|
||||
destination.search = window.location.search;
|
||||
destination.hash = window.location.hash;
|
||||
document.getElementById("destination").href = destination.href;
|
||||
window.location.replace(destination.href);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1204,6 +1204,71 @@ class SessionManager:
|
||||
self._save_to_disk()
|
||||
return session
|
||||
|
||||
def reduce_session_policy(
|
||||
self,
|
||||
token: str,
|
||||
ttl_seconds: int | None = None,
|
||||
grants: dict[str, float] | None = None,
|
||||
) -> Session | None:
|
||||
"""Atomically reduce a live session's lifetime and grant ceilings.
|
||||
|
||||
Unlike :meth:`update_session`, this operation never rebuilds omitted
|
||||
grants from defaults and never permits a policy expansion. It is the
|
||||
safe self-service primitive for an ordinary session bearer; broader
|
||||
updates require a separately authorized operator flow.
|
||||
|
||||
Raises :class:`ValueError` for unknown or malformed grants and
|
||||
:class:`PermissionError` for any requested policy expansion.
|
||||
"""
|
||||
self._cleanup()
|
||||
session = self._sessions.get(token)
|
||||
if session is None or session.is_expired:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
requested_session_expiry = session.expires_at
|
||||
if ttl_seconds is not None:
|
||||
requested_session_expiry = (
|
||||
math.inf if ttl_seconds == 0 else now + float(ttl_seconds)
|
||||
)
|
||||
if requested_session_expiry > session.expires_at:
|
||||
raise PermissionError(
|
||||
"operator approval required to extend session"
|
||||
)
|
||||
|
||||
requested_grant_expiries: dict[str, float] = {}
|
||||
for channel, seconds in (grants or {}).items():
|
||||
if channel not in session.grants:
|
||||
raise ValueError(f"unknown grant {channel!r}")
|
||||
if not isinstance(seconds, (int, float)) or not math.isfinite(
|
||||
seconds
|
||||
):
|
||||
raise ValueError(f"invalid grant duration for {channel!r}")
|
||||
if seconds < 0:
|
||||
raise ValueError(f"invalid grant duration for {channel!r}")
|
||||
requested_expiry = (
|
||||
math.inf if seconds == 0 else now + float(seconds)
|
||||
)
|
||||
if requested_expiry > session.grants[channel]:
|
||||
raise PermissionError(
|
||||
f"operator approval required to expand grant {channel!r}"
|
||||
)
|
||||
requested_grant_expiries[channel] = requested_expiry
|
||||
|
||||
session.expires_at = requested_session_expiry
|
||||
for channel, current_expiry in tuple(session.grants.items()):
|
||||
requested_expiry = requested_grant_expiries.get(
|
||||
channel,
|
||||
current_expiry,
|
||||
)
|
||||
session.grants[channel] = _clamp_grant_to_session(
|
||||
requested_expiry,
|
||||
session.expires_at,
|
||||
)
|
||||
session.last_seen = now
|
||||
self._save_to_disk()
|
||||
return session
|
||||
|
||||
def active_count(self) -> int:
|
||||
"""Return the number of non-expired sessions."""
|
||||
self._cleanup()
|
||||
|
||||
+93
-68
@@ -27,6 +27,7 @@ import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import ssl
|
||||
import stat
|
||||
@@ -186,16 +187,6 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def handle_pairing(request: web.Request) -> web.Response:
|
||||
"""Generate a new pairing code (for use by the Hermes agent/CLI).
|
||||
|
||||
POST /pairing → {"code": "ABC123"}
|
||||
"""
|
||||
server: RelayServer = request.app["server"]
|
||||
code = server.pairing.generate_code()
|
||||
return web.json_response({"code": code})
|
||||
|
||||
|
||||
async def handle_pairing_register(request: web.Request) -> web.Response:
|
||||
"""Pre-register an externally-provided pairing code.
|
||||
|
||||
@@ -810,22 +801,22 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_sessions_extend(request: web.Request) -> web.Response:
|
||||
"""Update a paired device's session TTL and/or per-channel grants.
|
||||
"""Reduce the calling device's session TTL and/or channel grants.
|
||||
|
||||
This is the "extend" action exposed on the phone's Paired Devices
|
||||
screen, but also handles arbitrary TTL updates — passing
|
||||
``ttl_seconds`` shorter than the current expiry clips the session
|
||||
(equivalent to shortening, though the button is labeled "Extend"
|
||||
for the common case). ``ttl_seconds == 0`` maps to never-expire.
|
||||
A normal Relay bearer is session identity, not session-management
|
||||
authority. It may reduce only its own live policy. Extending a
|
||||
lifetime, adding or lengthening a grant, or changing another session
|
||||
requires a fresh operator-approved pairing flow.
|
||||
|
||||
PATCH /sessions/{token_prefix}
|
||||
Body: {"ttl_seconds": 2592000} # extend only
|
||||
| {"grants": {"terminal": 604800}} # grants only
|
||||
| {"ttl_seconds": 0, "grants": {...}} # both
|
||||
Body: {"ttl_seconds": 300} # shorten only
|
||||
| {"grants": {"terminal": 60}} # reduce grants
|
||||
| {"ttl_seconds": 300, "grants": {...}} # both
|
||||
→ 200 {"ok": true, "expires_at": ..., "grants": {...}}
|
||||
→ 400 missing/invalid body or no fields provided
|
||||
→ 401 missing/invalid bearer
|
||||
→ 404 prefix doesn't match any active session
|
||||
→ 403 cross-session target or policy expansion
|
||||
→ 409 prefix matches multiple sessions
|
||||
"""
|
||||
server, current_session = _require_bearer_session(request)
|
||||
@@ -873,7 +864,12 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
|
||||
{"ok": False, "error": "'grants' must be an object"}, status=400
|
||||
)
|
||||
for k, v in grants.items():
|
||||
if not isinstance(k, str) or not isinstance(v, (int, float)) or v < 0:
|
||||
if (
|
||||
not isinstance(k, str)
|
||||
or not isinstance(v, (int, float))
|
||||
or not math.isfinite(v)
|
||||
or v < 0
|
||||
):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -901,20 +897,27 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
target = matches[0]
|
||||
updated = server.sessions.update_session(
|
||||
target.token,
|
||||
ttl_seconds=ttl_seconds,
|
||||
grants=grants,
|
||||
)
|
||||
if not secrets.compare_digest(target.token, current_session.token):
|
||||
raise web.HTTPForbidden(text="cannot modify another session")
|
||||
|
||||
try:
|
||||
updated = server.sessions.reduce_session_policy(
|
||||
target.token,
|
||||
ttl_seconds=ttl_seconds,
|
||||
grants=grants,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
except PermissionError as exc:
|
||||
raise web.HTTPForbidden(text=str(exc)) from exc
|
||||
if updated is None:
|
||||
# Raced with expiry or revocation between find_by_prefix and update.
|
||||
raise web.HTTPNotFound(text="session vanished mid-update, retry")
|
||||
|
||||
logger.info(
|
||||
"Extended session %s... (%s)%s",
|
||||
"Reduced session policy %s... (%s) [self]",
|
||||
target.token[:8],
|
||||
target.device_name,
|
||||
" [self]" if target.token == current_session.token else "",
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1704,17 +1707,10 @@ async def handle_media_by_path(request: web.Request) -> web.StreamResponse:
|
||||
# channel to the connected phone.
|
||||
#
|
||||
# Auth model:
|
||||
# * These routes are **unauthenticated** at the HTTP layer on purpose —
|
||||
# the legacy relay was unauthenticated too, and the trust boundary
|
||||
# is the same: only tools running on the same host as the relay can
|
||||
# reach localhost:8767. The relay's default bind is 0.0.0.0 for the
|
||||
# WebSocket side, but tools should always point at ``localhost``, so
|
||||
# an attacker reaching port 8767 from the LAN would need the phone
|
||||
# to also have auth'd with a valid pairing code — without a paired
|
||||
# phone, every bridge HTTP call just returns 503.
|
||||
# * If tightening is needed later, wrap these handlers with the same
|
||||
# ``_require_bearer_session`` pattern used by ``/media/*`` — the
|
||||
# bridge grant is already tracked per-session in ``Session.grants``.
|
||||
# * Every route requires a live Relay bearer with an active ``bridge``
|
||||
# grant. The same listener accepts external WebSocket connections, so
|
||||
# callers are never trusted merely because host tools normally use
|
||||
# ``localhost``.
|
||||
#
|
||||
# A paired but disconnected phone still drops bridge calls with 503 —
|
||||
# the tool caller should retry or tell the user to reconnect the app.
|
||||
@@ -1746,7 +1742,9 @@ async def _bridge_dispatch(
|
||||
path: str,
|
||||
) -> web.Response:
|
||||
"""Forward an HTTP request to an Android bridge device."""
|
||||
server: RelayServer = request.app["server"]
|
||||
server, session = _require_bearer_session(request)
|
||||
if session.channel_is_expired("bridge"):
|
||||
raise web.HTTPForbidden(text="active bridge grant required")
|
||||
method = request.method # GET or POST
|
||||
|
||||
params: dict[str, Any] = dict(request.query)
|
||||
@@ -2455,8 +2453,33 @@ def _parse_skill_frontmatter(text: str) -> dict[str, Any]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _public_profile_config(parsed: object) -> dict[str, Any]:
|
||||
"""Build the explicitly public subset of a Hermes profile config.
|
||||
|
||||
Remote profile inspection must not serialize arbitrary configuration
|
||||
sections: provider and extension fields may contain reusable credentials.
|
||||
Keep this schema deliberately small and add fields only after classifying
|
||||
them as safe for every paired Relay client.
|
||||
"""
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
|
||||
public: dict[str, Any] = {}
|
||||
description = parsed.get("description")
|
||||
if isinstance(description, str):
|
||||
public["description"] = description
|
||||
|
||||
model = parsed.get("model")
|
||||
if isinstance(model, dict):
|
||||
default_model = model.get("default")
|
||||
if isinstance(default_model, str):
|
||||
public["model"] = {"default": default_model}
|
||||
|
||||
return public
|
||||
|
||||
|
||||
async def handle_profile_config(request: web.Request) -> web.Response:
|
||||
"""Return the parsed ``config.yaml`` for a named profile.
|
||||
"""Return a safe view of ``config.yaml`` for a named profile.
|
||||
|
||||
GET /api/profiles/{name}/config
|
||||
→ 200 {"profile", "path", "config": {...}, "readonly": true}
|
||||
@@ -2464,9 +2487,10 @@ async def handle_profile_config(request: web.Request) -> web.Response:
|
||||
→ 404 profile dir missing or no config.yaml
|
||||
→ 500 yaml parse error
|
||||
|
||||
Loopback callers may skip bearer auth (matches
|
||||
``/notifications/recent``); remote callers must present a valid
|
||||
relay session token.
|
||||
Loopback callers may skip bearer auth and receive the complete parsed file.
|
||||
Remote callers must present a valid relay session token and receive only
|
||||
the explicitly public profile schema, never arbitrary config sections or
|
||||
the host filesystem path.
|
||||
"""
|
||||
is_loopback = request.remote in ("127.0.0.1", "::1")
|
||||
if is_loopback:
|
||||
@@ -2521,11 +2545,14 @@ async def handle_profile_config(request: web.Request) -> web.Response:
|
||||
if parsed is None:
|
||||
parsed = {}
|
||||
|
||||
response_config = parsed if is_loopback else _public_profile_config(parsed)
|
||||
response_path = str(config_path) if is_loopback else config_path.name
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"profile": name,
|
||||
"path": str(config_path),
|
||||
"config": parsed,
|
||||
"path": response_path,
|
||||
"config": response_config,
|
||||
"readonly": True,
|
||||
}
|
||||
)
|
||||
@@ -3764,14 +3791,10 @@ async def _authenticate(
|
||||
client_surface = str(payload.get("client_surface", "unknown") or "unknown")
|
||||
device_form_factor = str(payload.get("device_form_factor", "unknown") or "unknown")
|
||||
|
||||
# The phone MAY send ttl_seconds / grants in its auth envelope, but
|
||||
# if the operator pre-registered the code with metadata on the host
|
||||
# side, those host-provided values win — the host has the authority
|
||||
# to decide "how long and for which channels". Only fall back to the
|
||||
# phone-sent fields when the code had no attached metadata (e.g. old
|
||||
# phones predating the v2 auth envelope).
|
||||
phone_ttl = payload.get("ttl_seconds")
|
||||
phone_grants = payload.get("grants")
|
||||
# Pairing policy is attached by a loopback-only operator flow. Clients
|
||||
# may still send ttl_seconds / grants for wire compatibility, but those
|
||||
# fields are not an authority boundary and must not influence sessions.
|
||||
# Missing host metadata therefore resolves to SessionManager defaults.
|
||||
detected_transport = _detect_transport_hint(request)
|
||||
|
||||
# Try session token first (reconnection)
|
||||
@@ -3815,24 +3838,13 @@ async def _authenticate(
|
||||
if pairing_code:
|
||||
metadata = server.pairing.consume_code(pairing_code)
|
||||
if metadata is not None:
|
||||
# Thread host-side metadata (from /pairing/register) through
|
||||
# to the session. Fall back to phone-sent values, then to
|
||||
# library defaults.
|
||||
# Thread host-side metadata (from a loopback-only pairing flow)
|
||||
# through to the session. Missing metadata uses bounded library
|
||||
# defaults; the network client cannot author session policy.
|
||||
ttl_seconds: float | None = metadata.ttl_seconds
|
||||
grants: dict[str, float] | None = metadata.grants
|
||||
transport_hint = metadata.transport_hint or detected_transport
|
||||
|
||||
if ttl_seconds is None and isinstance(phone_ttl, (int, float)) and not isinstance(phone_ttl, bool):
|
||||
if phone_ttl >= 0:
|
||||
ttl_seconds = float(phone_ttl)
|
||||
if grants is None and isinstance(phone_grants, dict):
|
||||
cleaned: dict[str, float] = {}
|
||||
for channel, value in phone_grants.items():
|
||||
if isinstance(channel, str) and isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
|
||||
cleaned[channel] = float(value)
|
||||
if cleaned:
|
||||
grants = cleaned
|
||||
|
||||
session = server.sessions.create_session(
|
||||
device_name,
|
||||
device_id,
|
||||
@@ -3924,6 +3936,20 @@ async def _on_message(
|
||||
)
|
||||
_track_task(server, ws, task)
|
||||
elif channel == "terminal":
|
||||
token = server._clients.get(ws)
|
||||
session = server.sessions.get_session(token) if token else None
|
||||
if session is None or session.channel_is_expired("terminal"):
|
||||
logger.warning(
|
||||
"Rejected terminal message from device=%s: terminal grant expired",
|
||||
session.device_id if session is not None else "unknown",
|
||||
)
|
||||
await _send_system(
|
||||
ws,
|
||||
"error",
|
||||
{"message": "Terminal grant expired for this device"},
|
||||
msg_id=msg_id,
|
||||
)
|
||||
return
|
||||
task = asyncio.create_task(server.terminal.handle(ws, envelope))
|
||||
_track_task(server, ws, task)
|
||||
elif channel == "bridge":
|
||||
@@ -4110,7 +4136,6 @@ def create_app(config: RelayConfig) -> web.Application:
|
||||
app.router.add_get("/ws", handle_ws)
|
||||
app.router.add_get("/", handle_ws)
|
||||
app.router.add_get("/health", handle_health)
|
||||
app.router.add_post("/pairing", handle_pairing)
|
||||
app.router.add_post("/pairing/register", handle_pairing_register)
|
||||
app.router.add_post("/pairing/mint", handle_pairing_mint)
|
||||
app.router.add_post("/pairing/approve", handle_pairing_approve)
|
||||
|
||||
@@ -121,7 +121,9 @@ def _synthesize_gemini(
|
||||
|
||||
raw = config.get("gemini")
|
||||
gemini = dict(raw) if isinstance(raw, dict) else {}
|
||||
for key in ("model", "voice", "base_url"):
|
||||
# Never merge a request-supplied endpoint into credential-bearing host
|
||||
# configuration. ``base_url`` is deliberately operator-controlled.
|
||||
for key in ("model", "voice"):
|
||||
value = overrides.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
gemini[key] = value.strip()
|
||||
|
||||
@@ -506,7 +506,9 @@ def _extract_voice_overrides(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
nested = payload.get("enhanced") or payload.get("gemini")
|
||||
source = nested if isinstance(nested, dict) else payload
|
||||
overrides: dict[str, Any] = {}
|
||||
for key in ("provider", "voice", "model", "base_url", "language"):
|
||||
# Network destinations remain operator-controlled because provider
|
||||
# adapters attach host-owned credentials to their configured endpoints.
|
||||
for key in ("provider", "voice", "model", "language"):
|
||||
value = source.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
overrides[key] = value.strip()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Security regression tests for Relay HTTP-to-Android bridge authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class BridgeHttpAuthorizationTests(AioHTTPTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self._hermes_home = tempfile.TemporaryDirectory()
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self._hermes_home.name},
|
||||
)
|
||||
self._env_patch.start()
|
||||
await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await super().asyncTearDown()
|
||||
self._env_patch.stop()
|
||||
self._hermes_home.cleanup()
|
||||
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig(profile_discovery_enabled=False))
|
||||
|
||||
def _session(self) -> object:
|
||||
return self.app["server"].sessions.create_session(
|
||||
"bridge-security-test",
|
||||
"bridge-security-test",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def test_anonymous_and_invalid_callers_are_rejected_before_dispatch(self) -> None:
|
||||
dispatch = mock.AsyncMock(return_value={"status": 200, "result": {"ok": True}})
|
||||
self.app["server"].bridge.handle_command = dispatch
|
||||
|
||||
anonymous = await self.client.get("/screen?include_bounds=true")
|
||||
invalid = await self.client.post(
|
||||
"/tap",
|
||||
json={"x": 1, "y": 2},
|
||||
headers=self._bearer("invalid-token"),
|
||||
)
|
||||
|
||||
self.assertEqual(anonymous.status, 401)
|
||||
self.assertEqual(invalid.status, 401)
|
||||
dispatch.assert_not_awaited()
|
||||
|
||||
async def test_missing_and_expired_bridge_grants_are_rejected(self) -> None:
|
||||
dispatch = mock.AsyncMock(return_value={"status": 200, "result": {"ok": True}})
|
||||
self.app["server"].bridge.handle_command = dispatch
|
||||
|
||||
missing = self._session()
|
||||
missing.grants.pop("bridge")
|
||||
expired = self._session()
|
||||
expired.grants["bridge"] = time.time() - 1
|
||||
|
||||
missing_response = await self.client.get(
|
||||
"/screen", headers=self._bearer(missing.token)
|
||||
)
|
||||
expired_response = await self.client.post(
|
||||
"/tap", json={"x": 1, "y": 2}, headers=self._bearer(expired.token)
|
||||
)
|
||||
|
||||
self.assertEqual(missing_response.status, 403)
|
||||
self.assertEqual(expired_response.status, 403)
|
||||
dispatch.assert_not_awaited()
|
||||
|
||||
async def test_active_bridge_grant_preserves_request_and_device_selector(self) -> None:
|
||||
dispatch = mock.AsyncMock(
|
||||
return_value={"status": 200, "result": {"ok": True}}
|
||||
)
|
||||
self.app["server"].bridge.handle_command = dispatch
|
||||
session = self._session()
|
||||
|
||||
response = await self.client.post(
|
||||
"/tap?device=phone",
|
||||
json={"x": 10, "y": 20},
|
||||
headers=self._bearer(session.token),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(await response.json(), {"ok": True})
|
||||
dispatch.assert_awaited_once_with(
|
||||
method="POST",
|
||||
path="/tap",
|
||||
params={},
|
||||
body={"x": 10, "y": 20},
|
||||
device="phone",
|
||||
)
|
||||
@@ -53,6 +53,12 @@ class _FakeSession:
|
||||
class _FakeServer:
|
||||
def __init__(self, bridge: BridgeHandler) -> None:
|
||||
self.bridge = bridge
|
||||
session = _FakeSession("bridge-test-token", "test", "test")
|
||||
self.sessions = type(
|
||||
"FakeSessions",
|
||||
(),
|
||||
{"get_session": lambda _self, token: session if token == session.token else None},
|
||||
)()
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
@@ -70,6 +76,7 @@ class _FakeRequest:
|
||||
self.query = query or {}
|
||||
self._body = body
|
||||
self.remote = remote
|
||||
self.headers = {"Authorization": "Bearer bridge-test-token"}
|
||||
|
||||
@property
|
||||
def body_exists(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Security regression tests for operator-authorized Relay pairing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.auth import Session
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class PairingAuthorityTests(AioHTTPTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self._hermes_home = tempfile.TemporaryDirectory()
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self._hermes_home.name},
|
||||
)
|
||||
self._env_patch.start()
|
||||
await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await super().asyncTearDown()
|
||||
self._env_patch.stop()
|
||||
self._hermes_home.cleanup()
|
||||
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig(profile_discovery_enabled=False))
|
||||
|
||||
async def _authenticate(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
ttl_seconds: int,
|
||||
grants: dict[str, int],
|
||||
) -> Session:
|
||||
ws = await self.client.ws_connect("/ws")
|
||||
await ws.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"pairing_code": code,
|
||||
"device_name": "security-regression",
|
||||
"device_id": f"security-regression-{code}",
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"grants": grants,
|
||||
},
|
||||
}
|
||||
)
|
||||
message = await ws.receive_json()
|
||||
await ws.close()
|
||||
self.assertEqual(message["type"], "auth.ok")
|
||||
token = message["payload"]["session_token"]
|
||||
session = self.app["server"].sessions.get_session(token)
|
||||
self.assertIsNotNone(session)
|
||||
assert session is not None
|
||||
return session
|
||||
|
||||
async def test_anonymous_pairing_code_mint_is_not_exposed(self) -> None:
|
||||
server = self.app["server"]
|
||||
|
||||
response = await self.client.post("/pairing")
|
||||
|
||||
self.assertEqual(response.status, 404)
|
||||
self.assertEqual(server.pairing._codes, {})
|
||||
self.assertEqual(server.sessions.active_count(), 0)
|
||||
self.assertEqual(server.sessions._trusted_devices, {})
|
||||
|
||||
async def test_client_policy_is_ignored_when_host_metadata_is_absent(
|
||||
self,
|
||||
) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register",
|
||||
json={"code": "SAFE01"},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
session = await self._authenticate(
|
||||
"SAFE01",
|
||||
ttl_seconds=0,
|
||||
grants={"terminal": 0, "bridge": 0},
|
||||
)
|
||||
|
||||
self.assertFalse(math.isinf(session.expires_at))
|
||||
self.assertFalse(math.isinf(session.grants["terminal"]))
|
||||
self.assertFalse(math.isinf(session.grants["bridge"]))
|
||||
|
||||
async def test_explicit_host_policy_remains_authoritative(self) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register",
|
||||
json={
|
||||
"code": "SAFE02",
|
||||
"ttl_seconds": 0,
|
||||
"grants": {"terminal": 0},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
session = await self._authenticate(
|
||||
"SAFE02",
|
||||
ttl_seconds=60,
|
||||
grants={"terminal": 60},
|
||||
)
|
||||
|
||||
self.assertTrue(math.isinf(session.expires_at))
|
||||
self.assertTrue(math.isinf(session.grants["terminal"]))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Security tests for ``GET /api/profiles/{name}/config``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class ProfileConfigEndpointTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.hermes_dir = Path(self._tmp.name)
|
||||
config_path = self.hermes_dir / "config.yaml"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"description: Safe profile description",
|
||||
"model:",
|
||||
" default: safe-model",
|
||||
"platforms:",
|
||||
" api_server:",
|
||||
" api_key: POC_API_SERVER_KEY_DO_NOT_USE",
|
||||
"providers:",
|
||||
" synthetic:",
|
||||
" api_key: POC_PROVIDER_KEY_DO_NOT_USE",
|
||||
"extensions:",
|
||||
" future:",
|
||||
" nested:",
|
||||
" credential: POC_UNKNOWN_SECRET_DO_NOT_USE",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = create_app(RelayConfig(hermes_config_path=str(config_path)))
|
||||
|
||||
@web.middleware
|
||||
async def force_remote_client(
|
||||
request: web.Request,
|
||||
handler: web.RequestHandler,
|
||||
) -> web.StreamResponse:
|
||||
return await handler(request.clone(remote="198.51.100.27"))
|
||||
|
||||
app.middlewares.append(force_remote_client)
|
||||
self.session = app["server"].sessions.create_session(
|
||||
"profile-reader", "test-device", ttl_seconds=3600
|
||||
)
|
||||
self.session.grants = {"chat": time.time() + 3600}
|
||||
return app
|
||||
|
||||
async def test_remote_profile_config_returns_only_public_schema(self) -> None:
|
||||
response = await self.client.get(
|
||||
"/api/profiles/default/config",
|
||||
headers={"Authorization": f"Bearer {self.session.token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
body = await response.json()
|
||||
self.assertEqual(body["profile"], "default")
|
||||
self.assertEqual(body["path"], "config.yaml")
|
||||
self.assertEqual(
|
||||
body["config"],
|
||||
{
|
||||
"description": "Safe profile description",
|
||||
"model": {"default": "safe-model"},
|
||||
},
|
||||
)
|
||||
serialized = await response.text()
|
||||
self.assertNotIn("POC_API_SERVER_KEY_DO_NOT_USE", serialized)
|
||||
self.assertNotIn("POC_PROVIDER_KEY_DO_NOT_USE", serialized)
|
||||
self.assertNotIn("POC_UNKNOWN_SECRET_DO_NOT_USE", serialized)
|
||||
self.assertNotIn(str(self.hermes_dir), serialized)
|
||||
|
||||
async def test_remote_profile_config_still_requires_bearer(self) -> None:
|
||||
response = await self.client.get("/api/profiles/default/config")
|
||||
|
||||
self.assertEqual(response.status, 401)
|
||||
|
||||
|
||||
class ProfileConfigLoopbackEndpointTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.config_path = Path(self._tmp.name) / "config.yaml"
|
||||
self.config_path.write_text(
|
||||
"model:\n default: local-model\ncustom:\n enabled: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return create_app(
|
||||
RelayConfig(hermes_config_path=str(self.config_path))
|
||||
)
|
||||
|
||||
async def test_loopback_operator_retains_full_config_view(self) -> None:
|
||||
response = await self.client.get("/api/profiles/default/config")
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
body = await response.json()
|
||||
self.assertEqual(body["path"], str(self.config_path))
|
||||
self.assertEqual(body["config"]["custom"], {"enabled": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Security regressions for Relay session-policy authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class SessionPolicyAuthorizationTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig(profile_discovery_enabled=False))
|
||||
|
||||
def _create_session(self, name: str, *, ttl_seconds: int = 600):
|
||||
return self.app["server"].sessions.create_session(
|
||||
name,
|
||||
f"{name}-id",
|
||||
ttl_seconds=ttl_seconds,
|
||||
client_surface="phone",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def test_bounded_bearer_cannot_upgrade_its_policy(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
original_expiry = session.expires_at
|
||||
original_grants = dict(session.grants)
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={
|
||||
"ttl_seconds": 0,
|
||||
"grants": {
|
||||
"terminal": 0,
|
||||
"bridge": 0,
|
||||
"tui": 0,
|
||||
"voice:tts": 0,
|
||||
"attacker:custom": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 403, await response.text())
|
||||
self.assertEqual(session.expires_at, original_expiry)
|
||||
self.assertEqual(session.grants, original_grants)
|
||||
self.assertFalse(math.isinf(session.expires_at))
|
||||
|
||||
async def test_bearer_cannot_modify_another_session(self) -> None:
|
||||
caller = self._create_session("caller")
|
||||
target = self._create_session("target")
|
||||
original_expiry = target.expires_at
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{target.token[:8]}",
|
||||
headers=self._headers(caller.token),
|
||||
json={"ttl_seconds": 60},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 403, await response.text())
|
||||
self.assertEqual(target.expires_at, original_expiry)
|
||||
|
||||
async def test_bearer_cannot_add_or_lengthen_grants(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
|
||||
add_response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"attacker:custom": 30}},
|
||||
)
|
||||
extend_response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"terminal": 601}},
|
||||
)
|
||||
|
||||
self.assertEqual(add_response.status, 400, await add_response.text())
|
||||
self.assertEqual(extend_response.status, 403, await extend_response.text())
|
||||
self.assertNotIn("attacker:custom", session.grants)
|
||||
|
||||
async def test_nan_grant_is_rejected(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"terminal": float("nan")}},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 400, await response.text())
|
||||
self.assertFalse(math.isnan(session.grants["terminal"]))
|
||||
|
||||
async def test_self_service_can_only_reduce_policy(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
original_expiry = session.expires_at
|
||||
original_bridge_expiry = session.grants["bridge"]
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"ttl_seconds": 300, "grants": {"terminal": 60}},
|
||||
)
|
||||
body = await response.json()
|
||||
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertLess(session.expires_at, original_expiry)
|
||||
self.assertLessEqual(session.expires_at, time.time() + 301)
|
||||
self.assertLessEqual(session.grants["terminal"], time.time() + 61)
|
||||
self.assertLessEqual(session.grants["bridge"], original_bridge_expiry)
|
||||
self.assertLessEqual(session.grants["bridge"], session.expires_at)
|
||||
|
||||
async def test_grant_only_reduction_preserves_omitted_ceilings(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
original_expiry = session.expires_at
|
||||
original_grants = dict(session.grants)
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"terminal": 60}},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
self.assertEqual(session.expires_at, original_expiry)
|
||||
self.assertLess(session.grants["terminal"], original_grants["terminal"])
|
||||
for channel, expiry in original_grants.items():
|
||||
if channel != "terminal":
|
||||
self.assertEqual(session.grants[channel], expiry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Authorization tests for terminal WebSocket dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import RelayServer, _on_message
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_str(self, raw: str) -> None:
|
||||
self.sent.append(json.loads(raw))
|
||||
|
||||
async def close(self, **_kwargs: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class TerminalAuthorizationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self.server = RelayServer(RelayConfig())
|
||||
self.server.terminal.handle = AsyncMock()
|
||||
self.ws = _FakeWebSocket()
|
||||
self.server._client_tasks[self.ws] = set()
|
||||
self.session = self.server.sessions.create_session(
|
||||
"terminal-test",
|
||||
"terminal-test-id",
|
||||
grants={"terminal": 3600},
|
||||
)
|
||||
self.server._clients[self.ws] = self.session.token
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
for task in self.server._client_tasks.get(self.ws, set()):
|
||||
task.cancel()
|
||||
await asyncio.gather(
|
||||
*self.server._client_tasks.get(self.ws, set()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
await self.server.close()
|
||||
|
||||
async def _dispatch(self, msg_type: str = "terminal.attach") -> None:
|
||||
await _on_message(
|
||||
self.ws,
|
||||
self.server,
|
||||
json.dumps(
|
||||
{
|
||||
"channel": "terminal",
|
||||
"type": msg_type,
|
||||
"id": "terminal-request",
|
||||
"payload": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def _assert_authorization_error(self) -> None:
|
||||
self.server.terminal.handle.assert_not_awaited()
|
||||
self.assertEqual(len(self.ws.sent), 1)
|
||||
self.assertEqual(self.ws.sent[0]["channel"], "system")
|
||||
self.assertEqual(self.ws.sent[0]["type"], "error")
|
||||
self.assertEqual(self.ws.sent[0]["id"], "terminal-request")
|
||||
self.assertIn("Terminal grant", self.ws.sent[0]["payload"]["message"])
|
||||
|
||||
async def test_missing_terminal_grant_rejects_every_terminal_action(self) -> None:
|
||||
self.session.grants.pop("terminal")
|
||||
|
||||
for msg_type in (
|
||||
"terminal.attach",
|
||||
"terminal.input",
|
||||
"terminal.resize",
|
||||
"terminal.list",
|
||||
"terminal.detach",
|
||||
"terminal.kill",
|
||||
):
|
||||
with self.subTest(msg_type=msg_type):
|
||||
self.ws.sent.clear()
|
||||
self.server.terminal.handle.reset_mock()
|
||||
await self._dispatch(msg_type)
|
||||
self._assert_authorization_error()
|
||||
|
||||
async def test_expired_terminal_grant_is_rejected(self) -> None:
|
||||
self.session.grants["terminal"] = time.time() - 1
|
||||
|
||||
await self._dispatch()
|
||||
|
||||
self._assert_authorization_error()
|
||||
|
||||
async def test_revoked_session_on_connected_socket_is_rejected(self) -> None:
|
||||
self.server.sessions.revoke_session(self.session.token)
|
||||
|
||||
await self._dispatch()
|
||||
|
||||
self._assert_authorization_error()
|
||||
|
||||
async def test_current_terminal_grant_dispatches_to_handler(self) -> None:
|
||||
await self._dispatch()
|
||||
|
||||
self.server.terminal.handle.assert_awaited_once()
|
||||
self.assertEqual(self.ws.sent, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -810,6 +810,25 @@ class EnhancedVoiceHelpersTests(unittest.TestCase):
|
||||
self.assertEqual(out["audio_tags"], False)
|
||||
self.assertEqual(out["language"], "en")
|
||||
|
||||
def test_extract_overrides_drops_untrusted_base_urls(self) -> None:
|
||||
from plugin.relay.voice import _extract_voice_overrides
|
||||
|
||||
top_level = _extract_voice_overrides(
|
||||
{"text": "hi", "voice": "Puck", "base_url": "https://attacker.invalid"}
|
||||
)
|
||||
nested = _extract_voice_overrides(
|
||||
{
|
||||
"text": "hi",
|
||||
"gemini": {
|
||||
"voice": "Puck",
|
||||
"base_url": "https://attacker.invalid",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(top_level, {"voice": "Puck"})
|
||||
self.assertEqual(nested, {"voice": "Puck"})
|
||||
|
||||
def test_extract_overrides_empty_for_plain_body(self) -> None:
|
||||
from plugin.relay.voice import _extract_voice_overrides
|
||||
|
||||
@@ -855,6 +874,42 @@ class EnhancedVoiceHelpersTests(unittest.TestCase):
|
||||
self.assertEqual(block["voices"], []) # free-text on the client
|
||||
self.assertIn("language", block["overrides"])
|
||||
|
||||
def test_gemini_adapter_keeps_operator_configured_base_url(self) -> None:
|
||||
from plugin.relay import upstream_voice
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
tts_mod = sys.modules["tools.tts_tool"]
|
||||
original = getattr(tts_mod, "_generate_gemini_tts", None)
|
||||
|
||||
def _capture(text: str, output_path: str, config: dict[str, object]) -> str:
|
||||
captured["config"] = config
|
||||
return output_path
|
||||
|
||||
try:
|
||||
tts_mod._generate_gemini_tts = _capture
|
||||
result = upstream_voice._synthesize_gemini(
|
||||
{
|
||||
"provider": "gemini",
|
||||
"gemini": {"base_url": "https://operator.example", "voice": "Kore"},
|
||||
},
|
||||
"hello",
|
||||
"voice.mp3",
|
||||
{"base_url": "https://attacker.invalid", "voice": "Puck"},
|
||||
)
|
||||
finally:
|
||||
if original is None:
|
||||
delattr(tts_mod, "_generate_gemini_tts")
|
||||
else:
|
||||
tts_mod._generate_gemini_tts = original
|
||||
|
||||
self.assertEqual(result, {"success": True, "file_path": "voice.mp3"})
|
||||
config = captured["config"]
|
||||
assert isinstance(config, dict)
|
||||
gemini = config["gemini"]
|
||||
assert isinstance(gemini, dict)
|
||||
self.assertEqual(gemini["base_url"], "https://operator.example")
|
||||
self.assertEqual(gemini["voice"], "Puck")
|
||||
|
||||
def test_apply_xai_speech_tags_calls_through_and_fails_soft(self) -> None:
|
||||
# Used by the streaming /voice/output renderer to match the synthesize
|
||||
# path's xAI tone behavior; must call upstream when present, fail soft
|
||||
|
||||
@@ -25,7 +25,8 @@ def qualifier(tag: str) -> str:
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
normalized = path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def placeholders(value: str) -> list[tuple[int, str]]:
|
||||
@@ -115,6 +116,10 @@ def install(args: argparse.Namespace) -> None:
|
||||
"native_name": manifest["native_name"],
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
source_set: digest(APP_SRC / source_set / "res" / "values" / "strings.xml")
|
||||
for source_set in SOURCE_SETS
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
|
||||
@@ -189,8 +189,8 @@ def validate_status_registry(errors: list[str]) -> None:
|
||||
fail(f"{STATUS_PATH.relative_to(REPO_ROOT)}: cannot read status registry: {exc}", errors)
|
||||
return
|
||||
|
||||
if data.get("schema_version") != 2:
|
||||
fail("localization status schema_version must be 2", errors)
|
||||
if data.get("schema_version") != 3:
|
||||
fail("localization status schema_version must be 3", errors)
|
||||
if data.get("canonical_locale") != "en":
|
||||
fail("localization status canonical_locale must be 'en'", errors)
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate localized VitePress entry pages and their canonical source hashes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_ROOT = ROOT / "user-docs"
|
||||
STATUS_PATH = ROOT / "docs" / "localization-status.json"
|
||||
|
||||
CANONICAL_PAGES = {
|
||||
"index.md": "/",
|
||||
"guide/quick-start.md": "/guide/quick-start",
|
||||
"guide/getting-started.md": "/guide/getting-started",
|
||||
"guide/release-tracks.md": "/guide/release-tracks",
|
||||
"guide/troubleshooting.md": "/guide/troubleshooting",
|
||||
}
|
||||
|
||||
# Status registry key -> VitePress locale directory.
|
||||
LOCALES = {
|
||||
"de": "de",
|
||||
"es": "es",
|
||||
"ja": "ja",
|
||||
"pt-BR": "pt-BR",
|
||||
"zh-Hans": "zh-CN",
|
||||
}
|
||||
|
||||
LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
|
||||
FENCE_RE = re.compile(r"```[^\n]*\n(.*?)\n```", re.DOTALL)
|
||||
|
||||
|
||||
def normalized_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(normalized_text(path).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def resolve_doc_link(page: Path, raw_target: str) -> Path | None:
|
||||
target = unquote(raw_target.strip().split(maxsplit=1)[0])
|
||||
target = target.split("#", 1)[0].split("?", 1)[0]
|
||||
if not target or target.startswith(("#", "http://", "https://", "mailto:", "tel:")):
|
||||
return None
|
||||
|
||||
if target.startswith("/docs/"):
|
||||
target = target[len("/docs/") :]
|
||||
candidate = DOCS_ROOT / target
|
||||
elif target.startswith("/"):
|
||||
candidate = DOCS_ROOT / target.lstrip("/")
|
||||
else:
|
||||
candidate = page.parent / target
|
||||
|
||||
if candidate.suffix in {".png", ".jpg", ".jpeg", ".svg", ".webp", ".json", ".txt"}:
|
||||
return None
|
||||
if candidate.suffix == ".html":
|
||||
candidate = candidate.with_suffix("")
|
||||
if candidate.suffix == ".md":
|
||||
return candidate.resolve()
|
||||
|
||||
direct = candidate.with_suffix(".md")
|
||||
if direct.exists():
|
||||
return direct.resolve()
|
||||
return (candidate / "index.md").resolve()
|
||||
|
||||
|
||||
def validate_page(locale_dir: str, relative: str, canonical_route: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
source = DOCS_ROOT / relative
|
||||
page = DOCS_ROOT / locale_dir / relative
|
||||
label = f"{locale_dir}/{relative}"
|
||||
|
||||
if not page.exists():
|
||||
return [f"{label}: missing localized page"]
|
||||
|
||||
text = normalized_text(page)
|
||||
source_text = normalized_text(source)
|
||||
if text == source_text:
|
||||
errors.append(f"{label}: localized page is identical to English")
|
||||
if "translation_status: ai-translated" not in text[:500]:
|
||||
errors.append(f"{label}: missing translation_status: ai-translated frontmatter")
|
||||
if f"canonical_source: {canonical_route}" not in text[:500]:
|
||||
errors.append(f"{label}: canonical_source must be {canonical_route}")
|
||||
if text.count("```") % 2:
|
||||
errors.append(f"{label}: unbalanced fenced code block")
|
||||
|
||||
# Commands and configuration lines inside localized code blocks must remain
|
||||
# byte-for-byte present in the canonical English source. Localized prose and
|
||||
# headings are free to differ; executable material is not.
|
||||
for block in FENCE_RE.findall(text):
|
||||
for line in block.splitlines():
|
||||
command = line.strip()
|
||||
if command and not command.startswith("#") and command not in source_text:
|
||||
errors.append(f"{label}: translated or invented code line: {command}")
|
||||
|
||||
for raw_target in LINK_RE.findall(text):
|
||||
resolved = resolve_doc_link(page, raw_target)
|
||||
if resolved is not None and not resolved.exists():
|
||||
errors.append(f"{label}: broken internal link {raw_target} -> {resolved.relative_to(ROOT)}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def refresh(status: dict) -> None:
|
||||
source_hashes = {relative: digest(DOCS_ROOT / relative) for relative in CANONICAL_PAGES}
|
||||
for status_key, locale_dir in LOCALES.items():
|
||||
entry = status["locales"][status_key]
|
||||
entry["docs_locale"] = locale_dir
|
||||
entry["docs_source_sha256"] = source_hashes
|
||||
entry["surfaces"]["user_docs"] = "core-pages"
|
||||
STATUS_PATH.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Record current English page hashes after translations have been refreshed.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
status = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
||||
if args.refresh:
|
||||
refresh(status)
|
||||
status = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
errors: list[str] = []
|
||||
expected_hashes = {relative: digest(DOCS_ROOT / relative) for relative in CANONICAL_PAGES}
|
||||
|
||||
for status_key, locale_dir in LOCALES.items():
|
||||
entry = status.get("locales", {}).get(status_key)
|
||||
if entry is None:
|
||||
errors.append(f"{status_key}: missing localization status entry")
|
||||
continue
|
||||
if entry.get("docs_locale") != locale_dir:
|
||||
errors.append(f"{status_key}: docs_locale must be {locale_dir}")
|
||||
if entry.get("surfaces", {}).get("user_docs") != "core-pages":
|
||||
errors.append(f"{status_key}: user_docs surface must be core-pages")
|
||||
if entry.get("docs_source_sha256") != expected_hashes:
|
||||
errors.append(f"{status_key}: localized docs source hashes are stale; refresh translations, then run --refresh")
|
||||
|
||||
for relative, canonical_route in CANONICAL_PAGES.items():
|
||||
errors.extend(validate_page(locale_dir, relative, canonical_route))
|
||||
|
||||
if errors:
|
||||
print("Localized docs validation failed:")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"Localized docs validation passed "
|
||||
f"({len(LOCALES)} locales × {len(CANONICAL_PAGES)} core pages)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate marketing-site locale coverage and English-source freshness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TRANSLATIONS = ROOT / "website" / "src" / "i18n.ts"
|
||||
STATUS = ROOT / "docs" / "localization-status.json"
|
||||
LOCALES = {
|
||||
"de": "de",
|
||||
"es": "es",
|
||||
"ja": "ja",
|
||||
"pt-BR": "pt-BR",
|
||||
"zh-Hans": "zh-CN",
|
||||
}
|
||||
|
||||
|
||||
def english_source_hash(source: str) -> str:
|
||||
translations_start = source.index("export const translations")
|
||||
start = source.index(" en: {", translations_start)
|
||||
end = source.index("\n de: {", start)
|
||||
return hashlib.sha256(source[start:end].encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--refresh", action="store_true", help="Record the current canonical English copy hash")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = TRANSLATIONS.read_text(encoding="utf-8")
|
||||
status = json.loads(STATUS.read_text(encoding="utf-8"))
|
||||
source_hash = english_source_hash(source)
|
||||
failures: list[str] = []
|
||||
|
||||
for status_key, route_locale in LOCALES.items():
|
||||
entry = status.get("locales", {}).get(status_key)
|
||||
if not entry:
|
||||
failures.append(f"missing localization status entry: {status_key}")
|
||||
continue
|
||||
|
||||
if f" {route_locale!r}: {{" not in source and f" {route_locale}: {{" not in source:
|
||||
failures.append(f"missing marketing translation dictionary: {route_locale}")
|
||||
|
||||
if args.refresh:
|
||||
entry["website_source_sha256"] = source_hash
|
||||
entry.setdefault("surfaces", {})["website"] = "complete"
|
||||
else:
|
||||
if entry.get("website_source_sha256") != source_hash:
|
||||
failures.append(
|
||||
f"{status_key}: marketing translation is stale; review it and run "
|
||||
"scripts/check-website-locales.py --refresh"
|
||||
)
|
||||
if entry.get("surfaces", {}).get("website") != "complete":
|
||||
failures.append(f"{status_key}: surfaces.website must be complete")
|
||||
|
||||
english = status.get("locales", {}).get("en")
|
||||
if english:
|
||||
english.setdefault("surfaces", {})["website"] = "canonical"
|
||||
|
||||
if args.refresh:
|
||||
STATUS.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
if failures:
|
||||
print("Marketing locale validation failed:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print(f"Marketing locale validation passed ({len(LOCALES)} translations; source {source_hash[:12]})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+128
-49
@@ -18,33 +18,145 @@ function resolveAppVersion(): string {
|
||||
|
||||
const appVersion = resolveAppVersion()
|
||||
|
||||
type LocalizedThemeCopy = {
|
||||
label: string
|
||||
selectText: string
|
||||
quickStart: string
|
||||
installation: string
|
||||
releaseTracks: string
|
||||
troubleshooting: string
|
||||
englishReference: string
|
||||
overview: string
|
||||
coreGuides: string
|
||||
canonicalReference: string
|
||||
fullGuide: string
|
||||
remoteAccess: string
|
||||
apiReference: string
|
||||
outlineTitle: string
|
||||
previous: string
|
||||
next: string
|
||||
returnToTop: string
|
||||
menu: string
|
||||
appearance: string
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
function localizedTheme(prefix: string, copy: LocalizedThemeCopy) {
|
||||
return {
|
||||
label: copy.label,
|
||||
selectText: copy.selectText,
|
||||
nav: [
|
||||
{ text: copy.quickStart, link: `/${prefix}/guide/quick-start` },
|
||||
{ text: copy.installation, link: `/${prefix}/guide/getting-started` },
|
||||
{ text: copy.troubleshooting, link: `/${prefix}/guide/troubleshooting` },
|
||||
{ text: copy.englishReference, link: '/reference/api' },
|
||||
{ text: 'GitHub', link: 'https://github.com/Codename-11/hermes-relay' },
|
||||
],
|
||||
sidebar: {
|
||||
[`/${prefix}/`]: [
|
||||
{
|
||||
text: copy.coreGuides,
|
||||
items: [
|
||||
{ text: copy.overview, link: `/${prefix}/` },
|
||||
{ text: copy.quickStart, link: `/${prefix}/guide/quick-start` },
|
||||
{ text: copy.installation, link: `/${prefix}/guide/getting-started` },
|
||||
{ text: copy.releaseTracks, link: `/${prefix}/guide/release-tracks` },
|
||||
{ text: copy.troubleshooting, link: `/${prefix}/guide/troubleshooting` },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: copy.canonicalReference,
|
||||
items: [
|
||||
{ text: copy.fullGuide, link: '/guide/' },
|
||||
{ text: copy.remoteAccess, link: '/guide/remote-access' },
|
||||
{ text: copy.apiReference, link: '/reference/api' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
outlineTitle: copy.outlineTitle,
|
||||
docFooter: { prev: copy.previous, next: copy.next },
|
||||
returnToTopLabel: copy.returnToTop,
|
||||
sidebarMenuLabel: copy.menu,
|
||||
darkModeSwitchLabel: copy.appearance,
|
||||
lastUpdatedText: copy.lastUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
const localizedThemes = {
|
||||
de: localizedTheme('de', {
|
||||
label: 'Deutsch', selectText: 'Sprachen', quickStart: 'Schnellstart',
|
||||
installation: 'Installation', releaseTracks: 'App-Versionen', troubleshooting: 'Fehlerbehebung',
|
||||
englishReference: 'Englische Referenz', overview: 'Übersicht', coreGuides: 'Erste Schritte',
|
||||
canonicalReference: 'Englische Referenz', fullGuide: 'Vollständige Anleitung', remoteAccess: 'Fernzugriff',
|
||||
apiReference: 'API-Referenz', outlineTitle: 'Auf dieser Seite', previous: 'Zurück', next: 'Weiter',
|
||||
returnToTop: 'Nach oben', menu: 'Menü', appearance: 'Darstellung', lastUpdated: 'Zuletzt aktualisiert',
|
||||
}),
|
||||
es: localizedTheme('es', {
|
||||
label: 'Español', selectText: 'Idiomas', quickStart: 'Inicio rápido',
|
||||
installation: 'Instalación', releaseTracks: 'Versiones de la app', troubleshooting: 'Solución de problemas',
|
||||
englishReference: 'Referencia en inglés', overview: 'Descripción general', coreGuides: 'Primeros pasos',
|
||||
canonicalReference: 'Referencia en inglés', fullGuide: 'Guía completa', remoteAccess: 'Acceso remoto',
|
||||
apiReference: 'Referencia de API', outlineTitle: 'En esta página', previous: 'Anterior', next: 'Siguiente',
|
||||
returnToTop: 'Volver arriba', menu: 'Menú', appearance: 'Apariencia', lastUpdated: 'Última actualización',
|
||||
}),
|
||||
ja: localizedTheme('ja', {
|
||||
label: '日本語', selectText: '言語', quickStart: 'クイックスタート',
|
||||
installation: 'インストール', releaseTracks: 'アプリの種類', troubleshooting: 'トラブルシューティング',
|
||||
englishReference: '英語リファレンス', overview: '概要', coreGuides: 'はじめに',
|
||||
canonicalReference: '英語リファレンス', fullGuide: '完全なガイド', remoteAccess: 'リモートアクセス',
|
||||
apiReference: 'API リファレンス', outlineTitle: 'このページの内容', previous: '前へ', next: '次へ',
|
||||
returnToTop: 'ページ上部へ', menu: 'メニュー', appearance: '外観', lastUpdated: '最終更新',
|
||||
}),
|
||||
'pt-BR': localizedTheme('pt-BR', {
|
||||
label: 'Português (Brasil)', selectText: 'Idiomas', quickStart: 'Início rápido',
|
||||
installation: 'Instalação', releaseTracks: 'Versões do app', troubleshooting: 'Solução de problemas',
|
||||
englishReference: 'Referência em inglês', overview: 'Visão geral', coreGuides: 'Primeiros passos',
|
||||
canonicalReference: 'Referência em inglês', fullGuide: 'Guia completo', remoteAccess: 'Acesso remoto',
|
||||
apiReference: 'Referência da API', outlineTitle: 'Nesta página', previous: 'Anterior', next: 'Próxima',
|
||||
returnToTop: 'Voltar ao topo', menu: 'Menu', appearance: 'Aparência', lastUpdated: 'Última atualização',
|
||||
}),
|
||||
'zh-CN': localizedTheme('zh-CN', {
|
||||
label: '简体中文', selectText: '语言', quickStart: '快速开始',
|
||||
installation: '安装与设置', releaseTracks: '应用版本', troubleshooting: '故障排除',
|
||||
englishReference: '英文参考', overview: '概览', coreGuides: '入门指南',
|
||||
canonicalReference: '英文参考', fullGuide: '完整用户指南', remoteAccess: '远程访问',
|
||||
apiReference: 'API 参考', outlineTitle: '本页内容', previous: '上一页', next: '下一页',
|
||||
returnToTop: '返回顶部', menu: '菜单', appearance: '外观', lastUpdated: '最后更新',
|
||||
}),
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: '/hermes-relay/',
|
||||
base: '/docs/',
|
||||
title: 'Hermes-Relay',
|
||||
description: 'Hermes-Relay — the Android companion and remote-hands CLI for your Hermes agent. Runs on your machine; lives on your devices.',
|
||||
|
||||
locales: {
|
||||
root: { label: 'English', lang: 'en' },
|
||||
'zh-CN': { label: '简体中文', lang: 'zh-CN', link: '/zh-CN/' },
|
||||
de: { label: 'Deutsch', lang: 'de', link: '/de/', themeConfig: localizedThemes.de },
|
||||
es: { label: 'Español', lang: 'es', link: '/es/', themeConfig: localizedThemes.es },
|
||||
ja: { label: '日本語', lang: 'ja', link: '/ja/', themeConfig: localizedThemes.ja },
|
||||
'pt-BR': { label: 'Português (Brasil)', lang: 'pt-BR', link: '/pt-BR/', themeConfig: localizedThemes['pt-BR'] },
|
||||
'zh-CN': { label: '简体中文', lang: 'zh-CN', link: '/zh-CN/', themeConfig: localizedThemes['zh-CN'] },
|
||||
},
|
||||
|
||||
head: [
|
||||
// Favicon — base path is NOT auto-applied to head entries in VitePress,
|
||||
// so hard-prefix with /hermes-relay/ to match the GitHub Pages deploy.
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/hermes-relay/logo.svg' }],
|
||||
['link', { rel: 'apple-touch-icon', href: '/hermes-relay/logo.svg' }],
|
||||
// so hard-prefix with /docs/ to match the production deployment.
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/docs/logo.svg' }],
|
||||
['link', { rel: 'apple-touch-icon', href: '/docs/logo.svg' }],
|
||||
|
||||
// Canonical
|
||||
['link', { rel: 'canonical', href: 'https://codename-11.github.io/hermes-relay/' }],
|
||||
['link', { rel: 'canonical', href: 'https://hermes-relay.dev/docs/' }],
|
||||
|
||||
// Open Graph — crawlers (Facebook, Messenger, Slack, Discord, LinkedIn) need absolute URLs
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:site_name', content: 'Hermes-Relay' }],
|
||||
['meta', { property: 'og:title', content: 'Hermes-Relay — give your Hermes agent hands' }],
|
||||
['meta', { property: 'og:description', content: 'Runs on your machine. Lives on your devices. A native Android companion for chat, voice, and phone control — plus a single-binary CLI the agent uses to work on any machine you pair.' }],
|
||||
['meta', { property: 'og:url', content: 'https://codename-11.github.io/hermes-relay/' }],
|
||||
['meta', { property: 'og:image', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
|
||||
['meta', { property: 'og:image:secure_url', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
|
||||
['meta', { property: 'og:url', content: 'https://hermes-relay.dev/docs/' }],
|
||||
['meta', { property: 'og:image', content: 'https://hermes-relay.dev/docs/og-image.png' }],
|
||||
['meta', { property: 'og:image:secure_url', content: 'https://hermes-relay.dev/docs/og-image.png' }],
|
||||
['meta', { property: 'og:image:type', content: 'image/png' }],
|
||||
['meta', { property: 'og:image:width', content: '1024' }],
|
||||
['meta', { property: 'og:image:height', content: '500' }],
|
||||
@@ -54,7 +166,7 @@ export default defineConfig({
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
['meta', { name: 'twitter:title', content: 'Hermes-Relay — give your Hermes agent hands' }],
|
||||
['meta', { name: 'twitter:description', content: 'Android companion for chat, voice, and phone control + a single-binary CLI that gives your Hermes agent hands on any machine. One pair, every device.' }],
|
||||
['meta', { name: 'twitter:image', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
|
||||
['meta', { name: 'twitter:image', content: 'https://hermes-relay.dev/docs/og-image.png' }],
|
||||
['meta', { name: 'twitter:image:alt', content: 'Hermes-Relay — give your Hermes agent hands.' }],
|
||||
|
||||
// Theme — RelayRefresh.Background
|
||||
@@ -74,43 +186,7 @@ export default defineConfig({
|
||||
label: 'English',
|
||||
selectText: 'Languages',
|
||||
},
|
||||
'zh-CN': {
|
||||
label: '简体中文',
|
||||
selectText: '语言',
|
||||
nav: [
|
||||
{ text: '快速开始', link: '/zh-CN/guide/quick-start' },
|
||||
{ text: '功能', link: '/zh-CN/features/' },
|
||||
{ text: '英文参考', link: '/reference/api' },
|
||||
{ text: 'GitHub', link: 'https://github.com/Codename-11/hermes-relay' },
|
||||
],
|
||||
sidebar: {
|
||||
'/zh-CN/': [
|
||||
{
|
||||
text: 'Hermes-Relay',
|
||||
items: [
|
||||
{ text: '概览', link: '/zh-CN/' },
|
||||
{ text: '快速开始', link: '/zh-CN/guide/quick-start' },
|
||||
{ text: '功能概览', link: '/zh-CN/features/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '英文参考',
|
||||
items: [
|
||||
{ text: '完整用户指南', link: '/guide/' },
|
||||
{ text: '远程访问', link: '/guide/remote-access' },
|
||||
{ text: '故障排除', link: '/guide/troubleshooting' },
|
||||
{ text: 'API 参考', link: '/reference/api' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
outlineTitle: '本页内容',
|
||||
docFooter: { prev: '上一页', next: '下一页' },
|
||||
returnToTopLabel: '返回顶部',
|
||||
sidebarMenuLabel: '菜单',
|
||||
darkModeSwitchLabel: '外观',
|
||||
lastUpdatedText: '最后更新',
|
||||
},
|
||||
...localizedThemes,
|
||||
},
|
||||
|
||||
nav: [
|
||||
@@ -195,9 +271,12 @@ export default defineConfig({
|
||||
{
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Hermes API', link: '/reference/api' },
|
||||
{ text: 'API & Route Contract', link: '/reference/api' },
|
||||
{ text: 'Upstream Hermes', link: '/reference/upstream-hermes' },
|
||||
{ text: 'Relay API', link: '/reference/relay-api' },
|
||||
{ text: 'Compatibility', link: '/reference/compatibility' },
|
||||
{ text: 'Configuration', link: '/reference/configuration' },
|
||||
{ text: 'Relay Server', link: '/reference/relay-server' },
|
||||
{ text: 'Relay Server Operations', link: '/reference/relay-server' },
|
||||
{ text: 'Agent Cleanup Prompt', link: '/reference/agent-cleanup-prompt' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<script setup lang="ts">
|
||||
import { withBase } from 'vitepress'
|
||||
|
||||
const paths = [
|
||||
{
|
||||
label: 'Android companion',
|
||||
state: 'Stable',
|
||||
title: 'Put Hermes in your pocket',
|
||||
description:
|
||||
'Set up streaming chat, voice, Manage, notifications, and the optional sideload Device Control track.',
|
||||
links: [
|
||||
{ text: 'Quick start', href: withBase('/guide/quick-start.html') },
|
||||
{ text: 'Installation & setup', href: withBase('/guide/getting-started.html') },
|
||||
{ text: 'Release tracks', href: withBase('/guide/release-tracks.html') },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CLI · remote hands',
|
||||
state: 'Experimental',
|
||||
title: 'Pair another machine',
|
||||
description:
|
||||
'Install the single binary, pair it with Hermes, and expose consent-gated filesystem, terminal, and capture tools.',
|
||||
links: [
|
||||
{ text: 'Install the CLI', href: withBase('/desktop/installation.html') },
|
||||
{ text: 'Pair a machine', href: withBase('/desktop/pairing.html') },
|
||||
{ text: 'Commands', href: withBase('/desktop/subcommands.html') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const references = [
|
||||
{
|
||||
label: 'FEATURES',
|
||||
title: 'Understand a capability',
|
||||
description: 'Chat, voice, profiles, connections, tools, themes, and phone control.',
|
||||
href: withBase('/features/'),
|
||||
},
|
||||
{
|
||||
label: 'ARCHITECTURE',
|
||||
title: 'Trace the connection',
|
||||
description: 'See which Hermes, dashboard, and Relay surfaces handle each feature.',
|
||||
href: withBase('/architecture/'),
|
||||
},
|
||||
{
|
||||
label: 'REFERENCE',
|
||||
title: 'Look up the contract',
|
||||
description: 'API routes, configuration, Relay server deployment, and operational details.',
|
||||
href: withBase('/reference/api.html'),
|
||||
},
|
||||
]
|
||||
|
||||
const commonTasks = [
|
||||
{ text: 'Connect from outside your LAN', href: withBase('/guide/remote-access.html') },
|
||||
{ text: 'Troubleshoot a connection', href: withBase('/guide/troubleshooting.html') },
|
||||
{ text: 'Choose Google Play or sideload', href: withBase('/guide/release-tracks.html') },
|
||||
{ text: 'Add the optional Relay plugin', href: withBase('/guide/getting-started.html#relay-server-optional') },
|
||||
{ text: 'Check whether a connection is secure', href: withBase('/architecture/connection-security.html') },
|
||||
{ text: 'Troubleshoot the CLI', href: withBase('/desktop/troubleshooting.html') },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="docs-hub" aria-label="Documentation paths">
|
||||
<section class="docs-hub__section" aria-labelledby="choose-surface">
|
||||
<div class="docs-hub__heading">
|
||||
<p class="docs-hub__eyebrow">CHOOSE A SURFACE</p>
|
||||
<h2 id="choose-surface">Two clients. One Hermes.</h2>
|
||||
<p>Start with the device you want to connect. Relay stays optional until you need power tools.</p>
|
||||
</div>
|
||||
|
||||
<div class="surface-grid">
|
||||
<article v-for="path in paths" :key="path.label" class="surface-card">
|
||||
<header>
|
||||
<span>{{ path.label }}</span>
|
||||
<span class="surface-card__state">{{ path.state }}</span>
|
||||
</header>
|
||||
<h3>{{ path.title }}</h3>
|
||||
<p>{{ path.description }}</p>
|
||||
<nav :aria-label="`${path.label} documentation`">
|
||||
<a v-for="link in path.links" :key="link.href" :href="link.href">{{ link.text }} <span aria-hidden="true">→</span></a>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relay-path" aria-labelledby="relay-path-title">
|
||||
<div>
|
||||
<p class="docs-hub__eyebrow"><span class="status-dot" aria-hidden="true"></span> OPTIONAL POWER PATH</p>
|
||||
<h2 id="relay-path-title">Add Relay when you need hands.</h2>
|
||||
<p>
|
||||
Phone control, terminal sessions, remote desktop tools, notification forwarding, and paired CLI machines all begin here.
|
||||
</p>
|
||||
</div>
|
||||
<div class="relay-path__actions">
|
||||
<a class="primary-link" :href="withBase('/guide/getting-started.html#relay-server-optional')">Install the Relay plugin <span aria-hidden="true">→</span></a>
|
||||
<a :href="withBase('/reference/relay-server.html')">Relay server reference</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="docs-hub__section" aria-labelledby="find-answer">
|
||||
<div class="docs-hub__heading">
|
||||
<p class="docs-hub__eyebrow">GO DEEPER</p>
|
||||
<h2 id="find-answer">Find the level of detail you need.</h2>
|
||||
</div>
|
||||
|
||||
<div class="reference-grid">
|
||||
<a v-for="item in references" :key="item.href" class="reference-card" :href="item.href">
|
||||
<span>{{ item.label }}</span>
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
<strong aria-hidden="true">→</strong>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="common-tasks" aria-labelledby="common-tasks-title">
|
||||
<div>
|
||||
<p class="docs-hub__eyebrow">COMMON TASKS</p>
|
||||
<h2 id="common-tasks-title">Jump straight to the answer.</h2>
|
||||
</div>
|
||||
<nav aria-label="Common documentation tasks">
|
||||
<a v-for="task in commonTasks" :key="task.href" :href="task.href">
|
||||
<span>{{ task.text }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</nav>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-hub {
|
||||
width: min(1120px, calc(100% - 48px));
|
||||
margin: 8px auto 0;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.docs-hub__section {
|
||||
padding: 56px 0;
|
||||
border-top: 1px solid var(--hr-line);
|
||||
}
|
||||
|
||||
.docs-hub__heading {
|
||||
max-width: 660px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.docs-hub__eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--vp-c-brand-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.docs-hub h2,
|
||||
.docs-hub h3,
|
||||
.docs-hub p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-hub h2 {
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.docs-hub__heading > p:last-child,
|
||||
.relay-path p,
|
||||
.surface-card > p,
|
||||
.reference-card p {
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.docs-hub__heading > p:last-child {
|
||||
margin-top: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.surface-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.surface-card,
|
||||
.reference-card,
|
||||
.relay-path {
|
||||
border: 1px solid var(--hr-line);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
display: flex;
|
||||
min-height: 330px;
|
||||
padding: 26px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.surface-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--vp-c-brand-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.surface-card__state {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--hr-line);
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-text-3);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.surface-card h3 {
|
||||
margin-top: 38px;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 25px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.surface-card > p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.surface-card nav {
|
||||
display: grid;
|
||||
margin-top: auto;
|
||||
padding-top: 26px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.surface-card nav a,
|
||||
.common-tasks nav a {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--hr-line);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.surface-card nav a:hover,
|
||||
.common-tasks nav a:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.relay-path {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
|
||||
gap: 40px;
|
||||
padding: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.relay-path p:not(.docs-hub__eyebrow) {
|
||||
max-width: 650px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hr-green);
|
||||
}
|
||||
|
||||
.relay-path__actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relay-path__actions a {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--hr-line);
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.relay-path__actions .primary-link {
|
||||
border-color: var(--vp-c-brand-2);
|
||||
background: var(--vp-c-brand-2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reference-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reference-card {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
padding: 22px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: border-color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.reference-card:hover {
|
||||
border-color: var(--hr-line-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.reference-card > span {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.reference-card h3 {
|
||||
margin-top: 28px;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.reference-card p {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reference-card strong {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
bottom: 18px;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.common-tasks {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: 64px;
|
||||
padding: 56px 0 0;
|
||||
border-top: 1px solid var(--hr-line);
|
||||
}
|
||||
|
||||
.common-tasks h2 {
|
||||
font-size: clamp(24px, 2.5vw, 32px);
|
||||
}
|
||||
|
||||
.common-tasks nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 28px;
|
||||
}
|
||||
|
||||
.common-tasks nav a {
|
||||
min-height: 52px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.docs-hub {
|
||||
width: min(100% - 32px, 640px);
|
||||
margin-top: 0;
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
|
||||
.docs-hub__section {
|
||||
padding: 42px 0;
|
||||
}
|
||||
|
||||
.surface-grid,
|
||||
.reference-grid,
|
||||
.relay-path,
|
||||
.common-tasks,
|
||||
.common-tasks nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
min-height: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.surface-card h3 {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.relay-path {
|
||||
gap: 26px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.reference-card {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.common-tasks {
|
||||
gap: 24px;
|
||||
padding-top: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reference-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { withBase } from 'vitepress'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
maxWidth?: string
|
||||
}>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
|
||||
function openImage() {
|
||||
dialog.value?.showModal()
|
||||
}
|
||||
|
||||
function closeImage() {
|
||||
dialog.value?.close()
|
||||
}
|
||||
|
||||
function closeOnBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) closeImage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="expandable-image" :style="maxWidth ? { maxWidth } : undefined">
|
||||
<button type="button" class="expandable-image__trigger" :aria-label="`Expand image: ${alt}`" @click="openImage">
|
||||
<img :src="withBase(src)" :alt="alt" />
|
||||
<span class="expandable-image__hint">Expand</span>
|
||||
</button>
|
||||
<figcaption v-if="caption">{{ caption }}</figcaption>
|
||||
|
||||
<dialog ref="dialog" class="expandable-image__dialog" :aria-label="alt" @click="closeOnBackdrop">
|
||||
<div class="expandable-image__stage">
|
||||
<button type="button" class="expandable-image__close" @click="closeImage">Close</button>
|
||||
<img :src="withBase(src)" :alt="alt" />
|
||||
</div>
|
||||
</dialog>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.expandable-image {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0 1.75rem;
|
||||
}
|
||||
|
||||
.expandable-image[style] {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.expandable-image__trigger {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--hr-line);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.expandable-image__trigger img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.expandable-image__trigger:hover img {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.expandable-image__trigger:focus-visible,
|
||||
.expandable-image__close:focus-visible {
|
||||
outline: 2px solid var(--vp-c-brand-1);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.expandable-image__hint,
|
||||
.expandable-image__close {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expandable-image__hint {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid rgba(247, 246, 240, 0.24);
|
||||
border-radius: 4px;
|
||||
background: rgba(8, 9, 13, 0.88);
|
||||
color: #f7f6f0;
|
||||
}
|
||||
|
||||
.expandable-image figcaption {
|
||||
margin-top: 8px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.expandable-image__dialog {
|
||||
width: min(96vw, 1500px);
|
||||
max-width: none;
|
||||
height: min(92vh, 1000px);
|
||||
max-height: none;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(247, 246, 240, 0.2);
|
||||
border-radius: 8px;
|
||||
background: #08090d;
|
||||
color: #f7f6f0;
|
||||
}
|
||||
|
||||
.expandable-image__dialog::backdrop {
|
||||
background: rgba(2, 3, 7, 0.88);
|
||||
}
|
||||
|
||||
.expandable-image__stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 52px 20px 20px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.expandable-image__stage img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.expandable-image__close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(247, 246, 240, 0.24);
|
||||
border-radius: 4px;
|
||||
background: #191b31;
|
||||
color: #f7f6f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.expandable-image__trigger img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { withBase } from 'vitepress'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="first-run" aria-labelledby="first-run-title">
|
||||
<header class="first-run__header">
|
||||
<div>
|
||||
<span>What you’ll see</span>
|
||||
<h3 id="first-run-title">A successful connection, then your first chat.</h3>
|
||||
</div>
|
||||
<ol aria-label="First-run progress">
|
||||
<li><strong>01</strong> Save Hermes</li>
|
||||
<li><strong>02</strong> Check capabilities</li>
|
||||
<li><strong>03</strong> Send a message</li>
|
||||
</ol>
|
||||
</header>
|
||||
|
||||
<div class="first-run__frames">
|
||||
<figure>
|
||||
<img
|
||||
:src="withBase('/connections-demo.png')"
|
||||
alt="Hermes-Relay Connections screen with API, Dashboard, Voice, and Relay readiness rows."
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>
|
||||
<strong>Connection ready.</strong>
|
||||
Green rows show the surfaces your phone can reach. Relay may remain optional.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure>
|
||||
<img
|
||||
:src="withBase('/chat-demo.png')"
|
||||
alt="Hermes-Relay Chat screen showing a streamed response and message composer."
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>
|
||||
<strong>Chat ready.</strong>
|
||||
The status strip names the active route, model, and profile.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.first-run {
|
||||
margin: 1.5rem 0 2rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.first-run__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.9fr);
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.first-run__header > div > span {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.first-run__header h3 {
|
||||
margin: 6px 0 0;
|
||||
border: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.first-run__header ol {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.first-run__header li {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.first-run__header li strong {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.first-run__frames {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.first-run figure {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(140px, 0.8fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.first-run img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 330px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.first-run figcaption {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.first-run figcaption strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.first-run__frames,
|
||||
.first-run__header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.first-run figure {
|
||||
grid-template-columns: minmax(0, 145px) 1fr;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.first-run img {
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -63,22 +63,22 @@ const diagrams: Record<string, DiagramDef> = {
|
||||
// ── Chat Flow: message lifecycle through the app ──────────────
|
||||
'chat-flow': {
|
||||
nodes: [
|
||||
{ id: '1', type: 'hermes', position: { x: 0, y: 50 }, data: { label: 'User Message' }, sourcePosition: Position.Right },
|
||||
{ id: '2', type: 'hermes', position: { x: 190, y: 50 }, data: { label: 'ChatViewModel' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '3', type: 'hermes', position: { x: 190, y: 140 }, data: { label: 'Create Session' }, sourcePosition: Position.Right, targetPosition: Position.Top },
|
||||
{ id: '4', type: 'hermes', position: { x: 420, y: 50 }, data: { label: 'ApiClient', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '5', type: 'hermes', position: { x: 620, y: 50 }, data: { label: 'SSE Stream' }, sourcePosition: Position.Bottom, targetPosition: Position.Left },
|
||||
{ id: '6', type: 'hermes', position: { x: 620, y: 140 }, data: { label: 'ChatHandler' }, sourcePosition: Position.Left, targetPosition: Position.Top },
|
||||
{ id: '7', type: 'hermes', position: { x: 420, y: 140 }, data: { label: 'StateFlow' }, sourcePosition: Position.Left, targetPosition: Position.Right },
|
||||
{ id: '8', type: 'hermes', position: { x: 190, y: 230 }, data: { label: 'Compose UI', accent: true }, targetPosition: Position.Top },
|
||||
{ id: '1', type: 'hermes', position: { x: 0, y: 20 }, data: { label: 'User Message' }, sourcePosition: Position.Right },
|
||||
{ id: '2', type: 'hermes', position: { x: 180, y: 20 }, data: { label: 'ChatViewModel' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '3', type: 'hermes', position: { x: 300, y: 105 }, data: { label: 'Create Session' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '4', type: 'hermes', position: { x: 430, y: 20 }, data: { label: 'ApiClient', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '5', type: 'hermes', position: { x: 630, y: 20 }, data: { label: 'SSE Stream' }, sourcePosition: Position.Bottom, targetPosition: Position.Left },
|
||||
{ id: '6', type: 'hermes', position: { x: 630, y: 185 }, data: { label: 'ChatHandler' }, sourcePosition: Position.Left, targetPosition: Position.Top },
|
||||
{ id: '7', type: 'hermes', position: { x: 430, y: 185 }, data: { label: 'StateFlow' }, sourcePosition: Position.Left, targetPosition: Position.Right },
|
||||
{ id: '8', type: 'hermes', position: { x: 180, y: 185 }, data: { label: 'Compose UI', accent: true }, targetPosition: Position.Right },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e2', source: '2', target: '3', style: edgeDim, label: 'if new' },
|
||||
{ id: 'e2', source: '2', target: '3', style: edgeDim, label: 'if new', type: 'smoothstep' },
|
||||
{ id: 'e3', source: '2', target: '4', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e4', source: '3', target: '4', animated: edgeAnimated, style: edgeDim },
|
||||
{ id: 'e4', source: '3', target: '4', animated: edgeAnimated, style: edgeDim, type: 'smoothstep' },
|
||||
{ id: 'e5', source: '4', target: '5', animated: edgeAnimated, style: edgeStyle, label: '/chat/stream' },
|
||||
{ id: 'e6', source: '5', target: '6', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e6', source: '5', target: '6', animated: edgeAnimated, style: edgeStyle, type: 'smoothstep' },
|
||||
{ id: 'e7', source: '6', target: '7', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e8', source: '7', target: '8', animated: edgeAnimated, style: edgeStyle },
|
||||
],
|
||||
@@ -130,20 +130,20 @@ const diagrams: Record<string, DiagramDef> = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'hermes', position: { x: 0, y: 50 }, data: { label: 'App Start' }, sourcePosition: Position.Right },
|
||||
{ id: '2', type: 'hermes', position: { x: 160, y: 50 }, data: { label: 'Check Token' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '3', type: 'hermes', position: { x: 340, y: 0 }, data: { label: 'Connect (WSS)', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '4', type: 'hermes', position: { x: 340, y: 100 }, data: { label: 'Show Pairing Code' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '5', type: 'hermes', position: { x: 540, y: 100 }, data: { label: 'Validate Code' }, sourcePosition: Position.Top, targetPosition: Position.Left },
|
||||
{ id: '6', type: 'hermes', position: { x: 540, y: 0 }, data: { label: 'Store Token' }, sourcePosition: Position.Left, targetPosition: Position.Bottom },
|
||||
{ id: '7', type: 'hermes', position: { x: 700, y: 0 }, data: { label: 'Connected' }, targetPosition: Position.Left },
|
||||
{ id: '3', type: 'hermes', position: { x: 350, y: 0 }, data: { label: 'Connect (WSS)', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '4', type: 'hermes', position: { x: 350, y: 110 }, data: { label: 'Show Pairing Code' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '5', type: 'hermes', position: { x: 535, y: 110 }, data: { label: 'Validate Code' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '6', type: 'hermes', position: { x: 710, y: 110 }, data: { label: 'Store Token' }, sourcePosition: Position.Right, targetPosition: Position.Left },
|
||||
{ id: '7', type: 'hermes', position: { x: 880, y: 50 }, data: { label: 'Connected' }, targetPosition: Position.Left },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e2', source: '2', target: '3', animated: edgeAnimated, style: edgeStyle, label: 'has token' },
|
||||
{ id: 'e3', source: '2', target: '4', style: edgeDim, label: 'no token' },
|
||||
{ id: 'e3', source: '2', target: '4', style: edgeDim, label: 'no token', type: 'smoothstep' },
|
||||
{ id: 'e4', source: '4', target: '5', animated: edgeAnimated, style: edgeDim },
|
||||
{ id: 'e5', source: '5', target: '6', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e6', source: '6', target: '3', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e7', source: '3', target: '7', animated: edgeAnimated, style: edgeStyle },
|
||||
{ id: 'e6', source: '6', target: '7', animated: edgeAnimated, style: edgeStyle, type: 'smoothstep' },
|
||||
{ id: 'e7', source: '3', target: '7', animated: edgeAnimated, style: edgeStyle, type: 'smoothstep' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { withBase } from 'vitepress'
|
||||
|
||||
defineProps<{ explainRelay?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="track-chooser" aria-label="Choose an Android release track">
|
||||
|
||||
<article class="track-card track-card--recommended">
|
||||
<div class="track-card__label">Recommended</div>
|
||||
<h3>Google Play</h3>
|
||||
<p>Fastest setup, automatic updates, and the complete everyday Hermes experience.</p>
|
||||
<ul>
|
||||
<li>Chat, voice, profiles, and Manage</li>
|
||||
<li>Terminal, media, and notifications with Relay</li>
|
||||
<li>No AccessibilityService or unattended phone control</li>
|
||||
</ul>
|
||||
<StoreBadge />
|
||||
</article>
|
||||
|
||||
<article class="track-card">
|
||||
<div class="track-card__label">Power users</div>
|
||||
<h3>Sideload</h3>
|
||||
<p>The same app plus Device Control for people who want Hermes to operate the phone.</p>
|
||||
<ul>
|
||||
<li>Everything in the Google Play build</li>
|
||||
<li>Screen reading, taps, typing, and navigation</li>
|
||||
<li>Manual signed-APK installation and updates</li>
|
||||
</ul>
|
||||
<a class="track-card__action" href="https://github.com/Codename-11/hermes-relay/releases">
|
||||
Download the signed APK <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<aside v-if="explainRelay" class="track-boundary">
|
||||
<strong>Build choice ≠ Relay choice.</strong>
|
||||
<span>
|
||||
Google Play versus Sideload controls what Android can do. The optional
|
||||
Relay plugin controls access to terminal, media, notifications, and
|
||||
device channels on your Hermes host.
|
||||
</span>
|
||||
<a :href="withBase('/guide/getting-started.html#relay-server-optional')">How Relay fits in →</a>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.track-chooser {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.track-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.track-card--recommended {
|
||||
border-color: color-mix(in srgb, var(--vp-c-brand-1) 55%, var(--vp-c-divider));
|
||||
background: color-mix(in srgb, var(--vp-c-brand-soft) 45%, var(--vp-c-bg-alt));
|
||||
}
|
||||
|
||||
.track-card__label {
|
||||
width: fit-content;
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.track-card h3 {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.track-card p {
|
||||
margin: 8px 0 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.track-card ul {
|
||||
flex: 1;
|
||||
margin: 0 0 18px;
|
||||
padding-left: 1.15rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.track-card li {
|
||||
margin: 0.4rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.track-card__action {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--hr-line-strong);
|
||||
border-radius: 999px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.track-card__action:hover,
|
||||
.track-card__action:focus-visible {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.track-boundary {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px 18px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-left: 2px solid var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.track-boundary strong {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.track-boundary a {
|
||||
white-space: nowrap;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.track-chooser {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.track-boundary {
|
||||
grid-column: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<nav class="symptom-nav" aria-labelledby="symptom-nav-title">
|
||||
<header>
|
||||
<span>Start with the symptom</span>
|
||||
<h2 id="symptom-nav-title">What do you see?</h2>
|
||||
</header>
|
||||
<div class="symptom-nav__grid">
|
||||
<a href="#cannot-connect"><strong>Red dot or no connection</strong><span>Check routes, host binding, and firewall.</span></a>
|
||||
<a href="#messages-not-streaming"><strong>Chat does not stream</strong><span>Check authentication and server logs.</span></a>
|
||||
<a href="#no-internet"><strong>No internet banner</strong><span>Confirm Android network state.</span></a>
|
||||
<a href="#session-history"><strong>Sessions are missing</strong><span>Reconnect before loading history.</span></a>
|
||||
<a href="#app-crashes"><strong>App crashes on startup</strong><span>Reset local connection state safely.</span></a>
|
||||
<a href="#tailscale-checklist"><strong>Works at home, not away</strong><span>Verify the saved Tailscale route.</span></a>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.symptom-nav {
|
||||
margin: 1.5rem 0 2rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.symptom-nav header {
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.symptom-nav header span {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.symptom-nav h2 {
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.symptom-nav__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.symptom-nav__grid a {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 15px 18px;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.symptom-nav__grid a:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.symptom-nav__grid a:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.symptom-nav__grid a:hover,
|
||||
.symptom-nav__grid a:focus-visible {
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.symptom-nav__grid strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.symptom-nav__grid span {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.symptom-nav__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.symptom-nav__grid a,
|
||||
.symptom-nav__grid a:nth-child(2n),
|
||||
.symptom-nav__grid a:nth-last-child(-n + 2) {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.symptom-nav__grid a:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,16 +3,16 @@ import './custom.css';
|
||||
import { h } from 'vue';
|
||||
import { withBase } from 'vitepress';
|
||||
import HermesFlow from './components/HermesFlow.vue';
|
||||
import InstallSection from './components/InstallSection.vue';
|
||||
import HeroDemo from './components/HeroDemo.vue';
|
||||
import FeatureMatrix from './components/FeatureMatrix.vue';
|
||||
import SphereMark from './components/SphereMark.vue';
|
||||
import ExperimentalBadge from './components/ExperimentalBadge.vue';
|
||||
import HowItWorks from './components/HowItWorks.vue';
|
||||
import SurfaceCards from './components/SurfaceCards.vue';
|
||||
import StoreBadge from './components/StoreBadge.vue';
|
||||
import CombineModel from './components/CombineModel.vue';
|
||||
import PetPackBuilder from './components/PetPackBuilder.vue';
|
||||
import DocsHomeHub from './components/DocsHomeHub.vue';
|
||||
import ExpandableImage from './components/ExpandableImage.vue';
|
||||
import FirstRunPreview from './components/FirstRunPreview.vue';
|
||||
import ReleaseTrackChooser from './components/ReleaseTrackChooser.vue';
|
||||
import TroubleshootingNavigator from './components/TroubleshootingNavigator.vue';
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
@@ -24,22 +24,20 @@ export default {
|
||||
app.component('StoreBadge', StoreBadge);
|
||||
app.component('CombineModel', CombineModel);
|
||||
app.component('PetPackBuilder', PetPackBuilder);
|
||||
app.component('DocsHomeHub', DocsHomeHub);
|
||||
app.component('ExpandableImage', ExpandableImage);
|
||||
app.component('FirstRunPreview', FirstRunPreview);
|
||||
app.component('ReleaseTrackChooser', ReleaseTrackChooser);
|
||||
app.component('TroubleshootingNavigator', TroubleshootingNavigator);
|
||||
},
|
||||
Layout() {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
'home-hero-image': () => h(HeroDemo),
|
||||
'home-hero-actions-after': () =>
|
||||
h('div', { class: 'hero-store-row' }, [
|
||||
h(StoreBadge),
|
||||
h('p', { class: 'hero-platform-note' }, 'CLI: Windows today · macOS / Linux coming soon'),
|
||||
]),
|
||||
'home-hero-after': () => [h(SphereMark), h(HowItWorks), h(SurfaceCards), h(InstallSection)],
|
||||
'doc-after': () =>
|
||||
h('div', { class: 'doc-footer-cta' }, [
|
||||
h('hr'),
|
||||
h('p', {
|
||||
innerHTML:
|
||||
`<strong>[?]</strong> <a href="https://github.com/Codename-11/hermes-relay/discussions">Ask a Question</a> · <strong>[!]</strong> <a href="https://github.com/Codename-11/hermes-relay/issues/new">Found a Bug?</a> · <strong>[+]</strong> <a href="${withBase('/guide/getting-started')}">Get Started</a>`,
|
||||
`<strong>[?]</strong> <a href="${withBase('/guide/troubleshooting.html')}">Get Help</a> · <strong>[!]</strong> <a href="https://github.com/Codename-11/hermes-relay/issues/new">Found a Bug?</a> · <strong>[+]</strong> <a href="${withBase('/guide/getting-started.html')}">Get Started</a>`,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress'
|
||||
</script>
|
||||
|
||||
# Architecture
|
||||
|
||||
<img :src="withBase('/architecture-homepage.svg')" alt="How Hermes-Relay connects: Vanilla Hermes runs chat, Manage and voice with no plugin; the optional Relay plugin adds terminal, bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." style="width:100%;margin:0.5rem 0 1.75rem;" />
|
||||
<ExpandableImage
|
||||
src="/architecture-homepage.svg"
|
||||
alt="How Hermes-Relay connects: Vanilla Hermes runs chat, Manage and voice with no plugin; the optional Relay plugin adds terminal, bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build."
|
||||
caption="Select the diagram to inspect it at full size."
|
||||
/>
|
||||
|
||||
## Connection Model
|
||||
|
||||
@@ -77,7 +77,7 @@ The Hermes API Server streams events using Server-Sent Events. Each event type m
|
||||
|
||||
The relay connection (bridge/terminal) uses a pairing code for initial setup, then session tokens for persistence.
|
||||
|
||||
<HermesFlow diagram="auth-flow" height="200px" />
|
||||
<HermesFlow diagram="auth-flow" height="220px" />
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/getting-started
|
||||
---
|
||||
|
||||
# Installation & Einrichtung
|
||||
|
||||
Drei Schritte: App installieren, mit Hermes verbinden, erste Nachricht senden.
|
||||
Wenn Hermes bereits läuft, muss auf dem Server nichts zusätzlich installiert werden.
|
||||
|
||||
::: tip Übersetzungsstatus
|
||||
Diese kompakte Übersetzung beschreibt den üblichen Einstieg. Erweiterte
|
||||
Server-, TLS- und Betreiberoptionen stehen in der
|
||||
[vollständigen englischen Anleitung](/guide/getting-started).
|
||||
:::
|
||||
|
||||
## 1. App auswählen
|
||||
|
||||
| | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| Empfohlen für | Die meisten Nutzer | Nutzer von Device Control |
|
||||
| Updates | Automatisch | APK manuell aktualisieren |
|
||||
| Chat, Voice, Manage | Enthalten | Enthalten |
|
||||
| Terminal, Medien, Benachrichtigungen mit Relay | Enthalten | Enthalten |
|
||||
| Bildschirm lesen, tippen, schreiben, navigieren | Nicht enthalten | Enthalten |
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
Die signierte Sideload-Datei endet mit `-sideload-release.apk` und liegt unter
|
||||
[GitHub Releases](https://github.com/Codename-11/hermes-relay/releases). Lade
|
||||
nicht die `.aab`-Datei herunter; sie ist nur für Google Play bestimmt.
|
||||
|
||||
## 2. Hermes erreichbar machen
|
||||
|
||||
Android benötigt den Hermes-API-Server, normalerweise unter `:8642`:
|
||||
|
||||
- `API_SERVER_ENABLED=true` aktiviert den Server.
|
||||
- `API_SERVER_HOST=0.0.0.0` macht ihn im Netzwerk erreichbar.
|
||||
- `API_SERVER_KEY` schützt Chat-Anfragen mit einem Bearer-Schlüssel.
|
||||
- `hermes gateway` startet Hermes und den aktivierten API-Server.
|
||||
|
||||
::: warning Netzwerkzugriff absichern
|
||||
`0.0.0.0` erlaubt anderen Geräten im Netzwerk den Zugriff. Verwende einen
|
||||
starken API-Schlüssel. Stelle einen unverschlüsselten Port niemals direkt ins
|
||||
Internet; verwende für den Fernzugriff Tailscale, ein VPN oder HTTPS.
|
||||
:::
|
||||
|
||||
Das Dashboard unter `:9119` ist optional. Es wird für Manage und Standard-Voice
|
||||
benötigt und hat eine eigene Anmeldung; der API-Schlüssel ist kein Dashboard-Login.
|
||||
|
||||
## 3. Verbinden und chatten
|
||||
|
||||
1. Öffne **Connect** in der Android-App.
|
||||
2. Suche Hermes im LAN, scanne einen Einrichtungs-QR-Code oder trage API-URL und Schlüssel ein.
|
||||
3. Tippe auf **Connect**.
|
||||
4. Prüfe, ob **Chat · Ready** angezeigt wird.
|
||||
5. Öffne Chat und sende die erste Nachricht.
|
||||
|
||||
Manage und Voice dürfen noch eine Anmeldung verlangen. Ein ungepaartes Relay
|
||||
ist ebenfalls normal.
|
||||
|
||||
## Optional: Relay-Werkzeuge hinzufügen
|
||||
|
||||
Installiere das Plugin nur für Terminal, Device Control, Medien,
|
||||
Benachrichtigungen oder erweiterte Remote-Werkzeuge. Die maßgeblichen Befehle
|
||||
sind `hermes plugins install Codename-11/hermes-relay/plugin --enable`,
|
||||
`hermes relay doctor`, `hermes relay start --no-ssl` und `hermes pair`.
|
||||
|
||||
Device Control benötigt **beides**: die Sideload-App und ein gepaartes Relay.
|
||||
|
||||
[App-Versionen vergleichen →](/de/guide/release-tracks) ·
|
||||
[Fernzugriff auf Englisch →](/guide/remote-access) ·
|
||||
[Fehlerbehebung →](/de/guide/troubleshooting)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/quick-start
|
||||
---
|
||||
|
||||
# Schnellstart
|
||||
|
||||
Installieren → verbinden → chatten, in ungefähr zwei Minuten. Für diesen
|
||||
Standardweg reicht ein normaler Hermes Agent; das Relay-Plugin ist nicht nötig.
|
||||
|
||||
::: tip Übersetzungsstatus
|
||||
Diese Seite wurde KI-gestützt übersetzt und technisch geprüft. Englisch bleibt
|
||||
die verbindliche Quelle für Produkt- und Sicherheitsbedeutung.
|
||||
:::
|
||||
|
||||
## 1. App installieren
|
||||
|
||||
Für die meisten Nutzer ist **Google Play** der schnellste Weg: Installation mit
|
||||
einem Tipp und automatische Updates.
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
Wenn Hermes den Bildschirm lesen, tippen, Text eingeben oder Apps bedienen soll,
|
||||
installiere stattdessen die signierte **Sideload-APK**. Beide Varianten können
|
||||
gleichzeitig auf demselben Gerät installiert sein.
|
||||
|
||||
## 2. Hermes starten
|
||||
|
||||
Auf dem Host muss Hermes erreichbar sein und der API-Server laufen. Starte ihn
|
||||
bei Bedarf mit `hermes gateway`. Die ausführliche Einrichtung steht unter
|
||||
[Installation & Einrichtung](/de/guide/getting-started).
|
||||
|
||||
## 3. Verbinden
|
||||
|
||||
Öffne die App und gehe zu **Connect**. Nutze eine der folgenden Möglichkeiten:
|
||||
|
||||
1. **Scan for Hermes on LAN** sucht den Server im lokalen Netz.
|
||||
2. Trage die Adresse wie `http://<host>:8642` und den konfigurierten API-Schlüssel ein.
|
||||
3. Scanne einen Einrichtungs-QR-Code mit URL und Schlüssel.
|
||||
|
||||
Wenn der Host absichtlich ohne `API_SERVER_KEY` läuft, bleibt das Schlüsselfeld leer.
|
||||
|
||||
## 4. Status prüfen
|
||||
|
||||
- **Chat · Ready** bedeutet, dass du Nachrichten senden kannst.
|
||||
- **Manage** kann noch eine Dashboard-Anmeldung verlangen.
|
||||
- **Voice** wird mit derselben Dashboard-Anmeldung freigeschaltet.
|
||||
- **Relay** darf ungepaart bleiben und blockiert den Standardweg nicht.
|
||||
|
||||
## 5. Erste Nachricht senden
|
||||
|
||||
Öffne Chat und sende eine Nachricht. Ein grüner Verbindungspunkt im Kopfbereich
|
||||
bestätigt, dass die aktive Hermes-Verbindung erreichbar ist.
|
||||
|
||||
[Ausführliche Installation →](/de/guide/getting-started) ·
|
||||
[Fehlerbehebung →](/de/guide/troubleshooting) ·
|
||||
[Vollständige englische Anleitung →](/guide/quick-start)
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/release-tracks
|
||||
---
|
||||
|
||||
# App-Versionen: Google Play oder Sideload
|
||||
|
||||
Beginne mit Google Play, außer du brauchst ausdrücklich Device Control. Beide
|
||||
Apps stammen aus demselben Quellcode und können gleichzeitig installiert werden.
|
||||
|
||||
## Entscheidungshilfe
|
||||
|
||||
| Frage | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| Einfache Installation und automatische Updates? | Ja | Nein |
|
||||
| Chat, Profile, Manage und Voice? | Ja | Ja |
|
||||
| Terminal, Medien und Benachrichtigungen mit Relay? | Ja | Ja |
|
||||
| Bildschirm lesen oder aufnehmen? | Nein | Ja |
|
||||
| Tippen, schreiben, wischen und Apps bedienen? | Nein | Ja |
|
||||
|
||||
## App-Version und Relay sind getrennte Entscheidungen
|
||||
|
||||
Die **App-Version** bestimmt, ob Android Device Control enthält. Das optionale
|
||||
**Relay-Plugin** verbindet Terminal, Medien, Benachrichtigungen und
|
||||
Gerätekanäle mit dem Hermes-Host.
|
||||
|
||||
Device Control funktioniert nur mit **Sideload + gepaartem Relay**. Chat,
|
||||
Manage und Standard-Voice benötigen keines von beiden.
|
||||
|
||||
## Später wechseln
|
||||
|
||||
Google Play und Sideload verwenden unterschiedliche Anwendungs-IDs. Du kannst
|
||||
beide testen und eine Variante später entfernen. Einstellungen und Paarungen
|
||||
werden pro App gespeichert.
|
||||
|
||||
[Google Play öffnen](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay) ·
|
||||
[Sideload-APK herunterladen](https://github.com/Codename-11/hermes-relay/releases) ·
|
||||
[Vollständiger englischer Vergleich →](/guide/release-tracks)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/troubleshooting
|
||||
---
|
||||
|
||||
# Fehlerbehebung
|
||||
|
||||
Beginne mit dem sichtbaren Symptom. So lässt sich schnell trennen, ob Android,
|
||||
das Netzwerk oder der Hermes-Host die Ursache ist.
|
||||
|
||||
- [Roter Punkt oder keine Verbindung](#keine-verbindung)
|
||||
- [„No reachable endpoint“](#kein-erreichbarer-endpunkt)
|
||||
- [Chat streamt nicht](#chat-streamt-nicht)
|
||||
- [Manage oder Voice verlangt eine Anmeldung](#manage-und-voice)
|
||||
- [Sitzungen fehlen](#sitzungen-fehlen)
|
||||
- [App stürzt beim Start ab](#startabsturz)
|
||||
|
||||
## Keine Verbindung
|
||||
|
||||
1. Prüfe auf dem Host, ob `hermes gateway` läuft.
|
||||
2. Öffne auf dem **Telefon** `http://<host>:8642/health`.
|
||||
3. Prüfe `API_SERVER_ENABLED=true` und die Firewall.
|
||||
4. Verwende vom Telefon niemals `localhost` oder `127.0.0.1`; diese Adressen zeigen auf das Telefon selbst.
|
||||
|
||||
## Kein erreichbarer Endpunkt
|
||||
|
||||
Die App hat alle gespeicherten LAN-, Tailscale- und öffentlichen Routen geprüft,
|
||||
aber keine Antwort erhalten. Eine LAN-Adresse funktioniert nur im selben WLAN.
|
||||
Für Tailscale müssen Telefon und Server verbunden sein.
|
||||
|
||||
## Chat streamt nicht
|
||||
|
||||
- Prüfe API-URL und API-Schlüssel.
|
||||
- Tippe bei einem Fehlerbanner einmal auf **Retry**.
|
||||
- Prüfe die Hermes-Serverprotokolle.
|
||||
- Bei langen lokalen Modellläufen kann Android die Verbindung im Hintergrund trennen; die fertige Antwort wird nach der Wiederverbindung geladen.
|
||||
|
||||
## Manage und Voice
|
||||
|
||||
Manage und Standard-Voice verwenden die Dashboard-Anmeldung, nicht den
|
||||
`API_SERVER_KEY`. Melde dich einmal im Manage-Bereich an und prüfe, ob das
|
||||
Dashboard vom Telefon erreichbar ist.
|
||||
|
||||
## Sitzungen fehlen
|
||||
|
||||
Der Server muss beim Wechsel der Sitzung erreichbar sein. Große Sitzungen
|
||||
können kurz laden; warte auf die Ladeanzeige, bevor du erneut wechselst.
|
||||
|
||||
## Startabsturz
|
||||
|
||||
Öffne Android **Einstellungen → Apps → Hermes-Relay → Speicher** und lösche die
|
||||
App-Daten. Richte danach API-URL und Schlüssel erneut ein.
|
||||
|
||||
Für Relay-Probleme liefert `hermes relay doctor` eine schreibgeschützte Diagnose.
|
||||
|
||||
[Vollständige englische Fehlerbehebung →](/guide/troubleshooting) ·
|
||||
[Installation →](/de/guide/getting-started)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
layout: home
|
||||
translation_status: ai-translated
|
||||
canonical_source: /
|
||||
title: Hermes-Relay
|
||||
titleTemplate: Hermes-Relay Dokumentation auf Deutsch
|
||||
|
||||
hero:
|
||||
name: Dokumentation
|
||||
text: Beginne mit dem Gerät, das du verbinden möchtest.
|
||||
tagline: Verbinde die Android-App oder die CLI mit deinem bestehenden Hermes Agent. Der optionale Relay-Weg kommt erst hinzu, wenn du zusätzliche Werkzeuge brauchst.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Android-Schnellstart
|
||||
link: /de/guide/quick-start
|
||||
- theme: alt
|
||||
text: Installation
|
||||
link: /de/guide/getting-started
|
||||
- theme: alt
|
||||
text: Englische Referenz
|
||||
link: /reference/api
|
||||
|
||||
features:
|
||||
- title: Standard-Hermes genügt
|
||||
details: Chat, Manage und Standard-Voice verbinden sich direkt mit einem unveränderten Hermes Agent.
|
||||
- title: Eine klare App-Auswahl
|
||||
details: Google Play ist der empfohlene Weg. Sideload ergänzt Device Control für Bildschirmlesen, Tippen und Navigation.
|
||||
- title: Relay bleibt optional
|
||||
details: Installiere das Relay-Plugin nur für Terminal, Gerätesteuerung, Medien, Benachrichtigungen oder erweiterte Remote-Werkzeuge.
|
||||
---
|
||||
|
||||
## Umfang dieser Übersetzung
|
||||
|
||||
Diese KI-gestützte Übersetzung deckt den Einstieg, die Installation, die
|
||||
App-Auswahl und die häufigsten Fehler ab. Schnell veränderliche API-,
|
||||
Architektur-, Sicherheits- und CLI-Referenzen bleiben auf Englisch verbindlich.
|
||||
|
||||
[Schnellstart →](/de/guide/quick-start) ·
|
||||
[Fehlerbehebung →](/de/guide/troubleshooting) ·
|
||||
[Englische Referenz →](/reference/api)
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/getting-started
|
||||
---
|
||||
|
||||
# Instalación y configuración
|
||||
|
||||
Tres pasos: instala la aplicación, conéctala a Hermes y envía el primer mensaje.
|
||||
Si Hermes ya está funcionando, no necesitas instalar nada adicional en el servidor.
|
||||
|
||||
::: tip Estado de la traducción
|
||||
Esta guía resumida cubre la ruta habitual. Las opciones avanzadas de servidor,
|
||||
TLS y operación están en la [guía completa en inglés](/guide/getting-started).
|
||||
:::
|
||||
|
||||
## 1. Elige la aplicación
|
||||
|
||||
| | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| Recomendado para | La mayoría de usuarios | Usuarios de Device Control |
|
||||
| Actualizaciones | Automáticas | Actualización manual del APK |
|
||||
| Chat, Voice y Manage | Incluidos | Incluidos |
|
||||
| Terminal, multimedia y notificaciones con Relay | Incluidos | Incluidos |
|
||||
| Leer la pantalla, tocar, escribir y navegar | No incluido | Incluido |
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
El archivo firmado de Sideload termina en `-sideload-release.apk` y se publica
|
||||
en [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases). No
|
||||
descargues el archivo `.aab`; está destinado a Google Play.
|
||||
|
||||
## 2. Haz que Hermes sea accesible
|
||||
|
||||
Android necesita el servidor de API de Hermes, normalmente en `:8642`:
|
||||
|
||||
- `API_SERVER_ENABLED=true` activa el servidor.
|
||||
- `API_SERVER_HOST=0.0.0.0` permite el acceso desde la red.
|
||||
- `API_SERVER_KEY` protege las solicitudes de Chat con una clave bearer.
|
||||
- `hermes gateway` inicia Hermes y el servidor de API activado.
|
||||
|
||||
::: warning Protege el acceso de red
|
||||
`0.0.0.0` permite que otros dispositivos de la red lleguen al servicio. Usa una
|
||||
clave segura. No expongas directamente un puerto sin cifrar a Internet; para el
|
||||
acceso remoto utiliza Tailscale, una VPN o HTTPS.
|
||||
:::
|
||||
|
||||
El dashboard en `:9119` es opcional. Se utiliza para Manage y la voz estándar,
|
||||
y tiene su propio inicio de sesión; la clave de API no inicia sesión en el dashboard.
|
||||
|
||||
## 3. Conecta y conversa
|
||||
|
||||
1. Abre **Connect** en Android.
|
||||
2. Busca Hermes en la LAN, escanea un QR de configuración o introduce la URL y la clave.
|
||||
3. Pulsa **Connect**.
|
||||
4. Comprueba que aparezca **Chat · Ready**.
|
||||
5. Abre Chat y envía el primer mensaje.
|
||||
|
||||
Manage y Voice todavía pueden pedir una sesión. También es normal que Relay
|
||||
aparezca sin emparejar.
|
||||
|
||||
## Opcional: añade las herramientas de Relay
|
||||
|
||||
Instala el complemento solo para terminal, Device Control, multimedia,
|
||||
notificaciones o herramientas remotas avanzadas. Los comandos canónicos son
|
||||
`hermes plugins install Codename-11/hermes-relay/plugin --enable`,
|
||||
`hermes relay doctor`, `hermes relay start --no-ssl` y `hermes pair`.
|
||||
|
||||
Device Control necesita **las dos cosas**: la aplicación Sideload y un Relay emparejado.
|
||||
|
||||
[Comparar versiones →](/es/guide/release-tracks) ·
|
||||
[Acceso remoto en inglés →](/guide/remote-access) ·
|
||||
[Solución de problemas →](/es/guide/troubleshooting)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/quick-start
|
||||
---
|
||||
|
||||
# Inicio rápido
|
||||
|
||||
Instala → conecta → conversa, en unos dos minutos. Este recorrido funciona con
|
||||
un Hermes Agent normal; no requiere el complemento Relay.
|
||||
|
||||
::: tip Estado de la traducción
|
||||
Esta página se tradujo con asistencia de IA y pasó las comprobaciones técnicas.
|
||||
El inglés sigue siendo la fuente canónica del significado del producto y la seguridad.
|
||||
:::
|
||||
|
||||
## 1. Instala la aplicación
|
||||
|
||||
Para la mayoría de las personas, **Google Play** es el camino más rápido:
|
||||
instalación con un toque y actualizaciones automáticas.
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
Si quieres que Hermes lea la pantalla, toque, escriba o navegue por el teléfono,
|
||||
instala en su lugar el APK firmado de **Sideload**. Las dos versiones pueden
|
||||
estar instaladas a la vez.
|
||||
|
||||
## 2. Inicia Hermes
|
||||
|
||||
El servidor de API de Hermes debe estar activo y accesible desde el teléfono.
|
||||
Si es necesario, inicia el host con `hermes gateway`. Consulta
|
||||
[Instalación y configuración](/es/guide/getting-started) para preparar el servidor.
|
||||
|
||||
## 3. Conecta
|
||||
|
||||
Abre la aplicación y llega a **Connect**. Puedes:
|
||||
|
||||
1. Usar **Scan for Hermes on LAN** para buscar el servidor en tu red local.
|
||||
2. Introducir una dirección como `http://<host>:8642` y la clave de API configurada.
|
||||
3. Escanear un código QR de configuración que contenga la URL y la clave.
|
||||
|
||||
Si el host se ejecuta intencionadamente sin `API_SERVER_KEY`, deja la clave vacía.
|
||||
|
||||
## 4. Comprueba el estado
|
||||
|
||||
- **Chat · Ready** significa que ya puedes enviar mensajes.
|
||||
- **Manage** puede pedir que inicies sesión en el dashboard.
|
||||
- **Voice** se habilita con esa misma sesión del dashboard.
|
||||
- **Relay** puede seguir sin emparejar y no bloquea el funcionamiento estándar.
|
||||
|
||||
## 5. Envía el primer mensaje
|
||||
|
||||
Abre Chat y envía un mensaje. El indicador verde del encabezado confirma que la
|
||||
conexión activa con Hermes está disponible.
|
||||
|
||||
[Instalación detallada →](/es/guide/getting-started) ·
|
||||
[Solución de problemas →](/es/guide/troubleshooting) ·
|
||||
[Guía canónica en inglés →](/guide/quick-start)
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/release-tracks
|
||||
---
|
||||
|
||||
# Versiones de la aplicación: Google Play o Sideload
|
||||
|
||||
Empieza con Google Play salvo que necesites específicamente Device Control. Las
|
||||
dos versiones proceden del mismo código y pueden convivir en el teléfono.
|
||||
|
||||
## Ayuda para elegir
|
||||
|
||||
| Pregunta | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| ¿Instalación sencilla y actualizaciones automáticas? | Sí | No |
|
||||
| ¿Chat, perfiles, Manage y Voice? | Sí | Sí |
|
||||
| ¿Terminal, multimedia y notificaciones con Relay? | Sí | Sí |
|
||||
| ¿Leer o capturar la pantalla? | No | Sí |
|
||||
| ¿Tocar, escribir, deslizar y manejar aplicaciones? | No | Sí |
|
||||
|
||||
## La versión y Relay son decisiones independientes
|
||||
|
||||
La **versión de la aplicación** decide si Android incluye Device Control. El
|
||||
**complemento Relay** opcional conecta el terminal, el contenido multimedia,
|
||||
las notificaciones y los canales de dispositivos con el host de Hermes.
|
||||
|
||||
Device Control solo funciona con **Sideload + Relay emparejado**. Chat, Manage y
|
||||
la voz estándar no necesitan ninguno de los dos.
|
||||
|
||||
## Cambiar más adelante
|
||||
|
||||
Google Play y Sideload utilizan identificadores de aplicación distintos. Puedes
|
||||
probar ambas y eliminar una después. Cada aplicación conserva sus propios ajustes
|
||||
y emparejamientos.
|
||||
|
||||
[Abrir Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay) ·
|
||||
[Descargar el APK Sideload](https://github.com/Codename-11/hermes-relay/releases) ·
|
||||
[Comparación completa en inglés →](/guide/release-tracks)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/troubleshooting
|
||||
---
|
||||
|
||||
# Solución de problemas
|
||||
|
||||
Empieza por el síntoma que puedes ver. Así podrás separar rápidamente un
|
||||
problema de Android, de red o del host de Hermes.
|
||||
|
||||
- [Punto rojo o sin conexión](#sin-conexión)
|
||||
- [“No reachable endpoint”](#ningún-endpoint-accesible)
|
||||
- [Chat no transmite la respuesta](#chat-no-transmite)
|
||||
- [Manage o Voice pide iniciar sesión](#manage-y-voice)
|
||||
- [Faltan sesiones](#faltan-sesiones)
|
||||
- [La aplicación falla al iniciarse](#fallo-al-iniciar)
|
||||
|
||||
## Sin conexión
|
||||
|
||||
1. Comprueba en el host que `hermes gateway` está en ejecución.
|
||||
2. Abre `http://<host>:8642/health` desde el **teléfono**.
|
||||
3. Comprueba `API_SERVER_ENABLED=true` y las reglas del firewall.
|
||||
4. No uses `localhost` ni `127.0.0.1` en el teléfono; apuntan al propio teléfono.
|
||||
|
||||
## Ningún endpoint accesible
|
||||
|
||||
La aplicación probó todas las rutas guardadas —LAN, Tailscale y públicas— sin
|
||||
obtener respuesta. Una dirección LAN solo funciona en la misma red Wi-Fi. Para
|
||||
Tailscale, el teléfono y el servidor deben estar conectados.
|
||||
|
||||
## Chat no transmite
|
||||
|
||||
- Comprueba la URL y la clave de API.
|
||||
- Si aparece un error, pulsa **Retry** una sola vez.
|
||||
- Revisa los registros del servidor Hermes.
|
||||
- Los modelos locales pueden tardar varios minutos; si Android corta la conexión en segundo plano, la respuesta terminada se recuperará al reconectar.
|
||||
|
||||
## Manage y Voice
|
||||
|
||||
Manage y la voz estándar usan la sesión del dashboard, no `API_SERVER_KEY`.
|
||||
Inicia sesión una vez desde Manage y confirma que el dashboard sea accesible
|
||||
desde el teléfono.
|
||||
|
||||
## Faltan sesiones
|
||||
|
||||
El servidor debe estar disponible al cambiar de sesión. Las sesiones grandes
|
||||
pueden tardar unos instantes; espera al indicador de carga.
|
||||
|
||||
## Fallo al iniciar
|
||||
|
||||
Abre **Ajustes de Android → Aplicaciones → Hermes-Relay → Almacenamiento** y
|
||||
borra los datos de la aplicación. Después vuelve a configurar la URL y la clave.
|
||||
|
||||
Para problemas de Relay, `hermes relay doctor` ofrece un diagnóstico de solo lectura.
|
||||
|
||||
[Solución de problemas completa en inglés →](/guide/troubleshooting) ·
|
||||
[Instalación →](/es/guide/getting-started)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
layout: home
|
||||
translation_status: ai-translated
|
||||
canonical_source: /
|
||||
title: Hermes-Relay
|
||||
titleTemplate: Documentación de Hermes-Relay en español
|
||||
|
||||
hero:
|
||||
name: Documentación
|
||||
text: Empieza por el dispositivo que quieres conectar.
|
||||
tagline: Conecta la aplicación Android o la CLI con tu Hermes Agent actual. Añade Relay solo cuando necesites herramientas avanzadas.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Inicio rápido de Android
|
||||
link: /es/guide/quick-start
|
||||
- theme: alt
|
||||
text: Instalación
|
||||
link: /es/guide/getting-started
|
||||
- theme: alt
|
||||
text: Referencia en inglés
|
||||
link: /reference/api
|
||||
|
||||
features:
|
||||
- title: Hermes estándar es suficiente
|
||||
details: Chat, Manage y la voz estándar se conectan directamente a un Hermes Agent sin modificar.
|
||||
- title: Dos versiones claras
|
||||
details: Google Play es la opción recomendada. Sideload añade Device Control para leer la pantalla, tocar y navegar.
|
||||
- title: Relay sigue siendo opcional
|
||||
details: Instala el complemento Relay solo para terminal, control del teléfono, contenido multimedia, notificaciones o herramientas remotas.
|
||||
---
|
||||
|
||||
## Alcance de esta traducción
|
||||
|
||||
Esta traducción asistida por IA cubre los primeros pasos, la instalación, la
|
||||
elección de la aplicación y los problemas más frecuentes. Las referencias de
|
||||
API, arquitectura, seguridad y CLI que cambian rápidamente siguen teniendo su
|
||||
fuente canónica en inglés.
|
||||
|
||||
[Inicio rápido →](/es/guide/quick-start) ·
|
||||
[Solución de problemas →](/es/guide/troubleshooting) ·
|
||||
[Referencia en inglés →](/reference/api)
|
||||
@@ -191,7 +191,7 @@ For a still pack, each generated PNG becomes a one-frame clip. This manifest wir
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json",
|
||||
"$schema": "https://hermes-relay.dev/docs/pet.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "my-pet",
|
||||
"label": "My Pet",
|
||||
@@ -216,7 +216,7 @@ For an animated pack, each generated sheet becomes one clip. For a **4×4 grid o
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json",
|
||||
"$schema": "https://hermes-relay.dev/docs/pet.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "my-pet",
|
||||
"label": "My Pet",
|
||||
@@ -261,7 +261,7 @@ The helper validates `pet.json`, checks that every referenced image exists, excl
|
||||
<PetPackBuilder />
|
||||
|
||||
::: tip Validate as you author
|
||||
The example above starts with a `$schema` line pointing at the published [pet schema](https://codename-11.github.io/hermes-relay/pet.schema.json). Keep it and editors like VS Code will autocomplete the fields and flag mistakes — a missing `idle`, a bad frame count, a typo'd state key — before you ever push. The app ignores the `$schema` key, and an AI agent can lint its output against the same file.
|
||||
The example above starts with a `$schema` line pointing at the published [pet schema](https://hermes-relay.dev/docs/pet.schema.json). Keep it and editors like VS Code will autocomplete the fields and flag mistakes — a missing `idle`, a bad frame count, a typo'd state key — before you ever push. The app ignores the `$schema` key, and an AI agent can lint its output against the same file.
|
||||
:::
|
||||
|
||||
::: tip Safe box: leave room inside every cell
|
||||
|
||||
@@ -1,50 +1,26 @@
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress'
|
||||
</script>
|
||||
|
||||
# Installation & Setup
|
||||
|
||||
Three steps — install the app, point it at your Hermes, say hello. If your
|
||||
Hermes agent is already running, this takes about two minutes and needs nothing
|
||||
installed on the server.
|
||||
|
||||
<div class="gs-steps">
|
||||
<span>1 · Install the app</span>
|
||||
<span>2 · Point it at Hermes</span>
|
||||
<span>3 · Connect & chat</span>
|
||||
</div>
|
||||
<ol class="gs-steps" aria-label="Setup progress">
|
||||
<li><strong>01</strong><span>Install the app</span></li>
|
||||
<li><strong>02</strong><span>Point it at Hermes</span></li>
|
||||
<li><strong>03</strong><span>Connect & chat</span></li>
|
||||
</ol>
|
||||
|
||||
## 1. Install the app
|
||||
|
||||
The easiest way — install from Google Play and get automatic updates:
|
||||
Choose the build by one question: do you want Hermes to operate the phone, or
|
||||
only be available from it? Both builds come from the same codebase and can live
|
||||
side-by-side.
|
||||
|
||||
<div class="gs-install-cta">
|
||||
<StoreBadge />
|
||||
</div>
|
||||
<ReleaseTrackChooser />
|
||||
|
||||
That's the **Google Play** build: chat, profiles, voice, terminal/TUI relay,
|
||||
media, the notification companion, relay sessions, and diagnostics. It's what
|
||||
most people want.
|
||||
|
||||
Prefer to install the APK by hand, or want the full phone-control feature set?
|
||||
That's the **Sideload** build — same app, plus the agent can read your screen,
|
||||
tap, type, and navigate apps for you.
|
||||
|
||||
::: details Google Play vs. Sideload — which build is right for me?
|
||||
Both flavors are built from the same codebase and install with **different
|
||||
application IDs**, so you can run them side-by-side and try both.
|
||||
|
||||
| | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| Install | One tap, auto-updates | Manual APK from GitHub Releases |
|
||||
| Chat, voice, Manage | ✅ | ✅ |
|
||||
| Terminal / TUI relay, media, notifications | ✅ | ✅ |
|
||||
| Device Control (screen reading, taps, typing, vision navigation) | — | ✅ |
|
||||
|
||||
Most users want **Google Play**. Pick **Sideload** if you want the agent to
|
||||
operate your phone for you. The [Release tracks](/guide/release-tracks) page has
|
||||
the full feature comparison and a decision guide.
|
||||
:::
|
||||
The [Release tracks](/guide/release-tracks) page explains the full capability
|
||||
and safety differences. Building from source is an advanced alternative under
|
||||
the Sideload instructions below.
|
||||
|
||||
### Sideload APK {#sideload-apk}
|
||||
|
||||
@@ -297,22 +273,13 @@ using the highest-priority reachable one. Chat and Manage move together — LAN
|
||||
home, Tailscale when you leave.
|
||||
:::
|
||||
|
||||
### See it working
|
||||
### What you’ll see
|
||||
|
||||
Once you're connected, chat streams like this — responses, tool cards, markdown,
|
||||
and the personality picker, all live:
|
||||
The documentation uses deterministic renders from the real Android components,
|
||||
so these screens update with the canonical screenshot set instead of drifting
|
||||
like a hand-recorded demo.
|
||||
|
||||
<div class="demo-video-wrap">
|
||||
<video
|
||||
:src="withBase('/chat_demo.mp4')"
|
||||
:poster="withBase('/chat_demo_poster.jpg')"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
<FirstRunPreview />
|
||||
|
||||
The chat header shows the agent name with a green pulse on the avatar when the
|
||||
API server is reachable. If the dot is red:
|
||||
@@ -494,37 +461,41 @@ shared with other Hermes tools. Flags: `--dry-run`, `--keep-clone`,
|
||||
|
||||
<style scoped>
|
||||
.gs-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 1.25rem 0 2rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-divider);
|
||||
list-style: none;
|
||||
}
|
||||
.gs-steps li {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
.gs-steps strong {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
.gs-steps span {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
}
|
||||
.gs-install-cta {
|
||||
margin: 1.25rem 0 1.5rem;
|
||||
}
|
||||
.demo-video-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
.demo-video-wrap video {
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px var(--vp-c-divider);
|
||||
background: #000;
|
||||
@media (max-width: 640px) {
|
||||
.gs-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,7 +52,7 @@ the Hermes API Server's SSE routes. The relay server handles Bridge Core,
|
||||
terminal, TUI, media, notification companion, relay sessions, and relay-backed
|
||||
voice routes. Sideload builds additionally expose Android Device Control routes.
|
||||
|
||||
## Current Status — v1.0.0
|
||||
## Feature status
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
|
||||
@@ -6,10 +6,14 @@ nothing here requires the Relay plugin — a vanilla Hermes install is enough.
|
||||
|
||||
## 1. Install the app
|
||||
|
||||
Grab the latest APK from the
|
||||
[releases page](https://github.com/Codename-11/hermes-relay/releases) and
|
||||
install it. Play-store, integrity-verification, and build-from-source options
|
||||
live in [Installation & Setup](./getting-started).
|
||||
For most people, the fastest path is the Google Play build. It installs in one
|
||||
tap, updates automatically, and includes Chat, Voice, Manage, and the optional
|
||||
Relay-powered features that do not require phone control.
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
Need Hermes to read, tap, type, or navigate on your phone? Install the signed
|
||||
**Sideload** APK instead. [Compare the two builds and install either one →](./getting-started#_1-install-the-app)
|
||||
|
||||
## 2. Have Hermes running
|
||||
|
||||
@@ -21,8 +25,9 @@ host machine.
|
||||
## 3. Connect
|
||||
|
||||
Open the app and swipe through to **Connect**. Enter your server's address —
|
||||
`http://<host>:8642` — and your API key, and the wizard probes everything for
|
||||
you, finishing with a capability card:
|
||||
`http://<host>:8642` — and the API key configured on that Hermes host. If the
|
||||
server intentionally runs without `API_SERVER_KEY`, leave the key field blank.
|
||||
The wizard probes everything for you, finishing with a capability card:
|
||||
|
||||
> Don't want to type it? Tap **Scan for Hermes on LAN** to auto-find the server,
|
||||
> or use **Scan setup QR** — you can even ask your Hermes agent to generate a QR
|
||||
@@ -44,6 +49,14 @@ That same session also unlocks voice for the connection.
|
||||
|
||||
Type a message, or tap the mic and speak. That's the whole Vanilla Hermes setup.
|
||||
|
||||
::: tip You are connected when…
|
||||
The capability card shows **Chat · Ready** and the Chat header carries a green
|
||||
connection pulse. **Manage**, **Voice**, and **Relay** may still show optional or
|
||||
sign-in states without blocking your first message.
|
||||
:::
|
||||
|
||||
[See the exact first-run screens and detailed setup →](./getting-started#_3-connect-chat)
|
||||
|
||||
::: details Want more? Power tools via Relay
|
||||
Pairing the optional [Hermes-Relay plugin](./getting-started#relay-server-optional)
|
||||
adds Terminal (a real tmux on your server), Bridge device control (sideload
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
Hermes-Relay ships in **two flavors** built from the same codebase. Most users want the Google Play one. Power users who want the agent to drive their phone hands-free want the sideload one. This page explains what's different, why, and how to choose.
|
||||
|
||||
## TL;DR
|
||||
## Choose your build
|
||||
|
||||
- **Google Play** — easy install, automatic updates, every chat/profile/voice feature, terminal/TUI relay, media handoff, notification companion, relay sessions, and diagnostics. The relay-backed ones (terminal, media, notifications) need the [Relay plugin](#how-the-pieces-combine) paired to your Hermes host. It has **no AccessibilityService** and cannot read the screen, tap, type, swipe, capture screenshots, send SMS, make calls, access contacts/location, or perform unattended phone control.
|
||||
- **Sideload** — manual install from GitHub Releases, all of the above plus AccessibilityService-backed Device Control: screen reading, taps, typing, gestures, screenshots, voice-routed bridge intents ("text Sam I'll be late", "open Chrome"), direct SMS/call dispatch, file sharing/MMS attachment handoff, vision-driven navigation, and the full `android_*` bridge toolset. The toggle is labeled "Agent Control."
|
||||
- They coexist on a device — you can install both side-by-side and try them both.
|
||||
Start with Google Play unless you specifically want Device Control. The two
|
||||
builds use different application IDs, so they can coexist on one phone while
|
||||
you decide.
|
||||
|
||||
<ReleaseTrackChooser explain-relay />
|
||||
|
||||
## How the pieces combine
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Can't connect to API server
|
||||
Start with the symptom you can see. Each path begins with the smallest check
|
||||
that separates an Android problem from a server or network problem.
|
||||
|
||||
<TroubleshootingNavigator />
|
||||
|
||||
## Can't connect to API server {#cannot-connect}
|
||||
|
||||
- **Red dot in chat header**: API server is unreachable
|
||||
- Check that Hermes is running: `hermes gateway`
|
||||
@@ -33,7 +38,7 @@ server machine. Use the server's LAN IP (e.g. `http://192.168.1.100:8642`) or
|
||||
its Tailscale address instead.
|
||||
:::
|
||||
|
||||
### Tailscale checklist
|
||||
### Tailscale checklist {#tailscale-checklist}
|
||||
|
||||
If a Tailscale route fails its probe:
|
||||
|
||||
@@ -67,7 +72,7 @@ If `adb connect` is refused, the pairing succeeded but the wrong port was used,
|
||||
or Wireless debugging rotated ports. Reopen **Developer options -> Wireless
|
||||
debugging** on the phone and copy the current main port.
|
||||
|
||||
## Messages not streaming
|
||||
## Messages not streaming {#messages-not-streaming}
|
||||
|
||||
- Check your API key is correct
|
||||
- Look for error banners in the chat — tap **Retry** to resend
|
||||
@@ -88,18 +93,18 @@ To reduce drops in the first place:
|
||||
- Enable the gateway keep-alive option in Settings if you background the app
|
||||
during long turns.
|
||||
|
||||
## "No internet connection" banner
|
||||
## "No internet connection" banner {#no-internet}
|
||||
|
||||
- The app detected network loss via Android's ConnectivityManager
|
||||
- Check your WiFi/mobile data connection
|
||||
- The banner disappears automatically when connectivity returns
|
||||
|
||||
## Session history not loading
|
||||
## Session history not loading {#session-history}
|
||||
|
||||
- The server must be reachable when switching sessions
|
||||
- Large sessions may take a moment to load — watch for the loading indicator
|
||||
|
||||
## App crashes on startup
|
||||
## App crashes on startup {#app-crashes}
|
||||
|
||||
- Clear app data: Settings > Apps > Hermes-Relay > Clear Data
|
||||
- Re-enter your API server URL and key during onboarding
|
||||
|
||||
+11
-63
@@ -2,71 +2,19 @@
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: Hermes-Relay
|
||||
text: Runs on your machine. Lives on your devices.
|
||||
tagline: Pair your Hermes agent with the devices around you — a native Android companion for chat, voice, and full phone control, plus a single-binary CLI that gives the agent hands on any machine you put it on.
|
||||
name: Documentation
|
||||
text: Start with the surface you have.
|
||||
tagline: Connect the Android companion, pair the CLI, or add the optional Relay power path. Each route starts with the shortest working setup.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get the Android app
|
||||
link: /guide/getting-started
|
||||
text: Android quick start
|
||||
link: /guide/quick-start
|
||||
- theme: alt
|
||||
text: Give it hands — CLI
|
||||
link: /desktop/
|
||||
|
||||
features:
|
||||
- icon:
|
||||
src: /icons/chat.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Chat that streams, not spins
|
||||
details: Rides your dashboard's live gateway WebSocket when signed in — live thinking included — and falls back to direct HTTP/SSE against the API server. Markdown, syntax-highlighted code, reasoning blocks, tool-progress cards. No cloud relay in the path.
|
||||
- icon:
|
||||
src: /icons/personalities.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Hands-free voice
|
||||
details: Talk to your agent using your server's own TTS/STT — or opt into the provider-native Realtime Agent for low-latency, barge-in conversation.
|
||||
- icon:
|
||||
src: /icons/sessions.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Manage from your pocket
|
||||
details: Skills, cron jobs, profiles, models, and keys — your server's dashboard controls, rebuilt native. One sign-in unlocks Manage and voice.
|
||||
- icon:
|
||||
src: /icons/tools.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Your phone, on the agent's toolbelt
|
||||
details: With the Relay plugin paired and the Sideload build, the agent can read the screen, tap, type, and navigate apps — fenced by a per-app blocklist, destructive-verb confirmation, and auto-disable.
|
||||
link: /guide/release-tracks#how-the-pieces-combine
|
||||
linkText: How the pieces combine
|
||||
- icon:
|
||||
src: /icons/tokens.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Hands on any machine
|
||||
details: Install the single-binary CLI — desktop, laptop, or headless box — and the agent can read, write, search, run commands, and capture screens there. Consent-gated per device.
|
||||
- icon:
|
||||
src: /icons/markdown.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Works at home and away
|
||||
details: Connections carry LAN, Tailscale, and public routes, and the app picks the best one on every connect and network change — home, train, or VPN, it just works.
|
||||
- icon:
|
||||
src: /icons/reasoning.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Notifications and media in the loop
|
||||
details: Forward phone notifications to your agent, and let it hand files, images, and rich cards back into chat — shareable through native Android flows.
|
||||
- icon:
|
||||
src: /icons/security.svg
|
||||
width: 40
|
||||
height: 40
|
||||
title: Private by architecture
|
||||
details: No cloud in the path — QR pairing, Keystore-held tokens, TOFU cert pinning, per-channel grants with TTLs you choose, revocable from any client.
|
||||
text: Install the CLI
|
||||
link: /desktop/installation
|
||||
- theme: alt
|
||||
text: Product overview ↗
|
||||
link: https://hermes-relay.dev/
|
||||
---
|
||||
|
||||
<!-- Home body intentionally empty — the sphere, How-it-works strip, surface
|
||||
cards, and Get-started section are slotted via .vitepress/theme/index.ts
|
||||
(markdown body always renders below the VPFeatures grid, which is the
|
||||
wrong place for all of them). -->
|
||||
<DocsHomeHub />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/getting-started
|
||||
---
|
||||
|
||||
# インストールと設定
|
||||
|
||||
手順は 3 つです。アプリをインストールし、Hermes に接続し、最初の
|
||||
メッセージを送信します。Hermes がすでに動作している場合、サーバーへの追加
|
||||
インストールは不要です。
|
||||
|
||||
::: tip 翻訳ステータス
|
||||
この要約ガイドは一般的な導入手順を扱います。高度なサーバー、TLS、運用設定は
|
||||
[完全な英語ガイド](/guide/getting-started)を参照してください。
|
||||
:::
|
||||
|
||||
## 1. アプリを選ぶ
|
||||
|
||||
| | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| 推奨対象 | ほとんどのユーザー | Device Control を使うユーザー |
|
||||
| 更新 | 自動 | APK を手動更新 |
|
||||
| Chat、Voice、Manage | 含まれる | 含まれる |
|
||||
| Relay のターミナル、メディア、通知 | 含まれる | 含まれる |
|
||||
| 画面読み取り、タップ、入力、ナビゲーション | 含まれない | 含まれる |
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
署名済み Sideload ファイルは `-sideload-release.apk` で終わり、
|
||||
[GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) から
|
||||
取得できます。`.aab` は Google Play 用なのでインストールしないでください。
|
||||
|
||||
## 2. Hermes を到達可能にする
|
||||
|
||||
Android は通常 `:8642` の Hermes API サーバーを使用します。
|
||||
|
||||
- `API_SERVER_ENABLED=true` で API サーバーを有効にします。
|
||||
- `API_SERVER_HOST=0.0.0.0` でネットワークから到達可能にします。
|
||||
- `API_SERVER_KEY` は Chat リクエストを bearer キーで保護します。
|
||||
- `hermes gateway` で Hermes と有効化された API サーバーを起動します。
|
||||
|
||||
::: warning ネットワークアクセスを保護する
|
||||
`0.0.0.0` はネットワーク上の他のデバイスからの接続を許可します。強力な
|
||||
API キーを使用してください。暗号化されていないポートをインターネットへ
|
||||
直接公開せず、リモートアクセスには Tailscale、VPN、HTTPS を使用します。
|
||||
:::
|
||||
|
||||
`:9119` のダッシュボードはオプションです。Manage と標準 Voice に使用され、
|
||||
独自のログインがあります。API キーはダッシュボードのログイン情報ではありません。
|
||||
|
||||
## 3. 接続して会話する
|
||||
|
||||
1. Android アプリで **Connect** を開きます。
|
||||
2. LAN 検索、セットアップ QR、または API URL とキーの入力を選びます。
|
||||
3. **Connect** をタップします。
|
||||
4. **Chat · Ready** が表示されることを確認します。
|
||||
5. Chat を開いて最初のメッセージを送ります。
|
||||
|
||||
Manage と Voice はログインを求める場合があります。Relay が未ペアリングでも正常です。
|
||||
|
||||
## オプション: Relay ツールを追加する
|
||||
|
||||
ターミナル、Device Control、メディア、通知、高度なリモートツールが必要な
|
||||
場合だけプラグインを追加します。正規コマンドは
|
||||
`hermes plugins install Codename-11/hermes-relay/plugin --enable`、
|
||||
`hermes relay doctor`、`hermes relay start --no-ssl`、`hermes pair` です。
|
||||
|
||||
Device Control には **Sideload アプリとペアリング済み Relay の両方**が必要です。
|
||||
|
||||
[アプリの種類を比較 →](/ja/guide/release-tracks) ·
|
||||
[英語のリモートアクセス →](/guide/remote-access) ·
|
||||
[トラブルシューティング →](/ja/guide/troubleshooting)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/quick-start
|
||||
---
|
||||
|
||||
# クイックスタート
|
||||
|
||||
インストール → 接続 → 会話を約 2 分で行えます。この標準手順では通常の
|
||||
Hermes Agent だけを使用し、Relay プラグインは必要ありません。
|
||||
|
||||
::: tip 翻訳ステータス
|
||||
このページは AI 支援で翻訳され、技術検証を通過しています。製品と
|
||||
セキュリティの意味については英語版が正規情報です。
|
||||
:::
|
||||
|
||||
## 1. アプリをインストールする
|
||||
|
||||
ほとんどのユーザーには **Google Play** 版が最短です。1 回の操作で
|
||||
インストールでき、更新も自動で届きます。
|
||||
|
||||
<StoreBadge />
|
||||
|
||||
Hermes に画面の読み取り、タップ、文字入力、アプリ操作を許可したい場合は、
|
||||
署名済みの **Sideload APK** を使用します。2 つの版は同時にインストールできます。
|
||||
|
||||
## 2. Hermes を起動する
|
||||
|
||||
Hermes API サーバーが起動し、スマートフォンから到達できる必要があります。
|
||||
必要に応じて `hermes gateway` でホストを起動します。サーバーの準備は
|
||||
[インストールと設定](/ja/guide/getting-started)を参照してください。
|
||||
|
||||
## 3. 接続する
|
||||
|
||||
アプリを開いて **Connect** へ進み、次のいずれかを使用します。
|
||||
|
||||
1. **Scan for Hermes on LAN** でローカルネットワークを検索する。
|
||||
2. `http://<host>:8642` のようなアドレスと設定済み API キーを入力する。
|
||||
3. URL とキーを含むセットアップ QR コードを読み取る。
|
||||
|
||||
ホストが意図的に `API_SERVER_KEY` なしで動作している場合、キーは空欄にします。
|
||||
|
||||
## 4. 状態を確認する
|
||||
|
||||
- **Chat · Ready** ならメッセージを送信できます。
|
||||
- **Manage** ではダッシュボードへのログインを求められる場合があります。
|
||||
- **Voice** も同じダッシュボードセッションで有効になります。
|
||||
- **Relay** は未ペアリングのままでも標準機能を妨げません。
|
||||
|
||||
## 5. 最初のメッセージを送る
|
||||
|
||||
Chat を開いてメッセージを送信します。ヘッダーの緑色の接続表示は、現在の
|
||||
Hermes 接続が利用可能であることを示します。
|
||||
|
||||
[詳細なインストール →](/ja/guide/getting-started) ·
|
||||
[トラブルシューティング →](/ja/guide/troubleshooting) ·
|
||||
[英語の正規ガイド →](/guide/quick-start)
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/release-tracks
|
||||
---
|
||||
|
||||
# アプリの種類: Google Play または Sideload
|
||||
|
||||
Device Control が明確に必要でない限り、Google Play 版から始めてください。
|
||||
2 つの版は同じコードから作られ、同じ端末に共存できます。
|
||||
|
||||
## 選択ガイド
|
||||
|
||||
| 質問 | Google Play | Sideload |
|
||||
|---|---|---|
|
||||
| 簡単なインストールと自動更新 | はい | いいえ |
|
||||
| Chat、プロファイル、Manage、Voice | はい | はい |
|
||||
| Relay のターミナル、メディア、通知 | はい | はい |
|
||||
| 画面の読み取りやキャプチャ | いいえ | はい |
|
||||
| タップ、入力、スワイプ、アプリ操作 | いいえ | はい |
|
||||
|
||||
## アプリの種類と Relay は別の選択
|
||||
|
||||
**アプリの種類**は Android に Device Control を含めるかを決めます。
|
||||
オプションの **Relay プラグイン**は、ターミナル、メディア、通知、
|
||||
デバイスチャンネルを Hermes ホストへ接続します。
|
||||
|
||||
Device Control は **Sideload + ペアリング済み Relay** でのみ動作します。
|
||||
Chat、Manage、標準 Voice はどちらも必要としません。
|
||||
|
||||
## 後から切り替える
|
||||
|
||||
Google Play と Sideload は異なるアプリ ID を使用します。両方を試してから
|
||||
片方を削除できます。設定とペアリングはアプリごとに保存されます。
|
||||
|
||||
[Google Play を開く](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay) ·
|
||||
[Sideload APK を取得](https://github.com/Codename-11/hermes-relay/releases) ·
|
||||
[英語の完全な比較 →](/guide/release-tracks)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
translation_status: ai-translated
|
||||
canonical_source: /guide/troubleshooting
|
||||
---
|
||||
|
||||
# トラブルシューティング
|
||||
|
||||
画面に見えている症状から始めます。Android、ネットワーク、Hermes ホストの
|
||||
どこに問題があるかをすばやく切り分けられます。
|
||||
|
||||
- [赤い表示または接続なし](#接続できない)
|
||||
- [「No reachable endpoint」](#到達可能なエンドポイントがない)
|
||||
- [Chat がストリーミングしない](#chat-がストリーミングしない)
|
||||
- [Manage または Voice がログインを求める](#manage-と-voice)
|
||||
- [セッションが表示されない](#セッションが表示されない)
|
||||
- [起動時にアプリがクラッシュする](#起動時のクラッシュ)
|
||||
|
||||
## 接続できない
|
||||
|
||||
1. ホストで `hermes gateway` が動作していることを確認します。
|
||||
2. **スマートフォン**から `http://<host>:8642/health` を開きます。
|
||||
3. `API_SERVER_ENABLED=true` とファイアウォール設定を確認します。
|
||||
4. スマートフォンで `localhost` や `127.0.0.1` を使用しないでください。これらはスマートフォン自身を示します。
|
||||
|
||||
## 到達可能なエンドポイントがない
|
||||
|
||||
保存済みの LAN、Tailscale、公開ルートをすべて確認しましたが、応答がありません。
|
||||
LAN アドレスは同じ Wi-Fi 内でのみ動作します。Tailscale ではスマートフォンと
|
||||
サーバーの両方が接続済みである必要があります。
|
||||
|
||||
## Chat がストリーミングしない
|
||||
|
||||
- API URL と API キーを確認します。
|
||||
- エラーが表示された場合は **Retry** を 1 回タップします。
|
||||
- Hermes サーバーのログを確認します。
|
||||
- ローカルモデルは数分かかる場合があります。Android がバックグラウンド接続を切断しても、再接続時に完了済みの応答を取得します。
|
||||
|
||||
## Manage と Voice
|
||||
|
||||
Manage と標準 Voice は `API_SERVER_KEY` ではなくダッシュボードセッションを
|
||||
使用します。Manage から一度ログインし、スマートフォンからダッシュボードへ
|
||||
到達できることを確認してください。
|
||||
|
||||
## セッションが表示されない
|
||||
|
||||
セッション切り替え時にはサーバーへ接続できる必要があります。大きな
|
||||
セッションでは読み込みに時間がかかるため、進行表示を待ってください。
|
||||
|
||||
## 起動時のクラッシュ
|
||||
|
||||
Android の **設定 → アプリ → Hermes-Relay → ストレージ** でアプリデータを
|
||||
削除し、API URL とキーを再設定します。
|
||||
|
||||
Relay の問題には、読み取り専用診断の `hermes relay doctor` を使用できます。
|
||||
|
||||
[英語の完全なトラブルシューティング →](/guide/troubleshooting) ·
|
||||
[インストール →](/ja/guide/getting-started)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user