Compare commits

...
Author SHA1 Message Date
Bailey Dixon 60c14b5db1 Merge origin/dev into fix/android-interaction-notifications 2026-07-25 09:40:24 -04:00
Bailey Dixon e3d6dd9509 Merge pull request #244 from Codename-11/fix/onboarding-permissions-setup
fix(android): add onboarding permission setup
2026-07-24 22:59:24 -04:00
Bailey Dixon 91d05c982e Merge origin/dev into fix/onboarding-permissions-setup 2026-07-24 22:50:57 -04:00
Bailey Dixon 85bbbd004d Merge pull request #243 from Codename-11/fix/android-markdown-user-ca
fix(android): harden Markdown and private CA connections
2026-07-24 22:37:54 -04:00
Bailey Dixon a8f61f2aeb docs: restore route and localization checks 2026-07-24 22:04:46 -04:00
Bailey Dixon d554c1819a fix(android): preserve promoted background task rows 2026-07-24 22:04:42 -04:00
Bailey Dixon 920e7d58f0 fix(android): add onboarding permission setup 2026-07-24 17:34:20 -04:00
Bailey Dixon 0ce7c6c6b3 fix(android): trust user-installed certificate authorities 2026-07-24 16:44:36 -04:00
Bailey Dixon 46e8f90c39 fix(android): guard markdown highlight ranges 2026-07-24 16:44:35 -04:00
Bailey Dixon 2d28f171e0 fix(android): hide dashboard source badge 2026-07-24 16:29:44 -04:00
Bailey Dixon fdd8301796 fix(android): smooth image generation transition 2026-07-24 08:29:40 -04:00
Bailey Dixon c9ddc2ef8d fix(android): notify for blocked gateway interactions 2026-07-23 22:05:26 -04:00
Bailey Dixon b0d662b802 feat(relay): bridge image generation activity 2026-07-23 21:36:13 -04:00
Bailey Dixon 63ca0a1428 Merge feature/hrui-ledger-all-20260723 into dev 2026-07-23 21:11:00 -04:00
Bailey Dixon 8196856d76 docs: align upstream impact ledger guidance 2026-07-23 21:10:08 -04:00
Bailey Dixon 605ff00cc0 fix(android): consume upstream display metadata 2026-07-23 21:09:01 -04:00
Bailey Dixon c9b1e1b04e fix(android): keep active chat progress visible 2026-07-23 20:57:10 -04:00
Bailey Dixon a4466e4ca0 fix(android): show image animation when tools are hidden 2026-07-23 20:34:41 -04:00
Bailey Dixon e13e381e3a docs(android): clarify shared dashboard sessions 2026-07-23 20:20:31 -04:00
Bailey Dixon 19d33910d0 fix(android): share dashboard session across routes 2026-07-23 20:17:57 -04:00
Bailey Dixon 41480a3254 fix(android): refresh dashboard-only route changes 2026-07-23 19:33:13 -04:00
Bailey Dixon 50f151edba fix(android): derive dashboard for QR routes 2026-07-23 19:18:50 -04:00
Bailey Dixon 0f108fe971 fix(android): move dashboard with active route 2026-07-23 18:42:13 -04:00
Bailey Dixon c8d6119e1a fix(android): probe remote health with GET 2026-07-23 17:18:10 -04:00
Bailey Dixon b2b7a2572b fix(android): restore remote route surfaces 2026-07-23 09:03:20 -04:00
Bailey Dixon 1ccf87401a fix(android): rebind gateway turns and dedupe sessions 2026-07-22 23:23:34 -04:00
Bailey Dixon 50b1a17895 release(server): server-v1.4.3 2026-07-22 22:52:27 -04:00
Bailey Dixon d536923c55 release(android): android-v1.5.0 2026-07-22 22:50:30 -04:00
Bailey Dixon 14a2e01ac5 Merge feature/hrui-063-standard-voice-streaming into dev 2026-07-22 22:12:47 -04:00
Bailey Dixon a0d03f6947 feat(android): stream Standard Hermes voice replies 2026-07-22 22:12:39 -04:00
Bailey Dixon 478eeac3f7 Merge fix/hrui-061-correction-copy into dev 2026-07-22 22:01:35 -04:00
Bailey Dixon 799159457a fix(android): finish active-turn correction copy 2026-07-22 22:01:18 -04:00
Bailey Dixon f90650bdc9 Merge docs/hrui-056-ops-audit into dev 2026-07-22 21:06:06 -04:00
Bailey Dixon 6a41ec154c Merge feature/hrui-android-diagnostics-manage into dev 2026-07-22 21:05:57 -04:00
Bailey Dixon 53f4c8187b Merge feature/hrui-android-gateway-controls into dev 2026-07-22 21:05:49 -04:00
Bailey Dixon 675252090c Merge feature/hrui-plugin-compat-diagnostics into dev 2026-07-22 21:05:42 -04:00
Bailey Dixon e94db467e8 docs: add Hermes update restart audit 2026-07-22 21:05:25 -04:00
Bailey Dixon 65dae79c8c feat(android): consume dashboard health hints 2026-07-22 21:04:52 -04:00
Bailey Dixon e5d0334c8b feat(android): use gateway redirect and compress RPCs 2026-07-22 20:57:47 -04:00
Bailey Dixon 9cc7b25fd1 fix(plugin): align diagnostics and config route hygiene 2026-07-22 20:34:43 -04:00
Bailey Dixon 2ce352a320 fix(android): add voice settings translations 2026-07-20 20:40:37 -04:00
Bailey Dixon a6957268b9 Merge feature/upstream-ledger-next-batch into dev 2026-07-20 20:22:30 -04:00
Bailey Dixon 8f42b96be1 feat: consume upstream interim gateway events 2026-07-20 20:22:02 -04:00
Bailey Dixon 8a9c058ddb Merge feature/voice-settings-layout-v2 into dev 2026-07-20 20:08:59 -04:00
Bailey Dixon 5ef2c40f81 feat(android): expand voice settings discovery 2026-07-20 20:08:52 -04:00
Bailey Dixon 6b32fe7ddd Merge feature/voice-settings-preview into dev 2026-07-20 10:52:26 -04:00
Bailey Dixon 02f38322fa feat(android): improve voice settings previews 2026-07-20 10:52:11 -04:00
Bailey Dixon f40b7abaf0 Merge feature/open-ledger-batches into dev 2026-07-20 10:20:50 -04:00
Bailey Dixon 5fcbe5ff63 Merge origin/dev into dev 2026-07-20 10:20:37 -04:00
Bailey Dixon 6ce504b1c9 Merge OAuth dashboard binding fix 2026-07-20 10:07:17 -04:00
Bailey Dixon 31ebef825f fix(android): bind oauth flow to dashboard 2026-07-20 10:07:03 -04:00
Bailey Dixon 68abbf156d Merge Manage localization follow-up 2026-07-20 09:39:02 -04:00
Bailey Dixon 73a8af7c8f fix(android): localize manage parity strings 2026-07-20 09:38:44 -04:00
Bailey Dixon 2db00d4c59 Merge feature/hrui-direct-chat-compat 2026-07-20 09:10:47 -04:00
Bailey Dixon 847444d115 fix(android): cap inline image lifecycle memory 2026-07-20 09:09:27 -04:00
Bailey Dixon 798e8eef55 Merge feature/hrui-manage-parity 2026-07-20 09:05:14 -04:00
Bailey Dixon 35f791be50 Merge pull request #238 from Codename-11/feature/axi-129-image-generation-animation
feat(android): show diffusion animation during image generation
2026-07-20 09:03:45 -04:00
Bailey Dixon e727979897 fix(android): safely gate hosted oauth 2026-07-20 09:03:35 -04:00
Bailey Dixon 91025b733f fix(android): bound inline data image memory 2026-07-20 09:01:16 -04:00
Bailey Dixon 34da86a96a fix(android): refresh locale source hashes after image gen string
Translated catalogs already include image_generation_rendering; update
localization-status source_sha256 so check-android-locales stays green.

Forge: AXI-129
2026-07-20 08:56:50 -04:00
Bailey Dixon 30f9f51e2a fix(android): preserve hosted oauth scope 2026-07-20 08:54:33 -04:00
Bailey Dixon 713529f79f Merge feature/hrui-session-controls 2026-07-20 08:50:09 -04:00
Bailey Dixon 16d1728306 fix(android): honor session control reset semantics 2026-07-20 08:49:20 -04:00
Bailey Dixon 7a813995ba feat(android): show diffusion animation during image generation
Specialize pending image_generate tool parts with a theme-aware procedural
diffusion placeholder and accessibility announcement. Vanilla Hermes already
emits the generic tool lifecycle, so this stays client-only.

Forge: AXI-129
2026-07-20 08:49:12 -04:00
Bailey Dixon c86b2224ce feat(android): align direct chat with upstream contracts 2026-07-20 08:47:20 -04:00
Bailey Dixon 11a782bc44 feat(android): add hosted manage parity 2026-07-20 08:36:48 -04:00
Bailey Dixon 884a17ded7 Merge certification coverage fixes 2026-07-20 08:25:32 -04:00
Bailey Dixon 745904d468 test: cover background delivery ownership 2026-07-20 08:21:54 -04:00
Bailey Dixon 4258322acc test: run delegation ownership certification 2026-07-20 08:17:43 -04:00
Bailey Dixon 2c544b8ab0 Merge feature/hrui-live-certification 2026-07-20 08:11:43 -04:00
Bailey Dixon 5122ee69f6 test: automate upstream compatibility preflight 2026-07-20 08:11:18 -04:00
Bailey Dixon 512199448e Merge origin/dev into dev 2026-07-20 08:02:22 -04:00
dependabot[bot] 7a7b10f08a chore(deps): bump com.meta.spatial:spatial-gradle-plugin-impl (#237)
Bumps com.meta.spatial:spatial-gradle-plugin-impl from 0.13.1 to 0.13.2.

---
updated-dependencies:
- dependency-name: com.meta.spatial:spatial-gradle-plugin-impl
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:56:33 +00:00
dependabot[bot] f7845291e7 chore(deps): bump spatialsdk from 0.13.1 to 0.13.2 (#236)
Bumps `spatialsdk` from 0.13.1 to 0.13.2.

Updates `com.meta.spatial:meta-spatial-sdk` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-compose` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-ovrmetrics` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-toolkit` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-vr` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-isdk` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-castinputforward` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-hotreload` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-datamodelinspector` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-uiset` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-mruk` from 0.13.1 to 0.13.2

---
updated-dependencies:
- dependency-name: com.meta.spatial:meta-spatial-sdk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-compose
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-ovrmetrics
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-toolkit
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-vr
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-isdk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-castinputforward
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-hotreload
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-datamodelinspector
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-uiset
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-mruk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:56:03 +00:00
dependabot[bot] 689219405b chore(deps): bump the testing group with 2 updates (#235)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.68.0 to 1.70.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.68.0...1.70.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.68.0 to 1.70.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.68.0...1.70.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:55:47 +00:00
Bailey Dixon f4e9de425a Merge feature/upstream-ledger-batch into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DiagnosticsScreen.kt
#	docs/upstream-surface-matrix.md
2026-07-20 07:41:48 -04:00
Bailey Dixon d4cdb96bab fix: preserve running queued recovery handoff 2026-07-19 20:20:36 -04:00
Bailey Dixon 481ad48c57 chore(release): prepare Android 1.4.9 2026-07-19 19:55:35 -04:00
Bailey Dixon c5f35145b4 Merge queued recovery boundary fixes 2026-07-19 19:53:02 -04:00
Bailey Dixon 3283c9b601 fix: preserve resumed gateway turn boundaries 2026-07-19 19:52:46 -04:00
Bailey Dixon 74be630e05 Merge pull request #231 from Codename-11/feature/dashboard-primary-connections
feat(android): make dashboard the primary connection
2026-07-19 19:47:04 -04:00
Bailey Dixon 44f030f93b docs: block one-turn model automation on atomicity 2026-07-19 19:44:35 -04:00
Bailey Dixon abce00f49e Revert "Merge feature/hrui-046-model-once"
This reverts commit 61c4337807, reversing
changes made to 6e1338004c.
2026-07-19 19:44:19 -04:00
Bailey Dixon a61d27a92d feat(android): make dashboard the primary connection
Make the Hermes dashboard the standard connection path with API fallback and optional Relay pairing. Redesign onboarding and connection management, add startup preference and route/security details, reconcile pinned profile identity and hostname discovery, and update tests, docs, and localized resources.
2026-07-19 19:39:28 -04:00
Bailey Dixon 123f1d1263 docs: record upstream ledger follow-ups 2026-07-19 19:37:47 -04:00
Bailey Dixon 61c4337807 Merge feature/hrui-046-model-once 2026-07-19 19:26:42 -04:00
Bailey Dixon 6e1338004c Merge feature/hrui-047-queued-recovery 2026-07-19 19:25:59 -04:00
Bailey Dixon 348152a7cb Merge feature/hrui-044-windows-ca 2026-07-19 19:25:59 -04:00
Bailey Dixon 3ec34502ec feat(chat): add one-turn model selection 2026-07-19 19:25:55 -04:00
Bailey Dixon d30de8656d fix(android): scope diagnostics toolsets to profile 2026-07-19 19:25:52 -04:00
Bailey Dixon 9b9b8c7c06 Merge feature/hrui-038-profile-routing
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt
2026-07-19 19:25:21 -04:00
Bailey Dixon 93b82aa538 feat: surface queued recovery and project labels 2026-07-19 19:24:20 -04:00
Bailey Dixon 7f2049fa0a fix(desktop): trust Windows system certificate authorities 2026-07-19 19:24:16 -04:00
Bailey Dixon 92444a7039 feat(android): route multiplex profile API traffic 2026-07-19 19:23:31 -04:00
Bailey Dixon a02d7e10df Merge feature/hrui-diagnostics-bundle
# Conflicts:
#	docs/upstream-surface-matrix.md
2026-07-19 19:18:29 -04:00
Bailey Dixon 8a3935ffa1 feat: add upstream gateway diagnostics 2026-07-19 19:17:18 -04:00
Bailey Dixon e48935929a Merge bootstrap database initialization follow-up 2026-07-19 19:07:15 -04:00
Bailey Dixon 1e82347e7d fix(plugin): offload bootstrap database initialization 2026-07-19 19:06:59 -04:00
Bailey Dixon 75ed3c4226 Merge feature/hrui-004-012-bootstrap-async 2026-07-19 18:34:26 -04:00
Bailey Dixon bade61ac34 fix(plugin): offload bootstrap compatibility state 2026-07-19 18:28:14 -04:00
Bailey Dixon f88559f856 docs: define upstream compatibility gates 2026-07-19 18:25:57 -04:00
Bailey Dixon 22e4d24817 Merge pull request #229 from Codename-11/fix/windows-gateway-pid-probe
fix(plugin): avoid signalling gateway PID on Windows
2026-07-18 21:58:15 -04:00
Bailey Dixon 7edaa2df14 test(plugin): preserve PID identity coverage 2026-07-18 21:57:07 -04:00
Bailey Dixon 165feaa0d6 fix(plugin): avoid signalling gateway PID on Windows 2026-07-18 21:55:03 -04:00
Bailey Dixon 8c516c3c8d Merge pull request #228 from Codename-11/main
chore: back-merge Android 1.4.8 hotfix
2026-07-18 12:22:29 -04:00
Bailey Dixon 40bb0a4ef8 Merge pull request #227 from Codename-11/fix/privacy-policy-url-hotfix
fix(android): restore Play privacy policy URL
2026-07-18 11:42:37 -04:00
Bailey Dixon d96898a6aa fix(android): restore Play privacy policy URL 2026-07-18 11:30:17 -04:00
242 changed files with 22740 additions and 2897 deletions
@@ -0,0 +1,79 @@
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
#
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
# intentionally small: check out a clean upstream hermes-agent beside Relay and
# run the desktop typed-stream/renderer tests that protect the gateway event
# contract. A later expansion can boot the upstream gateway with a mock provider
# once that harness is stable enough for CI.
name: CI - Desktop Upstream Baseline
on:
workflow_dispatch:
inputs:
upstream_ref:
description: "NousResearch/hermes-agent ref to check"
required: false
default: "main"
schedule:
- cron: "30 6 * * 1"
concurrency:
group: ci-desktop-upstream-baseline-${{ github.ref }}
cancel-in-progress: true
jobs:
desktop-baseline:
name: Desktop typed gateway baseline
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v7
- name: Resolve upstream ref
id: ref
run: |
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}"
else
REF="main"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout vanilla upstream
uses: actions/checkout@v7
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Assert upstream checkout is vanilla
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
fi
git -C _upstream status --short --untracked-files=no
- name: Run desktop gateway baseline contract
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: "22"
cache: "npm"
cache-dependency-path: desktop/package-lock.json
- name: Install desktop dependencies
working-directory: desktop
run: npm ci
- name: Run desktop gateway baseline tests
working-directory: desktop
env:
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
+4 -1
View File
@@ -50,6 +50,7 @@ jobs:
- name: Syntax check (plugin relay — canonical location)
run: |
python -m py_compile plugin/relay/config.py
python -m py_compile plugin/relay/server.py
python -m py_compile plugin/relay/channels/terminal.py
python -m py_compile plugin/relay/channels/chat.py
@@ -103,4 +104,6 @@ jobs:
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py
plugin/tests/test_native_layout_imports.py \
plugin/tests/test_profile_discovery.py \
plugin/tests/test_profiles_updated_broadcast.py
+9 -1
View File
@@ -4,11 +4,13 @@ on:
pull_request:
paths:
- "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml"
push:
branches: [main]
paths:
- "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml"
workflow_dispatch:
@@ -34,10 +36,12 @@ jobs:
run: |
set -euo pipefail
source_file="legacy-pages-redirect/redirect.html"
privacy_file="website/public/privacy.html"
output_dir="legacy-pages-redirect/_site"
rm -rf "$output_dir"
mkdir -p \
"$output_dir/guide/getting-started" \
"$output_dir/privacy" \
"$output_dir/reference/relay-server" \
"$output_dir/architecture"
for target in \
@@ -50,8 +54,12 @@ jobs:
architecture/connection-security.html; do
cp "$source_file" "$output_dir/$target"
done
cp "$privacy_file" "$output_dir/privacy.html"
cp "$privacy_file" "$output_dir/privacy/index.html"
touch "$output_dir/.nojekyll"
test "$(find "$output_dir" -type f | wc -l)" -eq 8
test "$(find "$output_dir" -type f | wc -l)" -eq 10
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
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
@@ -81,6 +81,7 @@ jobs:
- name: Validate release metadata and source compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py --live
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
+4
View File
@@ -74,6 +74,9 @@ jobs:
echo "Version validated: $TAG_VERSION"
- name: Verify public privacy policy URLs
run: python3 scripts/check-privacy-policy.py --live
- name: Verify tagged commit belongs to main
run: |
set -euo pipefail
@@ -123,6 +126,7 @@ jobs:
- name: Validate release metadata and Android API compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
+6 -4
View File
@@ -36,10 +36,12 @@ a staging branch.
## Non-negotiables (the short list)
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work
against unmodified upstream hermes-agent. Server-side needs go through upstream
PRs or the optional relay plugin, never fork patches.
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
and Vanilla Hermes voice. The API server is an optional automatic fallback and
advanced headless-compatibility surface; Relay adds optional extensions. This
path must work against unmodified upstream hermes-agent. Server-side needs go
through upstream PRs or the optional relay plugin, never fork patches.
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
+57
View File
@@ -6,9 +6,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
### Fixed
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Server 1.4.3] - 2026-07-22
### Added
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
### Fixed
- **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.
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
## [Android 1.5.0] - 2026-07-22
### Added
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
### Fixed
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
## [1.4.9] - 2026-07-19
### Changed
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
### Fixed
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
## [1.4.8] - 2026-07-18
### Fixed
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
## [1.4.7] - 2026-07-18
+145
View File
@@ -1,5 +1,150 @@
# Hermes-Relay — Dev Log
## 2026-07-24 — Post-connect permission setup
Android onboarding now finishes with a layered permission step after a
successful Hermes connection. Standard Chat and Manage are explicitly ready
without a phone grant; Android notifications are recommended through one
user-triggered runtime prompt; camera, microphone, notification companion, and
flavor-supported device tools remain individually optional on the centralized
Permissions screen. Existing just-in-time permission prompts remain available
when users skip setup.
Coverage separates the Android-version notification policy from the Compose
presentation and checks the recommended, granted, optional-review, and skip
states. English and all shipped Android locale catalogs were updated together.
## 2026-07-24 — Realtime background-task delivery continuity
Chat stream completion now preserves an otherwise empty assistant row when it
owns a promoted background task. This keeps the task identity available after
the provider's initial spoken handoff, so later progress, completion, and
forced-summary events update and settle the initiating row even when a newer
persistent Voice command has started. Existing empty-response cleanup remains
unchanged for assistant rows without background work.
## 2026-07-23 — Background interaction notifications
Android Gateway chat now treats approval, clarification, elevated-permission,
and secret requests as actionable background events. Privacy-safe notifications
use stable per-session identities, reopen the exact conversation, replace
replayed requests, and clear on answer, expiry, or resumed turn activity.
Detached active turns retain and replay their pending interaction when the
conversation is reopened.
The audit classified `terminal.read.request` as renderer plumbing rather than a
user decision. Android now answers it with the upstream no-terminal empty
response instead of showing an interaction or waiting for the server timeout.
The shared main manifest continues to provide notification and persistent
connection support to both Google Play and sideload builds.
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
The shared Android network security configuration now accepts CA certificates
that the device owner deliberately installed in Android's user credential store,
in addition to system roots. Because the policy is attached to the common
application manifest, it covers both product flavors and every platform-backed
Hermes transport: endpoint probes, Dashboard requests and authentication
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
checks remain active, and Relay's independent certificate pinner is not
overridden.
An app-wide policy is required for arbitrary operator-supplied server names and
for consistent WebView behavior. A runtime opt-in would require parallel custom
trust implementations for each client and could not safely reconfigure WebView;
per-connection CA import would add private trust-material lifecycle without
covering all transports. The tradeoff is that Android disables public
Certificate Transparency verification when user trust anchors are enabled.
User documentation records the deliberate-installation boundary and a device or
emulator validation procedure with positive chain and negative hostname checks.
JVM coverage locks the accepted anchor sources and rejects pin overrides or
debug-only trust additions.
## 2026-07-23 — Bounded Android code-highlighting ranges
Android Markdown code rendering now validates every syntax-highlighting span
before applying it to Compose text. The bundled highlighting dependency can
emit a reversed multiline-comment range when malformed or incomplete code
contains a closing delimiter before its opening delimiter; the Markdown
renderer previously passed that range directly to `AnnotatedString` and
crashed. Both fenced and indented code use the guarded renderer, valid spans
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
coverage reproduces the dependency output and verifies the safe conversion.
## 2026-07-20 — Image generation placeholder during turns
Android chat now specializes the generic tool lifecycle for active
`image_generate` calls. While the tool is pending, the message shows a
theme-aware procedural diffusion canvas with a polite live-region announcement
instead of a generic tool card. The completed tool result still replaces the
placeholder through the existing tool completion path. Coverage includes pure
JVM selection/denoise tests and a Compose accessibility snapshot test.
## 2026-07-19 — Android 1.4.9 release preparation
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
connection and onboarding work merged to `dev`. The public changelog, GitHub
release notes, in-app What's New surfaces, English and Simplified Chinese Play
notes, and store-listing copy now describe the Android-only patch. Unreleased
Relay security hardening and the Windows gateway PID fix remain assigned to the
independent server release track.
## 2026-07-19 — Connection management detail pass
The Connections list now uses compact capability chips and exposes a cold-start
preference when more than one server is saved. Last used remains the default;
pinning a startup connection does not change the active connection during the
current run. Dashboard-only connections now render their configured Dashboard
and authentication status in Routes instead of an empty endpoint list, while
optional API fallback routes remain explicitly separate.
Advanced settings render directly in their dedicated tab without a redundant
expander. Security adds Dashboard authentication, credential-storage, Relay
session, sign-out, and transport posture facts sourced from current runtime
state. Android catalogs and localization source hashes were refreshed alongside
focused startup-persistence coverage.
## 2026-07-18 — Dashboard-primary connection contract
The canonical Android connection model now treats one Hermes installation as a
stable identity with independently optional endpoints. Dashboard/Gateway owns
the standard upstream chat, authentication, sessions, Manage, and voice path;
the API server is an automatic fallback and advanced headless compatibility
surface; Relay remains an additive extension for terminal, bridge, media, and
enhanced voice capabilities. Existing API-first records and pairing payloads
remain compatible without defining normal onboarding.
The audit reconciled `AGENTS.md`, `docs/spec.md`, `docs/decisions.md`,
`docs/upstream-surface-matrix.md`, `README.md`,
`user-docs/features/connections.md`, `user-docs/features/dashboard.md`,
`CHANGELOG.md`, and `DEVLOG.md`. `RELEASE.md` was reviewed and required no
change because it contains no connection-routing contract.
## 2026-07-18 — Non-signalling Windows gateway PID probes
Relay profile discovery now prefers Hermes' supported psutil liveness check and
falls back to native Windows process handles when psutil is unavailable. The
POSIX signal-zero probe is restricted to POSIX hosts, preventing relay startup
and periodic profile rescans from signalling or terminating the gateway named
by `gateway.pid`. Regression coverage uses disposable child processes instead
of pointing PID fixtures at the test runner.
## 2026-07-18 — Android privacy-policy URL hotfix
The canonical Android privacy policy moved to a stable public page on
hermes-relay.dev. The historical GitHub Pages path now serves the complete
policy for compatibility with existing store metadata instead of depending on
a client-side redirect. Android's About screen, public privacy references, and
Play submission documentation use the canonical site URL.
The legacy Pages workflow publishes both file and directory policy paths from
the same canonical HTML source. Repository validation checks the policy's
required disclosures and URL wiring, while Play preflight and stable tag release
jobs additionally require both deployed policy URLs to return complete policy
content before an artifact can be uploaded or promoted. Android advanced to
1.4.8 with versionCode 31 for the replacement Play submission.
## 2026-07-18 — Android 1.4.7 release preparation
Android advanced to 1.4.7 with versionCode 30 after the localization, streaming,
+7 -5
View File
@@ -1,20 +1,22 @@
# Hermes-Relay-Plugin v__VERSION__
**Release Date:** July 15, 2026
**Release Date:** July 22, 2026
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Added
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
### Fixed
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
## Install / update
+26 -30
View File
@@ -54,52 +54,48 @@ Install → connect → talk, in about two minutes.
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
### 2 · Have the Hermes Dashboard 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://hermes-relay.dev/docs/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
provider is vanilla Hermes setup:
```bash
hermes setup --portal # install / log in / pick a provider — skip if already done
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
hermes gateway
hermes setup --portal # install / log in / pick a provider — skip if already done
hermes dashboard # start the standard Dashboard/Gateway surface
```
`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://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).
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
covers Windows, remote access, and dashboard authentication. You do not need to
enable the separate API server or invent an API key for the standard path.
### 3 · Connect and talk
Open the app and pick how to connect — any of:
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
configured provider when prompted. The app probes the available upstream
capabilities and finishes with a connection summary.
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
The separate API server can be discovered automatically or added later under
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
key is requested only when that optional endpoint is configured. Existing
API-first setup QRs remain importable.
The wizard probes everything and finishes with a capability card:
| Line | What it means |
|------|---------------|
| **Chat** | API server reachable — you can talk |
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
| **Chat** | Dashboard/Gateway ready — you can talk |
| **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **Remote** | Fallback route configured — keeps working away from home |
| **Relay** | Optional power tools — fine to leave unpaired |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional extensions — fine to leave unpaired |
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.
One dashboard sign-in unlocks Chat, Manage, sessions, and standard 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://hermes-relay.dev/docs/guide/remote-access).
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. 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
@@ -133,7 +129,7 @@ the QR from the phone's Connections screen — or use
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
## Screenshots
+14 -9
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.4.7
# Hermes-Relay-Android v1.5.0
**Release Date:** July 18, 2026
**Release Date:** July 22, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.4.7-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,19 +12,24 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch adds German, Brazilian Portuguese, and Japanese and makes long streamed replies grow smoothly while staying anchored at the latest text.
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
## Added
- Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.
- Language-picker and catalog freshness checks keep every shipped locale aligned with the canonical English resources.
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
## Fixed
- Long streamed replies insert text at a display-paced cadence instead of visibly rebuilding the conversation.
- The active response remains anchored at the latest text through growth and completion while readers who scroll into history remain undisturbed.
- Voice settings and active-turn correction copy remain complete across supported languages.
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
## Install / Verify
- App version: **1.4.7** (versionCode **30**).
- App version: **1.5.0** (versionCode **33**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
+50 -1
View File
@@ -36,6 +36,55 @@ removal.
---
## Upstream impact certification follow-ups (2026-07-19)
The client/plugin implementation batch for queued recovery,
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
trust, and retained bootstrap async safety is implemented. The following gates
intentionally remain outside that code batch:
- **Image-generation lifecycle while tool progress is hidden.** The upstream
TUI gateway suppresses every `tool.start` / `tool.complete` event when
`display.tool_progress` is off, so a client cannot distinguish an active
`image_generate` turn from generic model work. Propose a narrow upstream
exception that always emits the lifecycle for `image_generate` while leaving
unrelated tool diagnostics hidden. Android already treats that lifecycle as
presentation state rather than a generic tool card and keeps the diffusion
canvas visible when its local tool display is off.
- Run `docs/upstream-compatibility-certification.md` against an approved test
gateway with real provider calls and an Android device. Include concurrent
model/image routing, turn isolation off/on, queued reconnect, same-profile
background-completion ownership, compression lineage, and the explicitly
approved restart case. Static upstream fixtures are necessary but do not
prove device or restart behavior.
- Upstream the atomic one-turn model arm/submit contract proposed in
`docs/upstream-contributions.md`. Until then, document the narrow race where a
disconnect or Stop after `/model --once` succeeds but before prompt submission
can leave the override armed for a later prompt.
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
exposes a reset on the active gateway session or an authoritative reset event.
`slash.exec` runs the command in a separate worker today, and the mirrored
slash side effects do not reset the active TUI session's agent. Relay must not
clear local model, reasoning, or Fast pins from a successful command response
that did not mutate the agent those controls describe.
- Keep profile-scoped cron execution attempts blocked on the public upstream API
proposed in `docs/upstream-contributions.md`. The first-class interim
assistant event is no longer blocked: Relay Android and desktop consume
upstream `message.interim` / `response_previewed`.
- Keep Standard voice labeled host-global until upstream exposes a stable
profile/per-request audio contract; do not emulate it through Relay on the
vanilla path.
- Keep provider exclusion/disable filtering out of Android Manage until the
public model-options payload identifies excluded and disabled providers.
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
empty models are not authoritative evidence that a provider should be hidden.
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
once the harness can boot a credential-free upstream gateway deterministically.
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
checkout and the desktop typed gateway renderer/tests.
---
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
Android profile hot-swap and concurrent Gateway turns are separate from proactive
@@ -1070,7 +1119,7 @@ Things to look into:
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
+2 -2
View File
@@ -326,8 +326,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -0,0 +1,48 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
/** Local device-review helper. Never runs in or ships with the application APK. */
@RunWith(AndroidJUnit4::class)
class ConnectionReviewSeedTest {
@Test
fun seedOfflineSecondaryConnection() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = ConnectionStore(context)
store.isHydrated.first { it }
if (store.connections.value.none { it.id == REVIEW_ID }) {
store.addConnection(
Connection(
id = REVIEW_ID,
label = "Lab NAS",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
dashboardUrl = "http://192.0.2.10:9119",
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
),
)
}
}
@Test
fun removeOfflineSecondaryConnection() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = ConnectionStore(context)
store.isHydrated.first { it }
if (store.connections.value.any { it.id == REVIEW_ID }) {
store.removeConnection(REVIEW_ID)
}
}
private companion object {
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
}
}
@@ -121,42 +121,39 @@ class OnboardingFlowTest {
}
@Test
fun connectPage_showsStandardChoiceFirst() {
fun connectPage_showsNearbyFirst() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("Vanilla Hermes")
.onNodeWithText("Enter address instead")
.assertIsDisplayed()
}
@Test
fun standardSetup_showsApiFields() {
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("API server URL")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("API key")
.onNodeWithText("Hermes address")
.assertIsDisplayed()
}
@Test
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
fun manualSetup_findButton_isShown() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Connect")
.assertIsEnabled()
.onNodeWithText("Find Hermes")
.assertIsDisplayed()
}
@Test
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Other connection methods").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Pair Relay by code")
.assertIsDisplayed()
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
android:exported="true"
android:screenOrientation="portrait" />
</application>
</manifest>
@@ -0,0 +1,128 @@
package com.hermesandroid.relay.ui.screens
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
/** Debug-build-only deterministic host for design QA screenshots. */
class VoiceSettingsDesignQaActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val themePreference = intent.getStringExtra("theme") ?: "auto"
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun VoiceSettingsDesignQaScene() {
val provider = remember {
RealtimeProviderInfo(
id = "xai_tts",
name = "xAI Grok TTS",
status = "ready",
models = listOf("grok-tts", "grok-tts-fast"),
voices = listOf("eve", "ara", "sal", "rex", "leo"),
model_labels = mapOf("grok-tts" to "Grok TTS"),
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
recommended_voices = listOf("eve", "ara"),
supports_tts = true,
)
}
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
var selectedVoice by remember { mutableStateOf("eve") }
var expanded by remember { mutableStateOf(false) }
val allVoices = remember {
listOf(
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
VoiceChoice("sal", "Sal", "Calm · grounded"),
VoiceChoice("rex", "Rex", "Direct · confident"),
VoiceChoice("leo", "Leo", "Bright · conversational"),
)
}
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
VoiceSettingsTabs(selectedSection) { selectedSection = it }
VoiceProviderGroupCard(
provider = provider,
providerValue = provider.id,
enabled = true,
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
onEnabledChange = {},
onProviderChange = {},
controlsEnabled = true,
)
ModelAndVoiceGroupCard(
modelValue = "grok-tts",
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
voices = previewVoiceChoices(allVoices, selectedVoice),
allVoices = allVoices,
selectedVoice = selectedVoice,
previewState = VoicePreviewUiState(
selectionKey = "voice:eve",
isPlaying = true,
amplitude = 0.42f,
),
onModelChange = {},
onVoiceChange = { selectedVoice = it },
onPreviewVoice = {},
enabled = true,
)
LanguageQualityCard(
expanded = expanded,
onExpandedChange = { expanded = it },
language = "English",
languages = listOf(VoiceChoice("en", "English")),
onLanguageChange = {},
sampleRate = "24000",
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
onSampleRateChange = {},
enabled = true,
)
}
}
}
@@ -1 +1 @@
Long streamed replies now grow smoothly and stay anchored at the newest text through completion, while scrolling into history preserves your reading position. German, Brazilian Portuguese, and Japanese are now available throughout the app.
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
@@ -1 +1 @@
长回复现在会平滑流式显示,并在完成时保持定位到最新文本;向上滚动查看历史记录时仍会保留阅读位置。应用现已支持德语、巴西葡萄牙语和日语。
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
+63
View File
@@ -1,5 +1,68 @@
{
"versions": [
{
"version": "1.5.0",
"title": "Voice that keeps pace",
"date": "2026-07-22",
"sections": [
{
"header": "A clearer voice studio",
"bullets": [
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
]
},
{
"header": "Natural streaming speech",
"bullets": [
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
]
},
{
"header": "More upstream-aware controls",
"bullets": [
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
]
}
]
},
{
"version": "1.4.9",
"title": "Clearer Hermes connections",
"date": "2026-07-19",
"sections": [
{
"header": "Dashboard-first setup",
"bullets": [
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
]
},
{
"header": "Consistent identity",
"bullets": [
"Server default now displays Hermes' pinned active profile consistently across the app.",
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
]
}
]
},
{
"version": "1.4.8",
"title": "Privacy policy restored",
"date": "2026-07-18",
"sections": [
{
"header": "Google Play compliance",
"bullets": [
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
]
}
]
},
{
"version": "1.4.7",
"title": "Smoother replies, more languages",
+4 -7
View File
@@ -1,8 +1,5 @@
v1.4.7 - Smoother replies, more languages
v1.5.0 - Voice that keeps pace
Smooth streaming
* Long replies grow steadily and stay anchored at the newest text through completion.
* Scrolling into history still leaves your reading position alone.
More languages
* Use German, Brazilian Portuguese, or Japanese throughout the app.
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
@@ -20,6 +20,7 @@ import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
// Action-required notifications are durable across process death.
// Once the authenticated chat surface is visible it owns presentation;
// unresolved asks are re-posted if the app returns to the background.
InteractionRequestNotifier.cancelAll(this)
// v0.4.1 — register this activity as the host for
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
// we don't leak the Activity past its lifecycle. The unattended-
@@ -31,7 +31,16 @@ object AgentDisplay {
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
serverDefaultProfileName: String? = null,
): Profile? {
selectedProfile?.let { return it }
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
return resolvedServerDefault
?.let { activeName ->
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
}
?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
}
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.data
/**
* Pure, persisted-state-derived availability for a Hermes connection.
*
* This deliberately describes configured surfaces, not live reachability or
* authentication. Runtime layers can combine it with their probe/auth state
* without treating a missing optional API server or Relay as a broken Hermes
* connection.
*/
data class ConnectionCapabilities(
val dashboardGatewayConfigured: Boolean,
val apiServerConfigured: Boolean,
val relayConfigured: Boolean,
) {
val gatewayChatAvailable: Boolean get() = dashboardGatewayConfigured
val manageAvailable: Boolean get() = dashboardGatewayConfigured
val standardVoiceAvailable: Boolean get() = dashboardGatewayConfigured
val apiChatFallbackAvailable: Boolean get() = apiServerConfigured
val relayFeaturesAvailable: Boolean get() = relayConfigured
val chatConfigured: Boolean get() = gatewayChatAvailable || apiChatFallbackAvailable
val anySurfaceConfigured: Boolean
get() = dashboardGatewayConfigured || apiServerConfigured || relayConfigured
}
val Connection.capabilities: ConnectionCapabilities
get() = ConnectionCapabilities(
dashboardGatewayConfigured = resolvedDashboardUrl.isNotBlank(),
apiServerConfigured = apiServerUrl.isNotBlank(),
relayConfigured = relayUrl.isNotBlank(),
)
@@ -13,13 +13,16 @@ data class DashboardConnectionStatus(
val authProvider: String? = null,
val gatewayTicketAvailable: Boolean? = null,
val message: String? = null,
val gatewayMode: String? = null,
val profiles: List<String> = emptyList(),
)
/**
* A "connection" = a distinct Hermes server connection the app can switch between.
*
* Each connection has its own:
* - API server URL + relay URL
* - One or more independently-configured Hermes surfaces. Dashboard/Gateway
* is the standard primary path; API server and Relay are optional.
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
* session token, device ID, API key, and paired-session metadata.
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
@@ -73,17 +76,34 @@ data class Connection(
val preferredRouteRole: String? = null,
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
val pairedAt: Long? = null,
/** Last time the user explicitly selected this connection. */
val lastUsedAt: Long? = null,
val lastActiveSessionId: String? = null,
val transportHint: String? = null,
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
val expiresAt: Long? = null,
) {
/**
* Effective Dashboard/Gateway endpoint. Legacy records did not persist a
* dashboard URL, so they retain the conventional same-host `:9119`
* derivation from the API server. Dashboard-only records persist an
* explicit URL and may leave [apiServerUrl] and [relayUrl] blank.
*/
val resolvedDashboardUrl: String
get() = dashboardUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
/** Stable display/host identity that does not depend on the API surface. */
val primaryEndpointUrl: String
get() = resolvedDashboardUrl.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
val primaryHost: String
get() = extractHost(primaryEndpointUrl).orEmpty()
companion object {
/**
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
@@ -94,6 +114,8 @@ data class Connection(
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
const val DEFAULT_DASHBOARD_PORT: Int = 9119
const val DEFAULT_API_PORT: Int = 8642
const val DEFAULT_RELAY_PORT: Int = 8767
/**
* Derive a stable per-connection EncryptedSharedPreferences filename
@@ -111,12 +133,40 @@ data class Connection(
* user typed a malformed value — better to show something recognizable
* than to crash).
*/
fun extractDefaultLabel(apiServerUrl: String): String {
return try {
URI(apiServerUrl).host ?: apiServerUrl
} catch (_: Exception) {
apiServerUrl
}
fun extractDefaultLabel(apiServerUrl: String): String =
extractHost(apiServerUrl) ?: apiServerUrl
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
fun chooseDiscoveredLabel(
currentLabel: String,
primaryHost: String,
discoveredHostname: String?,
): String {
val current = currentLabel.trim()
val discovered = discoveredHostname?.trim()?.takeIf { it.isNotBlank() }
val isAutomatic = current.isBlank() || current.equals(primaryHost.trim(), ignoreCase = true)
return if (isAutomatic && discovered != null) discovered else currentLabel
}
/**
* Dashboard-first label for a connection whose surfaces are optional.
* The one-argument overload above remains for source compatibility.
*/
fun extractDefaultLabel(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
): String {
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
return extractHost(primary) ?: primary
}
private fun extractHost(url: String): String? = try {
URI(url).host
} catch (_: Exception) {
null
}
fun deriveDefaultDashboardUrl(
@@ -141,6 +191,29 @@ data class Connection(
return "$scheme://$hostPart:$dashboardPort"
}
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
fun deriveDefaultApiUrl(
dashboardUrl: String,
apiPort: Int = DEFAULT_API_PORT,
): String? {
val trimmed = dashboardUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "http"
"https" -> "https"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$apiPort"
}
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
if (trimmed.isEmpty()) return true
@@ -150,7 +223,7 @@ data class Connection(
fun deriveDefaultRelayUrl(
apiServerUrl: String,
relayPort: Int = 8767,
relayPort: Int = DEFAULT_RELAY_PORT,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
@@ -199,7 +272,7 @@ data class Connection(
return routes
.distinctBy {
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
"${it.role.lowercase()}|${it.routeAuthority()}"
}
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
@@ -222,13 +295,13 @@ data class Connection(
existing: List<EndpointCandidate>,
): List<EndpointCandidate> {
val rebuiltHostPorts = rebuilt
.map { "${it.api.host.lowercase()}:${it.api.port}" }
.mapNotNull { it.mergeAuthority() }
.toSet()
val preserved = existing
.filter { it.priority > 0 }
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
return (rebuilt + preserved)
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
@@ -296,6 +369,85 @@ data class Connection(
)
}
/**
* De-duplication identity for rebuilding stored routes. Prefer the
* legacy API authority when present so an older API-only candidate and
* its dashboard-enriched replacement still collide. Dashboard-only
* candidates fall back to their primary route authority.
*/
private fun EndpointCandidate.mergeAuthority(): String? =
api?.let { endpoint -> "api|${endpoint.host.lowercase()}:${endpoint.port}" }
?: routeAuthority()?.let { authority -> "route|$authority" }
/**
* Build a Dashboard/Gateway-primary route from a remote host or URL.
* API and Relay are retained only when explicitly configured; callers
* no longer need to invent an API key or legacy surface URL.
*/
fun endpointCandidateFromDashboardUrl(
role: String,
priority: Int,
dashboardUrl: String,
apiServerUrl: String? = null,
relayUrl: String? = null,
): EndpointCandidate? {
val normalizedDashboard = normalizeDashboardUrlInput(dashboardUrl)
val dashboardUri = runCatching { URI(normalizedDashboard) }.getOrNull() ?: return null
if (dashboardUri.scheme?.lowercase() !in setOf("http", "https") ||
dashboardUri.host.isNullOrBlank()
) return null
val api = apiServerUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { apiUrl ->
val apiUri = runCatching { URI(apiUrl.trimEnd('/')) }.getOrNull()
?: return@let null
val tls = when (apiUri.scheme?.lowercase()) {
"http" -> false
"https" -> true
else -> return@let null
}
val host = apiUri.host?.takeIf { it.isNotBlank() } ?: return@let null
ApiEndpoint(host, if (apiUri.port > 0) apiUri.port else 8642, tls)
}
val relay = relayUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { url ->
val hint = when {
url.startsWith("wss://", ignoreCase = true) -> "wss"
url.startsWith("ws://", ignoreCase = true) -> "ws"
else -> null
}
RelayEndpoint(url, hint)
}
return EndpointCandidate(
role = role.ifBlank { inferRouteRole(normalizedDashboard) },
priority = priority,
dashboard = DashboardEndpoint(normalizedDashboard),
api = api,
relay = relay,
)
}
fun normalizeDashboardUrlInput(
raw: String,
defaultPort: Int = DEFAULT_DASHBOARD_PORT,
): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return trimmed
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
val withScheme = "http://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull()
val canAppendPort = uri != null &&
!uri.host.isNullOrBlank() &&
uri.port <= 0 &&
uri.rawPath.isNullOrEmpty() &&
uri.rawQuery == null
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
}
fun inferRouteRole(apiServerUrl: String): String {
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
.getOrNull()
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
class ConnectionStore private constructor(
private val dataStore: DataStore<Preferences>,
private val context: Context?,
private val scope: CoroutineScope,
) {
/**
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
constructor(context: Context) : this(
dataStore = context.relayDataStore,
context = context.applicationContext,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
/**
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
internal constructor(dataStore: DataStore<Preferences>) : this(
dataStore = dataStore,
context = null,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
internal constructor(
dataStore: DataStore<Preferences>,
scope: CoroutineScope,
) : this(dataStore = dataStore, context = null, scope = scope)
private val json = Json {
ignoreUnknownKeys = true
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
private val _activeConnectionId = MutableStateFlow<String?>(null)
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
/**
* Optional cold-start pin. `null` means restore the last connection the
* user actively selected, which remains the recommended default.
*/
private val _startupConnectionId = MutableStateFlow<String?>(null)
val startupConnectionId: StateFlow<String?> = _startupConnectionId.asStateFlow()
/**
* Flips to `true` once the initial DataStore hydrate completes (success OR
* failure). Until then [connections] / [activeConnection] hold their empty
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
val oldJson = prefs[KEY_LEGACY_PROFILES]
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
val startupId = prefs[KEY_STARTUP_CONNECTION_ID]
// Prefer the new key. If absent and the old key has data,
// migrate it once: write to the new key and clear the old ones
@@ -143,15 +157,21 @@ class ConnectionStore private constructor(
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
}
}
_connections.value = decodeConnections(oldJson)
_activeConnectionId.value = activeOld
val restored = decodeConnections(oldJson)
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeOld
Log.i(
TAG,
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
)
} else {
_connections.value = decodeConnections(newJson)
_activeConnectionId.value = activeNew
val restored = decodeConnections(newJson)
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeNew
}
} catch (e: Exception) {
Log.w(TAG, "Initial hydrate failed: ${e.message}")
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
_activeConnectionId.value = null
}
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_startupConnectionId.value = null
}
}
removed?.let { deleteTokenStoresFor(it) }
}
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
removed = decodeConnections(prefs[KEY_CONNECTIONS])
prefs.remove(KEY_CONNECTIONS)
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
prefs.remove(KEY_STARTUP_CONNECTION_ID)
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = emptyList()
_activeConnectionId.value = null
_startupConnectionId.value = null
}
removed.forEach { deleteTokenStoresFor(it) }
}
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
suspend fun replaceConnections(
connections: List<Connection>,
activeConnectionId: String? = null,
startupConnectionId: String? = null,
) {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
val normalizedActiveId = activeConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
?: normalizedConnections.firstOrNull()?.id
val normalizedStartupId = startupConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
}
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
if (normalizedStartupId == null) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
} else {
prefs[KEY_STARTUP_CONNECTION_ID] = normalizedStartupId
}
_connections.value = normalizedConnections
_activeConnectionId.value = normalizedActiveId
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
_startupConnectionId.value = normalizedStartupId
}
removed.forEach { deleteTokenStoresFor(it) }
}
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
suspend fun setActiveConnection(id: String) {
writeMutex.withLock {
dataStore.edit { prefs ->
val current = decodeConnections(prefs[KEY_CONNECTIONS])
if (current.any { it.id == id }) {
val next = current.map { connection ->
if (connection.id == id) {
connection.copy(lastUsedAt = System.currentTimeMillis())
} else {
connection
}
}
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
prefs[KEY_ACTIVE_CONNECTION_ID] = id
_activeConnectionId.value = id
}
}
}
/** Set a specific cold-start connection, or `null` to restore last used. */
suspend fun setStartupConnection(id: String?) {
writeMutex.withLock {
dataStore.edit { prefs ->
val validId = id?.takeIf { candidate ->
decodeConnections(prefs[KEY_CONNECTIONS]).any { it.id == candidate }
}
if (validId == null) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_activeConnectionId.value?.let { activeId ->
prefs[KEY_ACTIVE_CONNECTION_ID] = activeId
}
} else {
prefs[KEY_STARTUP_CONNECTION_ID] = validId
}
_startupConnectionId.value = validId
}
}
}
/**
* Update just the `lastActiveSessionId` on the identified connection.
* Called whenever the user picks a chat session so connection-switch can
@@ -504,6 +571,7 @@ class ConnectionStore private constructor(
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
private val KEY_STARTUP_CONNECTION_ID = stringPreferencesKey("startup_connection_id")
// Pre-rename DataStore keys — read once in init on first launch after
// the rename, then wiped. See the init block above.
@@ -52,6 +52,45 @@ object ConnectionValidation {
kind = "relay URL",
)
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("http", "https"),
kind = "Dashboard URL",
)
/** A blank API server means the optional SSE fallback is not configured. */
fun validateOptionalApiServerUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("http", "https"),
kind = "API server URL",
)
/** A blank Relay URL means Relay-only power features are not configured. */
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("ws", "wss"),
kind = "relay URL",
)
/**
* Validate the independently optional connection surfaces. A connection
* needs at least one endpoint, but Dashboard-only, API-only, and
* Relay-only records are all structurally valid.
*/
fun validateConnectionEndpoints(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
): String? {
if (dashboardUrl.isNullOrBlank() && apiServerUrl.isBlank() && relayUrl.isBlank()) {
return "Configure at least one Hermes endpoint"
}
return validateDashboardUrl(dashboardUrl.orEmpty())
?: validateOptionalApiServerUrl(apiServerUrl)
?: validateOptionalRelayUrl(relayUrl)
}
/**
* Catches the "added the same server twice" mistake. Matches when the
* candidate's api + relay URLs exactly match an existing connection
@@ -67,12 +106,33 @@ object ConnectionValidation {
apiServerUrl: String,
relayUrl: String,
excludeId: String? = null,
dashboardUrl: String? = null,
): Connection? = connections.firstOrNull { c ->
c.id != excludeId &&
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
c.relayUrl.equals(relayUrl, ignoreCase = true)
if (c.id == excludeId) {
false
} else {
val legacyExactMatch =
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
urlsEqual(c.apiServerUrl, apiServerUrl) &&
urlsEqual(c.relayUrl, relayUrl)
val candidateDashboard = dashboardUrl
?.takeIf { it.isNotBlank() }
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
val dashboardMatch = !candidateDashboard.isNullOrBlank() &&
urlsEqual(c.resolvedDashboardUrl, candidateDashboard)
legacyExactMatch || dashboardMatch
}
}
private fun validateOptionalUrl(
raw: String,
allowedSchemes: Set<String>,
kind: String,
): String? = if (raw.isBlank()) null else validateUrl(raw, allowedSchemes, kind)
private fun urlsEqual(first: String, second: String): Boolean =
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "$kind can't be blank"
@@ -72,10 +72,11 @@ class DataManager(
* - v5 (2026-06-08): full connection backups. Adds active connection id
* and `connectionSecrets`, including API keys, relay tokens, device id,
* paired metadata, and dashboard cookies.
* - v6 (2026-07-19): preserves the optional pinned startup connection.
*/
@Serializable
data class AppBackup(
val version: Int = 5,
val version: Int = 6,
val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null,
val relayUrl: String? = null,
@@ -83,6 +84,7 @@ class DataManager(
val onboardingCompleted: Boolean = false,
val connections: List<Connection> = emptyList(),
val activeConnectionId: String? = null,
val startupConnectionId: String? = null,
val containsSensitiveData: Boolean = true,
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
val exportedAt: Long = System.currentTimeMillis(),
@@ -160,6 +162,7 @@ class DataManager(
onboardingCompleted = onboardingCompleted,
connections = connectionsSnapshot,
activeConnectionId = connectionStore?.activeConnectionId?.value,
startupConnectionId = connectionStore?.startupConnectionId?.value,
containsSensitiveData = true,
connectionSecrets = connectionSecrets,
exportedAt = System.currentTimeMillis(),
@@ -173,6 +176,7 @@ class DataManager(
store.replaceConnections(
connections = backup.connections,
activeConnectionId = backup.activeConnectionId,
startupConnectionId = backup.startupConnectionId,
)
val connectionsById = backup.connections.associateBy { it.id }
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URI
/**
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
@@ -38,8 +39,10 @@ import kotlinx.serialization.Serializable
data class EndpointCandidate(
val role: String,
val priority: Int = 0,
val api: ApiEndpoint,
val relay: RelayEndpoint,
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
val api: ApiEndpoint? = null,
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
val relay: RelayEndpoint? = null,
val dashboard: DashboardEndpoint? = null,
val proxy: ProxyEndpoint? = null,
val security: String? = null,
@@ -137,13 +140,42 @@ fun EndpointCandidate.displayLabel(): String {
return when (role.lowercase()) {
"lan" -> "LAN"
"tailscale" -> "Tailscale"
"public" -> if (api.tls) "HTTPS" else "Public"
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
"HTTPS"
} else {
"Public"
}
"https" -> "HTTPS"
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
else -> "Custom VPN ($role)"
}
}
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
fun EndpointCandidate.primaryRouteUrl(): String? =
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: api?.url
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
/** Stable host/port identity without assuming that an API surface exists. */
fun EndpointCandidate.routeAuthority(): String? {
val rawUrl = primaryRouteUrl() ?: return null
val httpUrl = when {
rawUrl.startsWith("ws://", ignoreCase = true) -> "http://${rawUrl.substringAfter("://")}"
rawUrl.startsWith("wss://", ignoreCase = true) -> "https://${rawUrl.substringAfter("://")}"
else -> rawUrl
}
val uri = runCatching { URI(httpUrl) }.getOrNull() ?: return null
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val port = when {
uri.port > 0 -> uri.port
uri.scheme.equals("https", ignoreCase = true) -> 443
else -> 80
}
return "$host:$port"
}
fun EndpointCandidate.hasSecureProxy(): Boolean =
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
@@ -9,6 +9,7 @@ import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -325,17 +326,18 @@ class ConnectionManager(
// the synthesized list just collapses to the same URL anyway.
scope.launch {
val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url ?: url
val resolvedRelayUrl = resolved?.relay?.url?.takeIf { it.isNotBlank() }
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
if (resolved != null) {
_activeEndpoint.value = resolved
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
"relay=${resolved.relay.url} (fallback would have been $url)")
"route=${resolved.primaryRouteUrl()} (relay fallback would have been $url)")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
endpointRole = resolved.role,
url = resolved.relay.url,
url = resolved.primaryRouteUrl(),
)
} else {
_activeEndpoint.value = null
@@ -348,7 +350,11 @@ class ConnectionManager(
url = url,
)
}
connectToUrlOnMainPath(targetUrl)
if (targetUrl != null) {
connectToUrlOnMainPath(targetUrl)
} else {
Log.d(TAG, "connect: selected route has no Relay surface; route published for HTTP/Gateway clients")
}
}
}
@@ -656,10 +662,11 @@ class ConnectionManager(
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
// the swap path never re-checked it.)
if (!shouldReconnect) return@launch
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
val relayUrl = resolved.relay?.url?.takeIf { it.isNotBlank() } ?: return@launch
val normalizedNew = normalizeRelayUrl(relayUrl)
if (normalizedNew != current) {
Log.i(TAG, "network change: swapping $current → $normalizedNew")
connectToUrlOnMainPath(resolved.relay.url, closeReason)
connectToUrlOnMainPath(relayUrl, closeReason)
} else if (_connectionState.value == ConnectionState.Disconnected &&
reconnectGate()
) {
@@ -135,6 +135,91 @@ class RelayHttpClient(
val text: String,
)
@Serializable
data class ImageActivitySnapshot(
@SerialName("session_id") val sessionId: String,
val profile: String,
val activities: List<ImageActivity> = emptyList(),
)
@Serializable
data class ImageActivity(
@SerialName("call_id") val callId: String,
@SerialName("tool_name") val toolName: String,
val state: String,
@SerialName("started_at") val startedAt: Double,
@SerialName("completed_at") val completedAt: Double? = null,
)
/**
* Poll the optional Relay image lifecycle bridge. A null success means the
* connected Relay predates the endpoint; callers should stop polling and
* continue using native Gateway events without surfacing an error.
*/
suspend fun fetchImageActivity(
profile: String,
sessionId: String,
sinceEpochSeconds: Double,
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(
IllegalStateException("Relay URL not configured")
)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.failure(
IllegalStateException("Relay not paired — session token missing")
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = try {
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
.addQueryParameter("profile", profile)
.addQueryParameter("session_id", sessionId)
.addQueryParameter("since", sinceEpochSeconds.toString())
.build()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
}
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
val activityClient = okHttpClient.newBuilder()
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
try {
activityClient.newCall(request).execute().use { response ->
if (response.code == 404) {
return@withContext Result.success(null)
}
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("Image activity request failed (HTTP ${response.code})")
)
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
Result.success(
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
)
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
* [Result] — success carries a [FetchedMedia], failure wraps the
@@ -615,6 +700,14 @@ class RelayHttpClient(
val capabilities: List<String> = emptyList(),
val profiles: List<RelayProfileInfo> = emptyList(),
val health: String = "unknown",
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
)
@Serializable
data class GatewayHeartbeat(
val status: String = "missing",
val supported: Boolean = false,
@SerialName("age_seconds") val ageSeconds: Int? = null,
)
/** Fetch the installed plugin/protocol/profile capability contract. */
@@ -834,6 +834,11 @@ class RelayVoiceClient(
suspend fun runVoiceOutput(
text: String,
renderMode: String? = "verbatim",
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
onEvent: (RealtimeVoiceEvent) -> Unit,
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
@@ -844,7 +849,15 @@ class RelayVoiceClient(
return@withContext Result.failure(missingAuthError())
}
val sessionResult = createVoiceOutputSession(httpBase, token)
val sessionResult = createVoiceOutputSession(
httpBase = httpBase,
token = token,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
}
@@ -1261,8 +1274,10 @@ class RelayVoiceClient(
inputSampleRate: Int = 16_000,
chatSessionId: String? = null,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
@@ -1290,8 +1305,10 @@ class RelayVoiceClient(
token = token,
chatSessionId = chatSessionId,
conversationContext = conversationContext,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
@@ -2658,17 +2675,25 @@ class RelayVoiceClient(
token: String,
chatSessionId: String?,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
): Result<RealtimeSessionResponse> {
val body = buildJsonObject {
putProfile()
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
put("provider", JsonPrimitive(it))
}
model?.trim()?.takeIf { it.isNotBlank() }?.let {
put("model", JsonPrimitive(it))
}
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
put("voice", JsonPrimitive(it))
}
sampleRate?.takeIf { it > 0 }?.let {
put("sample_rate", JsonPrimitive(it))
}
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
put("chat_session_id", JsonPrimitive(it))
}
@@ -2726,8 +2751,23 @@ class RelayVoiceClient(
}
}
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
val body = buildJsonObject { putProfile() }.toString()
private fun createVoiceOutputSession(
httpBase: String,
token: String,
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
): Result<VoiceOutputSessionResponse> {
val body = buildVoiceOutputSessionPayload(
profile = currentProfileName(),
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
)
val request = Request.Builder()
.url("$httpBase/voice/output/session")
.post(body.toRequestBody(JSON_MEDIA_TYPE))
@@ -2970,6 +3010,22 @@ class RelayVoiceClient(
}
}
internal fun buildVoiceOutputSessionPayload(
profile: String?,
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
): String = buildJsonObject {
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
}.toString()
/**
* Wire shape of `GET /voice/config`. Providers are returned as nested
* objects describing the currently-active STT and TTS backend. Extra
@@ -4,6 +4,8 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@@ -56,7 +58,10 @@ data class RouteProbeOutcome(
* candidate is reachable we use it; reachability never promotes a lower
* priority over a higher one. Reachability is **only** the tiebreaker
* among candidates that share the same priority.
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
* * **Reachability probe.** Dashboard-first routes use `GET
* ${dashboard.url}/api/status`; legacy API routes use `GET
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
* Each request has a 4-second
* per-candidate timeout. Positive results are cached longer than negative
* results so repeated `connect()` calls don't hammer healthy routes, while
* transient handoff misses do not pin a good fallback offline.
@@ -101,6 +106,12 @@ class EndpointResolver(
*/
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
private data class ProbeTarget(
val baseUrl: String,
val requestUrl: String,
val path: String,
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
@@ -156,13 +167,13 @@ class EndpointResolver(
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
/**
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
* Stable cache key for a candidate: `"<role>|<primary host>:<port>"`.
* Roles are preserved case-verbatim (HMAC canonicalization contract)
* but hostnames are lowercased — two roles pointing at the same
* host:port share reachability state.
*/
internal fun cacheKey(candidate: EndpointCandidate): String =
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
"${candidate.role}|${candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()}"
}
/**
@@ -194,14 +205,14 @@ class EndpointResolver(
val winner = raceGroup(group)
if (winner != null) {
Log.i(TAG, "resolve winner: role=${winner.role} " +
"api=${winner.api.host}:${winner.api.port} priority=$priority")
"route=${winner.primaryRouteUrl()} priority=$priority")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
detail = "priority=$priority",
endpointRole = winner.role,
url = winner.relay.url,
url = winner.primaryRouteUrl(),
)
return winner
}
@@ -281,7 +292,7 @@ class EndpointResolver(
}
/**
* One-shot HEAD /health probe against a candidate. 2-second timeout,
* One-shot probe against a candidate's primary configured surface.
* no retries — callers that need retry semantics can re-invoke after
* the cache expires.
*
@@ -290,18 +301,19 @@ class EndpointResolver(
*/
private suspend fun probe(candidate: EndpointCandidate): Boolean {
val startedAtMs = clock()
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
val target = probeTarget(candidate)
val url = target?.requestUrl?.toHttpUrlOrNull()
?: run {
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "Invalid API URL",
detail = "No valid Dashboard, API, or Relay URL",
endpointRole = candidate.role,
url = candidate.api.url,
url = candidate.primaryRouteUrl(),
)
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
return false
}
val fastClient = httpClient.newBuilder()
@@ -310,11 +322,14 @@ class EndpointResolver(
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request = Request.Builder()
val requestBuilder = Request.Builder()
.url(url)
.head()
.header("Accept", "*/*")
.build()
// Hermes API's aiohttp health route accepts GET but returns 405 to
// HEAD. That response proves connectivity while the old probe marked
// the route unreachable. Health payloads are tiny, so follow the
// endpoint's actual public contract on every surface.
val request = requestBuilder.get().build()
return withContext(Dispatchers.IO) {
try {
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
@@ -331,13 +346,13 @@ class EndpointResolver(
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(
candidate,
reachable = ok,
detail = if (ok) null else "HTTP ${resp.code} from /health",
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
)
ok
}
@@ -346,9 +361,9 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
@@ -359,23 +374,23 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
} catch (e: Exception) {
Log.d(TAG, "probe failed role=${candidate.role} " +
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
"route=${target.baseUrl}: ${e.javaClass.simpleName}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
@@ -384,6 +399,50 @@ class EndpointResolver(
}
}
/** Choose the standard Dashboard/Gateway surface first when advertised. */
private fun probeTarget(candidate: EndpointCandidate): ProbeTarget? {
candidate.dashboard?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { base ->
return ProbeTarget(
baseUrl = base,
requestUrl = "$base/api/status",
path = "/api/status",
)
}
candidate.api?.url?.let { base ->
return ProbeTarget(
baseUrl = base,
requestUrl = "$base/health",
path = "/health",
)
}
candidate.relay?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { relayUrl ->
val httpBase = when {
relayUrl.startsWith("ws://", ignoreCase = true) ->
"http://${relayUrl.substringAfter("://")}"
relayUrl.startsWith("wss://", ignoreCase = true) ->
"https://${relayUrl.substringAfter("://")}"
else -> return null
}
return ProbeTarget(
baseUrl = relayUrl,
requestUrl = "$httpBase/health",
path = "/health",
)
}
return null
}
/**
* Map a probe exception to a short, actionable string for the Routes
* card. The TLS case is the headline: a route saved with `https://`
@@ -8,22 +8,29 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Inet4Address
import java.net.InetAddress
import java.util.concurrent.TimeUnit
data class HermesLanDiscoveryResult(
val host: String,
val hostname: String? = null,
val apiUrl: String,
val dashboardUrl: String?,
val apiReachable: Boolean,
val dashboardReachable: Boolean,
)
) {
val displayHost: String
get() = hostname ?: host
}
/**
* User-triggered local-network discovery for standard Hermes setup.
@@ -59,7 +66,9 @@ object HermesLanDiscovery {
hosts.map { host ->
async {
semaphore.withPermit {
probeHost(client, host, apiPort, dashboardPort)
probeHost(client, host, apiPort, dashboardPort)?.let { result ->
result.copy(hostname = resolveHostname(host))
}
}
}
}.awaitAll()
@@ -130,6 +139,24 @@ object HermesLanDiscovery {
}
}
private suspend fun resolveHostname(address: String): String? =
withTimeoutOrNull(350L) {
runInterruptible(Dispatchers.IO) {
normalizeResolvedHostname(
address = address,
resolved = InetAddress.getByName(address).canonicalHostName,
)
}
}
internal fun normalizeResolvedHostname(address: String, resolved: String?): String? {
val normalized = resolved?.trim()?.trimEnd('.')?.takeIf { it.isNotBlank() } ?: return null
if (normalized.equals(address.trim(), ignoreCase = true)) return null
if (normalized.equals("localhost", ignoreCase = true)) return null
if (normalized.matches(Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$"))) return null
return normalized
}
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
val lower = body.lowercase()
return contentType.contains("json", ignoreCase = true) && (
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.network.shared
import java.net.URI
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Resolves profile-scoped Hermes API URLs for phone use.
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
}
/**
* Resolve the canonical API base for a selected Hermes profile.
*
* A dedicated profile API URL remains authoritative when advertised. When
* the dashboard has positively identified a shared multiplex gateway and
* the selected non-default profile is in its served-profile list, route
* through the upstream `/p/<profile>` mirror on the connection's root API
* origin. Older/single-profile servers and incomplete topology snapshots
* deliberately keep the root URL.
*/
fun resolveChatBase(
profileApiUrl: String?,
baseApiUrl: String?,
selectedProfileName: String?,
gatewayMode: String?,
servedProfiles: Collection<String>,
): String? {
val base = normalize(baseApiUrl)
val dedicated = resolveForConnection(profileApiUrl, base)
if (dedicated != null) return dedicated
val profile = selectedProfileName
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
?: return base
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
servedProfiles.any { it.equals(profile, ignoreCase = false) }
if (!isKnownMultiplexProfile) return base
val root = base?.toHttpUrlOrNull() ?: return base
return root.newBuilder()
.addPathSegment("p")
.addPathSegment(profile)
.build()
.toString()
.trimEnd('/')
}
private fun isLocalBindHost(host: String): Boolean {
return when (host.lowercase().trim('[', ']')) {
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
enum class VoiceSpeechStreamStatus {
Completed,
Fallback,
Stopped,
Failed,
}
data class VoiceSpeechStreamOutcome(
val status: VoiceSpeechStreamStatus,
val audioStarted: Boolean,
val error: Throwable? = null,
)
data class VoiceSpeechStreamCallbacks(
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
)
interface VoiceSpeechStream {
fun append(text: String)
fun finish()
fun stop()
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
}
/**
* Transport-neutral STT/TTS contract. The routing seam between the Standard
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
/**
* Open one provider-backed PCM stream for an assistant reply. A null
* success means this route has no streaming surface and the caller should
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
* calls made before the socket opens and report whether any PCM was emitted
* so callers never replay already-heard audio during compatibility fallback.
*/
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
Result.success(null)
}
/**
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
override suspend fun synthesize(text: String): Result<File> =
runWithSelectedRoute { it.synthesize(text) }
override suspend fun openSpeechStream(
callbacks: VoiceSpeechStreamCallbacks,
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
client.openSpeechStream(callbacks)
}
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
@@ -1208,10 +1208,14 @@ class ChatHandler {
val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = items.mapNotNull { item ->
val role = when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val role = when {
displayKind == "model_switch" ||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
item.role == "user" -> MessageRole.USER
item.role == "assistant" -> MessageRole.ASSISTANT
item.role == "system" ->
// Upstream injects role:system STEERING markers into the
// session history on model/personality change — e.g.
// "[System: The active model for this chat has changed to …]"
@@ -1227,9 +1231,11 @@ class ChatHandler {
} else {
MessageRole.SYSTEM
}
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
else -> return@mapNotNull null
}
val displayContent = displayEventContent(displayKind, item.displayMetadata)
val rawServerContent = displayContent ?: item.contentText ?: ""
// If > 1e12, already in milliseconds; otherwise convert from seconds
val ts = item.timestamp ?: 0.0
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
@@ -1241,8 +1247,8 @@ class ChatHandler {
emptyList()
}
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
val rawContent = item.contentText ?: ""
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
val rawContent = rawServerContent
// Run the media marker parser on assistant content; strip matched
// lines and queue hits for post-assignment dispatch.
@@ -1496,19 +1502,53 @@ class ChatHandler {
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
* actually render.
*/
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" ->
if (!showSystemMarkers &&
item.contentText?.trimStart()?.startsWith("[System:") == true
) {
null
} else {
MessageRole.SYSTEM
private fun renderedRoleOf(item: MessageItem): MessageRole? =
when (item.displayKind?.trim()?.lowercase()) {
"hidden" -> null
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
else -> when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" ->
if (!showSystemMarkers &&
item.contentText?.trimStart()?.startsWith("[System:") == true
) {
null
} else {
MessageRole.SYSTEM
}
else -> null
}
else -> null
}
}
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
when (displayKind) {
"model_switch" -> {
val model = metadata.stringField("model")
?: metadata.stringField("to_model")
?: metadata.stringField("target_model")
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
}
"async_delegation_complete" -> {
val count = metadata.intField("task_count")
?: metadata.intField("tasks")
?: metadata.intField("count")
when (count) {
null -> "Background work completed"
1 -> "1 background task completed"
else -> "$count background tasks completed"
}
}
else -> null
}
private fun JsonObject?.stringField(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
private fun JsonObject?.intField(key: String): Int? =
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
primitive.contentOrNull?.toIntOrNull()
}
/**
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
@@ -1682,7 +1722,10 @@ class ChatHandler {
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
// local title whenever the server hasn't supplied a non-blank one.
val existingById = _sessions.value.associateBy { it.sessionId }
val mapped = items.map { item ->
// The drawer is always composed, even while closed, and keys rows by
// session id. A refresh race or duplicated upstream row must not put
// the same key into Compose's LazyColumn.
val mapped = items.distinctBy { it.id }.map { item ->
val startedAtMs = timestampToMillis(item.startedAt)
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
@@ -1760,7 +1803,15 @@ class ChatHandler {
* Add a newly created session to the list.
*/
fun addSession(session: ChatSession) {
_sessions.update { listOf(session) + it }
// Gateway onSessionId and an overlapping REST refresh can both publish
// the same freshly-created session. Treat this as an idempotent upsert,
// preferring an existing server-enriched row while collapsing any
// duplicates that were already present.
_sessions.update { current ->
val existing = current.firstOrNull { it.sessionId == session.sessionId }
listOf(existing ?: session) +
current.filterNot { it.sessionId == session.sessionId }
}
}
// --- SSE streaming event entry points ---
@@ -2867,13 +2918,22 @@ class ChatHandler {
}
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId || msg.isStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
messages
.filterNot { msg ->
msg.id == messageId &&
msg.role == MessageRole.ASSISTANT &&
msg.toolCalls.isEmpty() &&
msg.backgroundTask == null &&
msg.thinkingContent.isBlank() &&
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
}
.map { msg ->
if (msg.id == messageId || msg.isStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
}
}
}
}
// Post-stream reconciliation: re-scan final content for any annotation
@@ -3073,3 +3133,15 @@ internal fun formatPhoneActionResult(
append("Status ${result.status}.")
}
}
internal fun isIntentionalSilenceMarker(content: String): Boolean =
when (content.trim().trim('"', '\'', '`').uppercase()) {
"NO_REPLY",
"[NO_REPLY]",
"SILENT",
"[SILENT]",
"<SILENT>",
"(SILENT)",
-> true
else -> false
}
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -51,6 +52,37 @@ data class DashboardStatus(
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
val version: String? = null,
val message: String? = null,
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
val profiles: List<String> = emptyList(),
@SerialName("gateway_mode") val gatewayMode: String? = null,
val gateways: List<DashboardGatewayTopology> = emptyList(),
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
)
@Serializable
data class DashboardGatewayTopology(
val profile: String,
val ports: Map<String, Int> = emptyMap(),
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
)
@Serializable
data class DashboardComponentHealthRollup(
val supported: Boolean = false,
val overall: String? = null,
val components: List<DashboardComponentHealth> = emptyList(),
)
@Serializable
data class DashboardComponentHealth(
val name: String,
val status: String,
val message: String? = null,
val configured: Int? = null,
val connected: Int? = null,
val healthy: Boolean? = null,
val ok: Boolean? = null,
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
)
@Serializable
@@ -92,6 +124,53 @@ data class DashboardChatDisplaySettings(
val toolDisplay: String? = null,
)
data class DashboardMcpOAuthFlow(
val flowId: String,
val serverName: String,
val status: String,
val authorizationUrl: String? = null,
val error: String? = null,
) {
val isTerminal: Boolean get() = status == "approved" || status == "error"
}
data class DashboardCustomEndpoint(
val id: String,
val name: String,
val baseUrl: String,
val model: String,
val models: List<String> = emptyList(),
val contextLength: Int? = null,
val discoverModels: Boolean = true,
val hasApiKey: Boolean = false,
val apiKeyPreview: String? = null,
val isCurrent: Boolean = false,
)
data class DashboardCustomEndpoints(
val endpoints: List<DashboardCustomEndpoint>,
val currentProvider: String? = null,
val currentModel: String? = null,
)
data class DashboardCustomEndpointDraft(
val id: String? = null,
val name: String,
val baseUrl: String,
val model: String,
val apiKey: String? = null,
val contextLength: Int? = null,
val discoverModels: Boolean = true,
val makeDefault: Boolean = false,
)
data class DashboardCustomEndpointValidation(
val ok: Boolean,
val reachable: Boolean,
val message: String,
val models: List<String>,
)
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
data class ElevenLabsVoice(
val voiceId: String,
@@ -255,6 +334,14 @@ class DashboardApiClient(
*/
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
/**
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
* This adds plugin provider names and readiness metadata. Command-provider
* IDs remain sourced from the dynamic config-schema enum.
*/
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
getJsonObject("/api/tools/toolsets/tts/config")
/**
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
* writes the WHOLE document, so [config] MUST be the full values tree
@@ -466,25 +553,100 @@ class DashboardApiClient(
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
suspend fun setMcpServerEnabled(
name: String,
enabled: Boolean,
profile: String? = null,
): Result<JsonObject> =
putJsonObject(
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
payload = buildJsonObject { put("enabled", enabled) },
)
suspend fun testMcpServer(name: String): Result<JsonObject> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
suspend fun removeMcpServer(name: String): Result<JsonObject> =
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
suspend fun startMcpOAuth(
name: String,
profile: String? = null,
): Result<DashboardMcpOAuthFlow> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
.mapCatching(::parseMcpOAuthFlow)
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
.mapCatching(::parseMcpOAuthFlow)
/**
* Read-only hosted-OAuth capability probe. New dashboards recognize the
* flow-status route and return its canonical expired-flow 404; older
* FastAPI routers return the generic route-level 404. No OAuth worker is
* started and no provider/browser interaction occurs.
*/
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
return result.fold(
onSuccess = { Result.success(true) },
onFailure = { error ->
val message = error.message.orEmpty()
when {
message.contains("OAuth flow not found or expired") -> Result.success(true)
message.contains("HTTP 404") -> Result.success(false)
else -> Result.failure(error)
}
},
)
}
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
getJsonObject("/api/providers/custom-endpoints")
.mapCatching(::parseCustomEndpoints)
suspend fun saveCustomEndpoint(
draft: DashboardCustomEndpointDraft,
): Result<DashboardCustomEndpoints> =
postJsonObject(
"/api/providers/custom-endpoints",
customEndpointPayload(draft),
).mapCatching(::parseCustomEndpoints)
suspend fun validateCustomEndpoint(
draft: DashboardCustomEndpointDraft,
): Result<DashboardCustomEndpointValidation> =
postJsonObject(
"/api/providers/custom-endpoints/validate",
customEndpointPayload(draft),
).mapCatching { root ->
DashboardCustomEndpointValidation(
ok = root.booleanField("ok") == true,
reachable = root.booleanField("reachable") == true,
message = root.stringField("message").orEmpty(),
models = root.stringList("models"),
)
}
suspend fun activateCustomEndpoint(
id: String,
): Result<JsonObject> =
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
suspend fun deleteCustomEndpoint(
id: String,
): Result<DashboardCustomEndpoints> =
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
.mapCatching(::parseCustomEndpoints)
suspend fun installMcpCatalogEntry(
name: String,
env: Map<String, String> = emptyMap(),
enable: Boolean = true,
profile: String? = null,
): Result<JsonObject> =
postJsonObject(
path = "/api/mcp/catalog/install",
path = "/api/mcp/catalog/install${profileQuery(profile)}",
payload = buildJsonObject {
put("name", name)
put(
@@ -929,6 +1091,57 @@ class DashboardApiClient(
return params.joinToString(prefix = "?", separator = "&")
}
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
val flowId = root.stringField("flow_id")
?: throw IOException("MCP OAuth response did not include a flow id")
val status = root.stringField("status")
?: throw IOException("MCP OAuth response did not include a status")
return DashboardMcpOAuthFlow(
flowId = flowId,
serverName = root.stringField("server_name").orEmpty(),
status = status,
authorizationUrl = root.stringField("authorization_url"),
error = root.stringField("error"),
)
}
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
val current = root["current"] as? JsonObject
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("id") ?: return@mapNotNull null
DashboardCustomEndpoint(
id = id,
name = obj.stringField("name") ?: id,
baseUrl = obj.stringField("base_url").orEmpty(),
model = obj.stringField("model").orEmpty(),
models = obj.stringList("models"),
contextLength = obj.intField("context_length"),
discoverModels = obj.booleanField("discover_models") != false,
hasApiKey = obj.booleanField("has_api_key") == true,
apiKeyPreview = obj.stringField("api_key_preview"),
isCurrent = obj.booleanField("is_current") == true,
)
}
return DashboardCustomEndpoints(
endpoints = endpoints,
currentProvider = current?.stringField("provider"),
currentModel = current?.stringField("model"),
)
}
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
buildJsonObject {
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
put("name", draft.name)
put("base_url", draft.baseUrl)
put("model", draft.model)
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
put("discover_models", draft.discoverModels)
put("make_default", draft.makeDefault)
}
fun defaultClient(
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
): OkHttpClient = OkHttpClient.Builder()
@@ -947,6 +1160,20 @@ class DashboardApiClient(
?: root["providers"]
?: authObject?.get("providers")
val providers = parseProviders(providersElement)
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
}
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val profile = obj.stringField("profile") ?: return@mapNotNull null
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
}.toMap()
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull
}
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
}
return DashboardStatus(
authRequired = root.booleanField("auth_required")
?: authObject.booleanField("required")
@@ -955,6 +1182,45 @@ class DashboardApiClient(
authProviderDetails = providers,
version = root.stringField("version"),
message = root.stringField("message") ?: root.stringField("detail"),
nousSessionValid = root.stringField("nous_session_valid"),
profiles = profiles,
gatewayMode = root.stringField("gateway_mode"),
gateways = gateways,
componentHealth = parseComponentHealth(root),
)
}
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
val rawComponents = root["components"] as? JsonObject
?: return DashboardComponentHealthRollup()
val components = rawComponents.mapNotNull { (name, element) ->
val component = element as? JsonObject ?: return@mapNotNull null
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
DashboardComponentHealth(
name = safeName,
status = component.stringField("status")
?: component.stringField("state")
?: component.stringField("health")
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
?: "unknown",
message = component.stringField("message")
?: component.stringField("summary")
?: component.stringField("error")
?: component.stringField("reason"),
configured = component.intField("configured"),
connected = component.intField("connected"),
healthy = component.booleanField("healthy"),
ok = component.booleanField("ok"),
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
)
}.sortedBy { it.name }
return DashboardComponentHealthRollup(
supported = true,
overall = root.stringField("overall")
?: root.stringField("status")
?: root.stringField("health"),
components = components,
)
}
@@ -1203,6 +1469,61 @@ class DashboardCookieJar(
}
}
/**
* Copy only Hermes' authenticated dashboard session cookies to another host
* that belongs to the same saved Connection. Dashboard cookies are host-only
* by design, while a Connection may reach one server through LAN and
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
* explicit sign-out clears every mirrored host together.
*
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
* copied. Secure cookies also remain Secure; this helper never downgrades them
* for an HTTP route.
*/
fun mirrorDashboardSessionCookies(
store: DashboardCookieStore,
targetUrl: String,
trustedHosts: Set<String>,
clockMillis: () -> Long = { System.currentTimeMillis() },
): Int {
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
if (targetHost !in allowedHosts) return 0
val now = clockMillis()
val all = store.load()
val live = all.filterNot { it.isExpired(now) }
val existingTargetKeys = live.asSequence()
.filter { it.domain.equals(targetHost, ignoreCase = true) }
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
.toSet()
val mirrored = live.asSequence()
.filter { it.isDashboardSessionCookie() }
.filter { it.domain.lowercase() in allowedHosts }
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
.groupBy { "${it.name.lowercase()}|${it.path}" }
.values
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
.map { it.copy(domain = targetHost, hostOnly = true) }
.filterNot { it.key in existingTargetKeys }
.toList()
if (mirrored.isNotEmpty() || live.size != all.size) {
store.save(live + mirrored)
}
return mirrored.size
}
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
val bareName = name
.removePrefix("__Host-")
.removePrefix("__Secure-")
return bareName == "hermes_session" ||
bareName == "hermes_session_at" ||
bareName == "hermes_session_rt" ||
bareName == "hermes_session_provider"
}
/**
* Cookie jar that resolves the backing per-connection store at request time.
*
@@ -1353,3 +1674,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
private fun JsonObject?.intField(name: String): Int? =
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
private fun JsonObject?.stringList(name: String): List<String> =
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
}
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
* `category` field so it is robust to upstream's category-merging.
*/
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
fields.filter {
it.key.startsWith("tts.") ||
it.key.startsWith("stt.") ||
it.key.startsWith("voice.")
}
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
data class TtsToolsetProvider(
val id: String,
val name: String,
val status: String? = null,
val isActive: Boolean = false,
)
/**
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
*
* Unlike the config-schema enum, this payload adds readiness metadata and
* reliably discovered plugin providers. Command providers remain schema-owned.
* Older upstream builds may omit `tts_provider`;
* those rows are ignored because their picker label is not a stable config ID.
*/
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
val providers = root["providers"] as? JsonArray ?: return emptyList()
return providers.mapNotNull { element ->
val provider = element as? JsonObject ?: return@mapNotNull null
val id = provider.configString("tts_provider")
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
TtsToolsetProvider(
id = id,
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
status = provider.configString("status"),
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
)
}.distinctBy { it.id }
}
/** Read the value at a dot-path from the nested config values tree, or null. */
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.upstream
import android.util.Log
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.CompletableDeferred
@@ -17,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -24,6 +27,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -82,6 +86,8 @@ class GatewayChatClient(
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
) {
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
private val sessionSource = "webui"
companion object {
private const val TAG = "GatewayChatClient"
@@ -128,6 +134,7 @@ class GatewayChatClient(
* not enough.
*/
private const val ATTACH_RPC_TIMEOUT_MS = 60_000L
private const val COMPRESS_RPC_TIMEOUT_MS = 120_000L
/** Upstream image byte-upload RPC (underscore — `image.attach_bytes`, content_base64). */
private const val ATTACH_METHOD_UPSTREAM = "image.attach_bytes"
@@ -289,6 +296,10 @@ class GatewayChatClient(
private val _serverContext = MutableStateFlow<Pair<Int, Int>?>(null)
val serverContext: StateFlow<Pair<Int, Int>?> = _serverContext.asStateFlow()
/** Optional upstream project identity for the active session. */
private val _serverProject = MutableStateFlow<GatewaySessionProject?>(null)
val serverProject: StateFlow<GatewaySessionProject?> = _serverProject.asStateFlow()
/** Serializes connect / session-establish so concurrent sends share one socket. */
private val connectMutex = Mutex()
@@ -361,6 +372,7 @@ class GatewayChatClient(
private data class BackgroundTurn(
val storedSessionId: String,
val profile: String?,
@Volatile var pendingAsk: GatewayAsk? = null,
)
/**
@@ -399,6 +411,11 @@ class GatewayChatClient(
private var unmatchedTurnCompleteListener:
((GatewayBackgroundTurnCompletion) -> Unit)? = null
/** Input requested or resolved on a deliberately detached turn. */
@Volatile
private var backgroundInteractionListener:
((GatewayBackgroundInteractionEvent) -> Unit)? = null
/**
* Connection-level process listener. Unlike [GatewayTurnCallbacks], this is
* consulted even when there is no locally initiated [activeTurn].
@@ -528,10 +545,10 @@ class GatewayChatClient(
// into the SSE fallback, which would resubmit the same
// prompt as a duplicate turn. Recovery belongs to the
// stream: the watchdog and mid-turn rejoin own it.
if (turn.started || turn.ended) {
if (turn.started || turn.ended || turn.transportRecoveryStarted) {
Log.w(
TAG,
"prompt.submit ack failed after turn start " +
"prompt.submit ack failed after turn start/rejoin " +
"(${submitted.exceptionOrNull()?.message}) — no SSE fallback",
)
return@launch
@@ -580,10 +597,23 @@ class GatewayChatClient(
val turn = activeTurn?.takeIf { !it.ended } ?: return false
val liveId = liveSessionId ?: return false
val storedId = storedSessionId ?: return false
backgroundTurns[liveId] = BackgroundTurn(
val backgroundTurn = BackgroundTurn(
storedSessionId = storedId,
profile = liveSessionProfile,
pendingAsk = turn.pendingInteraction,
)
backgroundTurns[liveId] = backgroundTurn
backgroundTurn.pendingAsk?.let { ask ->
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Requested(
storedSessionId = storedId,
profile = backgroundTurn.profile,
ask = ask,
),
)
}
}
turn.detach()
return true
}
@@ -653,6 +683,12 @@ class GatewayChatClient(
unmatchedTurnCompleteListener = listener
}
fun setBackgroundInteractionListener(
listener: ((GatewayBackgroundInteractionEvent) -> Unit)?,
) {
backgroundInteractionListener = listener
}
fun setProcessEventListener(listener: ((GatewayProcessEvent) -> Unit)?) {
processEventListener = listener
}
@@ -720,14 +756,15 @@ class GatewayChatClient(
* New Hermes gateways expose `session.activate`, which attaches the new
* WebSocket transport to the exact live id saved in the client checkpoint.
* If that id has already been reaped (or the method is unavailable), fall
* back to `session.resume` by durable session id. Its `running` + `inflight`
* fields decide whether a live mapper is installed or history should settle
* the turn instead.
* back to `session.resume` by durable session id. Its `running`, `inflight`,
* and optional `queued` fields decide whether a live mapper is installed or
* history should settle the turn instead.
*/
suspend fun recoverTurn(
storedId: String,
preferredLiveId: String?,
callbacks: GatewayTurnCallbacks,
queuedTurnProvider: ((GatewayQueuedTurn) -> GatewayInboundTurnRegistration?)? = null,
): Result<GatewaySessionRecovery> = runCatching {
require(storedId.isNotBlank()) { "stored session id required" }
val requestedProfile = currentSessionProfile()
@@ -757,6 +794,7 @@ class GatewayChatClient(
boundTurn = GatewayTurn(
callbacks = dispatchOn(callbacks),
dedupeAdjacentMessageStarts = true,
deferEvents = true,
).also { turn ->
turn.markRecoveredStarted()
activeTurn = turn
@@ -770,6 +808,7 @@ class GatewayChatClient(
response = activated.getOrNull()
if (response == null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.discardDeferredEvents()
boundTurn.detach()
boundTurn = null
Log.d(
@@ -786,6 +825,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
).getOrElse { error ->
@@ -807,7 +847,7 @@ class GatewayChatClient(
storedSessionId = storedId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(storedId, recoveredLiveId)
(response["info"] as? JsonObject)?.let { applySessionInfo(it) }
applySessionResultInfo(response)
val inflight = (response["inflight"] as? JsonObject)?.let { value ->
GatewayInflightTurn(
@@ -816,6 +856,11 @@ class GatewayChatClient(
streaming = value.booleanField("streaming") == true,
)
}
val queued = (response["queued"] as? JsonObject)?.let { value ->
value.stringField("user")
?.takeIf { it.isNotBlank() }
?.let(::GatewayQueuedTurn)
}
val running = response.booleanField("running") == true || inflight?.streaming == true
if (running) {
@@ -828,10 +873,50 @@ class GatewayChatClient(
activeTurn = turn
}
}
queued?.let { queuedTurn ->
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
boundTurn.installQueuedSuccessor(registration)
}
}
boundTurn.releaseDeferredEvents()
claimedBackground?.pendingAsk?.let { ask ->
boundTurn.restoreInteraction(ask)
}
boundTurn.armWatchdog()
} else if (queued != null) {
// A queued-only snapshot belongs to the NEXT turn. Never let
// its events flow through the completed checkpoint's mapper.
val priorBoundTurn = boundTurn
boundTurn = null
val registration = queuedTurnProvider?.invoke(queued)
if (registration != null) {
val queuedTurn = GatewayTurn(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
)
// recoverTurn is resumed on its caller's coroutine context;
// ChatViewModel calls it from Main, so this admission runs
// atomically with the checkpoint handoff. Posting back
// through bindInboundTurn would deadlock waiting on the
// same paused Main dispatcher during cold-start recovery.
if (registration.onHandle(queuedTurn)) {
priorBoundTurn?.redirectDeferredEventsTo(queuedTurn)
boundTurn = queuedTurn
activeTurn = queuedTurn
queuedTurn.armWatchdog()
priorBoundTurn?.detach()
} else {
priorBoundTurn?.discardDeferredEvents()
priorBoundTurn?.detach()
}
} else {
priorBoundTurn?.discardDeferredEvents()
priorBoundTurn?.detach()
}
} else {
if (boundTurn != null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.discardDeferredEvents()
boundTurn.detach()
}
boundTurn = null
@@ -843,36 +928,109 @@ class GatewayChatClient(
running = running,
status = response.stringField("status"),
inflight = inflight,
handle = boundTurn?.takeUnless { it.ended },
queued = queued,
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
?.takeUnless { it.ended },
)
}
}
/**
* Inject [text] into the in-flight turn (`session.steer`). The server
* only accepts a steer while a tool batch is running — [SteerResult.Rejected]
* means "no batch in flight, queue it instead". [SteerResult.Failed]
* covers transport/RPC failure (no live session, socket down, 4010 …) —
* callers should fall back to the local queue for both non-queued cases.
* Inject [text] into the in-flight turn. Current upstream exposes this as
* `session.redirect`, which can redirect an active model turn while keeping
* valid work/context. Older gateways only expose `session.steer`; fall back
* to that legacy RPC only when the redirect method is absent/unsupported so
* old installs still queue the correction instead of dropping it.
*/
suspend fun steer(text: String): SteerResult {
val sid = liveSessionId ?: return SteerResult.Failed
val result = rpc(
"session.steer",
buildJsonObject {
put("session_id", sid)
put("text", text)
},
)
val params = buildJsonObject {
put("session_id", sid)
put("text", text)
}
val redirect = rpc("session.redirect", params)
val result = if (redirect.isLegacyRedirectUnsupported()) {
Log.i(TAG, "session.redirect unsupported — falling back to session.steer (session=$storedSessionId)")
rpc("session.steer", params)
} else {
redirect
}
val outcome = when (result.getOrNull()?.stringField("status")) {
"queued" -> SteerResult.Queued
"redirected", "queued" -> SteerResult.Queued
"rejected" -> SteerResult.Rejected
else -> SteerResult.Failed
}
Log.i(TAG, "Steer → $outcome (session=$storedSessionId)")
Log.i(TAG, "Active-turn correction → $outcome (session=$storedSessionId)")
return outcome
}
/**
* Compress the live gateway session through the dedicated upstream RPC.
* `/compress` is intentionally not sent through generic slash execution on
* modern gateways because `session.compress` returns authoritative transcript,
* usage, title/model/session-info, and compute-host isolation metadata.
*/
suspend fun compressSession(focusTopic: String? = null): Result<GatewayCompressResult> {
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
val params = buildJsonObject {
put("session_id", sid)
focusTopic?.trim()?.takeIf { it.isNotBlank() }?.let { put("focus_topic", it) }
}
val direct = rpc("session.compress", params, timeoutMs = COMPRESS_RPC_TIMEOUT_MS)
val result = if (direct.exceptionOrNull().isMethodNotFound()) {
val legacyCommand = "/compress" + focusTopic
?.trim()
?.takeIf { it.isNotBlank() }
?.let { " $it" }
.orEmpty()
slashExec(legacyCommand).map { slashResult ->
buildJsonObject {
put("status", "legacy")
slashResult.stringField("output")?.let { put("output", it) }
}
}
} else {
direct
}
return result.map { payload ->
applySessionResultInfo(payload)
payload.toGatewayCompressResult()
}
}
private fun Result<JsonObject>.isLegacyRedirectUnsupported(): Boolean {
val error = exceptionOrNull() ?: return false
val message = error.message.orEmpty()
return error.isMethodNotFound() ||
(error as? GatewayRpcException)?.code == 4010 ||
message.contains("does not support active-turn redirect", ignoreCase = true)
}
private fun JsonObject.toGatewayCompressResult(): GatewayCompressResult =
GatewayCompressResult(
status = stringField("status") ?: "completed",
output = stringField("output"),
removed = (this["removed"] as? JsonPrimitive)?.intOrNull,
beforeMessages = (this["before_messages"] as? JsonPrimitive)?.intOrNull,
afterMessages = (this["after_messages"] as? JsonPrimitive)?.intOrNull,
beforeTokens = (this["before_tokens"] as? JsonPrimitive)?.intOrNull,
afterTokens = (this["after_tokens"] as? JsonPrimitive)?.intOrNull,
usage = GatewayEventMapper.parseGatewayUsage(this["usage"] as? JsonObject),
info = this["info"] as? JsonObject,
messages = (this["messages"] as? JsonArray)?.let { messages ->
runCatching {
json.decodeFromJsonElement(
ListSerializer(MessageItem.serializer()),
messages,
)
}.getOrElse {
Log.w(TAG, "session.compress returned unreadable messages", it)
emptyList()
}
}.orEmpty(),
)
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> =
rpc(
@@ -1251,6 +1409,7 @@ class GatewayChatClient(
unsolicitedTurnProvider = null
coldPrewarmSessionReadyListener = null
unmatchedTurnCompleteListener = null
backgroundInteractionListener = null
processEventListener = null
closeSocket("client shutdown")
backgroundCloseJob?.cancel()
@@ -1350,6 +1509,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
)
@@ -1368,13 +1528,14 @@ class GatewayChatClient(
// resume result's embedded `info` (same shape session.info carries),
// so a reopened session shows its ACTUAL model immediately instead of
// a misleading default until the first turn's async session.info.
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
applySessionResultInfo(result)
}
}
/**
* Apply connection-level session info (model / provider / reasoning effort /
* personality / yolo / fast / context usage) into the `_server*` state flows.
* personality / yolo / fast / context usage / project) into the `_server*`
* state flows.
* Shared by the `session.info` event handler and the `session.resume` RPC
* result — the resume response embeds the same `info` object, so reopening a
* session can paint its real model up front rather than waiting for a turn.
@@ -1396,6 +1557,18 @@ class GatewayChatClient(
info.stringField("credential_warning")?.takeIf { it.isNotBlank() }
(info["yolo"] as? JsonPrimitive)?.booleanOrNull?.let { _serverYolo.value = it }
(info["fast"] as? JsonPrimitive)?.booleanOrNull?.let { _serverFast.value = it }
_serverProject.value = (info["project"] as? JsonObject)?.let { project ->
project.stringField("name")
?.takeIf { it.isNotBlank() }
?.let { name ->
GatewaySessionProject(
id = project.stringField("id")?.takeIf { it.isNotBlank() },
slug = project.stringField("slug")?.takeIf { it.isNotBlank() },
name = name,
primaryPath = project.stringField("primary_path")?.takeIf { it.isNotBlank() },
)
}
}
// Context usage: require used > 0 — a COLD resume resets counters and
// reports 0 until the first turn rebuilds the prompt; painting 0 would
// mislead on a session that actually has history.
@@ -1408,6 +1581,12 @@ class GatewayChatClient(
}
}
/** Apply a session create/resume result without leaking metadata from the prior session. */
private fun applySessionResultInfo(result: JsonObject) {
_serverProject.value = null
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
}
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
private suspend fun ensureLiveProcessSession(): Result<String> {
val requestedProfile = currentSessionProfile()
@@ -1495,6 +1674,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", requestedStoredId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
)
@@ -1505,7 +1685,7 @@ class GatewayChatClient(
storedSessionId = requestedStoredId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(requestedStoredId, live)
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
applySessionResultInfo(result)
return
}
Log.w(
@@ -1519,6 +1699,7 @@ class GatewayChatClient(
"session.create",
buildJsonObject {
put("cols", DEFAULT_COLS)
put("source", sessionSource)
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
requestedProfile?.let { put("profile", it) }
// Bind the in-chat overrides to the new session as its
@@ -1640,6 +1821,30 @@ class GatewayChatClient(
return
}
// read_terminal is a renderer query, not a user decision. Android has
// no xterm pane on the Gateway chat surface, so mirror upstream
// desktop's no-live-pane behavior and answer with empty text instead
// of blocking the agent for the server's 30-second timeout.
if (type == "terminal.read.request") {
val requestId = payload?.stringField("request_id")
val ownedSession = !eventSessionId.isNullOrBlank() &&
(eventSessionId == liveSessionId || backgroundTurns.containsKey(eventSessionId))
if (!requestId.isNullOrBlank() && ownedSession) {
scope.launch {
rpc(
"terminal.read.respond",
buildJsonObject {
put("request_id", requestId)
put("text", "")
},
).onFailure {
Log.w(TAG, "terminal.read.respond failed: ${it.message}")
}
}
}
return
}
// A profile/session switch may leave an upstream turn running while a
// different profile becomes visible. Its events must never paint the
// new transcript, but the terminal event still needs to reconcile the
@@ -1647,6 +1852,47 @@ class GatewayChatClient(
// switches back.
val backgroundTurn = eventSessionId?.let(backgroundTurns::get)
if (backgroundTurn != null) {
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
if (interactionRequest != null) {
val previous = backgroundTurn.pendingAsk
backgroundTurn.pendingAsk = interactionRequest
if (previous?.kind != interactionRequest.kind ||
previous.requestId != interactionRequest.requestId
) {
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Requested(
storedSessionId = backgroundTurn.storedSessionId,
profile = backgroundTurn.profile,
ask = interactionRequest,
),
)
}
}
return
}
val expiry = GatewayEventMapper.interactionExpiry(type, payload)
val pendingAsk = backgroundTurn.pendingAsk
val explicitlyExpired = expiry != null && pendingAsk != null &&
pendingAsk.kind == expiry.kind &&
(pendingAsk.kind == GatewayAsk.Kind.APPROVAL ||
pendingAsk.requestId == expiry.requestId)
val turnResumed = pendingAsk != null &&
GatewayEventMapper.isInteractionResumeEvent(type)
if (explicitlyExpired || turnResumed) {
backgroundTurn.pendingAsk = null
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Resolved(
storedSessionId = backgroundTurn.storedSessionId,
profile = backgroundTurn.profile,
ask = pendingAsk,
),
)
}
}
if (type == "message.complete" || type == "error") {
backgroundTurns.remove(eventSessionId, backgroundTurn)
val expectedText = if (type == "message.complete") payload?.stringField("text") else null
@@ -1877,12 +2123,14 @@ class GatewayChatClient(
/**
* Recover an in-flight turn after a mid-turn socket loss by reconnecting
* the SOCKET ONLY and keeping [preservedLiveId] as the live session id.
* the socket and rebinding [preservedLiveId] to the new transport.
*
* A bare reconnect lets the tail — including the final
* `message.complete` — keep matching without another RPC. Current upstream
* can rebind live sessions too, but older builds cannot, so this remains the
* lowest-common-denominator same-client recovery path.
* Current upstream detaches a live session from a closed WebSocket; a new
* socket receives no tail until `session.activate` attaches that exact live
* id. Older gateways do not expose the method, so method-not-found alone
* retains the legacy bare-socket behavior. We never call `session.resume`
* here: resuming a durable id can create a different live runtime and
* orphan the turn already executing server-side.
*
* Retries with backoff for up to [midTurnRejoinWindowMs] so a multi-second
* radio blip doesn't abandon the turn. Events emitted while the socket was
@@ -1900,7 +2148,39 @@ class GatewayChatClient(
connectCooldownUntil = 0L
ensureConnected()
}
true
if (preservedLiveId == null) {
true
} else {
// Bind before activation so a tail event racing the RPC ack
// still matches the original active turn.
liveSessionId = preservedLiveId
val activated = rpc(
"session.activate",
buildJsonObject { put("session_id", preservedLiveId) },
)
when {
activated.isSuccess -> {
activated.getOrNull()?.let(::applySessionResultInfo)
true
}
activated.exceptionOrNull().isMethodNotFound() -> {
Log.i(
TAG,
"session.activate unsupported during mid-turn rejoin — " +
"using legacy socket recovery",
)
true
}
else -> {
Log.d(
TAG,
"Mid-turn session.activate retry failed: " +
activated.exceptionOrNull()?.message,
)
false
}
}
}
} catch (e: Exception) {
Log.d(TAG, "Mid-turn reconnect retry failed: ${e.message}")
false
@@ -1912,7 +2192,8 @@ class GatewayChatClient(
if (preservedLiveId != null) liveSessionId = preservedLiveId
Log.i(
TAG,
"Gateway socket rejoined mid-turn (session=$storedSessionId) — kept live session, awaiting tail",
"Gateway socket rejoined mid-turn (session=$storedSessionId) — " +
"rebound live session, awaiting tail",
)
// A reconnect that followed a route RETARGET gets a short settle
// (the fresh socket won't replay the in-flight turn); a normal
@@ -2084,15 +2365,26 @@ class GatewayChatClient(
private fun watchdogTimeoutFor(eventType: String): Long = when (eventType) {
"clarify.request", "secret.request" -> ASK_CLARIFY_SECRET_TIMEOUT_MS
"sudo.request" -> ASK_SUDO_TIMEOUT_MS
"approval.request", "terminal.read.request" -> ASK_UNBOUNDED_TIMEOUT_MS
"approval.request" -> ASK_UNBOUNDED_TIMEOUT_MS
else -> turnIdleTimeoutMs
}
private inner class GatewayTurn(
val callbacks: GatewayTurnCallbacks,
dedupeAdjacentMessageStarts: Boolean = false,
deferEvents: Boolean = false,
) : ActiveTurnHandle {
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
val pendingInteraction: GatewayAsk?
get() = mapper.currentInteraction
fun restoreInteraction(ask: GatewayAsk) {
mapper.restoreInteraction(ask)
}
private val deferredEventLock = Any()
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
private var eventsDeferred = deferEvents
private var redirectedTo: GatewayTurn? = null
private var queuedSuccessor: Pair<GatewayInboundTurnRegistration, GatewayTurn>? = null
/** t0 = construction ≈ sendTurn entry (the moment the user sent). */
val tracer = TurnLatencyTracer("gateway")
@@ -2113,10 +2405,22 @@ class GatewayChatClient(
*/
fun beginRejoin(): Boolean {
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
if (shouldRejoin) reconcileRequired = true
if (shouldRejoin) {
reconcileRequired = true
transportRecoveryStarted = true
}
return shouldRejoin
}
/**
* A socket loss after `prompt.submit` makes server acceptance ambiguous
* even when no turn event or RPC ack reached Android. Once recovery has
* started, the caller must not resubmit through SSE.
*/
@Volatile
var transportRecoveryStarted = false
private set
private var watchdog: Job? = null
val ended: Boolean get() = mapper.turnEnded || cancelled
@@ -2133,6 +2437,21 @@ class GatewayChatClient(
private set
fun onEvent(type: String, payload: JsonObject?) {
val redirect = synchronized(deferredEventLock) {
if (eventsDeferred) {
deferredEvents += type to payload
return
}
redirectedTo
}
if (redirect != null) {
redirect.onEvent(type, payload)
return
}
processEvent(type, payload)
}
private fun processEvent(type: String, payload: JsonObject?) {
if (type != "session.info") started = true
tracer.mark("ttfe")
if (type == "message.delta" || type == "reasoning.delta" || type == "thinking.delta") {
@@ -2152,6 +2471,74 @@ class GatewayChatClient(
if (mapper.turnEnded) {
disarmWatchdog()
tracer.done()
handoffQueuedSuccessor()
}
}
/**
* Preserve a queued prompt reported beside an in-flight recovery as a
* distinct next turn. Its mapper starts deferred so events that race
* the resume acknowledgement cannot paint the completing prior turn.
*/
fun installQueuedSuccessor(registration: GatewayInboundTurnRegistration) {
synchronized(deferredEventLock) {
if (queuedSuccessor == null) {
queuedSuccessor = registration to GatewayTurn(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
deferEvents = true,
)
}
}
}
private fun handoffQueuedSuccessor() {
val successor = synchronized(deferredEventLock) {
queuedSuccessor?.also {
queuedSuccessor = null
redirectedTo = it.second
}
} ?: return
val (registration, turn) = successor
// Claim socket ownership immediately so the next message.start is
// buffered by this exact successor instead of being admitted as a
// generic unsolicited turn. UI admission is ordered after the
// prior turn's terminal callbacks on the shared dispatcher.
activeTurn = turn
callbackDispatcher {
if (registration.onHandle(turn)) {
turn.releaseDeferredEvents()
turn.armWatchdog()
} else {
turn.discardDeferredEvents()
turn.detach()
}
}
}
fun releaseDeferredEvents() {
val pending = synchronized(deferredEventLock) {
eventsDeferred = false
deferredEvents.toList().also { deferredEvents.clear() }
}
pending.forEach { (type, payload) -> onEvent(type, payload) }
}
fun redirectDeferredEventsTo(target: GatewayTurn) {
val pending = synchronized(deferredEventLock) {
eventsDeferred = false
redirectedTo = target
deferredEvents.toList().also { deferredEvents.clear() }
}
pending.forEach { (type, payload) -> target.onEvent(type, payload) }
}
fun discardDeferredEvents() {
synchronized(deferredEventLock) {
eventsDeferred = false
redirectedTo = null
deferredEvents.clear()
}
}
@@ -2315,6 +2702,9 @@ class GatewayChatClient(
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
onStart = { callbackDispatcher { callbacks.onStart() } },
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
onInterimMessage = { text, alreadyStreamed ->
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
},
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
@@ -2329,6 +2719,7 @@ class GatewayChatClient(
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
// onStatusUpdate a default no-op, so omitting it here silently swallows
// EVERY gateway status line — the ❌ terminal-error lifecycle update
@@ -2340,12 +2731,12 @@ class GatewayChatClient(
)
}
/** Outcome of [GatewayChatClient.steer] — Rejected and Failed both mean "queue locally instead". */
/** Outcome of an active-turn correction — Rejected and Failed both mean "queue locally instead". */
enum class SteerResult {
/** Server accepted — text lands in the next tool batch's last result. */
/** Server accepted the active-turn correction. */
Queued,
/** Server reachable but no tool batch in flight to steer. */
/** Server reachable but no active turn is available to correct. */
Rejected,
/** Transport/RPC failure (no live session, socket down, unsupported …). */
@@ -2377,6 +2768,28 @@ internal class GatewayRpcException(message: String, val code: Int? = null) : Exc
private const val JSONRPC_METHOD_NOT_FOUND = -32601
data class GatewayCompressResult(
val status: String,
val output: String? = null,
val removed: Int? = null,
val beforeMessages: Int? = null,
val afterMessages: Int? = null,
val beforeTokens: Int? = null,
val afterTokens: Int? = null,
val usage: UsageInfo? = null,
val info: JsonObject? = null,
val messages: List<MessageItem> = emptyList(),
) {
val effectiveUsage: UsageInfo?
get() = usage ?: GatewayEventMapper.parseGatewayUsage(info?.get("usage") as? JsonObject)
val title: String?
get() = info?.stringField("title")?.takeIf { it.isNotBlank() }
val isAuthoritative: Boolean
get() = messages.isNotEmpty()
}
private fun Throwable?.isMethodNotFound(): Boolean {
val rpcError = this as? GatewayRpcException ?: return false
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
@@ -31,13 +31,24 @@ class GatewayEventMapper(
var turnEnded: Boolean = false
private set
internal val currentInteraction: GatewayAsk?
get() = pendingInteraction
internal fun restoreInteraction(ask: GatewayAsk) {
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
pendingInteraction = ask
if (!duplicate) callbacks.onInteractionRequest(ask)
}
private var sawMessageStart = false
private var previousEventType: String? = null
private var sawTextDelta = false
private var sawThinkingDelta = false
private var previewedText: String? = null
private var syntheticToolCounter = 0
private var providerWaitStatusActive = false
private var compactionStatusActive = false
private var pendingInteraction: GatewayAsk? = null
/**
* `tool.complete` events match their `tool.start` by `tool_id`; when a
@@ -56,6 +67,30 @@ class GatewayEventMapper(
fun onEvent(type: String, payload: JsonObject?) {
if (turnEnded) return
interactionRequest(type, payload)?.let { ask ->
restoreInteraction(ask)
previousEventType = type
return
}
interactionExpiry(type, payload)?.let { expiry ->
val pending = pendingInteraction
if (pending != null && pending.matches(expiry)) {
pendingInteraction = null
}
callbacks.onInteractionExpired(expiry)
previousEventType = type
return
}
if (type in INTERACTION_RESUME_EVENTS) {
pendingInteraction?.let { ask ->
pendingInteraction = null
callbacks.onInteractionResolved(
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
)
}
}
when (type) {
"reasoning.delta" -> {
val text = payload.string("text")
@@ -95,13 +130,30 @@ class GatewayEventMapper(
"message.delta" -> {
val text = payload.string("text")
if (!text.isNullOrEmpty()) {
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
clearActivityStatuses()
sawTextDelta = true
previewedText = null
callbacks.onTextDelta(text)
}
}
"message.interim" -> {
val text = payload.string("text") ?: payload.string("message")
?: payload.string("preview") ?: payload.string("rendered")
val alreadyStreamed = payload.boolean("already_streamed") == true
if (!text.isNullOrBlank() || alreadyStreamed) {
clearActivityStatuses()
if (!sawMessageStart) {
sawMessageStart = true
callbacks.onStart()
}
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
previewedText = text
sawTextDelta = alreadyStreamed
}
}
"message.start" -> {
// The upstream background-completion poller currently emits
// message.start immediately before _run_prompt_submit(), which
@@ -114,6 +166,7 @@ class GatewayEventMapper(
// assistant message began — close out the previous one.
if (sawMessageStart) callbacks.onTurnComplete()
sawMessageStart = true
previewedText = null
callbacks.onStart()
}
@@ -164,7 +217,15 @@ class GatewayEventMapper(
// Non-streaming servers (or error turns) deliver everything
// here; backfill whatever never streamed.
val text = payload.string("text")
if (!sawTextDelta && !text.isNullOrEmpty()) {
val responsePreviewed = payload.boolean("response_previewed") == true
val duplicatesPreview = responsePreviewed &&
!text.isNullOrEmpty() &&
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
if (!text.isNullOrEmpty() &&
!duplicatesPreview &&
!isIntentionalSilenceMarker(text) &&
(!sawTextDelta || previewedText != null)
) {
callbacks.onTextDelta(text)
}
val reasoning = payload.string("reasoning")
@@ -209,36 +270,6 @@ class GatewayEventMapper(
)
}
"clarify.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
),
)
"approval.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
choices = payload.approvalChoices(),
smartDenied = payload.boolean("smart_denied") == true,
// Current Hermes omits timeout metadata. Keep the legacy
// no-countdown behavior unless a future contract exposes
// the effective per-request timeout explicitly.
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
),
)
"tool.output_risk" -> {
val toolId = payload.string("tool_id")
val risk = payload.string("risk")?.lowercase() ?: return
@@ -263,49 +294,6 @@ class GatewayEventMapper(
// Android does not currently render these upstream events.
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
"sudo.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
// Payload carries request_id ONLY — no command to show.
text = "Elevated permissions requested",
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
),
)
"secret.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
text = payload.string("prompt") ?: "The agent needs a secret value",
envVar = payload.string("env_var"),
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
),
)
"sudo.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
),
)
"secret.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
),
)
// Forward-compatible consumer for the proposed upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
),
)
"status.update" -> {
val text = payload.string("text")
if (!text.isNullOrBlank()) {
@@ -342,21 +330,93 @@ class GatewayEventMapper(
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
}
private fun JsonObject?.approvalChoices(): List<String>? =
(this?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
?.filter { it in APPROVAL_CHOICES }
?.distinct()
?.takeIf { it.isNotEmpty() }
private fun JsonObject?.boolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
companion object {
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting"
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
private val INTERACTION_RESUME_EVENTS = setOf(
"reasoning.delta",
"thinking.delta",
"reasoning.available",
"message.delta",
"message.interim",
"message.start",
"tool.generating",
"tool.start",
"tool.complete",
"message.complete",
"error",
)
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
)
"approval.request" -> GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
choices = payload.approvalChoices(),
smartDenied = payload.boolean("smart_denied") == true,
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
)
"sudo.request" -> GatewayAsk(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
text = "Elevated permissions requested",
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
)
"secret.request" -> GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
text = payload.string("prompt") ?: "The agent needs a secret value",
envVar = payload.string("env_var"),
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
)
else -> null
}
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
"clarify.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
)
"sudo.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
)
"secret.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
)
// Forward-compatible consumer for a future upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.APPROVAL,
requestId = null,
)
else -> null
}
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
/**
* Hermes 2026-07-15 emits these operational wait lines through the
@@ -422,3 +482,20 @@ private fun JsonObject?.int(key: String): Int? =
private fun JsonObject?.double(key: String): Double? =
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
private fun JsonObject?.boolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
private fun JsonObject?.approvalChoices(): List<String>? =
(this?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
?.filter { it in setOf("once", "session", "always", "deny") }
?.distinct()
?.takeIf { it.isNotEmpty() }
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
kind == other.kind && requestId == other.requestId
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
kind == expiry.kind &&
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
@@ -97,6 +97,19 @@ data class GatewayInflightTurn(
val streaming: Boolean,
)
/** A next-turn prompt accepted by upstream while the current turn was busy. */
data class GatewayQueuedTurn(
val user: String,
)
/** Optional project identity attached to newer upstream session metadata. */
data class GatewaySessionProject(
val id: String?,
val slug: String?,
val name: String,
val primaryPath: String?,
)
/** Result of reattaching Android to an existing durable Gateway session. */
data class GatewaySessionRecovery(
val storedSessionId: String,
@@ -104,9 +117,14 @@ data class GatewaySessionRecovery(
val running: Boolean,
val status: String?,
val inflight: GatewayInflightTurn?,
val queued: GatewayQueuedTurn?,
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
val handle: ActiveTurnHandle?,
)
) {
/** Whether upstream still owes this client live turn events. */
val hasPendingWork: Boolean
get() = running || queued != null
}
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
data class GatewayBackgroundTurnCompletion(
@@ -115,6 +133,25 @@ data class GatewayBackgroundTurnCompletion(
val expectedAssistantText: String?,
)
/** Input lifecycle from a deliberately detached Gateway turn. */
sealed interface GatewayBackgroundInteractionEvent {
val storedSessionId: String
val profile: String?
val ask: GatewayAsk
data class Requested(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
data class Resolved(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
}
/**
* One server-side interactive ask. The agent thread upstream is BLOCKED
* until the matching respond RPC arrives, the ask times out (resolves to ""
@@ -296,7 +333,9 @@ data class GatewayModelOptions(
*
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
* pins priority, `false` explicitly pins normal, and `null` omits the field so
* the profile's service tier is inherited.
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
* accept it as a per-session override, so it is applied post-create instead.
*/
@@ -328,6 +367,13 @@ class GatewayTurnCallbacks(
/** A gateway `message.start` opened an assistant response for this turn. */
val onStart: () -> Unit,
val onTextDelta: (String) -> Unit,
/**
* Gateway `message.interim` sealed an attempted assistant message before
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
* has not arrived through `message.delta` and should be rendered before
* sealing the current assistant segment.
*/
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
@@ -360,6 +406,8 @@ class GatewayTurnCallbacks(
val onInteractionRequest: (GatewayAsk) -> Unit,
/** Server declared a pending interaction expired; clear only the matching card. */
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
/** The turn resumed after a pending interaction was resolved elsewhere. */
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
/**
* Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -159,6 +160,146 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
}
}
@Serializable
data class ToolsetInfo(
val name: String,
val label: String = "",
val description: String = "",
val enabled: Boolean = false,
val configured: Boolean = false,
val tools: List<String> = emptyList(),
)
@Serializable
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
json.decodeFromString<ToolsetListResponse>(body).data
} catch (_: Exception) {
null
}
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
data class ApiModelOption(
val id: String,
val root: String? = null,
val parent: String? = null,
) {
/** Secondary picker copy for a configured route alias. */
val routeDetail: String?
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
}
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
val data = try {
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
} catch (_: Exception) {
null
} ?: return null
return data.mapNotNull { row ->
val obj = row as? JsonObject ?: return@mapNotNull null
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
ApiModelOption(
id = id,
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
)
}
}
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
internal fun streamHttpFailureMessage(
code: Int,
reason: String,
retryAfter: String?,
body: String?,
json: Json,
): String {
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
}
val errorObj = error as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
?: (error as? JsonPrimitive)?.contentOrNull
return buildString {
append("API error ").append(code).append(": ")
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
append(detail?.takeIf { it.isNotBlank() } ?: reason)
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
append(" (Retry-After: ").append(it).append("s)")
}
}
}
internal fun gatewayDrainRetryDelayMillis(
httpCode: Int?,
retryAfter: String?,
errorMessage: String,
receivedEvent: Boolean,
retryAlreadyScheduled: Boolean,
): Long? {
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
!errorMessage.startsWith("API error 503: gateway_draining:")
) return null
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
return seconds * 1_000L
}
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
private class RetryingEventSource(
private val originalRequest: Request,
private val handler: Handler,
) : EventSource {
private val lock = Any()
private var active: EventSource? = null
private var retryRunnable: Runnable? = null
private var cancelled = false
override fun request(): Request = originalRequest
fun attach(source: EventSource) {
synchronized(lock) {
if (cancelled) source.cancel() else active = source
}
}
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
val task = Runnable {
synchronized(lock) {
retryRunnable = null
if (cancelled) return@Runnable
// Keep creation under the same lock as cancel(): once Stop or
// a session switch wins, no delayed POST can start afterward.
active = create()
}
}
synchronized(lock) {
if (cancelled) return
retryRunnable = task
handler.postDelayed(task, delayMillis)
}
}
override fun cancel() {
val source: EventSource?
val task: Runnable?
synchronized(lock) {
if (cancelled) return
cancelled = true
source = active
active = null
task = retryRunnable
retryRunnable = null
}
task?.let(handler::removeCallbacks)
source?.cancel()
}
}
/**
* Direct HTTP/SSE client for the Hermes API Server.
*
@@ -199,8 +340,13 @@ class HermesApiClient(
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
response != null && !response.isSuccessful ->
"API error ${response.code}: ${response.message}"
response != null && !response.isSuccessful -> streamHttpFailureMessage(
code = response.code,
reason = response.message,
retryAfter = response.header("Retry-After"),
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
json = Json { ignoreUnknownKeys = true },
)
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
t != null -> "Stream error: ${t.message}"
else -> "Unknown stream error"
@@ -445,6 +591,24 @@ class HermesApiClient(
emptyList()
}
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/v1/toolsets").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(IOException("HTTP ${response.code}"))
}
val body = response.body?.string().orEmpty()
val parsed = parseToolsetListBody(json, body)
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
Result.success(parsed)
}
} catch (e: Exception) {
Result.failure(e)
}
}
// --- Available models ---
/**
@@ -453,17 +617,13 @@ class HermesApiClient(
* picker. Returns ids in server order; empty on any failure (the picker
* then offers only "Server default").
*/
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/v1/models").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
val data = (json.parseToJsonElement(body) as? JsonObject)
?.get("data") as? JsonArray ?: return@withContext emptyList()
data.mapNotNull {
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
}
parseModelOptionsBody(json, body).orEmpty()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch models: ${e.message}")
@@ -471,6 +631,9 @@ class HermesApiClient(
}
}
/** Compatibility view for callers that only need request ids. */
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
// --- Server personalities ---
/**
@@ -623,6 +786,9 @@ class HermesApiClient(
}
val completeCalled = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("sessions")
@@ -636,6 +802,7 @@ class HermesApiClient(
type: String?,
data: String
) {
receivedEvent.set(true)
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
@@ -799,9 +966,20 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) }
}
}
@@ -814,7 +992,8 @@ class HermesApiClient(
}
}
return sseFactory.newEventSource(request, listener)
turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
}
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
@@ -876,6 +1055,9 @@ class HermesApiClient(
val completeCalled = AtomicBoolean(false)
val messageStarted = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("completions")
@@ -886,6 +1068,7 @@ class HermesApiClient(
type: String?,
data: String
) {
receivedEvent.set(true)
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
@@ -941,9 +1124,20 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) }
}
}
@@ -956,7 +1150,8 @@ class HermesApiClient(
}
}
return sseFactory.newEventSource(request, listener)
turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
}
private fun openAiChoice(event: JsonObject): JsonObject? =
@@ -1070,6 +1265,9 @@ class HermesApiClient(
}
val completeCalled = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("runs")
@@ -1080,6 +1278,7 @@ class HermesApiClient(
type: String?,
data: String
) {
receivedEvent.set(true)
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
@@ -1246,9 +1445,20 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) }
}
}
@@ -1261,7 +1471,8 @@ class HermesApiClient(
}
}
return sseFactory.newEventSource(request, listener)
turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
}
// --- Capability detection ---
@@ -0,0 +1,80 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.IOException
/**
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
* remain in memory only; OAuth codes, callback state, and tokens never enter
* the Android client. The dashboard owns the complete PKCE/callback exchange.
*/
class McpOAuthFlowCoordinator(
private val client: DashboardApiClient,
private val pollDelayMillis: Long = 1_000,
private val maxPolls: Int = 900,
private val maxConsecutiveFailures: Int = 3,
private val sleep: suspend (Long) -> Unit = { delay(it) },
) {
suspend fun start(
serverName: String,
profile: String? = null,
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
if (started.status == "error") {
throw IOException(started.error ?: "MCP OAuth failed to start")
}
started
}
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
var failures = 0
repeat(maxPolls.coerceAtLeast(1)) {
val current = client.getMcpOAuthFlow(flowId)
if (current.isFailure) {
failures += 1
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
}
} else {
failures = 0
val flow = current.getOrThrow()
when (flow.status) {
"approved" -> return@runCatching flow
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
}
}
sleep(pollDelayMillis.coerceAtLeast(0))
}
throw IOException("MCP OAuth authorization timed out")
}.onFailure { error ->
if (error is CancellationException) throw error
}
suspend fun complete(
serverName: String,
profile: String? = null,
openAuthorization: (String) -> Boolean,
): Result<DashboardMcpOAuthFlow> = runCatching {
val started = start(serverName, profile).getOrThrow()
if (started.status == "approved") return@runCatching started
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
if (!openAuthorization(authorizationUrl)) {
throw IOException("No browser is available to complete MCP OAuth")
}
resume(started.flowId).getOrThrow()
}.onFailure { error ->
if (error is CancellationException) throw error
}
companion object {
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
val url = flow.authorizationUrl
?: throw IOException("MCP OAuth server did not provide an authorization URL")
if (url.toHttpUrlOrNull()?.scheme != "https") {
throw IOException("MCP OAuth authorization URL must use HTTPS")
}
url
}
}
}
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.io.File
import java.io.IOException
import java.util.Base64
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
// upstream ever adds profile-aware TTS. Until then, standard voice remains
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
private val profileProvider: () -> String? = { null },
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -57,9 +67,7 @@ class StandardHermesVoiceClient(
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
private val callClient: OkHttpClient =
okHttpClient.newBuilder()
.callTimeout(90, TimeUnit.SECONDS)
.build()
standardHermesDashboardAudioClient(okHttpClient)
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
@@ -148,6 +156,42 @@ class StandardHermesVoiceClient(
}
}
override suspend fun openSpeechStream(
callbacks: VoiceSpeechStreamCallbacks,
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
try {
val baseUrl = dashboardBaseUrl()
?: throw IllegalStateException("Hermes dashboard URL not configured")
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
val ticketRequest = Request.Builder()
.url(ticketUrl)
.post(ByteArray(0).toRequestBody(null))
.build()
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
.getOrThrow()
.stringField("ticket")
?: throw IOException("Dashboard websocket ticket response missing ticket")
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
baseUrl = baseUrl,
ticket = ticket,
path = "/api/audio/speak-stream",
) ?: throw IOException("Could not build Hermes speech stream URL")
val request = Request.Builder().url(websocketUrl).build()
StandardHermesSpeechStream(
request = request,
callbacks = callbacks,
json = json,
socketFactory = webSocketFactory ?: callClient::newWebSocket,
).also { it.connect() }
.let { Result.success<VoiceSpeechStream?>(it) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
}
private fun dashboardBaseUrl(): String? =
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
@@ -242,3 +286,209 @@ class StandardHermesVoiceClient(
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
}
}
private class StandardHermesSpeechStream(
private val request: Request,
private val callbacks: VoiceSpeechStreamCallbacks,
private val json: Json,
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
) : VoiceSpeechStream {
private val lock = Any()
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
private val pendingFrames = ArrayDeque<String>()
private var socket: WebSocket? = null
private var opened = false
private var stopped = false
private var finished = false
private var audioStarted = false
private var sampleRate = DEFAULT_SAMPLE_RATE
private var oddByteCarry: Byte? = null
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val frames = synchronized(lock) {
if (stopped || outcome.isCompleted) {
webSocket.cancel()
return
}
socket = webSocket
opened = true
pendingFrames.toList().also { pendingFrames.clear() }
}
frames.forEach(webSocket::send)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
when (root.stringField("type")) {
"start" -> {
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
?: DEFAULT_SAMPLE_RATE
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
?: 1
if (channels != 1) {
settle(
status = VoiceSpeechStreamStatus.Fallback,
error = IOException("Hermes speech stream returned $channels channels; mono required"),
)
webSocket.cancel()
return
}
synchronized(lock) { sampleRate = nextRate }
callbacks.onStart(nextRate, channels)
}
"fallback" -> {
val heardAudio = synchronized(lock) { audioStarted }
settle(
status = if (heardAudio) {
VoiceSpeechStreamStatus.Completed
} else {
VoiceSpeechStreamStatus.Fallback
},
)
webSocket.close(1000, "fallback")
}
"end" -> {
settle(VoiceSpeechStreamStatus.Completed)
webSocket.close(1000, "complete")
}
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
val delivery = synchronized(lock) {
if (stopped || outcome.isCompleted) return
var incoming = bytes.toByteArray()
oddByteCarry?.let { carry ->
incoming = byteArrayOf(carry) + incoming
oddByteCarry = null
}
val usable = incoming.size - (incoming.size % 2)
if (usable < incoming.size) oddByteCarry = incoming.last()
if (usable == 0) return
audioStarted = true
incoming.copyOf(usable) to sampleRate
}
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
.onFailure { error ->
settle(VoiceSpeechStreamStatus.Failed, error)
webSocket.cancel()
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
settleForDisconnect(t)
}
}
fun connect() {
val created = socketFactory(request, listener)
synchronized(lock) {
if (socket == null) socket = created
if (stopped) created.cancel()
}
}
override fun append(text: String) {
if (text.isEmpty()) return
sendFrame(buildJsonObject { put("text", text) })
}
override fun finish() {
synchronized(lock) {
if (finished || stopped || outcome.isCompleted) return
finished = true
}
sendFrame(buildJsonObject { put("done", true) })
}
override fun stop() {
val current = synchronized(lock) {
if (stopped) return
stopped = true
socket
}
if (current != null) {
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
current.cancel()
}
settle(VoiceSpeechStreamStatus.Stopped)
}
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
private fun sendFrame(frame: JsonObject) {
val encoded = json.encodeToString(JsonObject.serializer(), frame)
val current = synchronized(lock) {
if (stopped || outcome.isCompleted) return
if (!opened) {
pendingFrames.addLast(encoded)
return
}
socket
}
if (current?.send(encoded) != true) {
settleForDisconnect(IOException("Hermes speech stream send failed"))
}
}
private fun settleForDisconnect(error: Throwable) {
val heardAudio = synchronized(lock) { audioStarted }
settle(
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
error = error,
)
}
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
val result = synchronized(lock) {
if (outcome.isCompleted) return
VoiceSpeechStreamOutcome(
status = status,
audioStarted = audioStarted,
error = error,
)
}
outcome.complete(result)
}
private fun JsonObject.stringField(name: String): String? =
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
private companion object {
const val DEFAULT_SAMPLE_RATE = 24_000
}
}
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
requestedSeconds.coerceIn(
minimumValue = 180L,
maximumValue = 600L,
)
internal fun standardHermesDashboardAudioClient(
baseClient: OkHttpClient,
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
): OkHttpClient {
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
return baseClient.newBuilder()
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.build()
}
@@ -259,6 +259,8 @@ data class MessageItem(
val toolCallId: String? = null,
val timestamp: Double? = null,
@SerialName("finish_reason") val finishReason: String? = null,
@SerialName("display_kind") val displayKind: String? = null,
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
// Reasoning persisted with the assistant message (upstream serializes
// both names; reasoning is the canonical one). Restored into
// ChatMessage.thinkingContent so the Thought-process block survives a
@@ -0,0 +1,186 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayAsk
/**
* Action-required notifications for Gateway turns blocked on user input.
*
* Notification copy is deliberately generic. Approval commands, clarification
* questions, secret prompts, environment-variable names, and password requests
* stay inside the authenticated chat surface and never appear on the lock
* screen. A stable tag derived from the durable session and request identity
* makes replay/reconnect delivery replace the existing notification.
*/
object InteractionRequestNotifier {
private const val TAG = "InteractionNotifier"
internal const val CHANNEL_ID = "chat_interactions"
private const val CHANNEL_NAME = "Hermes needs input"
internal const val NOTIFICATION_ID = 3823
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
internal fun shouldPost(
alertsEnabled: Boolean,
appForeground: Boolean,
hasPermission: Boolean,
): Boolean = alertsEnabled && !appForeground && hasPermission
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
}
internal fun notificationTag(
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
internal fun chatRoute(sessionId: String, profile: String? = null): String =
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Hermes needs sensitive input"
}
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Open Hermes to respond securely."
}
@SuppressLint("MissingPermission", "NotificationPermission")
fun notify(
context: Context,
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
alertsEnabled: Boolean,
appForeground: Boolean,
): Boolean {
ensureChannel(context)
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
return false
}
val tag = notificationTag(sessionId, ask, profile)
val requestKey = requestKey(sessionId, ask, profile)
val requestCode = requestKey.hashCode()
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.Builder()
.scheme("hermes-relay")
.authority("interaction")
.appendPath(requestKey)
.build()
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
}
val tapPending = PendingIntent.getActivity(
context,
requestCode,
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val title = safeTitle(ask)
val body = safeBody(ask)
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes needs your input")
.setContentText("Open Hermes to continue.")
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(tapPending)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.build()
return runCatching {
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
true
}.onFailure {
Log.w(TAG, "notify failed", it)
}.getOrDefault(false)
}
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
runCatching {
NotificationManagerCompat.from(context)
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
}.onFailure {
Log.w(TAG, "cancel failed", it)
}
}
/**
* Clear only this feature's notifications. Android keeps notifications
* across process death, so the active-notification scan is also used when
* MainActivity returns without an in-memory request registry.
*/
fun cancelAll(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
runCatching {
manager.activeNotifications
.filter { it.notification.channelId == CHANNEL_ID }
.forEach { manager.cancel(it.tag, it.id) }
}.onFailure {
Log.w(TAG, "cancelAll failed", it)
}
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Alerts when a Hermes turn is waiting for your response."
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
setShowBadge(true)
},
)
}
private fun hasPostNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
}
@@ -103,10 +103,13 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BridgePreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
@@ -128,6 +131,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
@@ -151,12 +155,17 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.audio.VoicePlayer
import com.hermesandroid.relay.audio.VoiceRecorder
import com.hermesandroid.relay.audio.VoiceSfxPlayer
@@ -182,6 +191,68 @@ suspend fun SnackbarHostState.showHumanError(err: HumanError) {
)
}
/** Startup chrome should wait for either standard chat surface, not Relay. */
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
connection?.capabilities?.chatConfigured == true
/**
* App-root chat health derived only from the two transports that can carry a
* conversation. Optional Relay state is deliberately absent.
*/
internal fun resolveAppChatRuntimeStatus(
connection: Connection?,
gatewayAvailability: GatewayAvailability,
apiHealth: ConnectionViewModel.HealthStatus,
): ChatRuntimeStatus {
val capabilities = connection?.capabilities
val gateway = when {
capabilities?.dashboardGatewayConfigured != true -> ChatTransportReadiness.NotConfigured
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
}
val api = when {
capabilities?.apiServerConfigured != true -> ChatTransportReadiness.NotConfigured
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
apiHealth == ConnectionViewModel.HealthStatus.Unknown ||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
}
return resolveChatRuntimeStatus(gateway = gateway, apiSse = api)
}
/** Route represented by the app footer's currently usable chat transport. */
internal fun resolveFooterRouteCandidate(
runtimeStatus: ChatRuntimeStatus,
activeEndpoint: EndpointCandidate?,
connection: Connection?,
effectiveDashboardUrl: String,
): EndpointCandidate? {
val connected = runtimeStatus as? ChatRuntimeStatus.Connected ?: return null
return when (connected.transport) {
ChatTransportPath.Gateway -> {
val dashboardUrl = effectiveDashboardUrl.trim().trimEnd('/')
.ifBlank { connection?.resolvedDashboardUrl.orEmpty() }
if (dashboardUrl.isBlank()) {
null
} else {
val activeDashboardUrl = activeEndpoint?.dashboard?.url
?.trim()
?.trimEnd('/')
activeEndpoint?.takeIf { activeDashboardUrl == dashboardUrl }
?: Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(dashboardUrl),
priority = activeEndpoint?.priority ?: 0,
dashboardUrl = dashboardUrl,
)
}
}
ChatTransportPath.ApiSse -> activeEndpoint?.takeIf { it.api != null }
?: connection?.routeCandidates?.firstOrNull { it.api != null }
}
}
sealed class Screen(
val route: String,
val label: String,
@@ -203,17 +274,44 @@ sealed class Screen(
// NavHost, and the NavigationBarItem click must navigate via [route]()
// so no unresolved `{openAgentSheet}` leaks into the destination.
data object Chat : Screen(
"chat?openAgentSheet={openAgentSheet}",
"chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
"Chat",
Icons.AutoMirrored.Filled.Chat,
) {
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
fun route(openAgentSheet: Boolean = false): String =
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
const val ARG_SESSION_ID: String = "sessionId"
const val ARG_PROFILE: String = "profile"
fun route(
openAgentSheet: Boolean = false,
sessionId: String? = null,
profile: String? = null,
): String {
val params = buildList {
if (openAgentSheet) add("$ARG_OPEN_AGENT_SHEET=true")
sessionId?.takeIf { it.isNotBlank() }?.let {
add("$ARG_SESSION_ID=${android.net.Uri.encode(it)}")
}
profile?.takeIf { it.isNotBlank() }?.let {
add("$ARG_PROFILE=${android.net.Uri.encode(it)}")
}
}
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
}
}
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
data object DashboardSignIn : Screen(
"dashboard_sign_in?source={source}",
"Dashboard sign in",
Icons.Filled.Settings,
) {
const val ARG_SOURCE: String = "source"
const val SOURCE_GENERAL: String = "general"
const val SOURCE_PAIR: String = "pair"
const val SOURCE_ONBOARDING: String = "onboarding"
fun route(source: String = SOURCE_GENERAL): String = "dashboard_sign_in?source=$source"
}
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
// Non-bottom-nav destinations — reached by explicit navigation, not the
@@ -359,6 +457,12 @@ fun RelayApp() {
// ConnectionStore's mutations are all suspend fns and we don't want to
// block the main dispatcher from inside the composable body.
val connectionSwitchScope = rememberCoroutineScope()
// Add-connection preparation may outlive the initiating list frame now
// that navigation happens immediately. Keep the job by placeholder id so
// an instant Back can wait for creation and then discard it safely.
val pendingAddConnectionJobs = remember {
mutableMapOf<String, kotlinx.coroutines.Job>()
}
// One-time init: the terminal channel ViewModel registers with the shared
// multiplexer and observes the relay connection state so it can attach/
@@ -434,11 +538,13 @@ fun RelayApp() {
}
}
// Initialize ChatViewModel reactively when the chat-routed API client becomes available
// Bind chat state independently of the optional API fallback client.
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
val chatTransportReady by connectionViewModel.chatReady.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
val effectiveDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -633,9 +739,10 @@ fun RelayApp() {
}
}
LaunchedEffect(chatApiClient) {
val client = chatApiClient ?: return@LaunchedEffect
var boundCatalogConnectionId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(chatApiClient, activeConnectionId) {
val handler = connectionViewModel.chatHandler
val connectionChanged = boundCatalogConnectionId != activeConnectionId
// A route handoff / reconnect rebuilds the API client (new instance)
// while the chat is unchanged — the bound handler is the same. Take the
// cheap path: swap the client reference only, no re-init. This is what
@@ -643,11 +750,24 @@ fun RelayApp() {
// switch or a reconnect. A genuine re-bind (different handler) falls
// through to the full one-time wiring below.
if (chatViewModel.boundHandler === handler) {
chatViewModel.updateApiClient(client)
if (connectionChanged) {
chatViewModel.resetConnectionCatalogs()
// The active pointer changes before an API target is rebuilt.
// Never let the outgoing API client refill the new connection's
// catalogs during that window. Dashboard-only (null -> null)
// refreshes immediately through the already-installed loader.
chatViewModel.updateApiClient(null)
} else {
chatViewModel.updateApiClient(chatApiClient)
}
boundCatalogConnectionId = activeConnectionId
return@LaunchedEffect
}
chatViewModel.initialize(client, handler)
// The Dashboard/Gateway transport is independently sufficient for
// chat, so handler and dashboard callbacks must bind even when no API
// fallback client is configured.
chatViewModel.initialize(chatApiClient, handler)
// Wire inbound-media dependencies. Idempotent rewire of the
// ChatHandler callbacks.
@@ -665,6 +785,9 @@ fun RelayApp() {
chatViewModel.setSelectedProfileProvider {
connectionViewModel.selectedProfile.value
}
chatViewModel.setIsolatedProfileApiProvider {
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
}
chatViewModel.setSessionProfileNameProvider {
connectionViewModel.effectiveSessionProfileName.value
}
@@ -675,10 +798,7 @@ fun RelayApp() {
)
}
chatViewModel.setDisplayProfileProvider {
AgentDisplay.effectiveDisplayProfile(
selectedProfile = connectionViewModel.selectedProfile.value,
profiles = connectionViewModel.agentProfiles.value,
)
connectionViewModel.effectiveDisplayProfile.value
}
chatViewModel.setDisplayAliasProvider {
connectionViewModel.profileDisplayAlias.value
@@ -694,6 +814,12 @@ fun RelayApp() {
chatViewModel.setProfileMessageLoader { sessionId ->
connectionViewModel.loadProfileScopedMessages(sessionId)
}
// Personality choices live in Dashboard `/api/config` on a
// dashboard-only connection. The setter immediately refreshes after
// this callback is installed, covering the null-API initialization.
chatViewModel.setDashboardConfigLoader {
connectionViewModel.loadActiveDashboardConfig()
}
// …and delete from that same profile's DB so a non-default profile's
// session can't be resurrected by the next profile-scoped list.
chatViewModel.profileSessionDeleter = { sessionId ->
@@ -710,6 +836,7 @@ fun RelayApp() {
chatViewModel.onSessionChanged = { sessionId ->
connectionViewModel.saveLastSessionId(sessionId)
}
boundCatalogConnectionId = activeConnectionId
}
// Reload sessions / switch profile context only on a SEMANTIC change
@@ -718,16 +845,15 @@ fun RelayApp() {
// means a route handoff (which churns the client) no longer triggers a
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
// already no-ops when the context key + session are unchanged.
val chatClientReady = chatApiClient != null
LaunchedEffect(
chatClientReady,
chatTransportReady,
activeConnectionId,
selectedProfile?.name,
effectiveSessionProfileName,
lastSessionId,
profileSelectionSettled,
) {
if (!chatClientReady) return@LaunchedEffect
if (!chatTransportReady) return@LaunchedEffect
// Cold-start profile-isolation guard: hold the first profile-scoped load
// until the persisted profile selection has SETTLED, so the session
// drawer (and the restored session context) don't briefly load the
@@ -770,7 +896,7 @@ fun RelayApp() {
)
}
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) {
LaunchedEffect(selectedProfile?.name, effectiveDisplayProfile?.name, agentProfiles, profileDisplayAlias) {
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
}
@@ -847,10 +973,10 @@ fun RelayApp() {
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
// client to follow the new dashboard route instead of stranding the turn on
// the dead one.
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
// Debounce a route FLIP before re-acquiring the gateway chat client. The
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
// stable on a transient endpoint-resolution miss, so effectiveApiUrl should
// stable on a transient endpoint-resolution miss, so the Dashboard URL should
// not flap — this is belt-and-suspenders against any residual sub-second
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
@@ -859,9 +985,17 @@ fun RelayApp() {
// unaffected; only a genuine url change waits for a settle window, and if
// the url flips back within it the LaunchedEffect cancels + restarts so no
// rebuild happens.
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) {
var lastAcquiredDashboardUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(
streamingEndpoint,
serverCapabilities,
gatewayAvailability,
effectiveDashboardUrl,
) {
if (
lastAcquiredDashboardUrl != null &&
lastAcquiredDashboardUrl != effectiveDashboardUrl
) {
delay(750L)
}
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
@@ -870,7 +1004,7 @@ fun RelayApp() {
chatViewModel.updateGatewayClient(
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
)
lastAcquiredApiUrl = effectiveApiUrl
lastAcquiredDashboardUrl = effectiveDashboardUrl
}
// What's New auto-show
@@ -961,7 +1095,6 @@ fun RelayApp() {
CrashReportGate()
val navController = rememberNavController()
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the
@@ -1026,17 +1159,6 @@ fun RelayApp() {
}
}
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
val route = postOnboardingRoute
if (onboardingCompleted && route != null) {
postOnboardingRoute = null
navController.navigate(route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
}
// startDestination uses the route TEMPLATE so it matches the
// composable registered below; optional args default to null/false.
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
@@ -1127,11 +1249,8 @@ fun RelayApp() {
// bottom navigation bar so the voice overlay can own the entire screen
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
val voiceUiState by voiceViewModel.uiState.collectAsState()
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
@@ -1166,17 +1285,16 @@ fun RelayApp() {
startupGateTimedOut = true
}
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
// A published activeEndpoint counts as "hermes online": the route
// resolver only publishes a winner after a successful HEAD /health
// probe against that route's API URL. At cold start this evidence
// lands within ~1s — long before the client-based health probe,
// which can't run until the API client exists (the client build
// used to queue behind the Keystore decrypt; see
// apiKeyForClientBuild in ConnectionViewModel).
val startupApiUp = apiReachable ||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
activeEndpoint != null
val hasStartupConnection = hasConfiguredStartupChat(activeConnection)
val appChatRuntimeStatus = resolveAppChatRuntimeStatus(
connection = activeConnection,
gatewayAvailability = gatewayAvailability,
apiHealth = apiHealth,
)
// A Dashboard/Gateway-only connection is a complete standard Hermes
// connection. Startup readiness follows the same transport-neutral
// priority as the footer instead of waiting for an optional API probe.
val startupChatUp = appChatRuntimeStatus is ChatRuntimeStatus.Connected
// An Unreachable verdict only counts after it SURVIVES a settle
// window: the first health probe often runs against the persisted
@@ -1185,9 +1303,9 @@ fun RelayApp() {
// first verdict was what flashed the disconnected chat UI at users
// who were connected-just-waiting. The keyed effect restarts on
// every health flip, cancelling a pending settle.
LaunchedEffect(apiHealth, startupGateReleased) {
LaunchedEffect(appChatRuntimeStatus, startupGateReleased) {
if (startupGateReleased) return@LaunchedEffect
if (apiHealth == ConnectionViewModel.HealthStatus.Unreachable) {
if (hasStartupConnection && appChatRuntimeStatus is ChatRuntimeStatus.Unavailable) {
delay(3_000L)
startupUnreachableSettled = true
} else {
@@ -1211,16 +1329,16 @@ fun RelayApp() {
StartupCheckState.Done,
"route · ${startupEndpoint.displayLabel()}",
)
startupApiUp ->
startupChatUp ->
StartupCheck(StartupCheckState.Done, "route · direct")
appReady ->
StartupCheck(StartupCheckState.Active, "resolving route")
else -> StartupCheck(StartupCheckState.Pending, "route")
},
when {
startupApiUp ->
startupChatUp ->
StartupCheck(StartupCheckState.Done, "hermes online")
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
appChatRuntimeStatus is ChatRuntimeStatus.Unavailable ->
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
appReady ->
StartupCheck(StartupCheckState.Active, "contacting hermes")
@@ -1232,7 +1350,7 @@ fun RelayApp() {
when {
chatReady && initialChatSettled ->
StartupCheck(StartupCheckState.Done, "conversation ready")
startupApiUp ->
startupChatUp ->
StartupCheck(StartupCheckState.Active, "loading conversation")
else -> StartupCheck(StartupCheckState.Pending, "conversation")
},
@@ -1346,7 +1464,8 @@ fun RelayApp() {
// the previous run). The pre-warm fills cold keys and refreshes
// stale (disk-hydrated) ones, then mirrors results back to disk.
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
val effectiveManageProfile by connectionViewModel.effectiveSessionProfileName.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl, effectiveManageProfile) {
val connection = activeConnection ?: return@LaunchedEffect
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
@@ -1363,6 +1482,7 @@ fun RelayApp() {
cookieStore = cookieStore,
connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl,
effectiveProfileName = effectiveManageProfile,
cacheDir = hydrateContext.cacheDir,
context = hydrateContext,
)
@@ -1460,7 +1580,7 @@ fun RelayApp() {
// stays fully silent (the health "Connecting" cue used to flash here for a
// few seconds and then clear with no "Connected" toast).
val connectionReconnecting =
globalConnectionStatus?.active == true && !postResumeQuiet &&
appChatRuntimeStatus is ChatRuntimeStatus.Connecting && !postResumeQuiet &&
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
// === END v0.4.1 polish ===
@@ -1600,7 +1720,13 @@ fun RelayApp() {
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val routeLabel = activeEndpoint?.displayLabel()
val footerRoute = resolveFooterRouteCandidate(
runtimeStatus = appChatRuntimeStatus,
activeEndpoint = activeEndpoint,
connection = activeConnection,
effectiveDashboardUrl = effectiveDashboardUrl,
)
val routeLabel = footerRoute?.displayLabel()
?: activeConnection?.label
?: stringResource(R.string.status_no_route)
val transportStatus = resolveChatTransportStatus(
@@ -1613,12 +1739,9 @@ fun RelayApp() {
} else {
routeLabel
}
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
val profileLabel = AgentDisplay.profileDisplayName(effectiveDisplayProfile)
?: stringResource(R.string.status_profile_default)
val displayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val displayProfile = effectiveDisplayProfile
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(displayProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
@@ -1701,8 +1824,9 @@ fun RelayApp() {
}
},
onManageSignIn = {
postOnboardingRoute = Screen.Manage.route
connectionViewModel.completeOnboarding()
navController.navigate(
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_ONBOARDING),
)
},
onOpenPermissions = {
navController.navigate(Screen.PermissionsSettings.route)
@@ -1717,6 +1841,16 @@ fun RelayApp() {
type = NavType.BoolType
defaultValue = false
},
navArgument(Screen.Chat.ARG_SESSION_ID) {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument(Screen.Chat.ARG_PROFILE) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { backStackEntry ->
// Responsive bubble width based on screen width. The "Blend"
@@ -1741,6 +1875,48 @@ fun RelayApp() {
// sheet.
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val requestedSessionId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_SESSION_ID)
?.takeIf { it.isNotBlank() }
val requestedProfileRoute = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROFILE)
?.takeIf { it.isNotBlank() }
LaunchedEffect(
requestedSessionId,
requestedProfileRoute,
profileSelectionSettled,
effectiveSessionProfileName,
agentProfiles,
) {
val sessionId = requestedSessionId ?: return@LaunchedEffect
if (requestedProfileRoute != null) {
val targetProfile = requestedProfileRoute.takeUnless {
it == com.hermesandroid.relay.notifications
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
}
if (!profileSelectionSettled) return@LaunchedEffect
if (effectiveSessionProfileName != targetProfile) {
val selection = targetProfile?.let { name ->
agentProfiles.firstOrNull { it.name == name }
}
if (targetProfile == null || selection != null) {
connectionViewModel.selectProfile(selection)
}
return@LaunchedEffect
}
chatViewModel.switchProfileContext(
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnectionId,
profileName = targetProfile,
),
sessionId = sessionId,
)
} else if (chatViewModel.currentSessionId.value != sessionId) {
chatViewModel.switchSession(sessionId)
}
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
}
val screenChatLabel = stringResource(R.string.screen_chat_label)
@@ -1832,6 +2008,11 @@ fun RelayApp() {
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToSignIn = {
navController.navigate(Screen.DashboardSignIn.route()) {
launchSingleTop = true
}
},
// Standard back: return to wherever Manage was opened
// from (Settings → Hermes management, the agent sheet,
// etc.). The prior forced navigate(Chat) with
@@ -1865,6 +2046,39 @@ fun RelayApp() {
)
}
}
composable(
route = Screen.DashboardSignIn.route,
arguments = listOf(
navArgument(Screen.DashboardSignIn.ARG_SOURCE) {
type = NavType.StringType
defaultValue = Screen.DashboardSignIn.SOURCE_GENERAL
},
),
) { backStackEntry ->
val source = backStackEntry.arguments
?.getString(Screen.DashboardSignIn.ARG_SOURCE)
?: Screen.DashboardSignIn.SOURCE_GENERAL
DashboardSignInScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onAuthenticated = {
when (source) {
Screen.DashboardSignIn.SOURCE_ONBOARDING -> {
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Chat.route()) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
Screen.DashboardSignIn.SOURCE_PAIR -> {
navController.popBackStack()
navController.popBackStack()
}
else -> navController.popBackStack()
}
},
)
}
composable(Screen.Terminal.route) {
if (coldStartAuthState is AuthState.Paired) {
TerminalScreen(
@@ -2082,6 +2296,7 @@ fun RelayApp() {
voiceClient = voiceClient,
connectionId = activeConnectionId,
selectedProfile = selectedProfile,
displayProfile = effectiveDisplayProfile,
standardVoiceAvailability = standardVoiceAvailability,
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
relayVoiceReady = relayVoiceReady,
@@ -2230,18 +2445,20 @@ fun RelayApp() {
navController.navigate(Screen.ConnectionDetail.route(id))
},
onAddConnection = {
connectionSwitchScope.launch {
// Create and switch to the placeholder before
// opening the wizard. Otherwise a fast scan or
// standard save can write into the outgoing
// connection's auth store.
val id = connectionViewModel.beginAddConnection(
preAllocatedId = java.util.UUID.randomUUID().toString(),
)
navController.navigate(
Screen.Pair.route(connectionId = id)
)
val id = java.util.UUID.randomUUID().toString()
// Draw step 1 immediately. Placeholder persistence
// and the heavy connection-context switch continue
// underneath the discovery UI instead of blocking
// navigation on encrypted-store/client setup.
navController.navigate(Screen.Pair.route(connectionId = id))
val job = connectionSwitchScope.launch {
try {
connectionViewModel.beginAddConnection(preAllocatedId = id)
} finally {
pendingAddConnectionJobs.remove(id)
}
}
pendingAddConnectionJobs[id] = job
},
onBack = { navController.popBackStack() },
// Pass the VM so the list cards can read live status
@@ -2283,7 +2500,7 @@ fun RelayApp() {
onRepair = { id ->
connectionSwitchScope.launch {
connectionViewModel.switchConnection(id).join()
navController.navigate(Screen.Pair.route(id))
navController.navigate(Screen.Pair.route(id, autoStart = "relay"))
}
},
onRevoke = { id ->
@@ -2333,9 +2550,14 @@ fun RelayApp() {
?.getString(Screen.Pair.ARG_CONNECTION_ID)
val autoStartArg = backStackEntry.arguments
?.getString(Screen.Pair.ARG_AUTO_START)
val pairConnections by connectionViewModel.connections.collectAsState()
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
val pairSetupReady = connectionIdArg == null ||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
com.hermesandroid.relay.ui.screens.PairScreen(
connectionViewModel = connectionViewModel,
autoStart = autoStartArg,
setupReady = pairSetupReady,
// Offer demo only on the bare "Connect" entry (the
// "No Hermes connection" path) — not on add-connection /
// re-pair flows, which have a placeholder connection in
@@ -2354,8 +2576,9 @@ fun RelayApp() {
navController.popBackStack()
},
onManageSignIn = {
navController.popBackStack()
navController.navigate(Screen.Manage.route) {
navController.navigate(
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_PAIR),
) {
launchSingleTop = true
}
},
@@ -2367,6 +2590,10 @@ fun RelayApp() {
// never got a pairedAt stamp.
if (connectionIdArg != null) {
connectionSwitchScope.launch {
// If Back wins the race with background
// preparation, wait until the placeholder
// exists before attempting to discard it.
pendingAddConnectionJobs.remove(connectionIdArg)?.join()
connectionViewModel.discardPlaceholderConnection(connectionIdArg)
}
}
@@ -2502,9 +2729,9 @@ fun RelayApp() {
// doesn't happen to match the one we're inspecting
// (shouldn't normally happen since the entry is
// keyed off the same Profile).
val selectedProfile by connectionViewModel
.selectedProfile.collectAsState()
val modelLabel = selectedProfile
val inspectorDisplayProfile by connectionViewModel
.effectiveDisplayProfile.collectAsState()
val modelLabel = inspectorDisplayProfile
?.takeIf { it.name == profileNameArg }
?.model
File diff suppressed because it is too large Load Diff
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
if (!content.contains("![")) return content to emptyList()
val images = mutableListOf<ChatInlineImage>()
var inlineDataImages = 0
var inlineDataBytes = 0L
var inlineOverflowNoticeAdded = false
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
val alt = m.groupValues[2].trim()
val src = normalizeImageSrc(m.groupValues[3].trim())
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
val exceedsInlineBudget = isInlineData && (
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
)
if (exceedsInlineBudget) {
return@replace if (inlineOverflowNoticeAdded) {
""
} else {
inlineOverflowNoticeAdded = true
"\n\n_Additional inline images omitted for memory safety._\n\n"
}
}
images += ChatInlineImage(
alt = alt,
src = normalizeImageSrc(m.groupValues[3].trim()),
src = src,
sensitive = spoilerWrapped || isSensitiveAltText(alt),
)
if (inlineDataSize != null) {
inlineDataImages += 1
inlineDataBytes += inlineDataSize
}
""
}
if (images.isEmpty()) return content to emptyList()
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
return s.startsWith("http://") || s.startsWith("https://")
}
private fun ChatInlineImage.isInlineDataImage(): Boolean =
src.startsWith("data:image/", ignoreCase = true)
/**
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
@@ -232,6 +258,7 @@ fun ChatInlineImages(
images.forEach { image ->
when {
image.isRemote() -> RemoteChatImage(image, maxWidth)
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
// A server-local file the agent referenced — fetch it through
// /media/by-path and render inline; on failure the notice shows
// the ACTUAL reason (for debugging) instead of a generic message.
@@ -253,6 +280,94 @@ fun ChatInlineImages(
}
}
private sealed interface DataUrlImagePhase {
data object Loading : DataUrlImagePhase
data class Loaded(
val bitmap: ImageBitmap,
val mime: String,
) : DataUrlImagePhase
data object Rejected : DataUrlImagePhase
}
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
@Composable
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
var viewerOpen by remember(image.src) { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(image.src) { mutableStateOf(false) }
LaunchedEffect(image.src) {
phase = withInlineImageDecodeLock {
val decoded = decodeInlineImageDataUrl(image.src)
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
val bitmap = BitmapFactory.decodeByteArray(
decoded.bytes,
0,
decoded.bytes.size,
BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
},
)
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
}
}
when (val current = phase) {
DataUrlImagePhase.Loading -> Box(
modifier = Modifier
.widthIn(max = maxWidth)
.height(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
image.copy(src = "inline image"),
reason = "Unsupported or oversized inline image.",
)
is DataUrlImagePhase.Loaded -> {
if (viewerOpen) {
ChatImageViewer(
source = ChatImageViewerSource.Bitmap(
bitmap = current.bitmap,
displayName = image.alt.ifBlank { "image" },
mime = current.mime,
// Decode the already-retained data URL only when the
// user requests Save/Share; don't keep a second 5 MiB
// byte array beside every thumbnail.
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
),
onDismiss = { viewerOpen = false },
sensitive = image.sensitive,
initiallyRevealed = revealed,
)
}
InlineImageColumn(image, maxWidth) {
BlurredMedia(
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
onReveal = { revealed = true },
) {
androidx.compose.foundation.Image(
bitmap = current.bitmap,
contentDescription = image.alt.ifBlank { "Generated image" },
contentScale = ContentScale.Fit,
modifier = Modifier
.widthIn(max = maxWidth)
.heightIn(max = 360.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { viewerOpen = true },
)
}
}
}
}
}
@Composable
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
var viewerOpen by remember { mutableStateOf(false) }
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
* !isStreaming -> VOICE // GraphicEq, primary
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
* canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
* else -> QUEUE // Send glyph + clock badge, tertiary
* ```
*/
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
* the limit) only when length > [charLimit] - 200 — supportingText
* reflows the bar, the overline doesn't.
* - [caption] renders a single relayMetadataStyle line above the bar
* (steer/queue hinting during streaming-with-text); Cyan when the slot
* (correct/queue hinting during streaming-with-text); Cyan when the slot
* is STEER, muted otherwise. Null collapses the row.
* - Voice: GraphicEq glyph ("voice session", not "record"); when
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
@@ -191,7 +191,7 @@ fun ChatInputBar(
}
Column(modifier = modifier.fillMaxWidth()) {
// Caption row — steer/queue hinting, single line, no buttons.
// Caption row — correct/queue hinting, single line, no buttons.
AnimatedVisibility(visible = caption != null) {
Text(
text = caption ?: lastCaption.orEmpty(),
@@ -84,12 +84,16 @@ import com.hermesandroid.relay.data.ProfilePresence
import com.hermesandroid.relay.data.ProfilePresenceResolver
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import kotlinx.coroutines.launch
// ---------------------------------------------------------------------------
@@ -656,6 +660,7 @@ fun AgentInfoSheet(
// Profile + personality state — same flows the old pickers consumed.
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val resolvedDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
var showProfileManager by remember { mutableStateOf(false) }
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -667,7 +672,7 @@ fun AgentInfoSheet(
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val personalityNames by chatViewModel.personalityNames.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val availableModels by chatViewModel.availableModels.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
@@ -710,9 +715,12 @@ fun AgentInfoSheet(
// Connection summary state.
val authState by connectionViewModel.authState.collectAsState()
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
val chatMode by connectionViewModel.chatMode.collectAsState()
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
@@ -735,6 +743,26 @@ fun AgentInfoSheet(
val allConnections by connectionViewModel.connectionStore.connections.collectAsState()
val activeConnectionId by connectionViewModel.connectionStore
.activeConnectionId.collectAsState()
val activeConnection = remember(allConnections, activeConnectionId) {
allConnections.firstOrNull { it.id == activeConnectionId }
}
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> if (
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
apiServerReachable -> ChatTransportReadiness.Ready
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
// Mid-stream gate — mirrors what ProfilePicker's `enabled` flag was doing:
// a radio tap during an in-flight chat turn would race the request. Apply
@@ -758,12 +786,8 @@ fun AgentInfoSheet(
val profileOverridesPersonality =
selectedProfile?.systemMessage?.isNotBlank() == true
val effectiveDisplayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val serverResolvedAgentName = AgentDisplay.agentName(
profile = effectiveDisplayProfile,
profile = resolvedDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = null,
@@ -816,7 +840,7 @@ fun AgentInfoSheet(
?.name
?.takeIf { it.isNotBlank() }
AgentSheetHeader(
profile = effectiveDisplayProfile,
profile = resolvedDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
localDisplayAlias = profileDisplayAlias,
@@ -826,8 +850,7 @@ fun AgentInfoSheet(
// default with the session provider.
sessionModelName = selectedModelOverride ?: gatewayCurrentModel,
modelProviderLabel = currentProviderLabel,
apiServerReachable = apiServerReachable,
chatMode = chatMode,
chatRuntimeStatus = chatRuntimeStatus,
isCustomized = selectedProfile != null ||
selectedPersonality != "default" ||
profileDisplayAlias != null,
@@ -1133,8 +1156,7 @@ fun AgentInfoSheet(
}
val inspectProfileText = stringResource(R.string.conn_info_inspect_profile)
val inspectorTarget = selectedProfile
?: serverDefaultProfile
val inspectorTarget = resolvedDisplayProfile
?: visibleProfileKeys
.asSequence()
.mapNotNull { key -> agentProfiles.firstOrNull { it.name == key } }
@@ -1271,11 +1293,13 @@ fun AgentInfoSheet(
// SSE fallback model list — /v1/models plus the configured profiles'
// models (used only when the gateway model.options groups aren't
// available, e.g. on an SSE transport).
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
.distinct()
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
(apiModelOptions +
agentProfiles.mapNotNull { profile ->
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
} +
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
.distinctBy { it.id }
}
// Always show the Model picker — choosing a model is always possible
// (Server default at minimum). While the provider/model list is still
@@ -1344,14 +1368,14 @@ fun AgentInfoSheet(
} else {
sseModelOptions.forEach { model ->
ProfileRadioRow(
primary = model,
secondary = null,
selected = selectedModelOverride == model,
primary = AgentDisplay.displayModelName(model.id) ?: model.id,
secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
enabled = !isStreaming,
onSelect = {
if (selectedModelOverride != model) {
chatViewModel.selectModel(model)
toast(modelToast.format(model))
if (selectedModelOverride != model.id) {
chatViewModel.selectApiModel(model.id)
toast(modelToast.format(model.id))
}
},
)
@@ -1526,10 +1550,8 @@ fun AgentInfoSheet(
// Pre-resolve strings for Connection section
val connectionTitle = stringResource(R.string.conn_info_connection_title)
val switchServersHint = stringResource(R.string.conn_info_switch_servers_hint)
val authLabel = stringResource(R.string.conn_info_auth)
val apiReachableLabel = stringResource(R.string.conn_info_api_reachable)
val pairedLabel = stringResource(R.string.conn_info_paired)
val hermesLabel = stringResource(R.string.conn_info_hermes)
val relayAuthLabel = stringResource(R.string.conn_info_relay_auth)
val apiFallbackLabel = stringResource(R.string.active_section_optional_api_fallback)
SectionLabel(
title = connectionTitle,
@@ -1546,10 +1568,25 @@ fun AgentInfoSheet(
val sessionTransport = sessionPathTransport(
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint),
)
val routeLabel = activeEndpoint?.displayLabel()
?: com.hermesandroid.relay.data.Connection
.extractDefaultLabel(apiServerUrl)
.takeIf { it.isNotBlank() }
val routeLabel = if (sessionTransport.isGateway) {
activeConnection?.routeCandidates
?.firstOrNull {
it.dashboard?.url?.trimEnd('/') == effectiveDashboardUrl.trimEnd('/')
}
?.displayLabel()
?: effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
com.hermesandroid.relay.data.Connection.endpointCandidateFromDashboardUrl(
role = com.hermesandroid.relay.data.Connection.inferRouteRole(url),
priority = 0,
dashboardUrl = url,
)?.displayLabel()
}
} else {
activeEndpoint?.displayLabel()
?: com.hermesandroid.relay.data.Connection
.extractDefaultLabel(effectiveApiServerUrl)
.takeIf { it.isNotBlank() }
}
val relayConnected = relayConnectionState == ConnectionState.Connected
val threadsActive = connectionViewModel.proactiveEnabled.collectAsState().value &&
connectionViewModel.authState.collectAsState().value is
@@ -1570,7 +1607,7 @@ fun AgentInfoSheet(
// Multi-connection switcher. Renders inline as a radio list
// (mirrors the Profile + Personality sections above) when the
// user has ≥2 paired connections. Replaces the separate top-
// user has ≥2 connections. Replaces the separate top-
// bar ConnectionChip that used to be the only switch surface
// — folding it here keeps all agent/connection controls in
// one place, matching Bailey's ask in the 2026-04-20 audit.
@@ -1583,11 +1620,10 @@ fun AgentInfoSheet(
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
allConnections.forEach { connection ->
val isActive = connection.id == activeConnectionId
val hostname = com.hermesandroid.relay.data.Connection
.extractDefaultLabel(connection.apiServerUrl)
val hostname = connection.primaryHost.ifBlank { connection.label }
val statusLine = when {
connection.pairedAt == null -> stringResource(R.string.conn_info_hostname_hermes, hostname)
else -> stringResource(R.string.conn_info_hostname_paired, hostname)
else -> stringResource(R.string.conn_info_hostname_relay_paired, hostname)
}
ProfileRadioRow(
primary = connection.label,
@@ -1605,9 +1641,17 @@ fun AgentInfoSheet(
}
}
ChipRow(label = authLabel) { authStateChip(authState) }
ChipRow(label = apiReachableLabel) {
val (label, bg, fg) = if (apiServerReachable) {
if (relayUrl.isNotBlank() || authState is AuthState.Paired) {
ChipRow(label = relayAuthLabel) { authStateChip(authState) }
}
ChipRow(label = apiFallbackLabel) {
val (label, bg, fg) = if (apiServerUrl.isBlank()) {
Triple(
stringResource(R.string.active_section_not_configured),
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (apiServerReachable) {
Triple(
stringResource(R.string.conn_info_yes),
MaterialTheme.colorScheme.primaryContainer,
@@ -1674,8 +1718,8 @@ fun AgentInfoSheet(
routeLabel = routeLabel,
relayConnectionState = relayConnectionState,
capabilities = sessionCaps,
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
apiServerUrl = effectiveApiServerUrl,
relayUrl = effectiveRelayUrl,
streamingEndpoint = streamingEndpoint,
gatewayAvailability = gatewayAvailability,
serverCapabilities = serverCapabilities,
@@ -2307,8 +2351,7 @@ private fun AgentSheetHeader(
serverModelName: String,
sessionModelName: String? = null,
modelProviderLabel: String? = null,
apiServerReachable: Boolean,
chatMode: ChatMode,
chatRuntimeStatus: ChatRuntimeStatus,
isCustomized: Boolean,
) {
val agentName = AgentDisplay.agentName(
@@ -2326,17 +2369,18 @@ private fun AgentSheetHeader(
// The global default — surfaced as a quiet caption only when THIS session
// runs something different (the always-visible global-vs-session split).
val serverDefaultLabel = AgentDisplay.displayModelName(serverModelName)
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
val isConnected = chatRuntimeStatus is ChatRuntimeStatus.Connected
val isConnecting = chatRuntimeStatus is ChatRuntimeStatus.Connecting
val connectedText = stringResource(R.string.conn_info_connected)
val connectingText = stringResource(R.string.conn_info_connecting)
val disconnectedText = stringResource(R.string.conn_info_disconnected)
val statusText = when {
apiServerReachable -> connectedText
isConnected -> connectedText
isConnecting -> connectingText
else -> disconnectedText
}
val statusColor = when {
apiServerReachable -> MaterialTheme.colorScheme.primary
isConnected -> MaterialTheme.colorScheme.primary
isConnecting -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
@@ -0,0 +1,77 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class ConnectionSetupTimelineStep(
val title: String,
val detail: String,
)
/** Compact completed-state timeline shared by connection and auth flows. */
@Composable
fun ConnectionSetupTimeline(
steps: List<ConnectionSetupTimelineStep>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
steps.forEachIndexed { index, step ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(28.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp),
)
}
if (index != steps.lastIndex) {
Spacer(
modifier = Modifier
.size(width = 2.dp, height = 34.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)),
)
}
}
Column(
modifier = Modifier.padding(bottom = if (index == steps.lastIndex) 0.dp else 10.dp),
) {
Text(step.title, style = MaterialTheme.typography.titleSmall)
Text(
step.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Shield
@@ -29,6 +31,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.text.input.KeyboardType
@@ -44,8 +47,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
@@ -55,9 +60,12 @@ import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.data.isTlsUrl
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import java.net.URI
/**
* ADR 24 — per-endpoint visibility + override card for the Connection
@@ -72,9 +80,9 @@ import kotlinx.coroutines.launch
* on app start, tried first on every resolve; cleared by
* [onClearPreferred].
*
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
* do NOT collapse into a master row. The card itself is wrapped in a
* [SettingsExpandableCard] by the caller for page layout hygiene.
* Each route stays visible, while its Dashboard/API/Relay topology uses
* progressive disclosure: the active route opens by default and fallback
* routes keep a compact surface-and-port summary.
*
* Not visible on legacy installs: when [endpoints] is empty we render a
* helpful one-liner instead of an empty card, so freshly-upgraded users
@@ -106,6 +114,9 @@ fun EndpointsCard(
* resolver's cache-key scheme.
*/
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
/** Auth state applies only to the currently active Dashboard route. */
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
/**
* Route management — the standard path's manual equivalent of a v3 QR's
* `endpoints` array. Null callbacks hide the corresponding affordance.
@@ -170,11 +181,17 @@ fun EndpointsCard(
candidate = candidate,
isActive = activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
activeEndpoint.api.port == candidate.api.port,
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
isProbing = isProbing,
outcome = outcomeFor(candidate),
dashboardAuthenticated = dashboardAuthenticated.takeIf { activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.routeAuthority() == candidate.routeAuthority()
},
dashboardSignInRequired = dashboardSignInRequired && activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
onUseNow = { onUseNow(candidate) },
onPrefer = { onPreferEndpoint(candidate) },
onClearPrefer = onClearPreferred,
@@ -219,6 +236,8 @@ private fun EndpointRow(
isPreferred: Boolean,
isProbing: Boolean = false,
outcome: RouteProbeOutcome? = null,
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
onUseNow: () -> Unit,
onPrefer: () -> Unit,
onClearPrefer: () -> Unit,
@@ -230,6 +249,7 @@ private fun EndpointRow(
var menuOpen by remember { mutableStateOf(false) }
var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) }
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
val scope = rememberCoroutineScope()
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
@@ -278,17 +298,6 @@ private fun EndpointRow(
)
}
}
// Full URL, scheme included: http vs https decides whether
// the health probe TLS-handshakes, so two rows that both
// read "host:8642" can behave completely differently. The
// scheme must be visible to be debuggable.
Text(
text = candidate.api.url +
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
when {
isProbing -> Text(
text = stringResource(R.string.endpoints_checking),
@@ -307,6 +316,47 @@ private fun EndpointRow(
color = MaterialTheme.colorScheme.error,
)
}
val dashboardSurfaceUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val dashboardLabel = stringResource(R.string.active_section_dashboard)
val apiLabel = stringResource(R.string.active_section_api_server)
val relayLabel = stringResource(R.string.active_section_relay)
val surfaceSummary = listOfNotNull(
dashboardSurfaceUrl?.let { "$dashboardLabel ${displayPort(it)}" },
candidate.api?.url?.let { "$apiLabel ${displayPort(it)}" },
candidate.relay?.url?.let { "$relayLabel ${displayPort(it)}" },
).joinToString(" · ")
if (surfaceSummary.isNotBlank()) {
Row(
modifier = Modifier
.padding(top = 4.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { detailsExpanded = !detailsExpanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = surfaceSummary,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = if (detailsExpanded) {
Icons.Filled.ExpandLess
} else {
Icons.Filled.ExpandMore
},
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
}
// 3-dot overflow menu — actions per-row so the card stays flat
@@ -392,6 +442,15 @@ private fun EndpointRow(
}
}
}
if (detailsExpanded) {
RouteSurfaceMap(
candidate = candidate,
dashboardAuthenticated = dashboardAuthenticated,
dashboardSignInRequired = dashboardSignInRequired,
modifier = Modifier.padding(start = 26.dp, end = 4.dp, top = 8.dp),
)
}
}
if (confirmRemove && onRemove != null) {
@@ -400,7 +459,7 @@ private fun EndpointRow(
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
text = {
Text(
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
text = stringResource(R.string.endpoints_remove_route_body, candidate.routeAuthority().orEmpty()),
style = MaterialTheme.typography.bodySmall,
)
},
@@ -421,7 +480,7 @@ private fun EndpointRow(
pinDialogText?.let { body ->
AlertDialog(
onDismissRequest = { pinDialogText = null },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.routeAuthority().orEmpty())) },
text = {
Text(
text = body,
@@ -436,6 +495,106 @@ private fun EndpointRow(
}
}
@Composable
private fun RouteSurfaceMap(
candidate: EndpointCandidate,
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
modifier: Modifier = Modifier,
) {
val dashboardUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val apiUrl = candidate.api?.url
val relayUrl = candidate.relay?.url
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
shape = RoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
RouteSurfaceRow(
label = stringResource(R.string.active_section_dashboard),
url = dashboardUrl,
status = when {
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
dashboardAuthenticated == true -> stringResource(R.string.active_section_signed_in)
dashboardUrl != null -> stringResource(R.string.active_section_configured)
else -> stringResource(R.string.active_section_not_configured)
},
warning = dashboardSignInRequired,
)
RouteSurfaceRow(
label = stringResource(R.string.active_section_api_server),
url = apiUrl,
status = stringResource(
if (apiUrl != null) R.string.active_section_configured
else R.string.active_section_not_configured,
),
)
RouteSurfaceRow(
label = stringResource(R.string.active_section_relay),
url = relayUrl,
status = stringResource(
if (relayUrl != null) R.string.active_section_configured
else R.string.active_section_not_configured,
),
)
}
}
}
@Composable
private fun RouteSurfaceRow(
label: String,
url: String?,
status: String,
warning: Boolean = false,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = url ?: "—",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = status,
style = MaterialTheme.typography.labelSmall,
color = if (warning) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
private fun displayPort(url: String): String {
val uri = runCatching { URI(url) }.getOrNull()
val port = uri?.port?.takeIf { it > 0 } ?: when (uri?.scheme?.lowercase()) {
"https", "wss" -> 443
else -> 80
}
return ":$port"
}
@Composable
private fun ActiveChip(label: String) {
Row(
@@ -521,7 +680,7 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
* be classified independently before it's the active route.
*/
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
isTlsUrl(primaryRouteUrl().orEmpty()) -> SurfaceSecurityKind.Tls
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
else -> SurfaceSecurityKind.Plain
}
@@ -531,21 +690,24 @@ private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
* set up LAN ↔ Tailscale roaming without the plugin.
*
* The relay URL is derived from the API URL (same `:8767` convention the
* wizard uses); routes that need a custom relay URL still come from a QR.
* The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
* fallback uses `:8642`, and an already-enabled Relay uses `:8767`.
* Routes that need custom per-surface hosts or ports still come from a QR.
*
* @param original null = add a new route; non-null = edit (pre-fills role +
* URL, keeps the stored priority).
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
* @param onSave invoked with (role, dashboardUrl, resultCallback); the callback
* receives a user-facing error string to render inline, or null on
* success (the dialog then closes itself).
*/
@Composable
fun RouteEditorDialog(
original: EndpointCandidate?,
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
relayEnabled: Boolean = false,
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
val knownRoles = listOf("tailscale", "public")
var selectedRole by remember {
mutableStateOf(
@@ -561,7 +723,7 @@ fun RouteEditorDialog(
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
)
}
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
var url by remember(original) { mutableStateOf(original?.primaryRouteUrl().orEmpty()) }
var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
@@ -615,13 +777,20 @@ fun RouteEditorDialog(
// Live preview of what will actually be saved — scheme and
// port defaults applied — so "what, which port, http or
// https?" is answered before Save, not after a failed probe.
val previewCandidate = remember(url, effectiveRole) {
val previewCandidate = remember(url, effectiveRole, relayEnabled) {
url.takeIf { it.isNotBlank() }?.let {
Connection.endpointCandidateFromApiUrl(
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
Connection.endpointCandidateFromDashboardUrl(
role = effectiveRole.ifBlank { "custom" },
priority = original?.priority ?: 1,
apiServerUrl = Connection.normalizeApiUrlInput(it),
relayUrl = "",
dashboardUrl = dashboardUrl,
apiServerUrl = apiUrl,
relayUrl = if (relayEnabled) {
apiUrl?.let(Connection::deriveDefaultRelayUrl)
} else {
null
},
)
}
}
@@ -643,7 +812,7 @@ fun RouteEditorDialog(
url.isBlank() ->
supportingBlank
previewCandidate != null ->
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.primaryRouteUrl().orEmpty())
else ->
supportingEnter
},
@@ -652,6 +821,21 @@ fun RouteEditorDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
previewCandidate?.let { candidate ->
RouteSurfaceMap(candidate = candidate)
}
if (selectedRole == "tailscale") {
Text(
text = stringResource(R.string.endpoints_tailscale_setup_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(
onClick = { uriHandler.openUri(REMOTE_ACCESS_DOCS_URL) },
) {
Text(stringResource(R.string.endpoints_setup_help))
}
}
}
},
confirmButton = {
@@ -683,4 +867,7 @@ fun RouteEditorDialog(
)
}
private const val REMOTE_ACCESS_DOCS_URL =
"https://hermes-relay.dev/docs/guide/remote-access"
private const val CUSTOM_ROLE = "__custom__"
@@ -0,0 +1,200 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.sin
private const val IMAGE_GENERATION_TOOL = "image_generate"
private const val GRID_COLUMNS = 42
private const val GRID_ROWS = 24
internal fun ToolCall.showsImageGenerationPlaceholder(): Boolean =
!isComplete && name.trim().lowercase() == IMAGE_GENERATION_TOOL
/**
* Keep the image canvas alive across the short tool-complete → media-arrival
* handoff. Once a result surface exists it can crossfade into the same bubble;
* a failed or fully-finished turn never leaves a stale canvas behind.
*/
internal fun shouldShowImageGenerationPlaceholder(
toolCalls: List<ToolCall>,
isStreaming: Boolean,
hasMediaResult: Boolean,
): Boolean {
val imageCalls = toolCalls.filter {
it.name.trim().lowercase() == IMAGE_GENERATION_TOOL
}
if (imageCalls.any { !it.isComplete }) return true
return !hasMediaResult &&
isStreaming &&
imageCalls.any { it.isComplete && it.success != false }
}
/**
* Image generation is a user-visible result lifecycle, not generic tool
* diagnostics. Keep its active canvas visible even when upstream
* `display.tool_progress` hides ordinary tool cards.
*/
internal fun ToolCall.isVisibleForToolDisplay(toolDisplay: String): Boolean =
toolDisplay != "off" || showsImageGenerationPlaceholder()
/**
* Theme-aware latent diffusion preview for an active Hermes image-generation
* tool. It specializes the generic tool lifecycle already emitted by vanilla
* Hermes; no Relay-only protocol or server patch is required.
*/
@Composable
fun ImageGenerationPlaceholder(
modifier: Modifier = Modifier,
) {
val description = stringResource(R.string.image_generation_rendering)
val transition = rememberInfiniteTransition(label = "imageGenerationDiffusion")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4_800, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "imageGenerationDiffusionPhase",
)
val background = MaterialTheme.colorScheme.surfaceVariant
val foreground = MaterialTheme.colorScheme.onSurfaceVariant
val primary = MaterialTheme.colorScheme.primary
val tertiary = MaterialTheme.colorScheme.tertiary
Box(
modifier = modifier
.widthIn(max = 360.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(background)
.semantics {
contentDescription = description
liveRegion = LiveRegionMode.Polite
},
) {
DiffusionCanvas(
phase = phase,
background = background,
foreground = foreground,
primary = primary,
tertiary = tertiary,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
)
}
}
@Composable
private fun DiffusionCanvas(
phase: Float,
background: Color,
foreground: Color,
primary: Color,
tertiary: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
drawRect(background)
val cellWidth = size.width / GRID_COLUMNS
val cellHeight = size.height / GRID_ROWS
val denoise = diffusionDenoise(phase)
val time = phase * 9f
repeat(GRID_ROWS) { row ->
repeat(GRID_COLUMNS) { column ->
val signal = diffusionSignal(column, row, time, denoise)
if (signal < 0.2f) return@repeat
val x = column * cellWidth + cellWidth * 0.5f
val y = row * cellHeight + cellHeight * 0.5f
val radius = (cellWidth.coerceAtMost(cellHeight) * (0.14f + signal * 0.24f))
.coerceAtLeast(0.7.dp.toPx())
val warmMix = ((signal - 0.35f) / 0.65f).coerceIn(0f, 1f)
val base = lerpColor(foreground, primary, warmMix)
val color = lerpColor(base, tertiary, hash01(column + 17, row - 11) * 0.32f)
drawRoundRect(
color = color.copy(alpha = (0.08f + signal * 0.76f).coerceAtMost(0.84f)),
topLeft = Offset(x - radius, y - radius),
size = Size(radius * 2f, radius * 2f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius * 0.45f),
)
}
}
}
}
internal fun diffusionDenoise(phase: Float): Float {
val normalized = phase - floor(phase)
return if (normalized < 0.82f) {
smoothstep(0.02f, 0.82f, normalized)
} else {
1f - smoothstep(0.82f, 1f, normalized)
}
}
internal fun diffusionSignal(column: Int, row: Int, time: Float, denoise: Float): Float {
val nx = (column + 0.5f) / GRID_COLUMNS - 0.52f
val ny = (row + 0.5f) / GRID_ROWS - 0.5f
val radius = kotlin.math.sqrt(nx * nx * 1.35f + ny * ny)
val bloom = (1f - radius * 2.35f).coerceIn(0f, 1f)
val ring = (1f - abs(radius - (0.23f + sin(time * 0.44f) * 0.025f)) * 15f)
.coerceIn(0f, 1f)
val latent = (bloom * 0.75f + ring * 0.5f).coerceIn(0f, 1f)
val staticNoise = hash01(column + floor(time * 3f).toInt() * 19, row - floor(time * 3f).toInt() * 11)
val livingNoise = hash01(column + floor(time * 7f).toInt(), row + floor(time * 5f).toInt())
return (
staticNoise * (1f - denoise) +
latent * denoise +
(livingNoise - 0.5f) * (0.42f - denoise * 0.2f)
).coerceIn(0f, 1f)
}
private fun hash01(x: Int, y: Int): Float {
val value = sin(x * 127.1 + y * 311.7) * 43_758.5453
return (value - floor(value)).toFloat()
}
private fun smoothstep(edge0: Float, edge1: Float, value: Float): Float {
val t = ((value - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
return t * t * (3f - 2f * t)
}
private fun lerpColor(from: Color, to: Color, amount: Float): Color = Color(
red = from.red + (to.red - from.red) * amount,
green = from.green + (to.green - from.green) * amount,
blue = from.blue + (to.blue - from.blue) * amount,
alpha = from.alpha + (to.alpha - from.alpha) * amount,
)
@@ -0,0 +1,127 @@
package com.hermesandroid.relay.ui.components
import java.util.Base64
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
private const val MAX_HEADER_CHARS = 64
private val inlineImageDecodeMutex = Mutex()
private val DATA_IMAGE_HEADER = Regex(
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
RegexOption.IGNORE_CASE,
)
internal data class DecodedInlineImageData(
val bytes: ByteArray,
val mime: String,
)
/**
* Strict decoder for upstream API-server inline images.
*
* Only the five raster formats upstream emits for this path are accepted. The
* encoded input is bounded before allocation, strict base64 is required, and
* the decoded magic bytes must agree with the declared MIME type.
*/
internal fun decodeInlineImageDataUrl(
source: String,
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
): DecodedInlineImageData? {
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
val comma = source.indexOf(',')
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
?.groupValues?.get(1)?.lowercase() ?: return null
val encoded = source.substring(comma + 1)
if (encoded.isEmpty()) return null
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
if (encoded.length.toLong() > maxEncoded) return null
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
return DecodedInlineImageData(bytes, mime)
}
/** Conservative decoded-size estimate that does not allocate a byte array. */
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
if (!source.startsWith("data:image/", ignoreCase = true)) return null
val comma = source.indexOf(',')
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
val encodedLength = source.length.toLong() - comma - 1L
if (encodedLength <= 0) return null
val padding = when {
source.endsWith("==") -> 2L
source.endsWith('=') -> 1L
else -> 0L
}
return ((encodedLength + 3L) / 4L) * 3L - padding
}
/**
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
* allocations from overlapping.
*/
internal suspend fun <T> withInlineImageDecodeLock(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: () -> T,
): T = withContext(dispatcher) {
inlineImageDecodeMutex.withLock { block() }
}
internal suspend fun decodeInlineImageDataUrlOffMain(
source: String,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
decodeInlineImageDataUrl(source)
}
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
)
"image/jpeg" -> bytes.size >= 3 &&
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
"image/webp" -> bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
"image/gif" -> bytes.size >= 6 &&
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
else -> false
}
/**
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
*/
internal fun inlineImageSampleSize(
width: Int,
height: Int,
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
): Int? {
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
) return null
var sample = 1
while (true) {
val sampledWidth = (width + sample - 1L) / sample
val sampledHeight = (height + sample - 1L) / sample
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
sampledWidth <= maxPixels / sampledHeight
) return sample
if (sample >= 1 shl 15) return null
sample *= 2
}
}
@@ -41,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
@@ -152,7 +150,7 @@ fun MarkdownContent(
),
components = markdownComponents(
codeBlock = {
MarkdownHighlightedCodeBlock(
SafeMarkdownHighlightedCodeBlock(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
@@ -160,7 +158,7 @@ fun MarkdownContent(
)
},
codeFence = {
MarkdownHighlightedCodeFence(
SafeMarkdownHighlightedCodeFence(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
@@ -205,6 +206,11 @@ fun MessageBubble(
extractChatInlineImages(message.content)
}
}
val showImageGeneration = shouldShowImageGenerationPlaceholder(
toolCalls = message.toolCalls,
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
// Provide the sensitive-media blur mode to the attachment / inline-image
// renderers below, sourced as locally as possible (here, not threaded
@@ -309,6 +315,7 @@ fun MessageBubble(
val showBubble = isUser || isSystem ||
message.content.isNotBlank() ||
message.isStreaming ||
showImageGeneration ||
message.cards.isNotEmpty() ||
message.attachments.isNotEmpty() ||
inlineImages.isNotEmpty()
@@ -478,32 +485,41 @@ fun MessageBubble(
}
}
// Attachments — two or more loaded images collapse into one
// grid + swipe-across gallery. Every other item stays on the
// unified InboundAttachmentCard path, and layout items retain
// their original ChatMessage.attachments indices so retry /
// manual-fetch callbacks cannot drift after grouping.
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
val attachmentItems = remember(message.attachments) {
attachmentLayoutItems(message.attachments)
}
attachmentItems.forEach { item ->
when (item) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
attachments = item.attachmentIndices.map(message.attachments::get),
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
// Image generation owns the bubble's progress slot. It replaces
// the generic first-token dots, remains mounted through the
// tool-complete → MEDIA marker handoff, then crossfades into the
// attachment renderer in this same Surface.
Crossfade(
targetState = showImageGeneration,
animationSpec = tween(durationMillis = 220),
label = "imageGenerationToResult",
) { generating ->
if (generating) {
Spacer(modifier = Modifier.height(4.dp))
ImageGenerationPlaceholder()
} else if (message.attachments.isNotEmpty()) {
// Two or more loaded images collapse into one grid +
// swipe-across gallery. Every other item stays on the
// unified attachment path, retaining original indices.
Spacer(modifier = Modifier.height(4.dp))
val attachmentItems = attachmentLayoutItems(message.attachments)
attachmentItems.forEach { item ->
when (item) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
attachments = item.attachmentIndices.map(message.attachments::get),
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
@@ -514,7 +530,11 @@ fun MessageBubble(
// signal, so the pulsing dots stop (Messenger/Telegram drop the
// typing bubble the moment content appears) instead of throbbing
// under the text for the whole turn.
if (message.isStreaming && message.content.isBlank()) {
if (
message.isStreaming &&
message.content.isBlank() &&
!showImageGeneration
) {
// After a few seconds with no content yet, escalate the bare
// dots to a labeled "Still working…" so a slow first token
// never reads as a hang on the SSE / sessions paths.
@@ -219,6 +219,16 @@ data class RelayPairing(
val transportHint: String? = null,
)
/** True only when a scoped Relay scan can immediately begin authentication. */
internal fun HermesPairingPayload.hasUsableRelayPairing(): Boolean {
val relay = relay ?: return false
if (relay.code.isBlank()) return false
val uri = runCatching { URI(relay.url.trim()) }.getOrNull() ?: return false
val supportedScheme = uri.scheme.equals("ws", ignoreCase = true) ||
uri.scheme.equals("wss", ignoreCase = true)
return supportedScheme && !uri.host.isNullOrBlank()
}
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -258,8 +268,8 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
return try {
// Quick check: must contain a `host` field and be valid JSON. We no
// longer reject based on the `hermes` version int — future v4+ QRs
// should still parse on this phone so Bailey doesn't have to ship a
// whole release to keep up with wire-format growth.
// should still parse so wire-format growth does not require an app
// release for every compatible payload version.
val obj = json.decodeFromString<JsonObject>(raw)
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
if (version < 1) return null
@@ -532,7 +542,8 @@ private fun mapBoxToViewport(
@Composable
fun QrPairingScanner(
onPairingDetected: (HermesPairingPayload) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
relayOnly: Boolean = false,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -628,7 +639,10 @@ fun QrPairingScanner(
)
}
Text(
text = stringResource(R.string.qr_scanner_title),
text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_title
else R.string.qr_scanner_title,
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
@@ -740,7 +754,11 @@ fun QrPairingScanner(
) {
val rawValue = barcode.rawValue ?: continue
val payload = parseHermesPairingQr(rawValue)
if (payload != null && hasDetected.compareAndSet(false, true)) {
if (
payload != null &&
(!relayOnly || payload.hasUsableRelayPairing()) &&
hasDetected.compareAndSet(false, true)
) {
lockedPayload = payload
return@addOnSuccessListener
}
@@ -798,13 +816,19 @@ fun QrPairingScanner(
modifier = Modifier.size(32.dp)
)
Text(
text = stringResource(R.string.qr_scanner_instruction),
text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_instruction
else R.string.qr_scanner_instruction,
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.qr_scanner_subtext),
text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_subtext
else R.string.qr_scanner_subtext,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -0,0 +1,145 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.elements.MarkdownCodeBackground
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
@Composable
internal fun SafeMarkdownHighlightedCodeFence(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.code,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean = false,
) {
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
SafeMarkdownHighlightedCode(
code = code,
language = language,
style = codeStyle,
highlightsBuilder = highlightsBuilder,
showHeader = showHeader,
)
}
}
@Composable
internal fun SafeMarkdownHighlightedCodeBlock(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.code,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean = false,
) {
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
SafeMarkdownHighlightedCode(
code = code,
language = language,
style = codeStyle,
highlightsBuilder = highlightsBuilder,
showHeader = showHeader,
)
}
}
@Composable
private fun SafeMarkdownHighlightedCode(
code: String,
language: String?,
style: TextStyle,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean,
) {
val codeHighlights by produceState(
initialValue = AnnotatedString(code),
key1 = code,
key2 = language,
key3 = highlightsBuilder,
) {
value = withContext(Dispatchers.Default) {
buildSafeHighlightedAnnotatedString(code, language, highlightsBuilder)
}
}
val codeBackgroundCornerSize = LocalMarkdownDimens.current.codeBackgroundCornerSize
MarkdownCodeBackground(
color = LocalMarkdownColors.current.codeBackground,
shape = RoundedCornerShape(codeBackgroundCornerSize),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
showHeader = showHeader,
language = language,
code = code,
) {
MarkdownBasicText(
text = codeHighlights,
style = style,
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(LocalMarkdownPadding.current.codeBlock),
)
}
}
/**
* Converts Highlights ranges into Compose spans without trusting dependency
* offsets. Highlights 1.1.0 can return a reversed multiline-comment range
* when a multiline-comment closing delimiter precedes its opening delimiter.
* Streaming can expose that shape before a code block is complete.
*/
internal fun buildSafeHighlightedAnnotatedString(
code: String,
language: String?,
highlightsBuilder: Highlights.Builder,
): AnnotatedString {
val syntaxLanguage = language?.let(SyntaxLanguage::getByName)
val highlights = highlightsBuilder
.code(code)
.let { if (syntaxLanguage != null) it.language(syntaxLanguage) else it }
.build()
.getHighlights()
return buildAnnotatedString {
append(code)
highlights.forEach { highlight ->
val start = highlight.location.start.coerceIn(0, code.length)
val end = highlight.location.end.coerceIn(0, code.length)
if (start >= end) return@forEach
val style = when (highlight) {
is ColorHighlight -> SpanStyle(color = Color(highlight.rgb).copy(alpha = 1f))
is BoldHighlight -> SpanStyle(fontWeight = FontWeight.Bold)
}
addStyle(style = style, start = start, end = end)
}
}
}
@@ -19,15 +19,15 @@ data class SourceBadge(val label: String, val color: Color)
* The app's own chats — no badge (they're "your" conversations from this app /
* desktop / API, not a distinct gateway lane).
*/
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "")
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "webui", "")
/**
* Map a session `source` to a drawer badge, or null for the app's own chats and
* the phone **Thread** (which renders its own thread-spool chip). External
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
* small colored chip so the drawer reads like the desktop's per-channel tags.
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron,
* webhook, phone.
* Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
* cron, webhook, phone.
*/
fun sourceBadge(source: String?): SourceBadge? {
val s = source?.trim()?.lowercase() ?: return null
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import com.hermesandroid.relay.viewmodel.VoiceState
import kotlin.math.PI
import kotlin.math.max
@@ -140,6 +141,8 @@ fun VoiceWaveform(
amplitude: Float,
state: VoiceState,
outputAudioActive: Boolean = false,
height: Dp = 56.dp,
compactBars: Boolean = false,
modifier: Modifier = Modifier,
) {
// No downstream smoothing. The VoiceViewModel already runs an
@@ -154,6 +157,7 @@ fun VoiceWaveform(
// doesn't look abrupt.
val dim = MaterialTheme.colorScheme.onSurfaceVariant
val errorColor = MaterialTheme.colorScheme.error
val compactColor = MaterialTheme.colorScheme.primary
val targetPrimary = when (state) {
VoiceState.Idle -> dim.copy(alpha = 0.3f)
@@ -198,16 +202,38 @@ fun VoiceWaveform(
)
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
Canvas(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
) {
val canvasModifier = if (compactBars) {
modifier.height(height)
} else {
modifier.fillMaxWidth().height(height)
}
Canvas(modifier = canvasModifier) {
val width = size.width
val height = size.height
if (width <= 0f || height <= 0f) return@Canvas
val centerY = height / 2f
if (compactBars) {
val barCount = 10
val gap = 3.dp.toPx()
val barWidth = 2.5.dp.toPx()
val totalWidth = barWidth * barCount + gap * (barCount - 1)
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
repeat(barCount) { index ->
val wave = ((sin(phases[0] + index * 0.82f) + 1f) * 0.5f)
val activeHeight = height * (0.22f + wave * (0.28f + displayAmplitude * 0.5f))
val x = startX + index * (barWidth + gap)
drawRoundRect(
color = compactColor.copy(alpha = if (index < 5) 0.96f else 0.22f),
topLeft = Offset(x, centerY - activeHeight / 2f),
size = Size(barWidth, activeHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
)
}
return@Canvas
}
val peakPixels = height * PEAK_FRACTION
val strokePx = STROKE_WIDTH_DP.dp.toPx()
val centerX = width / 2f
@@ -1,11 +1,10 @@
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.foundation.background
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
@@ -20,23 +19,21 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.ui.theme.gradientBorder
@Composable
fun OnboardingPage(
@@ -44,7 +41,6 @@ fun OnboardingPage(
title: String,
description: String,
modifier: Modifier = Modifier,
transparentHero: Boolean = false,
heroContent: @Composable BoxScope.() -> Unit = {
FeatureHero(
icon = icon,
@@ -53,101 +49,44 @@ fun OnboardingPage(
},
content: @Composable ColumnScope.() -> Unit = {}
) {
val isDarkTheme = LocalBrand.current.isDark
val heroShape = RoundedCornerShape(30.dp)
val bodyShape = RoundedCornerShape(26.dp)
val heroBrush = Brush.radialGradient(
colors = if (isDarkTheme) {
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f),
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f),
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f),
)
} else {
listOf(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.70f),
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.60f),
)
}
)
// Short viewports (small phones, large font scale, split screen) shrink or
// drop the hero so the body text fits; the vertical scroll below is the
// safety net when even that isn't enough. The enclosing pager Box centers
// short content, so no Arrangement.Center here — it conflicts with
// verticalScroll when content overflows.
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val heroHeight = when {
maxHeight < 480.dp -> 0.dp
maxHeight < 620.dp -> 160.dp
else -> 232.dp
}
Column(
Column(
modifier = modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 42.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
.height(210.dp),
contentAlignment = Alignment.Center,
) {
if (heroHeight > 0.dp) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(heroHeight)
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
shape = heroShape,
colors = CardDefaults.cardColors(
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
)
) {
val heroModifier = Modifier
.fillMaxWidth()
.height(heroHeight)
Box(
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
contentAlignment = Alignment.Center
) {
heroContent()
}
}
Spacer(modifier = Modifier.height(18.dp))
}
Card(
modifier = Modifier
.fillMaxWidth()
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
shape = bodyShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
content()
}
}
heroContent()
}
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(
fontSize = 38.sp,
lineHeight = 40.sp,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 25.sp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
@@ -156,26 +95,45 @@ private fun FeatureHero(
icon: ImageVector,
title: String,
) {
val isDarkTheme = LocalBrand.current.isDark
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(136.dp)
.size(176.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f)
Color(0xFF7B55F6).copy(alpha = 0.07f)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(74.dp),
tint = MaterialTheme.colorScheme.primary
)
Box(
modifier = Modifier
.size(128.dp)
.clip(CircleShape)
.background(Color(0xFF7B55F6).copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Surface(
modifier = Modifier.size(86.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
border = BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(46.dp),
tint = Color(0xFF7B55F6),
)
}
}
}
}
}
}
@@ -1,10 +1,9 @@
package com.hermesandroid.relay.ui.onboarding
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -18,19 +17,33 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.MaterialTheme
@@ -45,28 +58,64 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
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.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
/** Page identifiers for dynamic onboarding flow. */
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
/** Page identifiers for the standard-first onboarding flow. */
internal enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect, Permissions }
internal val standardOnboardingPages = listOf(
OnboardingPage.Welcome,
OnboardingPage.Chat,
OnboardingPage.Manage,
OnboardingPage.Power,
OnboardingPage.Connect,
OnboardingPage.Permissions,
)
internal enum class OnboardingNotificationAction {
RequestPermission,
Finish,
}
internal fun onboardingNotificationAction(
sdkInt: Int,
notificationsPermitted: Boolean,
): OnboardingNotificationAction {
return if (
sdkInt >= Build.VERSION_CODES.TIRAMISU &&
!notificationsPermitted
) {
OnboardingNotificationAction.RequestPermission
} else {
OnboardingNotificationAction.Finish
}
}
private val OnboardingAccent = Color(0xFF7B55F6)
/**
* Standard-first onboarding:
@@ -112,21 +161,23 @@ fun OnboardingScreen(
*/
onTryDemo: () -> Unit = {},
) {
val pages = remember {
buildList {
add(OnboardingPage.Welcome)
add(OnboardingPage.Chat)
add(OnboardingPage.Manage)
add(OnboardingPage.Power)
add(OnboardingPage.Connect)
}
}
val pages = standardOnboardingPages
val pageCount = pages.size
val lastPage = pageCount - 1
val pagerState = rememberPagerState(pageCount = { pageCount })
val coroutineScope = rememberCoroutineScope()
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
var notificationsPermitted by remember {
mutableStateOf(AppPermissionStatusProbe.snapshot(context).notificationsPermitted)
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
notificationsPermitted =
granted || AppPermissionStatusProbe.snapshot(context).notificationsPermitted
}
Surface(
modifier = Modifier.fillMaxSize(),
@@ -156,22 +207,35 @@ fun OnboardingScreen(
}
Column(modifier = Modifier.fillMaxSize()) {
// Top bar with Skip — only on informational pages, not the wizard
// (which has its own "Skip for now" affordance).
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
val currentPage = pagerState.currentPage
val currentPageType = pages[currentPage]
// The selected welcome frame has no toolbar. Later information
// pages keep quiet navigation without reserving space above it.
if (
currentPageType == OnboardingPage.Chat ||
currentPageType == OnboardingPage.Manage ||
currentPageType == OnboardingPage.Power
) {
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(currentPage - 1) }
},
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_back))
}
TextButton(onClick = { showSkipConfirm = true }) {
Text(
text = stringResource(R.string.onboarding_skip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(stringResource(R.string.onboarding_skip))
}
}
}
@@ -179,7 +243,13 @@ fun OnboardingScreen(
// Pager content
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
// Connect and permission setup advance only through their
// explicit actions. This keeps the post-connect setup page
// unreachable until the wizard reports a successful save.
userScrollEnabled =
currentPageType != OnboardingPage.Connect &&
currentPageType != OnboardingPage.Permissions,
) { pageIndex ->
Box(
modifier = Modifier.fillMaxSize(),
@@ -194,243 +264,332 @@ fun OnboardingScreen(
)
OnboardingPage.Connect -> ConnectPage(
connectionViewModel = connectionViewModel,
onComplete = onComplete,
onComplete = {
notificationsPermitted =
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
coroutineScope.launch {
pagerState.animateScrollToPage(
pages.indexOf(OnboardingPage.Permissions),
)
}
},
onManageSignIn = onManageSignIn,
onSkip = { showSkipConfirm = true },
onTryDemo = onTryDemo,
)
OnboardingPage.Permissions -> PermissionSetupPage(
notificationAction = onboardingNotificationAction(
sdkInt = Build.VERSION.SDK_INT,
notificationsPermitted = notificationsPermitted,
),
onEnableNotifications = {
notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS,
)
},
onReviewPermissions = onOpenPermissions,
onFinish = onComplete,
)
}
}
}
// Bottom navigation only on informational pages — the wizard
// owns its own back/pair affordances.
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
// Short viewports get a tighter footer so more of the pager
// content stays above the fold; indicator + Back/Next remain
// pinned outside the (scrollable) pager pages either way.
if (
currentPageType != OnboardingPage.Connect &&
currentPageType != OnboardingPage.Permissions
) {
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = 42.dp)
.padding(bottom = if (compactHeight) 10.dp else 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (compactHeight) 8.dp else 12.dp),
) {
PageIndicator(
pageCount = pageCount,
currentPage = pagerState.currentPage
GradientOnboardingButton(
label = when {
currentPage == 0 -> stringResource(R.string.onboarding_get_started)
currentPage == lastPage - 1 -> stringResource(R.string.onboarding_connect)
else -> stringResource(R.string.onboarding_next)
},
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage((currentPage + 1).coerceAtMost(lastPage))
}
},
)
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = pagerState.currentPage > 0,
enter = fadeIn(),
exit = fadeOut()
) {
TextButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}
) {
Text(text = stringResource(R.string.onboarding_back))
}
}
if (pagerState.currentPage == 0) {
Spacer(modifier = Modifier.width(1.dp))
}
Button(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(
(pagerState.currentPage + 1).coerceAtMost(lastPage)
)
}
}
) {
if (currentPage == 0) {
TextButton(onClick = onTryDemo) {
Text(
text = if (pagerState.currentPage == lastPage - 1) {
stringResource(R.string.onboarding_connect)
} else {
stringResource(R.string.onboarding_next)
}
text = stringResource(R.string.chat_try_demo),
color = OnboardingAccent,
style = MaterialTheme.typography.labelLarge,
)
}
}
SegmentedOnboardingProgress(
pageCount = pageCount,
currentPage = currentPage,
)
Text(
text = stringResource(R.string.onboarding_step_count, currentPage + 1, pageCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
)
}
}
}
}
}
@Composable
private fun GradientOnboardingButton(
label: String,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
color = Color.Transparent,
shape = RoundedCornerShape(14.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
),
)
.padding(horizontal = 22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Spacer(Modifier.width(24.dp))
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
}
@Composable
private fun SegmentedOnboardingProgress(
pageCount: Int,
currentPage: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(pageCount) { index ->
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(999.dp))
.background(
if (index == currentPage) {
OnboardingAccent
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f)
},
),
)
}
}
}
@Composable
private fun WelcomePage() {
val context = LocalContext.current
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = stringResource(R.string.onboarding_welcome_title),
description = stringResource(R.string.onboarding_welcome_description),
transparentHero = true,
heroContent = {
Box(modifier = Modifier.fillMaxSize()) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = SphereState.Idle,
intensity = 0.12f,
),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 4.dp, vertical = 2.dp),
)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.80f))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = stringResource(R.string.onboarding_welcome_badge),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.size(30.dp)
)
Text(
text = "Hermes-Relay",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
Box(
modifier = Modifier
.fillMaxWidth()
.height(380.dp),
contentAlignment = Alignment.Center,
) {
SetupPathSummary(
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
SetupPathSummary(
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
Image(
painter = painterResource(R.drawable.onboarding_hero_option1),
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
)
}
Text(
text = stringResource(R.string.onboarding_setup_guide_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = buildAnnotatedString {
append(titleParts.first())
if (titleParts.size > 1) append("\n")
withStyle(SpanStyle(color = OnboardingAccent)) {
if (titleParts.size > 1) append(titleParts[1])
}
},
color = MaterialTheme.colorScheme.onBackground,
fontSize = 38.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/guide/getting-started"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_setup_guide))
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_hermes_docs))
}
}
Spacer(Modifier.height(14.dp))
Text(
text = stringResource(R.string.onboarding_api_server_docs),
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
text = stringResource(R.string.onboarding_welcome_description),
style = MaterialTheme.typography.bodyLarge.copy(
lineHeight = 27.sp,
fontWeight = FontWeight.Normal,
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
}
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
WelcomeCapabilityRow(
icon = Icons.Filled.Dashboard,
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
Spacer(Modifier.height(14.dp))
WelcomeCapabilityRow(
icon = Icons.Filled.Bolt,
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
)
Spacer(Modifier.height(12.dp))
}
}
@Composable
private fun WelcomeCapabilityRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 47.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(30.dp),
) {
Box {
Surface(
modifier = Modifier.size(58.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = OnboardingAccent,
)
}
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF57E389)),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SetupPathSummary(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = OnboardingAccent,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(96.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
@@ -448,14 +607,17 @@ private fun ChatPage() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
icon = Icons.Filled.Bolt,
label = stringResource(R.string.onboarding_streaming_label),
description = stringResource(R.string.onboarding_streaming_description),
)
SetupPathSummary(
icon = Icons.Filled.Person,
label = stringResource(R.string.onboarding_profiles_label),
description = stringResource(R.string.onboarding_profiles_description),
)
SetupPathSummary(
icon = Icons.Filled.Mic,
label = stringResource(R.string.onboarding_voice_label),
description = stringResource(R.string.onboarding_voice_description),
)
@@ -475,14 +637,17 @@ private fun ManagePage() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
icon = Icons.Filled.Tune,
label = stringResource(R.string.onboarding_control_label),
description = stringResource(R.string.onboarding_control_description),
)
SetupPathSummary(
icon = Icons.Filled.Extension,
label = stringResource(R.string.onboarding_skills_hub_label),
description = stringResource(R.string.onboarding_skills_hub_description),
)
SetupPathSummary(
icon = Icons.Filled.Lock,
label = stringResource(R.string.onboarding_one_sign_in_label),
description = stringResource(R.string.onboarding_one_sign_in_description),
)
@@ -504,14 +669,17 @@ private fun PowerToolsPage(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
icon = Icons.Outlined.Terminal,
label = stringResource(R.string.onboarding_terminal_label),
description = stringResource(R.string.onboarding_terminal_description),
)
SetupPathSummary(
icon = Icons.Filled.PhoneAndroid,
label = stringResource(R.string.onboarding_bridge_label),
description = stringResource(R.string.onboarding_bridge_description),
)
SetupPathSummary(
icon = Icons.Filled.GraphicEq,
label = stringResource(R.string.onboarding_realtime_label),
description = stringResource(R.string.onboarding_realtime_description),
)
@@ -531,6 +699,80 @@ private fun PowerToolsPage(
}
}
@Composable
internal fun PermissionSetupPage(
notificationAction: OnboardingNotificationAction,
onEnableNotifications: () -> Unit,
onReviewPermissions: () -> Unit,
onFinish: () -> Unit,
) {
OnboardingPage(
icon = Icons.Filled.Security,
title = stringResource(R.string.onboarding_finish_setup_title),
description = stringResource(R.string.onboarding_finish_setup_description),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
SetupPathSummary(
icon = Icons.Filled.CheckCircle,
label = stringResource(R.string.perms_chat_and_manage),
description = stringResource(R.string.onboarding_chat_manage_ready),
)
SetupPathSummary(
icon = Icons.Filled.Notifications,
label = stringResource(R.string.onboarding_chat_alerts),
description = if (
notificationAction == OnboardingNotificationAction.Finish
) {
stringResource(R.string.onboarding_chat_alerts_ready)
} else {
stringResource(R.string.onboarding_chat_alerts_description)
},
)
SetupPathSummary(
icon = Icons.Filled.Tune,
label = stringResource(R.string.onboarding_optional_features),
description = stringResource(R.string.onboarding_optional_features_description),
)
OutlinedButton(
onClick = onReviewPermissions,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_review_optional_permissions))
}
Spacer(Modifier.height(2.dp))
if (notificationAction == OnboardingNotificationAction.RequestPermission) {
GradientOnboardingButton(
label = stringResource(R.string.onboarding_enable_chat_alerts),
onClick = onEnableNotifications,
)
TextButton(
onClick = onFinish,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_not_now))
}
} else {
GradientOnboardingButton(
label = stringResource(R.string.onboarding_finish),
onClick = onFinish,
)
}
}
}
}
@Composable
private fun ConnectPage(
connectionViewModel: ConnectionViewModel,
@@ -432,7 +432,7 @@ fun AboutScreen(
// Privacy policy link
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay/blob/main/docs/privacy.md"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/privacy.html"))
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth()
@@ -115,6 +115,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayVoiceClient
@@ -195,8 +196,11 @@ import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
import com.hermesandroid.relay.ui.components.SessionDrawerContent
import com.hermesandroid.relay.ui.components.SlashCommand
import com.hermesandroid.relay.ui.components.StreamingDots
import com.hermesandroid.relay.ui.components.SubagentLane
import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.components.isVisibleForToolDisplay
import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
@@ -208,7 +212,10 @@ import androidx.compose.ui.text.style.TextAlign
import kotlin.math.roundToInt
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ChatConnectState
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.VoiceViewModel
import com.hermesandroid.relay.voice.VoiceOverlayHost
import com.hermesandroid.relay.voice.VoiceOverlaySession
@@ -220,6 +227,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
projectName: String?,
personalityName: String?,
modelName: String?,
): String = if (isStreaming) {
statusText
} else {
listOfNotNull(
projectName?.takeIf { it.isNotBlank() },
personalityName?.takeIf { it.isNotBlank() },
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
}
/**
* 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
@@ -521,6 +545,7 @@ fun ChatScreen(
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState()
@@ -539,6 +564,7 @@ fun ChatScreen(
// and is consumed by the AgentInfoSheet for the Profile section. The list
// of available profiles itself now lives entirely inside the sheet.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
// Server-advertised profile catalog — used to locate the "default" profile
// so the header can render its description/model when no explicit pick
// has been made (the /api/config fallback is more useful than the bare
@@ -547,11 +573,12 @@ fun ChatScreen(
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val availableModels by chatViewModel.availableModels.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val showThinking by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
@@ -583,7 +610,7 @@ fun ChatScreen(
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
// Whether the NEXT turn would ride the gateway transport — gates the
// "Edit & resend" menu entry (conversation rewind needs the gateway).
// Per-turn steerability comes from [steerableTurn], which also covers
// Per-turn correction availability comes from [steerableTurn], which also covers
// the preflight-SSE-fallback window.
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
@@ -809,8 +836,7 @@ fun ChatScreen(
provider = activeVoiceProvider,
model = activeVoiceModel,
voice = activeVoiceName,
profileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
?: selectedProfile?.name,
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
configScope = activeVoiceScope,
outputEnabled = activeVoiceEnabled,
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
@@ -1379,23 +1405,6 @@ fun ChatScreen(
}
}
// Display profile - the one the header should reflect. Priority:
// 1. explicit user pick (selectedProfile)
// 2. server-advertised profile named "default"
// 3. null (fall back to personality-derived name)
//
// Computed as a derived state so the header cross-fades when the user
// picks a new profile OR when the server's profile catalog finishes
// loading and a "default" entry shows up.
val effectiveProfile by remember(selectedProfile, agentProfiles) {
derivedStateOf {
AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
}
}
// Agent display name — used in header and info dialog.
//
// Precedence mirrors the messaging-app pattern: show the profile's
@@ -1439,7 +1448,7 @@ fun ChatScreen(
// drawer itself, so the overlay's pointer scrim alone can't block it.
gesturesEnabled = !voiceUiState.voiceMode,
drawerContent = {
val drawerTitle = if (selectedProfile != null) {
val drawerTitle = if (effectiveProfile != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName)
} else {
stringResource(R.string.chat_server_default_sessions)
@@ -1517,17 +1526,36 @@ fun ChatScreen(
}
},
title = {
val headerApiReachable = apiReachable || isStreaming
val isConnecting = isChatConnecting ||
(!headerApiReachable && chatMode != ChatMode.DISCONNECTED)
// Resolve the same Gateway-first, API-fallback runtime
// model used elsewhere; Relay is intentionally unrelated
// to Chat health.
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (chatGatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> if (
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
apiReachable -> ChatTransportReadiness.Ready
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
val headerChatReady = chatRuntimeStatus is ChatRuntimeStatus.Connected || isStreaming
val isConnecting = isChatConnecting || chatRuntimeStatus is ChatRuntimeStatus.Connecting
// Once we've been connected this session, a later drop reads as
// "Reconnecting…" (we had it, we're getting it back) rather than
// a first-time "Connecting…". Honest wording for the WhatsApp-
// style subtitle status.
var everConnected by remember { mutableStateOf(false) }
if (headerApiReachable) everConnected = true
if (headerChatReady) everConnected = true
val statusText = when {
headerApiReachable -> if (isStreaming) {
headerChatReady -> if (isStreaming) {
stringResource(R.string.chat_streaming)
} else {
stringResource(R.string.chat_connected_label)
@@ -1540,7 +1568,7 @@ fun ChatScreen(
else -> stringResource(R.string.chat_disconnected_label)
}
val statusColor = when {
headerApiReachable -> Color(0xFF4CAF50)
headerChatReady -> Color(0xFF4CAF50)
isConnecting -> Color(0xFFFFA726)
else -> MaterialTheme.colorScheme.error
}
@@ -1578,14 +1606,18 @@ fun ChatScreen(
// yet (server config still loading), fall back to the plain
// connection status \u2014 never the literal "None"/"Default"
// personality label.
val subtitleText = when {
!headerApiReachable -> statusText
else -> listOfNotNull(
nonDefaultPersonality,
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
val subtitleText = if (!headerChatReady) {
statusText
} else {
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
statusText = statusText,
projectName = gatewayProjectName,
personalityName = nonDefaultPersonality,
modelName = modelName,
)
}
val subtitleColor = if (headerApiReachable) {
val subtitleColor = if (headerChatReady) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
statusColor
@@ -1643,7 +1675,7 @@ fun ChatScreen(
}
}
ConnectionStatusBadge(
isConnected = headerApiReachable,
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
@@ -1716,6 +1748,10 @@ fun ChatScreen(
transitionSpec = { loadedContentTransform() },
label = "chatHeaderSubtitle",
) { line ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
@@ -1723,6 +1759,13 @@ fun ChatScreen(
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (isStreaming && animationEnabled) {
StreamingDots(
color = subtitleColor,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
}
}
}
@@ -1924,6 +1967,9 @@ fun ChatScreen(
chatReady = chatReady,
isLoadingHistory = isLoadingHistory,
isLoadingSessions = isLoadingSessions,
gatewayAvailability = chatGatewayAvailability,
dashboardRouteMovedHint = dashboardRouteMovedHint,
onNavigateToManage = onNavigateToManage,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier.fillMaxSize(),
)
@@ -1972,7 +2018,7 @@ fun ChatScreen(
// thread itself (not just the header) -
// the desktop's intro.
ChatConnectState.Ready ->
if (selectedProfile != null) {
if (effectiveProfile != null) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else {
stringResource(R.string.chat_start_conversation)
@@ -2310,7 +2356,7 @@ fun ChatScreen(
)
}
// Steered sends live inside a server-side tool
// Legacy steered sends live inside a server-side tool
// result, not a user message — flag the local
// bubble so the scrollback explains itself.
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
@@ -2327,12 +2373,21 @@ fun ChatScreen(
}
}
if (toolDisplay != "off" && !hasBackgroundTask) {
if (!hasBackgroundTask) {
// Subagent children (taskIndex != null) group
// into lanes after the top-level tool cards;
// the null group renders exactly as before.
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
laneGroups[null]?.forEach { toolCall ->
// Image generation renders inside MessageBubble
// so the progress canvas and final media share
// one stable Surface and transition in place.
if (toolCall.showsImageGenerationPlaceholder()) {
return@forEach
}
if (!toolCall.isVisibleForToolDisplay(toolDisplay)) {
return@forEach
}
Spacer(modifier = Modifier.height(4.dp))
when (toolDisplay) {
"compact" -> CompactToolCall(toolCall = toolCall)
@@ -2342,12 +2397,14 @@ fun ChatScreen(
)
}
}
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
Spacer(modifier = Modifier.height(4.dp))
SubagentLane(
taskIndex = taskIndex,
calls = laneGroups.getValue(taskIndex),
)
if (toolDisplay != "off") {
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
Spacer(modifier = Modifier.height(4.dp))
SubagentLane(
taskIndex = taskIndex,
calls = laneGroups.getValue(taskIndex),
)
}
}
}
}
@@ -2675,7 +2732,7 @@ fun ChatScreen(
}
// Input bar — pill field with ONE trailing slot morphing
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker,
// Send / Voice / Stop / Correct / Queue. "+" taps the file picker,
// long-press opens the CommandPalette (the dedicated "/" button
// is gone — typing "/" still surfaces InlineAutocomplete).
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
@@ -2702,13 +2759,16 @@ fun ChatScreen(
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
.distinct()
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
(apiModelOptions +
agentProfiles.mapNotNull { profile ->
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
} +
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
.distinctBy { it.id }
}
val currentModelForInput = AgentDisplay.displayModelName(selectedModelOverride)
val currentModelForInput = apiModelOptions.firstOrNull { it.id == selectedModelOverride }?.id
?: AgentDisplay.displayModelName(selectedModelOverride)
?: AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(effectiveProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
@@ -2780,9 +2840,10 @@ fun ChatScreen(
sseModelOptions.forEach { model ->
add(
ChatInputPickerOption(
label = model,
value = model,
selected = selectedModelOverride == model,
label = AgentDisplay.displayModelName(model.id) ?: model.id,
value = model.id,
secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
),
)
}
@@ -2927,7 +2988,11 @@ fun ChatScreen(
isDarkTheme = isDarkTheme,
modelControl = modelControl,
onModelOptionSelected = { option ->
chatViewModel.selectModel(option.value, option.provider)
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
option.value?.let(chatViewModel::selectApiModel)
} else {
chatViewModel.selectModel(option.value, option.provider)
}
},
effortControl = effortControl,
onEffortOptionSelected = { option ->
@@ -2944,7 +3009,11 @@ fun ChatScreen(
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
onSelect = { option ->
showModelSheet = false
chatViewModel.selectModel(option.value, option.provider)
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
option.value?.let(chatViewModel::selectApiModel)
} else {
chatViewModel.selectModel(option.value, option.provider)
}
},
onDismiss = { showModelSheet = false },
)
@@ -3081,8 +3150,7 @@ fun ChatScreen(
voiceOutputProvider = activeVoiceProvider,
voiceOutputModel = activeVoiceModel,
voiceOutputVoice = activeVoiceName,
voiceProfileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
?: selectedProfile?.name,
voiceProfileName = AgentDisplay.profileDisplayName(effectiveProfile),
voiceConfigScope = activeVoiceScope,
voiceOutputEnabled = activeVoiceEnabled,
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
@@ -3222,6 +3290,9 @@ private fun ChatColdStartLoadingState(
chatReady: Boolean,
isLoadingHistory: Boolean,
isLoadingSessions: Boolean,
gatewayAvailability: GatewayAvailability,
dashboardRouteMovedHint: String?,
onNavigateToManage: () -> Unit,
onNavigateToConnections: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -3284,14 +3355,51 @@ private fun ChatColdStartLoadingState(
)
}
ChatLoadingCommandPanel(
commands = commands,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
)
val dashboardSignInRequired =
gatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
if (dashboardSignInRequired) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = dashboardRouteMovedHint?.let { route ->
stringResource(R.string.dashboard_signin_route_hint, route)
} ?: stringResource(R.string.chat_settings_gateway_needs_signin_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onNavigateToManage,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.voice_settings_sign_in_via_manage))
}
}
}
} else {
ChatLoadingCommandPanel(
commands = commands,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
)
}
}
}
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
HorizontalDivider()
// Turn-complete notification toggle. First enable on
// Background chat-alert toggle. First enable on
// API 33+ runs the POST_NOTIFICATIONS request (the
// BridgeScreen master-toggle precedent); if the user
// denies, the notifier silently no-ops at post time.
@@ -8,12 +8,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
@@ -42,12 +46,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
@@ -60,7 +66,6 @@ import com.hermesandroid.relay.ui.components.SessionInfoSheet
import com.hermesandroid.relay.ui.theme.LocalBrand
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.statusText
/**
* Tabbed detail for a single Hermes connection — the level-2 screen the
@@ -105,8 +110,6 @@ fun ConnectionDetailScreen(
val connections by connectionViewModel.connections.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRow by connectionViewModel.relayRowState.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
@@ -200,6 +203,12 @@ fun ConnectionDetailScreen(
menuExpanded = false
onRepair(connectionId)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
)
},
)
if (connection.pairedAt != null) {
DropdownMenuItem(
@@ -265,8 +274,6 @@ fun ConnectionDetailScreen(
ActiveOverview(
connectionViewModel = connectionViewModel,
connection = connection,
relayConfigured = relayConfigured,
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
relayUiState = relayUiState,
relayEnabled = relayEnabled,
onReconnect = onReconnect,
@@ -289,18 +296,23 @@ fun ConnectionDetailScreen(
connectionViewModel = connectionViewModel,
connection = connection,
liveState = relayUiState,
onEditDashboard = {
selectedTab = tabs.indexOf(DetailTab.Advanced)
},
)
DetailTab.Advanced -> ActiveCardAdvancedSection(
connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
isDarkTheme = isDarkTheme,
onPairRelay = { onRepair(connectionId) },
onInsecureAckRequested = { showInsecureAckDialog = true },
)
DetailTab.Security -> ActiveCardSecurityPosture(
connectionViewModel = connectionViewModel,
onNavigateToPairedDevices = onNavigateToPairedDevices,
onRevokeRelay = { showRevokeConfirm = true },
)
}
@@ -410,8 +422,6 @@ private enum class DetailTab {
private fun ActiveOverview(
connectionViewModel: ConnectionViewModel,
connection: Connection,
relayConfigured: Boolean,
relayStatusText: String,
relayUiState: RelayUiState,
relayEnabled: Boolean,
onReconnect: () -> Unit,
@@ -421,37 +431,67 @@ private fun ActiveOverview(
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname
val hostname = connection.primaryHost.ifBlank { connection.label }
val dashboardReady = connection.dashboardLastStatus?.reachable == true
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = headerLine,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = hostname,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Dashboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.detail_dashboard_primary),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = connection.resolvedDashboardUrl.ifBlank { hostname },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
text = if (dashboardReady) {
stringResource(R.string.detail_core_ready)
} else {
stringResource(R.string.detail_core_configured)
},
style = MaterialTheme.typography.bodyMedium,
color = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface,
)
}
}
}
Text(
text = stringResource(R.string.detail_routes_help),
text = stringResource(R.string.detail_overview_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -463,6 +503,7 @@ private fun ActiveOverview(
onOpenDashboard = onOpenDashboard,
onOpenRelayInfo = onOpenRelayInfo,
onOpenSessionInfo = onOpenSessionInfo,
onPairRelay = onRepair,
)
Row(
@@ -472,9 +513,6 @@ private fun ActiveOverview(
if (relayUiState == RelayUiState.Stale) {
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
}
TextButton(onClick = onRepair) {
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
}
}
}
@@ -489,10 +527,10 @@ private fun InactiveOverview(
onSwitch: () -> Unit,
onRepair: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val hostname = connection.primaryHost.ifBlank { connection.label }
val statusLine = when {
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
connection.apiServerUrl.isNotBlank() -> stringResource(R.string.detail_standard_not_paired)
connection.capabilities.chatConfigured -> stringResource(R.string.detail_standard_not_paired)
else -> stringResource(R.string.detail_not_configured)
}
@@ -1,6 +1,9 @@
package com.hermesandroid.relay.ui.screens
import android.content.Context
import android.text.format.DateUtils
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -21,12 +24,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -39,21 +54,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.statusText
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Level-1 list of Hermes connections. Reachable via Settings → Connections.
@@ -91,6 +118,15 @@ fun ConnectionsSettingsScreen(
} else {
false
}
val startupConnectionId: String? = if (connectionViewModel != null) {
val startupId by connectionViewModel.startupConnectionId.collectAsState()
startupId
} else {
null
}
val scope = rememberCoroutineScope()
var switchingConnectionId by remember { mutableStateOf<String?>(null) }
var justSwitchedConnectionId by remember { mutableStateOf<String?>(null) }
// Kick a WSS reconnect on entry in case the user landed here from a Stale
// chip (same intent as the old inline screen).
@@ -101,7 +137,22 @@ fun ConnectionsSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.conn_title)) },
title = {
Column {
Text(stringResource(R.string.conn_title))
if (connections.isNotEmpty()) {
Text(
text = pluralStringResource(
R.plurals.conn_server_count,
connections.size,
connections.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -115,13 +166,6 @@ fun ConnectionsSettingsScreen(
),
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAddConnection,
icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.conn_add_connection)) },
)
},
) { innerPadding ->
if (connections.isEmpty()) {
// Shown in practice only during tests / after a wipe; cold start
@@ -149,6 +193,15 @@ fun ConnectionsSettingsScreen(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (connections.size > 1 && connectionViewModel != null) {
item {
StartupConnectionSelector(
connections = connections,
startupConnectionId = startupConnectionId,
onSelect = connectionViewModel::setStartupConnection,
)
}
}
items(connections, key = { it.id }) { connection ->
val isActive = connection.id == activeConnectionId
ConnectionListCard(
@@ -162,10 +215,118 @@ fun ConnectionsSettingsScreen(
connection.hasConfiguredRelay()
},
onClick = { onOpenConnection(connection.id) },
onSwitch = if (!isActive && connectionViewModel != null) {
{
if (switchingConnectionId == null) {
scope.launch {
switchingConnectionId = connection.id
connectionViewModel.switchConnection(connection.id).join()
switchingConnectionId = null
if (connectionViewModel.activeConnectionId.value == connection.id) {
justSwitchedConnectionId = connection.id
delay(800)
if (justSwitchedConnectionId == connection.id) {
justSwitchedConnectionId = null
}
}
}
}
}
} else {
null
},
isSwitching = switchingConnectionId == connection.id,
justSwitched = justSwitchedConnectionId == connection.id,
)
}
// Footer spacer so the last card isn't hidden by the FAB.
item { Spacer(modifier = Modifier.height(72.dp)) }
item {
Button(
onClick = onAddConnection,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
Text(
text = stringResource(R.string.conn_add_connection),
modifier = Modifier.padding(start = 8.dp),
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
@Composable
private fun StartupConnectionSelector(
connections: List<Connection>,
startupConnectionId: String?,
onSelect: (String?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = startupConnectionId
?.let { id -> connections.firstOrNull { it.id == id }?.label }
?: stringResource(R.string.conn_startup_last_used)
Box {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true },
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.AccessTime,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 12.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.conn_startup_title),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.conn_startup_choose),
)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = {
Column {
Text(stringResource(R.string.conn_startup_last_used))
Text(
text = stringResource(R.string.conn_startup_recommended),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
expanded = false
onSelect(null)
},
)
connections.forEach { connection ->
DropdownMenuItem(
text = { Text(connection.label) },
onClick = {
expanded = false
onSelect(connection.id)
},
)
}
}
}
@@ -185,16 +346,28 @@ private fun ConnectionListCard(
activeConnectionViewModel: ConnectionViewModel?,
relayConfigured: Boolean,
onClick: () -> Unit,
onSwitch: (() -> Unit)?,
isSwitching: Boolean,
justSwitched: Boolean,
) {
val context = LocalContext.current
// Active card: muted indigo wash instead of full-strength primaryContainer —
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
// feedback); small accents keep the vivid blue.
val containerColor = if (isActive) {
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
val targetContainerColor = if (isActive || isSwitching || justSwitched) {
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.25f)
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val containerColor by animateColorAsState(targetValue = targetContainerColor, label = "connectionCardColor")
val borderColor by animateColorAsState(
targetValue = if (isSwitching || justSwitched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0f)
},
label = "connectionCardBorder",
)
Card(
modifier = Modifier
@@ -202,6 +375,7 @@ private fun ConnectionListCard(
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = containerColor),
border = BorderStroke(if (isSwitching || justSwitched) 1.5.dp else 0.dp, borderColor),
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -209,52 +383,87 @@ private fun ConnectionListCard(
) {
// ── Title row ────────────────────────────────────────────────
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(50))
.background(
if (isActive) com.hermesandroid.relay.ui.theme.RelayRefresh.Green
else MaterialTheme.colorScheme.primary,
),
)
Text(
text = connection.label,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
)
if (isActive) {
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Text(text = stringResource(R.string.conn_active), modifier = Modifier.padding(horizontal = 6.dp))
Text(
text = stringResource(R.string.conn_active).uppercase(),
modifier = Modifier.padding(horizontal = 6.dp),
)
}
}
if (onSwitch != null || isSwitching || justSwitched) {
OutlinedButton(
onClick = { onSwitch?.invoke() },
enabled = !isSwitching && !justSwitched,
) {
when {
isSwitching -> {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
}
justSwitched -> {
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
}
else -> Text(stringResource(R.string.conn_switch))
}
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
}
// ── Subtitle: hostname + status ──────────────────────────────
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val hasStandardApi = connection.apiServerUrl.isNotBlank()
val pairedStatus = when {
liveState != null &&
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected))
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt)
hasStandardApi -> stringResource(R.string.conn_standard_not_paired)
else -> stringResource(R.string.conn_not_paired)
}
val statusColor = if (liveState == RelayUiState.Stale) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
val hostname = connection.primaryHost.ifBlank { connection.label }
val dashboardStatus = connection.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val connectionStatus = when {
dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
dashboardStatus?.reachable == true -> stringResource(R.string.conn_dashboard_available)
dashboardStatus != null && !dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_unchecked)
else -> stringResource(R.string.conn_dashboard_missing)
}
Text(
text = "$hostname • $pairedStatus",
text = if (isSwitching) {
stringResource(R.string.conn_connecting_to, connection.label)
} else {
"Dashboard · $hostname"
},
style = MaterialTheme.typography.bodySmall,
color = statusColor,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (isActive) {
stringResource(R.string.conn_last_used_now)
} else {
connection.lastUsedAt?.let { formatUsedRelative(context, it) } ?: connectionStatus
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ── Steps/timeline summary (every card) ──────────────────────
ConnectionSurfaceSummary(
@@ -267,6 +476,42 @@ private fun ConnectionListCard(
// detail — the same destination as the card tap.
onOpenDashboard = onClick,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Lan,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(18.dp)
.padding(end = 2.dp),
)
Text(
text = when {
connection.routeCandidates.isNotEmpty() -> connection.routeCandidates
.sortedBy { it.priority }
.joinToString(" · ") { route ->
route.role.replaceFirstChar { it.titlecase() }
}
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_only_route)
else -> stringResource(R.string.conn_no_routes)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -304,6 +549,12 @@ private fun ConnectionSurfaceSummary(
} else {
null
}
val gatewayAvailability: GatewayAvailability? = if (activeConnectionViewModel != null) {
val availability by activeConnectionViewModel.gatewayAvailability.collectAsState()
availability
} else {
null
}
val standardVoiceAvailability: StandardVoiceAvailability? =
if (activeConnectionViewModel != null) {
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
@@ -315,17 +566,41 @@ private fun ConnectionSurfaceSummary(
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val apiText = when {
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing)
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking)
activeApiReachable == true -> stringResource(R.string.conn_api_ready)
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline)
else -> stringResource(R.string.conn_api_configured)
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown, null -> ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
connection.apiServerUrl.isBlank() -> ChatTransportReadiness.NotConfigured
activeApiReachable == true -> ChatTransportReadiness.Ready
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
activeApiHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
else -> ChatTransportReadiness.Connecting
},
)
} else {
null
}
val apiTone = when {
activeApiReachable == true -> SummaryTone.Good
connection.apiServerUrl.isBlank() -> SummaryTone.Warning
isActive && activeApiReachable == false -> SummaryTone.Warning
val chatText = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> stringResource(R.string.conn_api_ready)
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> stringResource(R.string.conn_api_checking)
gatewayAvailability == GatewayAvailability.SignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
dashboardStatus?.reachable == true && !dashboardSignInRequired -> stringResource(R.string.conn_dashboard_available)
connection.capabilities.chatConfigured -> stringResource(R.string.conn_api_configured)
else -> stringResource(R.string.conn_api_missing)
}
val chatTone = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> SummaryTone.Good
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> SummaryTone.Info
gatewayAvailability == GatewayAvailability.SignInRequired -> SummaryTone.Info
dashboardStatus?.reachable == true && !dashboardSignInRequired -> SummaryTone.Good
connection.capabilities.chatConfigured -> SummaryTone.Neutral
else -> SummaryTone.Neutral
}
@@ -374,99 +649,78 @@ private fun ConnectionSurfaceSummary(
else -> SummaryTone.Neutral
}
// A single grouped surface with dot + label + value rows — the
// steps/timeline vocabulary shared with the detail's Features list.
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone)
SurfaceRowDivider()
ConnectionSurfaceRow(
label = stringResource(R.string.conn_dashboard_label),
value = dashboardText,
tone = dashboardTone,
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
)
SurfaceRowDivider()
ConnectionSurfaceRow(
label = stringResource(R.string.conn_voice_label),
value = voiceText,
tone = voiceTone,
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
)
SurfaceRowDivider()
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone)
}
ConnectionStatusChip(Icons.Filled.Chat, stringResource(R.string.conn_chat_label), chatText, chatTone, Modifier.weight(1f))
ConnectionStatusChip(
Icons.Filled.Dashboard,
stringResource(R.string.conn_manage_label),
dashboardText,
dashboardTone,
Modifier.weight(1f),
if (dashboardSignInRequired) onOpenDashboard else null,
)
ConnectionStatusChip(
Icons.Filled.GraphicEq,
stringResource(R.string.conn_voice_label),
voiceText,
voiceTone,
Modifier.weight(1f),
if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
)
ConnectionStatusChip(Icons.Filled.CellTower, stringResource(R.string.conn_relay_label), relayText, relayTone, Modifier.weight(1f))
}
}
private enum class SummaryTone { Neutral, Good, Info, Warning }
/** Hairline divider between summary rows — inset so it reads as a list. */
@Composable
private fun SurfaceRowDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
)
}
/**
* One health line: status dot + label + value. The dot carries the tone so
* the value text stays short and the row stays light.
*/
@Composable
private fun ConnectionSurfaceRow(
private fun ConnectionStatusChip(
icon: ImageVector,
label: String,
value: String,
tone: SummaryTone,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val dotColor = when (tone) {
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
SummaryTone.Info -> MaterialTheme.colorScheme.primary
SummaryTone.Warning -> MaterialTheme.colorScheme.error
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
val valueColor = when (tone) {
val accent = when (tone) {
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
SummaryTone.Info -> MaterialTheme.colorScheme.primary
SummaryTone.Warning -> MaterialTheme.colorScheme.error
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 8.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Surface(
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
shape = RoundedCornerShape(10.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(50))
.background(dotColor),
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(18.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
color = accent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -477,21 +731,15 @@ private fun Connection.hasConfiguredRelay(): Boolean {
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
}
/**
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils`
* returns awkward copy ("in 0 minutes") for small deltas.
*/
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String {
val deltaMs = System.currentTimeMillis() - pairedAtMillis
if (deltaMs < 0) return context.getString(R.string.conn_just_paired)
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
return when {
minutes < 1L -> context.getString(R.string.conn_just_paired)
minutes < 60L -> context.resources.getQuantityString(R.plurals.conn_paired_minutes_ago, minutes.toInt(), minutes)
hours < 24L -> context.resources.getQuantityString(R.plurals.conn_paired_hours_ago, hours.toInt(), hours)
days < 30L -> context.resources.getQuantityString(R.plurals.conn_paired_days_ago, days.toInt(), days)
else -> context.getString(R.string.conn_paired)
private fun formatUsedRelative(context: Context, usedAtMillis: Long): String {
val relative = if (System.currentTimeMillis() - usedAtMillis < DateUtils.MINUTE_IN_MILLIS) {
context.getString(R.string.conn_just_now)
} else {
DateUtils.getRelativeTimeSpanString(
usedAtMillis,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
}
return context.getString(R.string.conn_last_used_format, relative)
}
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
EnableMcp,
DisableMcp,
TestMcp,
AuthenticateMcp,
RemoveMcp,
InstallMcpCatalog,
ViewProfileSoul,
ActivateProfile,
DeleteProfile,
EditCustomEndpoint,
ValidateCustomEndpoint,
ActivateCustomEndpoint,
DeleteCustomEndpoint,
// Input-backed kinds — intercepted before runAction and routed to a
// text-input or model-picker dialog instead of firing immediately.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,467 @@
package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
* Manage so onboarding, connection setup, Voice, Chat, and Manage can all use
* the same cookie/session flow without inheriting Manage's navigation stack.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardSignInScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onAuthenticated: () -> Unit,
) {
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val routeHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val connectionId = activeConnection?.id ?: "default"
var providers by remember(dashboardUrl, connectionId) {
mutableStateOf<List<DashboardAuthProvider>>(emptyList())
}
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
var actionInFlight by remember { mutableStateOf(false) }
var actionMessage by remember { mutableStateOf<String?>(null) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var authenticationComplete by remember { mutableStateOf(false) }
val cookieStoreFactory = remember(context, connectionId) {
{
connectionViewModel.activeDashboardCookieStore()
?: EncryptedDashboardCookieStore(context, connectionId)
}
}
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
{
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
)
}
}
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
val status = client.getStatus().getOrNull()
val session = client.currentSession().getOrNull()
val ticketAvailable = if (session?.authenticated == true) {
client.requestWsTicket().isSuccess
} else {
null
}
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = status != null,
gatewayTicketAvailable = ticketAvailable,
)
return session
}
fun finishAuthentication() {
scope.launch {
invalidateDashboardManageCache(context.cacheDir)
connectionViewModel.refreshStandardVoice()
connectionViewModel.refreshDashboardProfiles()
authenticationComplete = true
}
}
LaunchedEffect(dashboardUrl, connectionId) {
if (dashboardUrl.isBlank()) {
loading = false
actionMessage = context.getString(R.string.dashboard_no_url_configured)
return@LaunchedEffect
}
val client = clientFactory()
try {
val status = client.getStatus().getOrElse {
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
return@LaunchedEffect
}
providers = status.authProviderDetails.ifEmpty {
client.getAuthProviders().getOrNull().orEmpty()
}
val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = true,
gatewayTicketAvailable = null,
)
if (!status.authRequired || session?.authenticated == true) {
finishAuthentication()
}
} finally {
loading = false
client.shutdown()
}
}
fun submitPassword(provider: String, username: String, password: String) {
if (actionInFlight || dashboardUrl.isBlank()) return
actionInFlight = true
actionMessage = null
scope.launch {
val client = clientFactory()
try {
val result = client.loginPassword(provider, username, password)
val session = if (result.isSuccess) verifyAndRecord(client) else null
if (result.isSuccess && session?.authenticated == true) {
finishAuthentication()
} else {
actionMessage = result.exceptionOrNull()?.message
?: context.getString(R.string.dashboard_signin_no_session)
}
} catch (e: Exception) {
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
} finally {
actionInFlight = false
client.shutdown()
}
}
}
oauthProvider?.let { provider ->
DashboardOAuthDialog(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
onDismiss = { oauthProvider = null },
onAuthenticated = { session ->
oauthProvider = null
scope.launch {
val client = clientFactory()
try {
verifyAndRecord(client)
} finally {
client.shutdown()
}
actionMessage = session.provider?.let {
context.getString(R.string.dashboard_signed_in_with, it)
} ?: context.getString(R.string.dashboard_signed_in)
finishAuthentication()
}
},
onError = { actionMessage = it },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.dashboard_sign_in)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (authenticationComplete) {
DashboardAuthenticationComplete(onContinue = onAuthenticated)
} else if (loading) {
CircularProgressIndicator()
} else {
DashboardSignInForm(
dashboardUrl = dashboardUrl,
routeHint = routeHint,
providers = providers,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onSignIn = ::submitPassword,
onOAuthSignIn = { oauthProvider = it },
)
}
}
}
}
@Composable
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.cw_step_3_3),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.cw_dashboard_connected_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.cw_dashboard_connected_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectionSetupTimeline(
steps = listOf(
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_discovered),
stringResource(R.string.cw_timeline_discovered_detail),
),
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_access),
stringResource(R.string.cw_timeline_authenticated),
),
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_ready),
stringResource(R.string.cw_timeline_ready_detail),
),
),
)
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.cw_continue))
}
}
}
@Composable
private fun DashboardSignInForm(
dashboardUrl: String,
routeHint: String?,
providers: List<DashboardAuthProvider>,
actionInFlight: Boolean,
actionMessage: String?,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val passwordProvider = providers.firstOrNull { it.supportsPassword }
val redirectProviders = providers.filter { it.isRedirectProvider }
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.dashboard_signin_required_body, dashboardUrl),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
routeHint?.let {
Text(
text = stringResource(R.string.dashboard_signin_route_hint, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
redirectProviders.forEach { provider ->
Button(
onClick = { onOAuthSignIn(provider) },
enabled = !actionInFlight,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
}
}
if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider()
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.dashboard_username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.dashboard_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Button(
onClick = { onSignIn(passwordProvider?.name ?: "basic", username, password) },
enabled = !actionInFlight && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (actionInFlight) stringResource(R.string.dashboard_signing_in) else stringResource(R.string.dashboard_sign_in))
}
}
actionMessage?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
}
@Composable
private fun DashboardOAuthDialog(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
onDismiss: () -> Unit,
onAuthenticated: (DashboardAuthSession) -> Unit,
onError: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val initialStatus = stringResource(R.string.dashboard_oauth_initial_status)
val verifyingStatus = stringResource(R.string.dashboard_oauth_verifying)
val notAcceptedStatus = stringResource(R.string.dashboard_oauth_not_accepted)
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
var checking by remember { mutableStateOf(false) }
val loginUrl = remember(dashboardUrl, provider.name) {
DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl,
provider = provider.name,
next = DashboardApiClient.authLandingPath(dashboardUrl),
)
}
fun maybeVerify(url: String?) {
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
val root = dashboardUrl.trim().trimEnd('/')
val relative = loadedUrl.trim().removePrefix(root)
val stillAuthenticating = relative.startsWith("/login", true) ||
relative.startsWith("/auth/login", true) ||
relative.startsWith("/auth/callback", true)
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
val manager = CookieManager.getInstance()
manager.flush()
val imported = importDashboardCookieHeader(
cookieStoreFactory(),
loadedUrl,
manager.getCookie(loadedUrl),
)
if (checking || imported == 0) return
checking = true
statusText = verifyingStatus
scope.launch {
val client = DashboardApiClient(
dashboardUrl,
DashboardApiClient.defaultClient(cookieStoreFactory()),
)
try {
val session = client.currentSession().getOrNull()
if (session?.authenticated == true) onAuthenticated(session) else {
checking = false
statusText = notAcceptedStatus
}
} catch (e: Exception) {
checking = false
val message = e.message ?: verifyFailedStatus
statusText = message
onError(message)
} finally {
client.shutdown()
}
}
}
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
}
Text(statusText, style = MaterialTheme.typography.bodySmall)
AndroidView(
modifier = Modifier.fillMaxWidth().weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean = false
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
maybeVerify(url)
}
}
loadUrl(loginUrl)
}
},
)
}
}
}
}
@@ -37,11 +37,16 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.StatusCheck
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.shared.ConnectivityObserver
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
/**
* Dedicated Diagnostics screen — replaces the old modal bottom sheet. Hosts a
@@ -62,17 +67,21 @@ fun DiagnosticsScreen(
onBack: () -> Unit,
) {
val network by connectionViewModel.networkStatus.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
val capabilities by connectionViewModel.serverCapabilities.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val chatReady by connectionViewModel.chatReady.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayHealth by connectionViewModel.relayServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val relayUpdateInfo by connectionViewModel.relayUpdateInfo.collectAsState()
val relayInfo by connectionViewModel.relayInfo.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveSessionProfileName by
connectionViewModel.effectiveSessionProfileName.collectAsState()
val toolsets by connectionViewModel.toolsetInventory.collectAsState()
val checkedAt by connectionViewModel.diagnosticsCheckedAt.collectAsState()
val refreshing by connectionViewModel.diagnosticsRefreshing.collectAsState()
val voiceReady by connectionViewModel.voiceReady.collectAsState()
@@ -82,17 +91,20 @@ fun DiagnosticsScreen(
val context = androidx.compose.ui.platform.LocalContext.current
val checks = remember(
network, apiHealth, apiUrl, capabilities, authState, chatReady,
network, activeConnection, dashboardUrl, gatewayAvailability,
apiHealth, apiUrl, capabilities, authState,
relayConfigured, relayHealth, relayReady, relayUpdateInfo,
voiceReady, relayVoiceReady, entries,
) {
buildStatusChecks(
network = network,
dashboardUrl = dashboardUrl,
gatewayAvailability = gatewayAvailability,
apiConfigured = activeConnection?.apiServerUrl?.isNotBlank() == true,
apiHealth = apiHealth,
apiUrl = apiUrl,
capabilities = capabilities,
authState = authState,
chatReady = chatReady,
relayConfigured = relayConfigured,
relayHealth = relayHealth,
relayReady = relayReady,
@@ -171,7 +183,19 @@ fun DiagnosticsScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val profileKey = selectedProfile?.name ?: "(default)"
info.gatewayHeartbeat?.let { heartbeat ->
val detail = heartbeat.ageSeconds?.let { " · ${it}s" }.orEmpty()
Text(
text = stringResource(R.string.diag_gateway_heartbeat, heartbeat.status, detail),
style = MaterialTheme.typography.bodySmall,
color = if (heartbeat.status in setOf("stale", "malformed", "pid_mismatch", "start_mismatch")) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
val profileKey = effectiveSessionProfileName ?: "(default)"
val profileState = info.profiles.firstOrNull { it.name == profileKey }?.relayState
?: stringResource(R.string.diag_check_not_checked)
Text(
@@ -180,6 +204,22 @@ fun DiagnosticsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
toolsets?.let { inventory ->
val enabled = inventory.count { it.enabled }
val relayVisible = inventory.any { item ->
item.tools.any { it.startsWith("relay_") || it.startsWith("android_") }
}
Text(
text = stringResource(
R.string.diag_toolsets_inventory,
enabled,
inventory.size,
if (relayVisible) stringResource(R.string.diag_yes) else stringResource(R.string.diag_no),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusCheckTimeline(
checks = checks,
@@ -233,11 +273,13 @@ fun DiagnosticsScreen(
*/
internal fun buildStatusChecks(
network: ConnectivityObserver.Status,
dashboardUrl: String,
gatewayAvailability: GatewayAvailability,
apiConfigured: Boolean,
apiHealth: ConnectionViewModel.HealthStatus,
apiUrl: String,
capabilities: ServerCapabilities,
authState: AuthState,
chatReady: Boolean,
relayConfigured: Boolean,
relayHealth: ConnectionViewModel.HealthStatus,
relayReady: Boolean,
@@ -279,37 +321,94 @@ internal fun buildStatusChecks(
)
}
// 2) API server reachability.
val apiLabel = context.getString(R.string.diag_check_api_server)
// 2) Dashboard/Gateway — the standard upstream Hermes connection.
val dashboardLabel = context.getString(R.string.cw_dashboard) + " / " +
context.getString(R.string.chat_settings_gateway)
val reachableAt = context.getString(R.string.diag_check_reachable_at)
val reachable = context.getString(R.string.diag_check_reachable)
val notReachableAt = context.getString(R.string.diag_check_not_reachable_at)
val notReachable = context.getString(R.string.diag_check_not_reachable)
val probing = context.getString(R.string.diag_check_probing)
val notChecked = context.getString(R.string.diag_check_not_checked)
val dashboardHost = DiagnosticsLog.sanitizeUrl(dashboardUrl)
val dashboardErr = recentError(DiagnosticCategory.Endpoint)
checks += when {
dashboardUrl.isBlank() ->
StatusCheck(
dashboardLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_not_configured),
category = DiagnosticCategory.Endpoint,
)
gatewayAvailability == GatewayAvailability.Ready ->
StatusCheck(
dashboardLabel, CheckStatus.Pass,
reason = dashboardHost?.let {
context.getString(R.string.diag_check_reachable_at, it)
} ?: reachable,
category = DiagnosticCategory.Endpoint,
)
gatewayAvailability == GatewayAvailability.SignInRequired ->
StatusCheck(
dashboardLabel, CheckStatus.Warn,
reason = context.getString(R.string.cw_dashboard_sign_in_required),
category = DiagnosticCategory.Auth,
)
gatewayAvailability == GatewayAvailability.Unreachable ->
StatusCheck(
dashboardLabel, CheckStatus.Fail,
reason = dashboardErr?.message()
?: dashboardHost?.let {
context.getString(R.string.diag_check_not_reachable_at, it)
}
?: notReachable,
category = DiagnosticCategory.Endpoint,
timestampMs = dashboardErr?.timestampMs,
)
gatewayAvailability == GatewayAvailability.Unsupported ->
StatusCheck(
dashboardLabel, CheckStatus.Fail,
reason = context.getString(R.string.diag_check_no_chat_endpoint),
category = DiagnosticCategory.Session,
)
else ->
StatusCheck(
dashboardLabel, CheckStatus.Unknown,
reason = notChecked,
category = DiagnosticCategory.Endpoint,
)
}
// 3) Optional API-server fallback reachability.
val apiLabel = context.getString(R.string.active_section_optional_api_fallback)
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
val apiErr = recentError(DiagnosticCategory.Api)
checks += when (apiHealth) {
ConnectionViewModel.HealthStatus.Reachable ->
checks += when {
!apiConfigured ->
StatusCheck(
apiLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_not_configured),
category = DiagnosticCategory.Api,
)
apiHealth == ConnectionViewModel.HealthStatus.Reachable ->
StatusCheck(
apiLabel, CheckStatus.Pass,
reason = host?.let { context.getString(R.string.diag_check_reachable_at, it) } ?: reachable,
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unreachable ->
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
StatusCheck(
apiLabel, CheckStatus.Fail,
reason = apiErr?.message() ?: (host?.let { context.getString(R.string.diag_check_not_reachable_at, it) } ?: notReachable),
category = DiagnosticCategory.Api,
timestampMs = apiErr?.timestampMs,
)
ConnectionViewModel.HealthStatus.Probing ->
apiHealth == ConnectionViewModel.HealthStatus.Probing ->
StatusCheck(
apiLabel, CheckStatus.Unknown,
reason = probing,
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unknown ->
else ->
StatusCheck(
apiLabel, CheckStatus.Unknown,
reason = notChecked,
@@ -317,13 +416,19 @@ internal fun buildStatusChecks(
)
}
// 3) Server capabilities (which chat surfaces the server advertises).
// 4) Optional API-server capabilities.
val capsLabel = context.getString(R.string.diag_check_server_capabilities)
val capsNoHealthy = context.getString(R.string.diag_check_no_server_yet)
val capsNativeSessions = context.getString(R.string.diag_check_native_sessions)
val capsSseFallback = context.getString(R.string.diag_check_sse_fallback)
val capsNoEndpoint = context.getString(R.string.diag_check_no_chat_endpoint)
checks += when {
!apiConfigured ->
StatusCheck(
capsLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_not_configured),
category = DiagnosticCategory.Api,
)
!capabilities.healthy ->
StatusCheck(
capsLabel, CheckStatus.Unknown,
@@ -350,55 +455,96 @@ internal fun buildStatusChecks(
)
}
// 4) Chat transport readiness.
// 5) Chat transport readiness.
val chatLabel = context.getString(R.string.diag_check_chat_transport)
val chatReadyFmt = context.getString(R.string.diag_check_ready_with)
val chatNotReady = context.getString(R.string.diag_check_not_ready)
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
checks += if (chatReady) {
StatusCheck(
chatLabel, CheckStatus.Pass,
reason = context.getString(R.string.diag_check_ready_with, capabilities.preferredChatEndpoint()),
category = DiagnosticCategory.Session,
)
} else {
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
StatusCheck(
chatLabel,
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
reason = chatErr?.message() ?: chatNotReady,
category = DiagnosticCategory.Session,
timestampMs = chatErr?.timestampMs,
)
val chatErr = recentError(DiagnosticCategory.Session)
?: recentError(DiagnosticCategory.Endpoint)
?: recentError(DiagnosticCategory.Api)
val chatRuntime = resolveChatRuntimeStatus(
gateway = when {
dashboardUrl.isBlank() -> ChatTransportReadiness.NotConfigured
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
apiSse = when {
!apiConfigured -> ChatTransportReadiness.NotConfigured
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
checks += when (chatRuntime) {
is ChatRuntimeStatus.Connected -> {
val route = when (chatRuntime.transport) {
ChatTransportPath.Gateway -> context.getString(R.string.chat_settings_gateway)
ChatTransportPath.ApiSse -> capabilities.preferredChatEndpoint()
}
StatusCheck(
chatLabel,
if (chatRuntime.fallback) CheckStatus.Warn else CheckStatus.Pass,
reason = context.getString(R.string.diag_check_ready_with, route),
category = DiagnosticCategory.Session,
)
}
ChatRuntimeStatus.Connecting ->
StatusCheck(
chatLabel, CheckStatus.Unknown,
reason = probing,
category = DiagnosticCategory.Session,
)
ChatRuntimeStatus.Unavailable ->
StatusCheck(
chatLabel,
if (gatewayAvailability == GatewayAvailability.SignInRequired) {
CheckStatus.Warn
} else {
CheckStatus.Fail
},
reason = if (gatewayAvailability == GatewayAvailability.SignInRequired) {
context.getString(R.string.cw_dashboard_sign_in_required)
} else {
chatErr?.message() ?: chatNotReady
},
category = DiagnosticCategory.Session,
timestampMs = chatErr?.timestampMs,
)
}
// 5) Relay / pairing auth.
// 6) Optional Relay / pairing auth.
val authLabel = context.getString(R.string.diag_check_pairing_auth)
val authRelayActive = context.getString(R.string.diag_check_relay_active)
val authPairingProg = context.getString(R.string.diag_check_pairing_progress)
val authNotPaired = context.getString(R.string.diag_check_not_paired)
val authErr = recentError(DiagnosticCategory.Auth)
checks += when (authState) {
is AuthState.Paired ->
checks += when {
!relayConfigured ->
StatusCheck(
authLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_optional),
category = DiagnosticCategory.Auth,
)
authState is AuthState.Paired ->
StatusCheck(
authLabel, CheckStatus.Pass,
reason = authRelayActive,
category = DiagnosticCategory.Auth,
)
is AuthState.Pairing ->
authState is AuthState.Pairing ->
StatusCheck(
authLabel, CheckStatus.Warn,
reason = authPairingProg,
category = DiagnosticCategory.Auth,
)
is AuthState.Failed ->
authState is AuthState.Failed ->
StatusCheck(
authLabel, CheckStatus.Fail,
reason = authState.reason,
category = DiagnosticCategory.Auth,
timestampMs = authErr?.timestampMs,
)
is AuthState.Unpaired ->
else ->
StatusCheck(
authLabel, CheckStatus.Unknown,
reason = authNotPaired,
@@ -406,8 +552,8 @@ internal fun buildStatusChecks(
)
}
// 6) Relay server (optional — Unknown when not paired/configured).
val relayLabel = context.getString(R.string.diag_check_relay_server)
// 7) Relay server (optional — Unknown when not paired/configured).
val relayLabel = context.getString(R.string.active_section_optional_relay)
val relayNotConfigured = context.getString(R.string.diag_check_relay_not_configured)
val relayConnected = context.getString(R.string.diag_check_connected)
val relayReachableNotReady = context.getString(R.string.diag_check_reachable_not_ready)
@@ -442,7 +588,7 @@ internal fun buildStatusChecks(
)
}
// 7) Relay plugin version + release availability. The update route is
// 8) Relay plugin version + release availability. The update route is
// optional on older plugin versions, so a connected relay with no result is
// explicitly Unknown rather than incorrectly reported as current.
val pluginLabel = context.getString(R.string.diag_check_relay_plugin)
@@ -494,7 +640,7 @@ internal fun buildStatusChecks(
)
}
// 8) Voice readiness.
// 9) Voice readiness.
val voiceLabel = context.getString(R.string.diag_check_voice)
val voiceRelayReady = context.getString(R.string.diag_check_voice_relay)
val voiceStandardReady = context.getString(R.string.diag_check_voice_standard)
@@ -32,10 +32,9 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
* button), and pops back to wherever it came from on complete or cancel.
*
* [autoStart] lets the caller deep-link into a specific pair method. When
* set to `"scan"`, the wizard jumps straight to camera-permission-request
* → scanner on first composition. Null (default) shows the full Method
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
* method.
* set to `"scan"`, the wizard jumps straight to the scanner. `"relay"`
* opens the connection-scoped Relay method chooser without exposing the
* new-server flow. Null shows the full connection chooser.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -45,6 +44,7 @@ fun PairScreen(
onCancel: () -> Unit,
onManageSignIn: (() -> Unit)? = null,
autoStart: String? = null,
setupReady: Boolean = true,
/**
* Optional offline "Try the demo" entry, forwarded to [ConnectionWizard].
* Wired by [RelayApp] only for the bare Connect entry (no placeholder
@@ -65,7 +65,14 @@ fun PairScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.pair_connect_to_hermes)) },
title = {
Text(
stringResource(
if (autoStart == "relay") R.string.detail_pair_relay
else R.string.pair_connect_to_hermes,
),
)
},
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
@@ -93,6 +100,7 @@ fun PairScreen(
onManageSignIn = onManageSignIn,
showSkip = false,
autoStart = autoStart,
setupReady = setupReady,
onTryDemo = onTryDemo,
)
}
@@ -64,6 +64,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.ui.components.SessionTtlPickerDialog
import com.hermesandroid.relay.ui.components.TransportSecurityBadge
import com.hermesandroid.relay.ui.components.TransportSecuritySize
@@ -872,8 +873,7 @@ private fun EndpointsSubList(
for (candidate in endpoints) {
val isActive = activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
activeEndpoint.api.port == candidate.api.port
activeEndpoint.routeAuthority() == candidate.routeAuthority()
Row(
modifier = Modifier
.fillMaxWidth()
@@ -899,7 +899,7 @@ private fun EndpointsSubList(
},
)
Text(
text = "${candidate.api.host}:${candidate.api.port}",
text = candidate.routeAuthority().orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
@@ -1,5 +1,10 @@
package com.hermesandroid.relay.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -91,15 +96,19 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.ui.components.AgentAvatarFace
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
/**
* Root Settings destination. After the 2026-04-11 split, Settings is a
@@ -176,6 +185,7 @@ fun SettingsScreen(
// ring-accent, and subtitle.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
@@ -183,27 +193,45 @@ fun SettingsScreen(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val relayPaired = authState is AuthState.Paired
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
apiServerReachable -> ChatTransportReadiness.Ready
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
else -> ChatTransportReadiness.Connecting
},
)
// Status pills are exception-only: a pill appears only when the surface
// needs attention (missing / checking / offline / sign-in). When it's
// healthy the pill is null so the card + agent summary stay clean.
val apiPill: SettingsStatusPillModel? = when {
activeConnection?.apiServerUrl.isNullOrBlank() -> SettingsStatusPillModel(
label = stringResource(R.string.settings_api_missing),
tone = SettingsStatusTone.Warning,
)
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> SettingsStatusPillModel(
label = stringResource(R.string.settings_api_checking),
// Chat is transport-level: Gateway is primary and API is only fallback.
// A healthy transport suppresses warnings from the other optional surface.
val chatPill: SettingsStatusPillModel? = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> null
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> null
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_sign_in),
tone = SettingsStatusTone.Info,
)
apiServerReachable -> null
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> SettingsStatusPillModel(
label = stringResource(R.string.settings_api_offline),
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_missing),
tone = SettingsStatusTone.Warning,
)
gatewayAvailability == GatewayAvailability.Unreachable ||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_offline),
tone = SettingsStatusTone.Warning,
)
else -> null
@@ -213,38 +241,18 @@ fun SettingsScreen(
label = stringResource(R.string.settings_dashboard_missing),
tone = SettingsStatusTone.Warning,
)
dashboardStatus == null -> null
!dashboardStatus.reachable -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_offline),
tone = SettingsStatusTone.Warning,
)
dashboardSignInRequired -> SettingsStatusPillModel(
gatewayAvailability == GatewayAvailability.Ready -> null
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_sign_in),
tone = SettingsStatusTone.Info,
)
dashboardStatus.authenticated == true -> null
gatewayAvailability == GatewayAvailability.Unreachable ||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_offline),
tone = SettingsStatusTone.Warning,
)
else -> null
}
val relayPill: SettingsStatusPillModel? = when (relayUiState) {
RelayUiState.Connected -> null
RelayUiState.Connecting -> SettingsStatusPillModel(
label = stringResource(R.string.settings_relay_reconnecting),
tone = SettingsStatusTone.Info,
)
RelayUiState.Stale -> SettingsStatusPillModel(
label = stringResource(R.string.settings_relay_stale),
tone = SettingsStatusTone.Warning,
)
RelayUiState.Expired -> SettingsStatusPillModel(
label = stringResource(R.string.settings_pairing_expired),
tone = SettingsStatusTone.Warning,
)
RelayUiState.Disconnected -> SettingsStatusPillModel(
label = if (relayPaired) stringResource(R.string.settings_relay_offline) else stringResource(R.string.settings_requires_pairing),
tone = SettingsStatusTone.Warning,
)
RelayUiState.NotConfigured -> null
}
// The Power tools below all ride the relay plugin. Rather than stamp an
// identical badge on every card (noise, not signal), the dependency is
// surfaced ONCE on the section header as a single plugin-state badge.
@@ -323,10 +331,6 @@ fun SettingsScreen(
// + one-line `connection · model · personality` subtitle).
// Tapping opens AgentInfoSheet inline so users can change
// Connection / Profile / Personality without leaving Settings.
val effectiveProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
ActiveAgentCard(
agentName = AgentDisplay.agentName(
profile = effectiveProfile,
@@ -342,7 +346,7 @@ fun SettingsScreen(
defaultPersonality = defaultPersonality,
),
isCustomized = selectedProfile != null || selectedPersonality != "default",
statusPills = listOfNotNull(apiPill, dashboardPill, relayPill),
statusPills = listOfNotNull(chatPill),
onClick = { showAgentSheet = true },
isDarkTheme = isDarkTheme,
)
@@ -354,8 +358,7 @@ fun SettingsScreen(
// `default` profile — the relay always advertises one, and
// it IS the effective agent. Still falls back to disabled
// when no profiles have loaded yet (unpaired / pre-auth).
val inspectorTarget = selectedProfile
?: agentProfiles.firstOrNull { it.name == "default" }
val inspectorTarget = effectiveProfile
?: agentProfiles.firstOrNull()
ProfileInspectorCard(
activeProfile = inspectorTarget,
@@ -438,7 +441,7 @@ fun SettingsScreen(
icon = Icons.AutoMirrored.Filled.Chat,
title = stringResource(R.string.settings_chat),
subtitle = stringResource(R.string.settings_chat_desc),
badge = apiPill,
badge = chatPill,
onClick = onNavigateToChatSettings,
isDarkTheme = isDarkTheme,
)
@@ -814,6 +817,20 @@ private fun QuickControlsCard(
) {
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
val context = LocalContext.current
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Posting re-checks the grant. */ }
val requestNotificationPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -846,7 +863,10 @@ private fun QuickControlsCard(
stringResource(R.string.settings_connect_on_demand)
},
checked = gatewayKeepAlive,
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) },
onCheckedChange = { enabled ->
connectionViewModel.setGatewayKeepAlive(enabled)
if (enabled && notifyTurnComplete) requestNotificationPermission()
},
)
// Doze: even with the keep-alive service running, a specialUse FGS
// still gets its network deferred in deep sleep unless the app is
@@ -865,7 +885,10 @@ private fun QuickControlsCard(
stringResource(R.string.settings_turn_complete_alerts_off)
},
checked = notifyTurnComplete,
onCheckedChange = { connectionViewModel.setNotifyTurnComplete(it) },
onCheckedChange = { enabled ->
connectionViewModel.setNotifyTurnComplete(enabled)
if (enabled) requestNotificationPermission()
},
)
}
}
File diff suppressed because it is too large Load Diff
@@ -227,6 +227,18 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
val msg = t.message.orEmpty().lowercase()
// Upstream rejects new API work with this stable code while an intentional
// shutdown/external drain is in progress. Keep it distinct from provider
// 503s: the server is healthy and will accept work after the drain clears.
if (msg.startsWith("api error 503: gateway_draining:")) {
return HumanError(
title = "Hermes is restarting",
body = "Hermes is draining active work. Wait a moment, then retry this message.",
retryable = true,
actionLabel = ctx?.getString(R.string.error_classify_retry) ?: "Retry",
)
}
// Typed exceptions are checked first because an IOException message scan
// would otherwise swallow SSL/timeout/connect errors whose messages
// happen to contain HTTP-ish substrings. Only fall through to the
@@ -0,0 +1,52 @@
package com.hermesandroid.relay.viewmodel
/** Runtime readiness for one independently optional chat transport. */
enum class ChatTransportReadiness {
NotConfigured,
Connecting,
Ready,
Unavailable,
}
enum class ChatTransportPath {
Gateway,
ApiSse,
}
/**
* Transport-neutral UI state for chat connectivity. Relay is intentionally
* absent: it is an optional bridge surface and cannot make chat unhealthy.
*/
sealed interface ChatRuntimeStatus {
data class Connected(
val transport: ChatTransportPath,
val fallback: Boolean,
) : ChatRuntimeStatus
data object Connecting : ChatRuntimeStatus
data object Unavailable : ChatRuntimeStatus
}
/**
* Resolve chat health in product priority order:
* Gateway primary, API/SSE fallback, pending connection, then unavailable.
*/
fun resolveChatRuntimeStatus(
gateway: ChatTransportReadiness,
apiSse: ChatTransportReadiness,
): ChatRuntimeStatus = when {
gateway == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
transport = ChatTransportPath.Gateway,
fallback = false,
)
apiSse == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
transport = ChatTransportPath.ApiSse,
fallback = true,
)
gateway == ChatTransportReadiness.Connecting ||
apiSse == ChatTransportReadiness.Connecting -> ChatRuntimeStatus.Connecting
else -> ChatRuntimeStatus.Unavailable
}
File diff suppressed because it is too large Load Diff
@@ -59,18 +59,16 @@ import kotlinx.coroutines.withTimeoutOrNull
* 8. Update URL flows via [setApiServerUrl] and [setRelayUrl] so
* [HermesApiClient] and [RelayHttpClient] pick up the new endpoints.
* [persistUrls] writes both values to the app DataStore in one pass.
* 9. Rebuild the API client via [rebuildApiClient] so subsequent chat
* calls hit the new API server with the new API key.
* 10. Await the new AuthManager's first non-[AuthState.Unpaired] /
* 9. Persist the new active-connection id so Dashboard/Gateway probes resolve
* against the target connection rather than the outgoing one.
* 10. Rebuild the API client (or clear it for Dashboard-only) and probe the
* target Dashboard/Gateway surface.
* 11. Await the new AuthManager's first non-[AuthState.Unpaired] /
* non-[AuthState.Loading] emission (or a short timeout) before deciding
* whether to kick the WSS handshake. Connecting too early fires an
* auth envelope with no pair context, which tick the relay's rate
* limiter — exactly the trap [AuthManager.hasPairContext] was
* introduced to avoid.
* 11. Persist the new active-connection id via
* [ConnectionStore.setActiveConnection] last so an early failure leaves
* the previous active connection in place.
*
* The whole sequence runs under [switchMutex] so rapid-fire switches from
* the UI queue cleanly instead of interleaving partial teardowns.
*/
@@ -269,11 +267,22 @@ class ConnectionSwitchCoordinator(
runCatching { persistUrls(target.apiServerUrl, targetRelayUrl) }
.onFailure { Log.w(TAG, "persistUrls failed: ${it.message}") }
// 9 — rebuild the API client against the new URL + key.
// 9 — publish the target context BEFORE rebuilding/probing. The
// Dashboard URL and cookie store are resolved from activeConnection;
// probing first would accidentally re-probe the outgoing connection
// and leave a Dashboard-only target stuck at Gateway.Unknown.
runCatching { connectionStore.setActiveConnection(connectionId) }
.onFailure {
Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}")
return@withLock
}
// 10 — rebuild the API client against the new URL + key (or clear
// it and probe Dashboard/Gateway when this is Dashboard-only).
runCatching { rebuildApiClient() }
.onFailure { Log.w(TAG, "rebuildApiClient failed: ${it.message}") }
// 10 — wait briefly for the new AuthManager to hydrate its
// 11 — wait briefly for the new AuthManager to hydrate its
// stored token (if any) so hasPairContext returns the right
// answer. The authState flow seeds to Unpaired and flips to
// Paired asynchronously; without this await we'd almost
@@ -309,24 +318,16 @@ class ConnectionSwitchCoordinator(
)
}
if (newAuth.hasPairContext) {
if (newAuth.hasPairContext && targetRelayUrl.isNotBlank()) {
runCatching { connectionManager.connect(targetRelayUrl) }
.onFailure { Log.w(TAG, "connectionManager.connect failed: ${it.message}") }
} else {
Log.i(
TAG,
"switchConnection: new connection has no pair context — skipping WSS connect " +
"(user will pair from the Connection screen)",
"switchConnection: Relay is not ready for this connection — " +
"skipping WSS connect",
)
}
// 11 — persist last so an earlier failure leaves the previous
// active pointer in place. setActiveConnection updates the
// connectionStore.activeConnectionId StateFlow synchronously
// after the DataStore write lands, so the UI reacts without
// waiting for a recomposition pass.
runCatching { connectionStore.setActiveConnection(connectionId) }
.onFailure { Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}") }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
package com.hermesandroid.relay.viewmodel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/** Non-secret handle required to resume a dashboard-owned MCP OAuth flow. */
data class PendingMcpOAuth(
val flowId: String,
val serverName: String,
val profile: String?,
/** Non-secret connection + normalized dashboard identity that owns this flow. */
val routeIdentity: String,
)
/**
* Process-recreatable Manage OAuth state. Authorization URLs, OAuth codes,
* callback state, and tokens are deliberately never written here.
*/
class DashboardManageOAuthViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val flowId = savedStateHandle.getStateFlow<String?>(KEY_FLOW_ID, null)
private val serverName = savedStateHandle.getStateFlow<String?>(KEY_SERVER_NAME, null)
private val profile = savedStateHandle.getStateFlow<String?>(KEY_PROFILE, null)
private val routeIdentity = savedStateHandle.getStateFlow<String?>(KEY_ROUTE_IDENTITY, null)
val pending: StateFlow<PendingMcpOAuth?> = combine(
flowId,
serverName,
profile,
routeIdentity,
) { id, server, scope, route ->
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) null
else PendingMcpOAuth(id, server, scope, route)
}.stateIn(viewModelScope, SharingStarted.Eagerly, currentPending())
val unsupportedRoutes: StateFlow<ArrayList<String>> =
savedStateHandle.getStateFlow(KEY_UNSUPPORTED_ROUTES, arrayListOf())
val supportedRoutes: StateFlow<ArrayList<String>> =
savedStateHandle.getStateFlow(KEY_SUPPORTED_ROUTES, arrayListOf())
fun remember(flowId: String, serverName: String, profile: String?, routeIdentity: String) {
if (routeIdentity.isBlank()) return
savedStateHandle[KEY_FLOW_ID] = flowId
savedStateHandle[KEY_SERVER_NAME] = serverName
savedStateHandle[KEY_PROFILE] = profile
savedStateHandle[KEY_ROUTE_IDENTITY] = routeIdentity
}
fun clear() {
savedStateHandle[KEY_FLOW_ID] = null
savedStateHandle[KEY_SERVER_NAME] = null
savedStateHandle[KEY_PROFILE] = null
savedStateHandle[KEY_ROUTE_IDENTITY] = null
}
fun markUnsupported(routeKey: String) {
if (routeKey.isBlank()) return
val next = ArrayList(unsupportedRoutes.value)
if (routeKey !in next) {
next += routeKey
savedStateHandle[KEY_UNSUPPORTED_ROUTES] = next
}
removeRoute(KEY_SUPPORTED_ROUTES, supportedRoutes.value, routeKey)
}
fun markSupported(routeKey: String) {
if (routeKey.isBlank()) return
val next = ArrayList(supportedRoutes.value)
if (routeKey !in next) {
next += routeKey
savedStateHandle[KEY_SUPPORTED_ROUTES] = next
}
removeRoute(KEY_UNSUPPORTED_ROUTES, unsupportedRoutes.value, routeKey)
}
private fun removeRoute(key: String, routes: ArrayList<String>, routeKey: String) {
if (routeKey !in routes) return
val next = ArrayList(routes)
next.remove(routeKey)
savedStateHandle[key] = next
}
private fun currentPending(): PendingMcpOAuth? {
val id = savedStateHandle.get<String>(KEY_FLOW_ID)
val server = savedStateHandle.get<String>(KEY_SERVER_NAME)
val route = savedStateHandle.get<String>(KEY_ROUTE_IDENTITY)
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) return null
return PendingMcpOAuth(id, server, savedStateHandle.get(KEY_PROFILE), route)
}
private companion object {
const val KEY_FLOW_ID = "manage_mcp_oauth_flow_id"
const val KEY_SERVER_NAME = "manage_mcp_oauth_server"
const val KEY_PROFILE = "manage_mcp_oauth_profile"
const val KEY_ROUTE_IDENTITY = "manage_mcp_oauth_route_identity"
const val KEY_UNSUPPORTED_ROUTES = "manage_mcp_oauth_unsupported_routes"
const val KEY_SUPPORTED_ROUTES = "manage_mcp_oauth_supported_routes"
}
}
@@ -43,6 +43,8 @@ import kotlinx.coroutines.launch
* live here.
*/
data class VoiceConfigUiState(
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val voiceConfig: VoiceConfig? = null,
val voiceConfigError: String? = null,
val voiceOutputConfig: VoiceOutputConfig? = null,
@@ -107,6 +109,7 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
val configErrorEvents: SharedFlow<HumanError> = _configErrorEvents.asSharedFlow()
private var loadJob: Job? = null
private var loadGeneration: Long = 0L
fun setBargeInEnabled(enabled: Boolean) {
viewModelScope.launch { bargeInRepo.setEnabled(enabled) }
@@ -132,65 +135,78 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
*/
fun loadVoiceConfig(client: RelayVoiceClient?, relayVoiceReady: Boolean) {
loadJob?.cancel()
val generation = ++loadGeneration
if (client == null || !relayVoiceReady) {
_configState.value = VoiceConfigUiState()
return
}
_configState.value = VoiceConfigUiState(isLoading = true)
loadJob = viewModelScope.launch {
val voiceResult = client.getVoiceConfig()
if (voiceResult.isSuccess) {
_configState.update {
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
try {
val voiceResult = client.getVoiceConfig()
if (generation != loadGeneration) return@launch
if (voiceResult.isSuccess) {
_configState.update {
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
}
} else {
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
} else {
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
val outputResult = client.getVoiceOutputConfig()
if (outputResult.isSuccess) {
val config = outputResult.getOrNull()
_configState.update {
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
voiceOutputProviderOptions =
it.voiceOutputProviderOptions + (provider.id to provider),
)
val outputResult = client.getVoiceOutputConfig()
if (generation != loadGeneration) return@launch
if (outputResult.isSuccess) {
val config = outputResult.getOrNull()
_configState.update {
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
if (generation != loadGeneration) return@launch
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
voiceOutputProviderOptions =
it.voiceOutputProviderOptions + (provider.id to provider),
)
}
}
}
} else {
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceOutputConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
} else {
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceOutputConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
val realtimeResult = client.getRealtimeAgentConfig()
if (realtimeResult.isSuccess) {
val config = realtimeResult.getOrNull()
_configState.update {
it.copy(realtimeConfig = config, realtimeConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
realtimeProviderOptions =
it.realtimeProviderOptions + (provider.id to provider),
)
val realtimeResult = client.getRealtimeAgentConfig()
if (generation != loadGeneration) return@launch
if (realtimeResult.isSuccess) {
val config = realtimeResult.getOrNull()
_configState.update {
it.copy(realtimeConfig = config, realtimeConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
if (generation != loadGeneration) return@launch
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
realtimeProviderOptions =
it.realtimeProviderOptions + (provider.id to provider),
)
}
}
}
} else {
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
}
} finally {
if (generation == loadGeneration) {
_configState.update { it.copy(isLoading = false, hasLoaded = true) }
}
} else {
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
}
}
}
@@ -36,6 +36,10 @@ import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
import com.hermesandroid.relay.network.relay.VoiceHandoffEvent
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import com.hermesandroid.relay.util.HumanError
import com.hermesandroid.relay.util.classifyError
import com.hermesandroid.relay.voice.VoiceIntentSyncBuilder
@@ -53,6 +57,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -68,6 +73,7 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.contentOrNull
import java.io.File
import java.io.IOException
import java.util.Base64
import java.util.Collections
import java.util.concurrent.atomic.AtomicBoolean
@@ -82,6 +88,17 @@ import com.hermesandroid.relay.data.VoiceAudioRoute
*/
enum class VoiceState { Idle, Listening, Transcribing, Thinking, Speaking, Error }
private enum class StandardSpeechStreamState {
Idle,
Opening,
Streaming,
LegacyFallback,
Consumed,
}
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
@@ -155,6 +172,16 @@ data class VoiceUiState(
val backgroundRun: BackgroundRunState? = null,
)
data class VoicePreviewUiState(
val selectionKey: String? = null,
val isLoading: Boolean = false,
val isPlaying: Boolean = false,
val amplitude: Float = 0f,
val error: String? = null,
) {
val isActive: Boolean get() = isLoading || isPlaying
}
/** ADR 33 background/promoted Hermes run surface for the voice overlay. */
data class BackgroundRunState(
val runId: String? = null,
@@ -579,6 +606,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(VoiceUiState())
val uiState: StateFlow<VoiceUiState> = _uiState.asStateFlow()
private val _voicePreviewState = MutableStateFlow(VoicePreviewUiState())
val voicePreviewState: StateFlow<VoicePreviewUiState> = _voicePreviewState.asStateFlow()
private var voicePreviewJob: Job? = null
private var voicePreviewGeneration: Long = 0L
// Rolling voice pipeline telemetry for StatsForNerds → Voice section.
// Updated on discrete lifecycle events (turn start/stop, STT call
// complete, TTS call complete, barge-in fire, threshold read, state
@@ -658,6 +690,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var pendingRawDelta: StringBuilder = StringBuilder()
private var streamObserverJob: Job? = null
private var standardSpeechStreamJob: Job? = null
private var standardSpeechStream: VoiceSpeechStream? = null
private var standardSpeechStreamGeneration: Long = 0L
private var standardSpeechStreamState: StandardSpeechStreamState = StandardSpeechStreamState.Idle
private var standardSpeechStreamText: StringBuilder = StringBuilder()
private var standardSpeechStreamFinishRequested: Boolean = false
private val standardSpeechStreamAudioSeen = AtomicBoolean(false)
private val standardSpeechStreamAudioBytes = AtomicInteger(0)
private val standardSpeechStreamBargeInStarted = AtomicBoolean(false)
private var ttsConsumerJob: Job? = null
private var realtimeTtsConsumerJob: Job? = null
private var amplitudeBridgeJob: Job? = null
@@ -928,6 +969,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
voiceRelayPreflight: (suspend () -> Result<Unit>)? = null,
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
) {
cancelStandardSpeechStream("voice dependencies rewired")
this.voiceClient = voiceClient
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
this.chatViewModel = chatViewModel
@@ -1647,6 +1689,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// B4: tear down the barge-in listener + timers before we kill the
// player so AEC doesn't try to track a released audio session.
stopBargeInListener()
cancelStandardSpeechStream("voice mode exited")
duckingWatchdog?.cancel(); duckingWatchdog = null
resumeWatchdog?.cancel(); resumeWatchdog = null
handoffStatusClearJob?.cancel(); handoffStatusClearJob = null
@@ -1742,6 +1785,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// listener and cancel any pending resume. Normal "tap mic to
// talk" path also lands here, so we always leave Speaking cleanly.
stopBargeInListener()
cancelStandardSpeechStream("microphone capture started")
resumeWatchdog?.cancel(); resumeWatchdog = null
lastInterruptedAtChunkIndex = null
clearSpokenChunksState()
@@ -2053,6 +2097,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// bargeInDetected while the resume watchdog is deliberating.
// stopBargeInListener is null-safe.
stopBargeInListener()
cancelStandardSpeechStream("speech interrupted")
// 2026-04-18: the silence watchdog only runs during Listening, but
// cancel defensively so a stale job from the prior turn can't
// fire stopListening() after we've already returned to Idle.
@@ -2254,6 +2299,222 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Plays an ephemeral Relay voice-output session using the editor's draft
* selection. None of these values are persisted; saving remains an
* explicit, separate settings action.
*
* Calling this with the currently-playing key acts as a pause/stop toggle.
* Starting another key always stops the previous preview first, so two
* providers can never talk over one another.
*/
fun previewVoiceOutput(
selectionKey: String,
provider: String,
model: String,
voice: String,
sampleRate: Int,
language: String,
sample: String = "Hello, this is Hermes. This is how this voice sounds.",
onResult: (Result<Unit>) -> Unit = {},
) {
if (_voicePreviewState.value.isActive &&
_voicePreviewState.value.selectionKey == selectionKey
) {
stopVoicePreview()
return
}
val client = voiceClient
val pcmPlayer = realtimePcmPlayer
if (client == null || pcmPlayer == null) {
val error = IllegalStateException("Relay voice preview is not available")
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
onResult(Result.failure(error))
return
}
val previousJob = voicePreviewJob
val generation = ++voicePreviewGeneration
_voicePreviewState.value = VoicePreviewUiState(
selectionKey = selectionKey,
isLoading = true,
)
voicePreviewJob = viewModelScope.launch {
val audioBytes = AtomicInteger(0)
try {
previousJob?.cancelAndJoin()
if (generation != voicePreviewGeneration) return@launch
pcmPlayer.stop()
val result = client.runVoiceOutput(
text = sample,
renderMode = "verbatim",
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
) { event ->
if (!event.isAudioDelta) return@runVoiceOutput
val encoded = event.audioBase64 ?: return@runVoiceOutput
val audio = try {
Base64.getDecoder().decode(encoded)
} catch (_: Exception) {
return@runVoiceOutput
}
if (audio.isEmpty()) return@runVoiceOutput
if (generation != voicePreviewGeneration) return@runVoiceOutput
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
audioBytes.addAndGet(audio.size)
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
} else {
state
}
}
}
val finalResult = result.fold(
onSuccess = {
if (audioBytes.get() <= 0) {
Result.failure(IllegalStateException("Voice preview returned no audio"))
} else {
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
delay(drainMs)
Result.success(Unit)
}
},
onFailure = { Result.failure(it) },
)
if (generation == voicePreviewGeneration) {
onResult(finalResult)
_voicePreviewState.value = VoicePreviewUiState(
error = finalResult.exceptionOrNull()?.message,
)
}
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
if (generation == voicePreviewGeneration) {
pcmPlayer.stop()
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey) {
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
} else {
state
}
}
}
}
}
}
/**
* Opens a one-shot provider-native session with the Realtime editor's
* unsaved provider/model/voice selection. The session endpoint accepts
* these as request-scoped overrides, so auditioning never mutates relay
* configuration or the active chat session.
*/
fun previewRealtimeAgent(
selectionKey: String,
provider: String,
model: String,
voice: String,
sampleRate: Int,
sample: String = "Introduce this voice in one short sentence.",
onResult: (Result<Unit>) -> Unit = {},
) {
if (_voicePreviewState.value.isActive && _voicePreviewState.value.selectionKey == selectionKey) {
stopVoicePreview()
return
}
val client = voiceClient
val pcmPlayer = realtimePcmPlayer
if (client == null || pcmPlayer == null) {
val error = IllegalStateException("Realtime voice preview is not available")
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
onResult(Result.failure(error))
return
}
val previousJob = voicePreviewJob
val generation = ++voicePreviewGeneration
_voicePreviewState.value = VoicePreviewUiState(selectionKey = selectionKey, isLoading = true)
voicePreviewJob = viewModelScope.launch {
val audioBytes = AtomicInteger(0)
try {
previousJob?.cancelAndJoin()
if (generation != voicePreviewGeneration) return@launch
pcmPlayer.stop()
val result = client.runRealtimeAgent(
prompt = sample,
inputPcm = ByteArray(0),
inputSampleRate = 16_000,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
) { event, _ ->
if (!event.isAudioDelta) return@runRealtimeAgent
val encoded = event.audioBase64 ?: return@runRealtimeAgent
val audio = try {
Base64.getDecoder().decode(encoded)
} catch (_: Exception) {
return@runRealtimeAgent
}
if (audio.isEmpty()) return@runRealtimeAgent
if (generation != voicePreviewGeneration) return@runRealtimeAgent
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
audioBytes.addAndGet(audio.size)
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
} else {
state
}
}
}
val finalResult = result.fold(
onSuccess = {
if (audioBytes.get() <= 0) {
Result.failure(IllegalStateException("Realtime preview returned no audio"))
} else {
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
delay(drainMs)
Result.success(Unit)
}
},
onFailure = { Result.failure(it) },
)
if (generation == voicePreviewGeneration) {
onResult(finalResult)
_voicePreviewState.value = VoicePreviewUiState(error = finalResult.exceptionOrNull()?.message)
}
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
if (generation == voicePreviewGeneration) {
pcmPlayer.stop()
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey) {
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
} else {
state
}
}
}
}
}
}
fun stopVoicePreview() {
voicePreviewGeneration += 1
voicePreviewJob?.cancel()
voicePreviewJob = null
realtimePcmPlayer?.stop()
_voicePreviewState.value = VoicePreviewUiState()
}
fun testRealtimeAgent(
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
onResult: (Result<Unit>) -> Unit = {},
@@ -2820,6 +3081,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
ignoreAssistantId = chatVm.messages.value
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
prepareStandardSpeechStream()
// Kick off streaming observer BEFORE sending the message so we don't
// miss early deltas that arrive synchronously from the callback.
startStreamObserver(chatVm)
@@ -2874,6 +3137,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
fun sessionIsCurrent(): Boolean =
realtimeSessionGeneration.get() == sessionGeneration
if (!prewarm) providerRealtimeAgentTurnActive.set(true)
cancelStandardSpeechStream("provider-native realtime turn")
// New turn requested → allow this response's audio through again.
realtimeAudioSuppressed = false
streamObserverJob?.cancel()
@@ -3778,6 +4042,186 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// Sentence-boundary streaming from ChatViewModel
// ---------------------------------------------------------------------
/**
* Optimistically open upstream's per-reply PCM socket while Hermes starts
* the chat turn. Deltas are buffered until the fresh single-use dashboard
* ticket is minted and the WebSocket opens. Older hosts, expired auth, and
* non-streaming providers all converge on [activateStandardSpeechFallback]
* before any PCM is heard, preserving the existing POST pipeline.
*/
private fun prepareStandardSpeechStream() {
cancelStandardSpeechStream("new Standard voice turn")
val client = voiceAudioClient ?: return
if (client.effectiveRoute != VoiceAudioRoute.Standard || realtimePcmPlayer == null) return
val generation = ++standardSpeechStreamGeneration
standardSpeechStreamState = StandardSpeechStreamState.Opening
standardSpeechStreamText = StringBuilder()
standardSpeechStreamFinishRequested = false
standardSpeechStreamAudioSeen.set(false)
standardSpeechStreamAudioBytes.set(0)
standardSpeechStreamBargeInStarted.set(false)
standardSpeechStreamJob = viewModelScope.launch {
var openedSession: VoiceSpeechStream? = null
try {
val result = client.openSpeechStream(
VoiceSpeechStreamCallbacks(
onStart = { sampleRate, channels ->
Log.i(
TAG,
"Standard speech stream ready sampleRate=$sampleRate channels=$channels",
)
},
onPcm = { pcm, sampleRate ->
viewModelScope.launch {
if (generation != standardSpeechStreamGeneration ||
standardSpeechStreamState == StandardSpeechStreamState.Idle ||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback
) {
return@launch
}
handleStandardSpeechPcm(pcm, sampleRate)
}
},
),
)
if (generation != standardSpeechStreamGeneration) {
result.getOrNull()?.stop()
return@launch
}
openedSession = result.getOrNull()
if (openedSession == null) {
activateStandardSpeechFallback(
generation,
result.exceptionOrNull() ?: IOException("Streaming voice unavailable"),
)
return@launch
}
standardSpeechStream = openedSession
standardSpeechStreamState = StandardSpeechStreamState.Streaming
val buffered = standardSpeechStreamText.toString()
if (buffered.isNotEmpty()) openedSession.append(buffered)
if (standardSpeechStreamFinishRequested) openedSession.finish()
val outcome = openedSession.awaitOutcome()
if (generation != standardSpeechStreamGeneration) return@launch
standardSpeechStream = null
if (shouldFallbackStandardSpeech(outcome)) {
activateStandardSpeechFallback(generation, outcome.error)
return@launch
}
standardSpeechStreamState = StandardSpeechStreamState.Consumed
if (outcome.audioStarted) {
realtimePcmPlayer?.flushBufferedPlayback()
Log.i(
TAG,
"Standard speech stream finished status=${outcome.status} " +
"bytes=${standardSpeechStreamAudioBytes.get()}",
)
}
scheduleAgentAudioCompletionCheck()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
if (generation == standardSpeechStreamGeneration) {
activateStandardSpeechFallback(generation, error)
}
} finally {
if (generation != standardSpeechStreamGeneration) openedSession?.stop()
if (generation == standardSpeechStreamGeneration) standardSpeechStreamJob = null
}
}
}
/** Returns true when the Standard WebSocket owns this text delta. */
private fun offerStandardSpeechText(text: String): Boolean {
if (text.isEmpty()) return standardSpeechStreamOwnsReply()
return when (standardSpeechStreamState) {
StandardSpeechStreamState.Opening -> {
standardSpeechStreamText.append(text)
true
}
StandardSpeechStreamState.Streaming -> {
standardSpeechStreamText.append(text)
standardSpeechStream?.append(text)
true
}
StandardSpeechStreamState.Consumed -> true
StandardSpeechStreamState.Idle,
StandardSpeechStreamState.LegacyFallback,
-> false
}
}
private fun standardSpeechStreamOwnsReply(): Boolean =
standardSpeechStreamState == StandardSpeechStreamState.Opening ||
standardSpeechStreamState == StandardSpeechStreamState.Streaming ||
standardSpeechStreamState == StandardSpeechStreamState.Consumed
/** Returns true when stream completion is owned by the Standard socket. */
private fun finishStandardSpeechStream(): Boolean {
return when (standardSpeechStreamState) {
StandardSpeechStreamState.Opening -> {
standardSpeechStreamFinishRequested = true
true
}
StandardSpeechStreamState.Streaming -> {
standardSpeechStreamFinishRequested = true
standardSpeechStream?.finish()
true
}
StandardSpeechStreamState.Consumed -> true
StandardSpeechStreamState.Idle,
StandardSpeechStreamState.LegacyFallback,
-> false
}
}
private fun activateStandardSpeechFallback(generation: Long, error: Throwable?) {
if (generation != standardSpeechStreamGeneration ||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback ||
standardSpeechStreamState == StandardSpeechStreamState.Idle
) {
return
}
Log.i(TAG, "Standard speech streaming unavailable; using POST fallback: ${error?.message}")
standardSpeechStream?.stop()
standardSpeechStream = null
standardSpeechStreamState = StandardSpeechStreamState.LegacyFallback
val buffered = standardSpeechStreamText.toString()
standardSpeechStreamText = StringBuilder()
if (buffered.isNotEmpty()) {
appendSanitizedDelta(buffered)
if (standardSpeechStreamFinishRequested) {
flushRemainingBuffer()
} else {
drainSentences()
rearmIdleFlush()
}
}
scheduleAgentAudioCompletionCheck()
}
private fun cancelStandardSpeechStream(reason: String) {
if (standardSpeechStreamState != StandardSpeechStreamState.Idle) {
Log.i(TAG, "Stopping Standard speech stream: $reason")
}
standardSpeechStreamGeneration += 1
standardSpeechStream?.stop()
standardSpeechStream = null
standardSpeechStreamJob?.cancel()
standardSpeechStreamJob = null
standardSpeechStreamState = StandardSpeechStreamState.Idle
standardSpeechStreamText = StringBuilder()
standardSpeechStreamFinishRequested = false
standardSpeechStreamAudioSeen.set(false)
standardSpeechStreamAudioBytes.set(0)
standardSpeechStreamBargeInStarted.set(false)
}
/**
* Observe [ChatViewModel.messages]. When the last assistant message
* grows (isStreaming=true), diff the content against our last snapshot,
@@ -3806,7 +4250,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
} else if (lastObservedMessageId != msgId) {
// A new assistant turn appeared — flush whatever's left
// from the previous one, then switch tracking.
flushRemainingBuffer()
if (!finishStandardSpeechStream()) flushRemainingBuffer()
resetBrokeredToolSpeechState()
lastObservedMessageId = msgId
lastObservedContentLength = 0
@@ -3829,7 +4273,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
flushRemainingBuffer()
if (!finishStandardSpeechStream()) flushRemainingBuffer()
// Speaking state will naturally end when TTS queue drains.
// We can't easily wait here without blocking the collector;
// the TTS consumer transitions back to Idle.
@@ -3848,7 +4292,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* (the chat surface has its own renderer and wants the markdown).
*/
private fun onStreamDelta(delta: String, fullContent: String) {
appendSanitizedDelta(delta)
_uiState.update {
it.copy(
state = VoiceState.Speaking,
@@ -3856,6 +4299,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
responseText = fullContent,
)
}
if (offerStandardSpeechText(delta)) return
appendSanitizedDelta(delta)
drainSentences()
rearmIdleFlush()
}
@@ -3894,7 +4339,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun enqueueBrokeredToolStatus(status: String) {
if (status.isBlank()) return
if (enqueueSentenceForTts(status, immediate = true)) {
val offeredToStandardStream = offerStandardSpeechText("$status ")
if (offeredToStandardStream || enqueueSentenceForTts(status, immediate = true)) {
_uiState.update { state ->
state.copy(
state = VoiceState.Speaking,
@@ -4287,7 +4733,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
} catch (_: Exception) {
return
}
if (audio.isEmpty()) return
handlePcmAudioChunk(
audio = audio,
sampleRate = event.sampleRate ?: 24_000,
source = "Realtime",
pcmPlayer = pcmPlayer,
audioSeen = audioSeen,
audioBytes = audioBytes,
bargeInStarted = bargeInStarted,
)
}
private fun handleStandardSpeechPcm(audio: ByteArray, sampleRate: Int) {
val pcmPlayer = realtimePcmPlayer ?: return
handlePcmAudioChunk(
audio = audio,
sampleRate = sampleRate,
source = "Standard",
pcmPlayer = pcmPlayer,
audioSeen = standardSpeechStreamAudioSeen,
audioBytes = standardSpeechStreamAudioBytes,
bargeInStarted = standardSpeechStreamBargeInStarted,
)
}
private fun handlePcmAudioChunk(
audio: ByteArray,
sampleRate: Int,
source: String,
pcmPlayer: RealtimePcmPlayer,
audioSeen: AtomicBoolean,
audioBytes: AtomicInteger,
bargeInStarted: AtomicBoolean,
) {
if (audio.isEmpty() || sampleRate <= 0) return
audioSeen.set(true)
audioBytes.addAndGet(audio.size)
lastRealtimeAudioDeltaAtMs = System.currentTimeMillis()
@@ -4298,11 +4777,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
deliveringChipClearJob?.cancel()
settleBackgroundRunChip(reason = "summary_audio_started")
}
val sampleRate = event.sampleRate ?: 24_000
Log.i(
TAG,
"Realtime audio delta event=${event.audioEventId ?: 0} bytes=${audio.size} " +
"sampleRate=$sampleRate rms=${event.rmsLevel ?: -1f} peak=${event.peakLevel ?: -1f}",
"$source audio delta bytes=${audio.size} sampleRate=$sampleRate",
)
val level = pcmPlayer.write(audio, sampleRate)
scheduleRealtimeAmplitudeRelease(audio.size, sampleRate, lastRealtimeAudioDeltaAtMs)
@@ -4727,7 +5204,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return decideAgentAudioCompletion(
voiceMode = _uiState.value.voiceMode,
observerStopped = streamObserverJob?.isActive != true,
pendingTtsWork = pendingInTtsQueue.get(),
pendingTtsWork = pendingInTtsQueue.get() +
if (standardSpeechStreamState == StandardSpeechStreamState.Opening ||
standardSpeechStreamState == StandardSpeechStreamState.Streaming
) {
1
} else {
0
},
hasPendingSynthFiles = pendingTtsFiles.isNotEmpty(),
realtimePlaybackRemainingMs = effectiveRemaining,
realtimeTailGuardRemainingMs = continuousResumeTailGuardRemainingMs(),
@@ -4737,6 +5221,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun finishAgentAudioOutput() {
continuousResumeJob = null
stopBargeInListener()
cancelStandardSpeechStream("audio output finished")
realtimeAmplitudeDecayJob?.cancel()
realtimeAmplitudeDecayJob = null
firstFrameWatchdogJob?.cancel()
@@ -5444,10 +5929,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() {
super.onCleared()
voicePreviewJob?.cancel()
voicePreviewJob = null
closeRealtimeSession()
// B4: release the listener + VAD engine native resources before
// anything else. stopBargeInListener is defensive / idempotent.
stopBargeInListener()
cancelStandardSpeechStream("view model cleared")
duckingWatchdog?.cancel()
resumeWatchdog?.cancel()
silenceWatchdogJob?.cancel()
@@ -132,6 +132,19 @@ class ProfileController(
AgentDisplay.effectiveSessionProfileName(selected?.name, serverDefault?.active)
}.stateIn(scope, SharingStarted.Eagerly, null)
/** Display identity resolved through the same sticky server default used by session routing. */
val effectiveDisplayProfile: StateFlow<Profile?> = combine(
selectedProfile,
agentProfiles,
serverDefaultProfileScope,
) { selected, profiles, serverDefault ->
AgentDisplay.effectiveDisplayProfile(
selectedProfile = selected,
profiles = profiles,
serverDefaultProfileName = serverDefault?.active,
)
}.stateIn(scope, SharingStarted.Eagerly, null)
/**
* True once the active connection's persisted profile selection has SETTLED
* — i.e. profile-scoped reads (session drawer, transcript restore, voice
Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

+224 -26
View File
@@ -12,14 +12,16 @@
<string name="onboarding_back">Voltar</string>
<string name="onboarding_next">Avançar</string>
<string name="onboarding_connect">Conectar</string>
<string name="onboarding_welcome_title">Hermes-Relay para Android</string>
<string name="onboarding_welcome_description">Converse com o Hermes e gerencie seu painel pelo celular.</string>
<string name="onboarding_get_started">Começar</string>
<string name="onboarding_step_count">Etapa %1$d de %2$d</string>
<string name="onboarding_welcome_title">Hermes,\nno seu bolso</string>
<string name="onboarding_welcome_description">Converse, gerencie e use voz\ncom o seu próprio Hermes.</string>
<string name="onboarding_welcome_badge">Boas-vindas</string>
<string name="onboarding_hermes_logo">Logotipo do Hermes</string>
<string name="onboarding_chat_manage_label">Chat e gerenciamento</string>
<string name="onboarding_chat_manage_description">Conecte-se ao painel e à API do Hermes em execução. Não é necessário instalar nem parear o Relay.</string>
<string name="onboarding_power_tools_label">Ferramentas avançadas</string>
<string name="onboarding_power_tools_description">Adicione o Hermes-Relay para usar Terminal, Bridge, sessões do relay e permissões de canais.</string>
<string name="onboarding_chat_manage_label">Painel primeiro</string>
<string name="onboarding_chat_manage_description">Não requer Relay</string>
<string name="onboarding_power_tools_label">Ferramentas depois</string>
<string name="onboarding_power_tools_description">Pareie o Relay para usar\nTerminal ou Bridge</string>
<string name="onboarding_setup_guide_hint">O guia de configuração tem comandos para copiar e colar quando você precisar iniciar o Hermes em um computador ou servidor.</string>
<string name="onboarding_setup_guide">Guia de configuração</string>
<string name="onboarding_hermes_docs">Documentação do Hermes</string>
@@ -49,6 +51,18 @@
<string name="onboarding_realtime_label">Tempo real</string>
<string name="onboarding_realtime_description">Agente de voz em tempo real nativo do provedor e provedores de voz específicos para cada perfil.</string>
<string name="onboarding_review_permissions">Revisar permissões</string>
<string name="onboarding_finish_setup_title">Concluir configuração</string>
<string name="onboarding_finish_setup_description">Sua conexão com o Hermes está pronta. Escolha agora o que este telefone pode fazer ou altere depois nas Configurações.</string>
<string name="onboarding_chat_manage_ready">Pronto — nenhuma permissão do telefone é necessária.</string>
<string name="onboarding_chat_alerts">Alertas do chat</string>
<string name="onboarding_chat_alerts_description">Permita notificações do Android para que os alertas ativados cheguem em segundo plano.</string>
<string name="onboarding_chat_alerts_ready">As notificações estão ativadas para este app.</string>
<string name="onboarding_optional_features">Recursos opcionais</string>
<string name="onboarding_optional_features_description">Câmera, microfone, assistente de notificações e ferramentas disponíveis do dispositivo ficam desativados até você escolhê-los.</string>
<string name="onboarding_review_optional_permissions">Revisar permissões opcionais</string>
<string name="onboarding_enable_chat_alerts">Ativar alertas do chat</string>
<string name="onboarding_not_now">Agora não</string>
<string name="onboarding_finish">Concluir configuração</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">Conversa por voz ao vivo</string>
<string name="chat_input_add_attachment">Adicionar anexo</string>
@@ -62,7 +76,7 @@
<string name="chat_input_start_voice">Iniciar conversa por voz</string>
<string name="chat_input_voice_setup_needed">Conversa por voz — configuração necessária</string>
<string name="chat_input_stop_streaming">Parar transmissão</string>
<string name="chat_input_steer_response">Direcionar a resposta</string>
<string name="chat_input_steer_response">Corrigir a resposta</string>
<string name="chat_input_queue_message">Colocar mensagem na fila</string>
<!-- Bridge return labels -->
<string name="bridge_return_chat_label">Chat</string>
@@ -88,7 +102,7 @@
<string name="demo_feature_manage">Gerenciar</string>
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">Edite sua mensagem…</string>
<string name="chat_placeholder_steer">Direcione a resposta…</string>
<string name="chat_placeholder_steer">Corrija a resposta…</string>
<string name="chat_placeholder_queue">Coloque uma mensagem na fila…</string>
<string name="chat_placeholder_message">Mensagem…</string>
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
@@ -253,6 +267,39 @@
<string name="error_classify_open_settings">Abrir Configurações</string>
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Conectar ao Hermes</string>
<string name="cw_nearby_description">Vamos procurar o Hermes nesta rede. O Painel e o Gateway fornecem a conexão padrão para Chat, Gerenciar e Voz.</string>
<string name="cw_before_connecting">Antes de conectar</string>
<string name="cw_connect_step_server_title">Inicie o Hermes no computador</string>
<string name="cw_connect_step_server_body">Inicie o Painel no computador com Hermes. No primeiro acesso pela rede local, use o guia abaixo para torná-lo acessível pelo celular e ativar o login.</string>
<string name="cw_connect_step_network_title">Deixe o servidor acessível</string>
<string name="cw_connect_step_network_body">Use a mesma rede Wi-Fi ou conecte os dois dispositivos ao Tailscale.</string>
<string name="cw_connect_step_phone_title">Conecte por este celular</string>
<string name="cw_connect_step_phone_body">Pesquise abaixo ou informe o endereço do Painel, como 192.168.1.10:9119. Entre na conta se solicitado; nenhuma chave de API é necessária.</string>
<string name="cw_nearby_searching">Procurando por perto…</string>
<string name="cw_nearby_searching_hint">Mantenha o celular na mesma rede que o servidor Hermes.</string>
<string name="cw_nearby_heading">Hermes por perto</string>
<string name="cw_nearby_empty">Nenhum servidor Hermes encontrado</string>
<string name="cw_nearby_failed">Não foi possível pesquisar nesta rede</string>
<string name="cw_nearby_empty_hint">Verifique se o Hermes está em execução e pesquise novamente ou informe o endereço.</string>
<string name="cw_nearby_search_again">Pesquisar novamente</string>
<string name="cw_nearby_enter_address">Informar endereço</string>
<string name="cw_other_connection_methods">Outros métodos de conexão</string>
<string name="cw_manual_hermes_title">Informe o endereço do Hermes</string>
<string name="cw_manual_hermes_description">Informe o endereço do Painel usado no navegador. Se ele não abrir neste celular, inicie hermes dashboard e verifique o Wi-Fi ou o Tailscale.</string>
<string name="cw_hermes_address">Endereço do Hermes</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 ou hermes.example.com</string>
<string name="cw_hermes_address_hint">Nenhuma chave de API é necessária. A porta 9119 é usada quando nenhuma porta é informada.</string>
<string name="cw_find_hermes">Procurar Hermes</string>
<string name="cw_hermes_found">Hermes encontrado</string>
<string name="cw_ready_to_connect">Pronto para conectar</string>
<string name="cw_ready">Pronto</string>
<string name="cw_available_after_signin">Disponível após entrar</string>
<string name="cw_unavailable_server">Indisponível neste servidor</string>
<string name="cw_could_not_verify">Não foi possível verificar</string>
<string name="cw_choose_another">Escolher outro</string>
<string name="cw_sign_in_to_hermes">Entrar no Hermes</string>
<string name="cw_semantics_hermes_available">Hermes disponível</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Inicie a API/o painel do Hermes no host e conecte este app. O pareamento com o Relay é opcional e necessário apenas para Terminal, Bridge, sessões do relay e permissões de canais.</string>
<string name="cw_try_demo">Experimentar a demonstração</string>
<string name="cw_try_demo_subtitle">Explore offline — nenhum servidor é necessário.</string>
@@ -295,7 +342,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 ou http://your-server:8642</string>
<string name="cw_api_url_supporting">API do Hermes usada pelo Chat e pelas sessões — a porta 8642 da API e http:// são presumidos para hosts sem esquema (a porta 9119 do painel é determinada separadamente)</string>
<string name="cw_scan_message">Procurando o painel/a API do Hermes nesta LAN…</string>
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e a voz — a chave da API é opcional para o Chat.</string>
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e voz — a chave da API é usada apenas no fallback opcional pela API direta.</string>
<string name="cw_pair_relay_section">Parear o Relay (opcional)</string>
<string name="cw_pair_relay_section_desc">O plugin do Relay já está em execução? Faça o pareamento aqui para ativar Terminal, Bridge e permissões de canais.</string>
<string name="cw_pair_relay_url_label">URL do Relay</string>
@@ -417,7 +464,7 @@
<string name="endpoints_public">Pública</string>
<string name="endpoints_custom">Personalizada</string>
<string name="endpoints_route_name">Nome da rota</string>
<string name="endpoints_api_url_host">URL ou host do servidor API</string>
<string name="endpoints_api_url_host">URL ou host do Dashboard/Gateway</string>
<string name="endpoints_saving">Salvando…</string>
<string name="endpoints_save">Salvar</string>
<string name="endpoints_close">Fechar</string>
@@ -544,7 +591,7 @@
<string name="settings_hermes_management">Gerenciamento do Hermes</string>
<string name="settings_hermes_management_desc">Recursos do painel: habilidades, cron, MCP, perfis e modelos</string>
<string name="settings_chat">Chat</string>
<string name="settings_chat_desc">Comportamento do chat da API, endpoints, exibição de ferramentas e tamanho das mensagens</string>
<string name="settings_chat_desc">Comportamento do chat, Gateway, fallback da API, exibição de ferramentas e tamanho das mensagens</string>
<string name="settings_voice_mode">Modo de voz</string>
<string name="settings_voice_mode_desc">Voz do painel, opções de relay em tempo real e provedores</string>
<string name="settings_threads">Threads</string>
@@ -586,9 +633,9 @@
<string name="settings_persistent_connection">Conexão persistente</string>
<string name="settings_persistent_connection_desc">Mantém sua conexão com o Hermes aberta em segundo plano</string>
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</string>
<string name="settings_turn_complete_alerts">Alertas de turno concluído</string>
<string name="settings_turn_complete_alerts_desc">Notifique quando uma resposta terminar enquanto o app estiver em segundo plano</string>
<string name="settings_turn_complete_alerts_off">Sem alerta quando uma resposta em segundo plano terminar</string>
<string name="settings_turn_complete_alerts">Alertas de chat</string>
<string name="settings_turn_complete_alerts_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
<string name="settings_keep_connected_deep_sleep">Manter conectado em suspensão profunda</string>
<string name="settings_keep_connected_battery_desc">O Android ainda pode pausar a conexão depois que a tela fica desligada por algum tempo (Doze). Permita o uso irrestrito da bateria para que a Conexão persistente continue funcionando em segundo plano.</string>
<string name="settings_allow_unrestricted_battery">Permitir uso irrestrito da bateria</string>
@@ -617,8 +664,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Mostre suas mensagens recentes como chips tocáveis acima do editor para enviá-las novamente. Desativado por padrão.</string>
<string name="chat_settings_keep_keyboard_open">Manter o teclado aberto ao enviar</string>
<string name="chat_settings_keep_keyboard_open_desc">Permaneça no editor após enviar. Desative para fechar o teclado depois de cada mensagem enviada.</string>
<string name="chat_settings_notify_when_finishes">Notificar quando o Hermes terminar</string>
<string name="chat_settings_notify_when_finishes_desc">Publique uma notificação quando uma resposta for concluída enquanto o app estiver em segundo plano</string>
<string name="chat_settings_notify_when_finishes">Alertas de chat em segundo plano</string>
<string name="chat_settings_notify_when_finishes_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
<string name="chat_settings_share_phone_status">Compartilhar o status do celular com o agente</string>
<string name="chat_settings_share_phone_status_desc">Inclua uma breve mensagem do sistema sobre o app e o celular em cada turno do chat</string>
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
@@ -699,6 +746,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">Opcional</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">Chat</string>
<string name="conn_manage_label">Gerenciar</string>
<string name="conn_dashboard_label">Painel</string>
<string name="conn_voice_label">Voz</string>
<string name="conn_relay_label">Relay</string>
@@ -1375,7 +1424,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Voz do plugin do Relay — provedores específicos por perfil e saída de voz em streaming.</string>
<string name="voice_settings_optional">Opcional</string>
<string name="voice_settings_signin_route_hint">Você está conectado pela rota %1$s, e os logins do painel são específicos por host — o login feito na sua rede doméstica não é transferido. Entre uma vez em Gerenciar enquanto estiver nesta rota para liberar a voz aqui também.</string>
<string name="voice_settings_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Abra Gerenciar para entrar novamente e liberar a voz.</string>
<string name="voice_settings_signin_default_hint">Seu painel do Hermes exige login antes que a voz padrão possa transcrever ou falar. Entrar uma vez em Gerenciar libera a voz para esta conexão.</string>
<string name="voice_settings_sign_in_via_manage">Entrar por Gerenciar</string>
<string name="voice_settings_unsupported_build_body">Esta versão do servidor Hermes ainda não oferece as rotas de áudio do painel. Atualize o hermes-agent no servidor ou pareie o Relay para usar a voz do Relay.</string>
@@ -1605,7 +1654,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">É necessário entrar no painel</string>
<string name="dashboard_signin_required_body">O Gerenciar usa a sessão do painel do Hermes em %1$s.</string>
<string name="dashboard_signin_route_hint">Você está na rota %1$s. Os logins do painel são específicos por host, então o login da outra rota não é transferido — entre uma vez aqui, e o app manterá as duas sessões.</string>
<string name="dashboard_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Entre novamente para atualizá-la nas rotas confiáveis desta conexão.</string>
<string name="dashboard_signin_with_provider">Entrar com %1$s</string>
<string name="dashboard_username_password">Nome de usuário e senha</string>
<string name="dashboard_username">Nome de usuário</string>
@@ -1651,6 +1700,31 @@
<string name="dashboard_action_use">Usar</string>
<string name="dashboard_action_describe">Descrever</string>
<string name="dashboard_action_model">Modelo</string>
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
<string name="dashboard_tile_custom_endpoints_title">Endpoints personalizados</string>
<string name="dashboard_tile_custom_endpoints_sub">Provedores compatíveis com OpenAI</string>
<string name="dashboard_action_authenticate">Autenticar</string>
<string name="dashboard_action_validate">Validar</string>
<string name="dashboard_action_edit">Editar</string>
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
<string name="dashboard_mcp_oauth_body">O Hermes abrirá o provedor no navegador. Volte aqui depois de aprovar o acesso; as credenciais permanecem no servidor Hermes.</string>
<string name="dashboard_mcp_oauth_approved">Autenticação MCP aprovada</string>
<string name="dashboard_mcp_oauth_failed">Falha na autenticação MCP</string>
<string name="dashboard_mcp_oauth_no_browser">Nenhum navegador está disponível para concluir a autenticação MCP.</string>
<string name="dashboard_custom_endpoint_add">Adicionar endpoint</string>
<string name="dashboard_custom_endpoint_edit">Editar endpoint</string>
<string name="dashboard_custom_endpoint_name">Nome</string>
<string name="dashboard_custom_endpoint_url">URL base</string>
<string name="dashboard_custom_endpoint_model">Modelo padrão</string>
<string name="dashboard_custom_endpoint_key">Chave de API (opcional)</string>
<string name="dashboard_custom_endpoint_key_help">Deixe em branco para preservar uma chave existente ao salvar. A validação usa somente a chave inserida aqui; o Hermes não expõe nem apaga as chaves salvas.</string>
<string name="dashboard_custom_endpoint_context">Tamanho do contexto (opcional)</string>
<string name="dashboard_custom_endpoint_discover">Descobrir modelos em /models</string>
<string name="dashboard_custom_endpoint_valid">Endpoint acessível · %1$d modelo(s)</string>
<string name="dashboard_custom_endpoint_validate_failed">Falha ao validar o endpoint</string>
<string name="dashboard_custom_endpoint_save_failed">Falha ao salvar o endpoint</string>
<string name="dashboard_custom_endpoint_saved">Endpoint personalizado salvo</string>
<string name="dashboard_action_completed">%1$s concluído</string>
<string name="dashboard_action_failed">Falha em %1$s</string>
<string name="dashboard_more">Mais</string>
@@ -1945,9 +2019,9 @@
<string name="active_section_allow_plain_connections">Permitir conexões sem criptografia</string>
<string name="active_section_api_hermes_voice_reachable">API acessível — a voz do Hermes está configurada</string>
<string name="active_section_api_key_already_set">A chave da API já está definida</string>
<string name="active_section_api_key_needed_hint">Insira uma chave se o servidor API exigir.</string>
<string name="active_section_api_key_needed_hint">Use o API_SERVER_KEY criado no seu servidor Hermes. O aplicativo não fornece essa chave.</string>
<string name="active_section_api_key_not_configured">Chave da API não configurada</string>
<string name="active_section_api_key_optional">Chave da API (opcional)</string>
<string name="active_section_api_key_optional">Chave da API direta</string>
<string name="active_section_api_key_stored_hint">A chave está armazenada com segurança.</string>
<string name="active_section_api_reachable_voice_review">API acessível — revise a configuração de voz</string>
<string name="active_section_api_relay_voice_reachable">API acessível — a voz do Relay está configurada</string>
@@ -2054,6 +2128,7 @@
<string name="conn_info_api_server_title">Servidor API</string>
<string name="conn_info_approvals_off">As aprovações estão DESATIVADAS</string>
<string name="conn_info_auth">Autenticação</string>
<string name="conn_info_relay_auth">Autenticação do Relay</string>
<string name="conn_info_avg_ttft">TTFT médio</string>
<string name="conn_info_channel_grants">Permissões de canais</string>
<string name="conn_info_checking">Verificando…</string>
@@ -2093,6 +2168,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (pareado)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay pareado</string>
<string name="conn_info_idle_suffix"> · Ocioso</string>
<string name="conn_info_insecure_mode_allowed">Modo inseguro permitido</string>
<string name="conn_info_inspect_profile">Inspecionar %1$s</string>
@@ -2304,6 +2380,7 @@
<string name="tool_progress_status_completed">concluída</string>
<string name="tool_progress_status_failed">falhou</string>
<string name="tool_progress_status_running">em execução</string>
<string name="image_generation_rendering">Gerando imagem</string>
<string name="tool_progress_cd_collapse">Recolher</string>
<string name="tool_progress_cd_expand">Expandir</string>
<!-- AgentIconRow -->
@@ -2399,7 +2476,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">Ler QR do Hermes</string>
<string name="qr_scanner_instruction">Leia um QR de configuração do Hermes</string>
<string name="qr_scanner_subtext">Peça ao Hermes: "Gere um código QR com a URL da API e a chave da API."</string>
<string name="qr_scanner_subtext">Escaneie um QR de configuração do Hermes ou de pareamento do Relay. Conexões padrão pelo Dashboard não exigem uma chave de API.</string>
<string name="qr_scanner_relay_title">Escanear QR do Relay</string>
<string name="qr_scanner_relay_instruction">Escaneie um QR de pareamento do Relay</string>
<string name="qr_scanner_relay_subtext">Abra o QR de pareamento do Relay no seu servidor Hermes. A conexão do Dashboard e as rotas existentes não serão alteradas.</string>
<string name="qr_scanner_camera_error">Não foi possível iniciar a câmera neste dispositivo.</string>
<string name="qr_scanner_fallback_message">Você pode parear sem usar a câmera.</string>
<string name="qr_scanner_pair_manual">Parear manualmente</string>
@@ -2818,12 +2898,12 @@
<string name="diag_check_voice_relay">Voz (Relay)</string>
<string name="diag_check_voice_standard">Voz (Padrão)</string>
<string name="endpoints_pin_title">Endpoints fixados</string>
<string name="endpoints_route_editor_desc">Editar rota</string>
<string name="endpoints_route_editor_desc">Adicione o endereço do Dashboard/Gateway que este celular deve usar nessa rede.</string>
<string name="endpoints_route_name_placeholder">Nome da rota</string>
<string name="endpoints_url_host_placeholder">Host</string>
<string name="endpoints_url_supporting_blank">Sem URLs de apoio</string>
<string name="endpoints_url_supporting_enter">Insira uma URL</string>
<string name="endpoints_url_supporting_preview">Prévia</string>
<string name="endpoints_url_host_placeholder">100.x.y.z ou host.ts.net</string>
<string name="endpoints_url_supporting_blank">Insira o endereço do Dashboard do servidor</string>
<string name="endpoints_url_supporting_enter">Use um endereço de Dashboard http:// ou https://</string>
<string name="endpoints_url_supporting_preview">Será testado: %1$s</string>
<string name="image_viewer_error">Erro ao salvar a imagem</string>
<string name="image_viewer_failed">Falha ao salvar a imagem</string>
<string name="image_viewer_failed_template">Falha ao salvar a imagem: %s</string>
@@ -2979,4 +3059,122 @@
<string name="tool_output_risk_a11y">, risco %1$s na saída</string>
<string name="tool_output_risk_findings">Riscos encontrados na saída</string>
<string name="tool_output_risk_redacted">Trechos sensíveis foram ocultados na origem.</string>
<string name="conn_startup_title">Ao iniciar o app</string>
<string name="conn_startup_last_used">Última usada</string>
<string name="conn_startup_recommended">Recomendado</string>
<string name="conn_startup_choose">Escolher conexão inicial</string>
<string name="active_section_primary_dashboard">Dashboard principal</string>
<string name="active_section_dashboard_reachable_signed_in">Acessível · Conectado</string>
<string name="active_section_dashboard_reachable">Acessível</string>
<string name="active_section_dashboard_not_checked">Ainda não verificado</string>
<string name="active_section_dashboard_unreachable">Inacessível</string>
<string name="active_section_no_fallback_routes">Ainda não há rotas alternativas</string>
<string name="active_section_no_fallback_routes_desc">Adicione uma rota de API opcional para chat direto alternativo e troca de rede.</string>
<string name="active_section_add_api_fallback">Adicionar rota alternativa</string>
<string name="active_section_security_authentication">Autenticação</string>
<string name="active_section_dashboard_session">Sessão do Dashboard</string>
<string name="active_section_credential_storage">Armazenamento de credenciais</string>
<string name="active_section_encrypted_storage">Armazenamento criptografado</string>
<string name="active_section_no_relay_credential">Sem credencial do Relay</string>
<string name="active_section_sign_out_dashboard">Sair do Dashboard</string>
<string name="active_section_credentials_encrypted">As credenciais permanecem criptografadas neste dispositivo.</string>
<string name="cw_preparing_connection">Preparando a conexão…</string>
<string name="cw_preparing_connection_hint">Configurando o armazenamento local seguro. Isso levará apenas um instante.</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Emparelhe o Relay para adicionar Terminal, Bridge, ferramentas do dispositivo, sessões do Relay e permissões.</string>
<string name="cw_relay_pair_qr">Emparelhar Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Escaneie um QR de configuração do Relay</string>
<string name="cw_relay_enter_code">Inserir código de emparelhamento</string>
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
<string name="cw_dashboard_connected_body">Dashboard e Gateway estão prontos. Você pode começar a conversar ou abrir Manage.</string>
<string name="cw_continue">Continuar</string>
<string name="cw_timeline_discovered">Hermes encontrado</string>
<string name="cw_timeline_discovered_detail">Identidade do Dashboard e endpoint de status verificados</string>
<string name="cw_timeline_access">Acesso ao Dashboard</string>
<string name="cw_timeline_access_ready">Nenhum login adicional necessário</string>
<string name="cw_timeline_authenticated">Autenticação verificada</string>
<string name="cw_timeline_ready">Conexão pronta</string>
<string name="cw_timeline_ready_detail">Chat, Manage e Voice podem usar este Dashboard</string>
<string name="active_section_optional_api_fallback">Fallback opcional pela API direta</string>
<string name="active_section_api_not_required">Não é necessário quando esta conexão usa o Hermes Dashboard.</string>
<string name="active_section_where_api_key">Onde obtenho essa chave?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY é criada no seu servidor Hermes; este aplicativo não fornece a chave. Configure-a somente ao ativar o servidor de API opcional, que exige uma chave utilizável, e insira aqui o mesmo valor.</string>
<string name="active_section_scan_relay_qr">Escanear QR do Relay</string>
<string name="active_section_other_relay_methods">Outros métodos de pareamento</string>
<string name="cw_pair_relay_for">Emparelhar Relay com %1$s</string>
<string name="cw_pair_relay_scoped_desc">Adicione a extensão opcional Relay a esta conexão Hermes salva. Isso não adiciona nem substitui um servidor.</string>
<string name="cw_current_connection">Conexão atual</string>
<string name="cw_current_hermes_connection">CONEXÃO HERMES ATUAL</string>
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice e suas rotas salvas permanecem inalterados.</string>
<string name="detail_dashboard_primary">Dashboard principal</string>
<string name="detail_core_ready">Núcleo do Hermes pronto</string>
<string name="detail_core_configured">Núcleo do Hermes configurado</string>
<string name="detail_overview_summary">Chat, Manage e Voice usam o Hermes padrão. O Relay é uma extensão opcional para recursos avançados do dispositivo.</string>
<plurals name="conn_server_count">
<item quantity="one">%1$d servidor</item>
<item quantity="other">%1$d servidores</item>
</plurals>
<string name="conn_switch">Trocar</string>
<string name="conn_switching">Trocando…</string>
<string name="conn_switched">Ativa</string>
<string name="conn_connecting_to">Conectando a %1$s…</string>
<string name="conn_last_used_now">Último uso: agora mesmo</string>
<string name="conn_last_used_format">Último uso: %1$s</string>
<string name="conn_just_now">agora mesmo</string>
<string name="conn_dashboard_only_route">Somente Dashboard</string>
<string name="conn_no_routes">Nenhuma rota configurada</string>
<string name="active_section_reachable_badge">ACESSÍVEL</string>
<string name="active_section_unchecked_badge">NÃO VERIFICADO</string>
<string name="active_section_edit">Editar</string>
<string name="active_section_fallback_routes">Rotas alternativas</string>
<string name="active_section_route_selection">Seleção de rota</string>
<string name="active_section_automatic">Automática</string>
<string name="active_section_api_access">Acesso à API</string>
<string name="active_section_core_hermes">Núcleo do Hermes</string>
<string name="active_section_optional_relay">Relay opcional</string>
<string name="active_section_extend_connection">Ampliar esta conexão</string>
<string name="active_section_relay_connected_features">Extensões do Relay</string>
<string name="active_section_relay_optional_summary">Adicione Terminal, Bridge, ferramentas do dispositivo, sessões do Relay e rotas remotas seguras.</string>
<string name="active_section_view_relay_details">Ver detalhes do Relay</string>
<string name="active_section_core_unchanged">Sua conexão Hermes atual permanece inalterada.</string>
<string name="active_section_api_optional_direct">Opcional para chat direto e integrações</string>
<string name="active_section_configure_test">Configurar e testar</string>
<string name="active_section_relay_optional_bridge">Recursos opcionais do Bridge e acesso remoto</string>
<string name="active_section_configure_relay">Configurar Relay</string>
<string name="active_section_pair_device_using_code">Emparelhe este dispositivo usando um código do servidor.</string>
<string name="active_section_enter_pairing_code">Inserir código de emparelhamento</string>
<string name="active_section_done">Concluído</string>
<string name="active_section_connection_behavior">Comportamento da conexão</string>
<string name="active_section_manual_pairing">Emparelhamento manual</string>
<string name="active_section_paired">Emparelhado</string>
<string name="active_section_not_paired">Não emparelhado</string>
<string name="active_section_transport">Transporte</string>
<string name="active_section_hardware_backed">Protegido por hardware</string>
<string name="active_section_relay_session">Sessão do Relay</string>
<string name="active_section_access">Acesso</string>
<string name="active_section_paired_devices">Dispositivos emparelhados</string>
<string name="active_section_device_count">%1$d dispositivos</string>
<string name="active_section_session_activity">Atividade da sessão</string>
<string name="active_section_last_checked_just_now">Verificado agora mesmo</string>
<string name="active_section_protected">Protegido</string>
<string name="active_section_not_encrypted">Não criptografado</string>
<string name="active_section_no_security_issues">Nenhum problema de segurança detectado</string>
<string name="active_section_unencrypted_transport">Esta conexão usa um transporte não criptografado</string>
<string name="active_section_actions">Ações</string>
<string name="active_section_revoke_relay">Revogar emparelhamento do Relay</string>
<string name="active_section_choose_how_phone_reaches_named">Como este celular acessa %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · LAN doméstica</string>
<string name="endpoints_tailscale_setup_hint">O Tailscale deve estar conectado nos dois dispositivos, e o Hermes Dashboard deve estar acessível na porta 9119.</string>
<string name="endpoints_setup_help">Ajuda para configurar o Tailscale</string>
<string name="voice_settings_provider_desc">Onde a fala e gerada. As opcoes disponiveis vêm deste host Hermes e dos provedores instalados.</string>
<string name="voice_settings_model_desc">Latest acompanha atualizacoes do provedor. Escolha um modelo versionado para manter o comportamento de voz fixo.</string>
<string name="voice_settings_voice_desc">Escolha como as respostas soam. Previsualizar uma voz nao salva a selecao.</string>
<string name="voice_settings_language_desc">Automatico deixa o provedor detectar o idioma falado. Escolha um idioma somente quando a deteccao nao for confiavel.</string>
<string name="voice_settings_language_auto">Automatico</string>
<string name="voice_settings_provider_options_title">Opcoes de voz</string>
<string name="voice_settings_provider_options_desc">Somente configuracoes aceitas pelo provedor selecionado sao mostradas.</string>
<string name="voice_settings_auto_speak">Ler respostas em voz alta</string>
<string name="voice_settings_auto_speak_desc">Fala automaticamente as respostas do assistente em superficies Hermes que respeitam esta configuracao do host.</string>
<string name="voice_settings_realtime_model_desc">Controla a sessao de fala ao vivo, nao o modelo de chat Hermes. Latest acompanha atualizacoes do provedor; um modelo versionado fica fixo.</string>
<string name="voice_settings_realtime_voice_desc">A voz usada dentro da sessao ao vivo. Vozes integradas e personalizadas aparecem quando o provedor as anuncia.</string>
</resources>
+223 -26
View File
@@ -14,14 +14,16 @@
<string name="onboarding_next">下一步</string>
<string name="onboarding_connect">连接</string>
<string name="onboarding_welcome_title">Hermes-Relay Android 版</string>
<string name="onboarding_welcome_description">在手机上和 Hermes 对话,并管理你的仪表盘。</string>
<string name="onboarding_get_started">开始使用</string>
<string name="onboarding_step_count">第 %1$d 步,共 %2$d 步</string>
<string name="onboarding_welcome_title">Hermes,\n尽在掌中</string>
<string name="onboarding_welcome_description">与你自己的 Hermes 聊天、管理\n并使用语音。</string>
<string name="onboarding_welcome_badge">欢迎</string>
<string name="onboarding_hermes_logo">Hermes 标志</string>
<string name="onboarding_chat_manage_label">聊天与管理</string>
<string name="onboarding_chat_manage_description">连接正在运行的 Hermes 仪表盘和 API。无需安装 Relay,也无需配对。</string>
<string name="onboarding_power_tools_label">高级工具</string>
<string name="onboarding_power_tools_description">需要终端、Bridge、Relay 会话或频道授权时,再添加 Hermes-Relay。</string>
<string name="onboarding_chat_manage_label">仪表盘优先</string>
<string name="onboarding_chat_manage_description">无需 Relay</string>
<string name="onboarding_power_tools_label">高级工具稍后添加</string>
<string name="onboarding_power_tools_description">需要终端或 Bridge 时\n再配对 Relay</string>
<string name="onboarding_setup_guide_hint">设置指南里提供了复制即用的命令,方便你在电脑或服务器上启动 Hermes。</string>
<string name="onboarding_setup_guide">安装指南</string>
<string name="onboarding_hermes_docs">Hermes 文档</string>
@@ -54,6 +56,18 @@
<string name="onboarding_realtime_label">实时语音</string>
<string name="onboarding_realtime_description">提供商原生的实时语音代理,以及按配置文件区分的语音提供商。</string>
<string name="onboarding_review_permissions">查看权限</string>
<string name="onboarding_finish_setup_title">完成设置</string>
<string name="onboarding_finish_setup_description">Hermes 连接已就绪。现在选择这部手机可以使用的功能,或稍后在设置中更改。</string>
<string name="onboarding_chat_manage_ready">已就绪 — 无需手机权限。</string>
<string name="onboarding_chat_alerts">聊天提醒</string>
<string name="onboarding_chat_alerts_description">允许 Android 通知,以便已启用的提醒可在后台送达。</string>
<string name="onboarding_chat_alerts_ready">此应用的通知已启用。</string>
<string name="onboarding_optional_features">可选功能</string>
<string name="onboarding_optional_features_description">相机、麦克风、通知助手和可用的设备工具会保持关闭,直到你主动选择。</string>
<string name="onboarding_review_optional_permissions">查看可选权限</string>
<string name="onboarding_enable_chat_alerts">启用聊天提醒</string>
<string name="onboarding_not_now">暂不</string>
<string name="onboarding_finish">完成设置</string>
<!-- 聊天输入栏 -->
<string name="chat_input_live_voice_hint">实时语音对话</string>
@@ -68,7 +82,7 @@
<string name="chat_input_start_voice">开始语音对话</string>
<string name="chat_input_voice_setup_needed">语音对话——需要先设置</string>
<string name="chat_input_stop_streaming">停止流式响应</string>
<string name="chat_input_steer_response">引导回复方向</string>
<string name="chat_input_steer_response">修正回复</string>
<string name="chat_input_queue_message">排队发送消息</string>
<!-- Bridge 返回标签 -->
@@ -99,7 +113,7 @@
<!-- 聊天界面占位符 -->
<string name="chat_placeholder_edit">编辑你的消息…</string>
<string name="chat_placeholder_steer">引导回复方向…</string>
<string name="chat_placeholder_steer">修正回复…</string>
<string name="chat_placeholder_queue">排队发送消息…</string>
<string name="chat_placeholder_message">输入消息…</string>
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
@@ -274,6 +288,39 @@
<!-- 连接向导 —— Connect 页核心文案 -->
<string name="cw_connect_to_hermes">连接 Hermes</string>
<string name="cw_nearby_description">我们将在此网络中查找 Hermes。仪表板和网关提供聊天、管理和语音的标准连接。</string>
<string name="cw_before_connecting">连接之前</string>
<string name="cw_connect_step_server_title">在电脑上启动 Hermes</string>
<string name="cw_connect_step_server_body">在 Hermes 电脑上启动仪表板。首次通过局域网访问时,请使用下方的设置指南,让手机能够访问并启用登录。</string>
<string name="cw_connect_step_network_title">让服务器可访问</string>
<string name="cw_connect_step_network_body">使用同一个 Wi-Fi,或将两台设备都连接到 Tailscale。</string>
<string name="cw_connect_step_phone_title">从此手机连接</string>
<string name="cw_connect_step_phone_body">在下方搜索或输入仪表板地址,例如 192.168.1.10:9119。如有提示请登录;无需 API 密钥。</string>
<string name="cw_nearby_searching">正在搜索附近设备…</string>
<string name="cw_nearby_searching_hint">请确保手机与 Hermes 服务器位于同一网络。</string>
<string name="cw_nearby_heading">附近的 Hermes</string>
<string name="cw_nearby_empty">未找到 Hermes 服务器</string>
<string name="cw_nearby_failed">无法搜索此网络</string>
<string name="cw_nearby_empty_hint">请确认 Hermes 正在运行,然后重新搜索或输入其地址。</string>
<string name="cw_nearby_search_again">重新搜索</string>
<string name="cw_nearby_enter_address">改为输入地址</string>
<string name="cw_other_connection_methods">其他连接方式</string>
<string name="cw_manual_hermes_title">输入 Hermes 地址</string>
<string name="cw_manual_hermes_description">请输入你在浏览器中打开的仪表板地址。如果此手机无法打开,请启动 hermes dashboard 并检查 Wi-Fi 或 Tailscale。</string>
<string name="cw_hermes_address">Hermes 地址</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 或 hermes.example.com</string>
<string name="cw_hermes_address_hint">无需 API 密钥。未指定端口时使用仪表板端口 9119。</string>
<string name="cw_find_hermes">查找 Hermes</string>
<string name="cw_hermes_found">已找到 Hermes</string>
<string name="cw_ready_to_connect">可以连接</string>
<string name="cw_ready">就绪</string>
<string name="cw_available_after_signin">登录后可用</string>
<string name="cw_unavailable_server">此服务器不支持</string>
<string name="cw_could_not_verify">无法验证</string>
<string name="cw_choose_another">选择其他服务器</string>
<string name="cw_sign_in_to_hermes">登录 Hermes</string>
<string name="cw_semantics_hermes_available">Hermes 可用</string>
<string name="cw_semantics_capability">%1$s:%2$s</string>
<string name="cw_connect_description">先在主机上启动 Hermes API 或仪表盘,再在此连接。Relay 配对是可选的,仅在需要终端、Bridge、Relay 会话或频道授权时才使用。</string>
<string name="cw_try_demo">体验演示</string>
<string name="cw_try_demo_subtitle">离线探索——无需服务器。</string>
@@ -318,8 +365,13 @@
<string name="cw_api_url_placeholder">192.168.1.10 或 http://你的服务器:8642</string>
<string name="cw_api_url_supporting">聊天和会话使用的 Hermes API——裸主机名默认使用 API 端口 8642 和 http://(仪表盘的 9119 端口单独推导)</string>
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥对聊天是可选的。</string>
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥仅用于可选的直接 API 回退。</string>
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
<string name="cw_pair_relay_for">为 %1$s 配对 Relay</string>
<string name="cw_pair_relay_scoped_desc">为这个已保存的 Hermes 连接添加可选的 Relay 扩展。这不会添加或替换服务器。</string>
<string name="cw_current_connection">当前连接</string>
<string name="cw_current_hermes_connection">当前 HERMES 连接</string>
<string name="cw_existing_connection_unchanged">Chat、Manage、Voice 和已保存的路由均保持不变。</string>
<string name="cw_pair_relay_section_desc">已经在运行 Relay 插件?在此配对以启用终端、Bridge 和频道授权。</string>
<string name="cw_pair_relay_url_label">Relay 地址</string>
<string name="cw_pair_relay_code_label">配对码</string>
@@ -441,7 +493,7 @@
<string name="endpoints_public">公网</string>
<string name="endpoints_custom">自定义</string>
<string name="endpoints_route_name">路由名称</string>
<string name="endpoints_api_url_host">API 服务器地址或主机名</string>
<string name="endpoints_api_url_host">Dashboard/Gateway 地址或主机名</string>
<string name="endpoints_saving">保存中…</string>
<string name="endpoints_save">保存</string>
<string name="endpoints_close">关闭</string>
@@ -580,7 +632,7 @@
<string name="settings_hermes_management">Hermes 管理</string>
<string name="settings_hermes_management_desc">仪表盘功能:技能、定时任务、MCP、配置文件、模型</string>
<string name="settings_chat">聊天</string>
<string name="settings_chat_desc">API 聊天行为、端点、工具显示、消息长度</string>
<string name="settings_chat_desc">聊天行为、Gateway、API 回退、工具显示、消息长度</string>
<string name="settings_voice_mode">语音模式</string>
<string name="settings_voice_mode_desc">仪表盘语音、实时 Relay 选项、提供商</string>
<string name="settings_threads">话题</string>
@@ -622,9 +674,9 @@
<string name="settings_persistent_connection">持久连接</string>
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
<string name="settings_turn_complete_alerts">回合完成提醒</string>
<string name="settings_turn_complete_alerts_desc">应用在后台时回复完成则通知</string>
<string name="settings_turn_complete_alerts_off">后台回复完成时不提醒</string>
<string name="settings_turn_complete_alerts">聊天提醒</string>
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</string>
<string name="settings_keep_connected_deep_sleep">在深度睡眠中保持连接</string>
<string name="settings_keep_connected_battery_desc">屏幕关闭一段时间后 Android 仍可能暂停连接(Doze)。允许不受限制的电池使用,让持久连接在后台继续工作。</string>
<string name="settings_allow_unrestricted_battery">允许不受限制的电池</string>
@@ -654,8 +706,8 @@
<string name="chat_settings_recent_prompt_chips_desc">在输入栏上方显示最近消息为可点击的标签以便重发。默认关闭。</string>
<string name="chat_settings_keep_keyboard_open">发送时保持键盘打开</string>
<string name="chat_settings_keep_keyboard_open_desc">发送后留在输入栏。关闭则在每条消息发送后收起键盘。</string>
<string name="chat_settings_notify_when_finishes">Hermes 完成时通知</string>
<string name="chat_settings_notify_when_finishes_desc">应用在后台时回复完成则发送通知</string>
<string name="chat_settings_notify_when_finishes">后台聊天提醒</string>
<string name="chat_settings_notify_when_finishes_desc">Hermes 在后台需要输入或完成回复时通知</string>
<string name="chat_settings_share_phone_status">与代理分享手机状态</string>
<string name="chat_settings_share_phone_status_desc">每轮聊天附带一条关于应用和手机的简短系统消息</string>
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
@@ -737,6 +789,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">可选</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">聊天</string>
<string name="conn_manage_label">管理</string>
<string name="conn_dashboard_label">仪表盘</string>
<string name="conn_voice_label">语音</string>
<string name="conn_relay_label">Relay</string>
@@ -776,6 +830,10 @@
<string name="detail_inactive_body">此连接未激活。切换到它以查看实时状态并管理路由、高级设置和 Relay 会话。</string>
<string name="detail_switch_to">切换到此连接</string>
<string name="detail_rename_title">重命名连接</string>
<string name="detail_dashboard_primary">Dashboard 为主要连接方式</string>
<string name="detail_core_ready">Hermes 核心已就绪</string>
<string name="detail_core_configured">Hermes 核心已配置</string>
<string name="detail_overview_summary">Chat、Manage 和 Voice 使用标准 Hermes。Relay 是为高级设备功能提供的可选扩展。</string>
<string name="detail_tab_overview">概览</string>
<string name="detail_tab_routes">路由</string>
<string name="detail_tab_advanced">高级</string>
@@ -1427,7 +1485,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Relay 插件语音——支持按个人资料的提供商和流式语音输出。</string>
<string name="voice_settings_optional">可选</string>
<string name="voice_settings_signin_route_hint">您正通过 %1$s 路由连接,而仪表板登录是按主机区分的——家庭网络上的登录不会延续到此。请在此路由上在 Manage 中登录一次,以在此处解锁语音。</string>
<string name="voice_settings_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请打开“管理”重新登录并解锁语音。</string>
<string name="voice_settings_signin_default_hint">您的 Hermes 仪表板需要先登录,标准语音才能转录或朗读。在 Manage 中登录一次即可为此连接解锁。</string>
<string name="voice_settings_sign_in_via_manage">通过 Manage 登录</string>
<string name="voice_settings_unsupported_build_body">此 Hermes 服务器构建尚未公开仪表板音频路由。请在服务器上更新 hermes-agent,或配对 Relay 以使用 Relay 语音。</string>
@@ -1666,7 +1724,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">需要登录仪表盘</string>
<string name="dashboard_signin_required_body">管理功能使用位于 %1$s 的 Hermes 仪表盘会话。</string>
<string name="dashboard_signin_route_hint">你当前使用 %1$s 路由。仪表盘登录按主机区分,所以在另一条路由上的登录不会自动延续——在这里登录一次,应用会同时保留两个会话。</string>
<string name="dashboard_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请重新登录,以便在此连接的可信路由之间刷新会话。</string>
<string name="dashboard_signin_with_provider">使用 %1$s 登录</string>
<string name="dashboard_username_password">用户名与密码</string>
<string name="dashboard_username">用户名</string>
@@ -1717,6 +1775,31 @@
<string name="dashboard_action_use">使用</string>
<string name="dashboard_action_describe">描述</string>
<string name="dashboard_action_model">模型</string>
<string name="dashboard_tab_custom_endpoints">端点</string>
<string name="dashboard_tab_custom_endpoints_lower">端点</string>
<string name="dashboard_tile_custom_endpoints_title">自定义端点</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 兼容提供商</string>
<string name="dashboard_action_authenticate">认证</string>
<string name="dashboard_action_validate">验证</string>
<string name="dashboard_action_edit">编辑</string>
<string name="dashboard_mcp_oauth_title">认证 %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes 将在浏览器中打开提供商。批准访问后请返回此页面;凭据将保留在 Hermes 服务器上。</string>
<string name="dashboard_mcp_oauth_approved">MCP 认证已获批准</string>
<string name="dashboard_mcp_oauth_failed">MCP 认证失败</string>
<string name="dashboard_mcp_oauth_no_browser">没有可用于完成 MCP 认证的浏览器。</string>
<string name="dashboard_custom_endpoint_add">添加端点</string>
<string name="dashboard_custom_endpoint_edit">编辑端点</string>
<string name="dashboard_custom_endpoint_name">名称</string>
<string name="dashboard_custom_endpoint_url">基础 URL</string>
<string name="dashboard_custom_endpoint_model">默认模型</string>
<string name="dashboard_custom_endpoint_key">API 密钥(可选)</string>
<string name="dashboard_custom_endpoint_key_help">保存时留空可保留现有密钥。验证仅使用在此输入的密钥;Hermes 不会显示或清除已保存的密钥。</string>
<string name="dashboard_custom_endpoint_context">上下文长度(可选)</string>
<string name="dashboard_custom_endpoint_discover">从 /models 发现模型</string>
<string name="dashboard_custom_endpoint_valid">端点可访问 · %1$d 个模型</string>
<string name="dashboard_custom_endpoint_validate_failed">端点验证失败</string>
<string name="dashboard_custom_endpoint_save_failed">端点保存失败</string>
<string name="dashboard_custom_endpoint_saved">自定义端点已保存</string>
<string name="dashboard_action_completed">%1$s 已完成</string>
<string name="dashboard_action_failed">%1$s 失败</string>
<string name="dashboard_more">更多</string>
@@ -2029,9 +2112,9 @@
<string name="active_section_allow_plain_connections">允许明文连接</string>
<string name="active_section_api_hermes_voice_reachable">API 可达——Hermes 语音已配置</string>
<string name="active_section_api_key_already_set">API 密钥已设置</string>
<string name="active_section_api_key_needed_hint">如果 API 服务器需要密钥,请在此输入。</string>
<string name="active_section_api_key_needed_hint">使用在 Hermes 服务器上创建的 API_SERVER_KEY。此密钥并非由应用提供。</string>
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
<string name="active_section_api_key_optional">API 密钥(可选)</string>
<string name="active_section_api_key_optional">直接 API 的 API 密钥</string>
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
<string name="active_section_api_reachable_voice_review">API 可达——请检查语音配置</string>
<string name="active_section_api_relay_voice_reachable">API 可达——Relay 语音已配置</string>
@@ -2139,6 +2222,7 @@
<string name="conn_info_api_server_title">API 服务器</string>
<string name="conn_info_approvals_off">审批已关闭</string>
<string name="conn_info_auth">认证</string>
<string name="conn_info_relay_auth">Relay 认证</string>
<string name="conn_info_avg_ttft">平均 TTFT</string>
<string name="conn_info_channel_grants">频道授权</string>
<string name="conn_info_checking">检查中…</string>
@@ -2178,6 +2262,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s(Hermes)</string>
<string name="conn_info_hostname_paired">%1$s(已配对)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay 已配对</string>
<string name="conn_info_idle_suffix">· 空闲</string>
<string name="conn_info_insecure_mode_allowed">允许不安全模式</string>
<string name="conn_info_inspect_profile">查看 %1$s</string>
@@ -2404,6 +2489,7 @@
<string name="tool_progress_status_completed">已完成</string>
<string name="tool_progress_status_failed">失败</string>
<string name="tool_progress_status_running">运行中</string>
<string name="image_generation_rendering">正在生成图像</string>
<string name="tool_progress_cd_collapse">折叠</string>
<string name="tool_progress_cd_expand">展开</string>
@@ -2510,7 +2596,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">扫描 Hermes QR</string>
<string name="qr_scanner_instruction">扫描 Hermes 设置二维码</string>
<string name="qr_scanner_subtext">询问 Hermes:"生成一个包含我的 API URL 和 API 密钥的二维码。"</string>
<string name="qr_scanner_subtext">扫描 Hermes 设置二维码或 Relay 配对二维码。标准 Dashboard 连接不需要 API 密钥。</string>
<string name="qr_scanner_relay_title">扫描 Relay 二维码</string>
<string name="qr_scanner_relay_instruction">扫描 Relay 配对二维码</string>
<string name="qr_scanner_relay_subtext">在 Hermes 服务器上打开 Relay 配对二维码。现有 Dashboard 连接和路由将保持不变。</string>
<string name="qr_scanner_camera_error">无法在此设备上启动相机。</string>
<string name="qr_scanner_fallback_message">您可以在没有相机的情况下配对。</string>
<string name="qr_scanner_pair_manual">手动配对</string>
@@ -2913,12 +3002,12 @@
<string name="diag_check_voice_relay">语音(Relay)</string>
<string name="diag_check_voice_standard">语音(标准)</string>
<string name="endpoints_pin_title">固定端点</string>
<string name="endpoints_route_editor_desc">编辑路由</string>
<string name="endpoints_route_editor_desc">添加此手机在该网络上应使用的 Dashboard/Gateway 地址。</string>
<string name="endpoints_route_name_placeholder">路由名称</string>
<string name="endpoints_url_host_placeholder">主机</string>
<string name="endpoints_url_supporting_blank">无支持的 URL</string>
<string name="endpoints_url_supporting_enter">输入 URL</string>
<string name="endpoints_url_supporting_preview">预览</string>
<string name="endpoints_url_host_placeholder">100.x.y.z 或 host.ts.net</string>
<string name="endpoints_url_supporting_blank">输入服务器的 Dashboard 地址</string>
<string name="endpoints_url_supporting_enter">使用 http:// 或 https:// Dashboard 地址</string>
<string name="endpoints_url_supporting_preview">将测试:%1$s</string>
<string name="image_viewer_error">保存图片时出错</string>
<string name="image_viewer_failed">保存图片失败</string>
<string name="image_viewer_failed_template">保存图片失败:%s</string>
@@ -3074,4 +3163,112 @@
<string name="tool_output_risk_a11y">,%1$s 输出风险</string>
<string name="tool_output_risk_findings">输出风险发现</string>
<string name="tool_output_risk_redacted">敏感片段已在上游被隐去。</string>
<plurals name="conn_server_count">
<item quantity="other">%1$d 台服务器</item>
</plurals>
<string name="conn_switch">切换</string>
<string name="conn_switching">正在切换…</string>
<string name="conn_switched">使用中</string>
<string name="conn_connecting_to">正在连接到 %1$s…</string>
<string name="conn_last_used_now">刚刚使用过</string>
<string name="conn_last_used_format">上次使用:%1$s</string>
<string name="conn_just_now">刚刚</string>
<string name="conn_dashboard_only_route">仅 Dashboard</string>
<string name="conn_no_routes">未配置路由</string>
<string name="conn_startup_title">应用启动时</string>
<string name="conn_startup_last_used">上次使用</string>
<string name="conn_startup_recommended">推荐</string>
<string name="conn_startup_choose">选择启动连接</string>
<string name="active_section_primary_dashboard">主 Dashboard</string>
<string name="active_section_dashboard_reachable_signed_in">可访问 · 已登录</string>
<string name="active_section_dashboard_reachable">可访问</string>
<string name="active_section_dashboard_not_checked">尚未检查</string>
<string name="active_section_dashboard_unreachable">无法访问</string>
<string name="active_section_no_fallback_routes">尚无备用路由</string>
<string name="active_section_no_fallback_routes_desc">可添加 API 路由,用于直接聊天回退和网络切换。</string>
<string name="active_section_add_api_fallback">添加备用路由</string>
<string name="active_section_security_authentication">身份验证</string>
<string name="active_section_dashboard_session">Dashboard 会话</string>
<string name="active_section_credential_storage">凭据存储</string>
<string name="active_section_encrypted_storage">加密存储</string>
<string name="active_section_no_relay_credential">无 Relay 凭据</string>
<string name="active_section_sign_out_dashboard">退出 Dashboard</string>
<string name="active_section_credentials_encrypted">凭据始终在此设备上加密存储。</string>
<string name="active_section_reachable_badge">可访问</string>
<string name="active_section_unchecked_badge">未检查</string>
<string name="active_section_edit">编辑</string>
<string name="active_section_fallback_routes">备用路由</string>
<string name="active_section_route_selection">路由选择</string>
<string name="active_section_automatic">自动</string>
<string name="active_section_api_access">API 访问</string>
<string name="active_section_core_hermes">Hermes 核心</string>
<string name="active_section_optional_relay">可选 Relay</string>
<string name="active_section_extend_connection">扩展此连接</string>
<string name="active_section_relay_connected_features">Relay 扩展</string>
<string name="active_section_relay_optional_summary">添加 Terminal、Bridge、设备工具、Relay 会话和安全远程路由。</string>
<string name="active_section_view_relay_details">查看 Relay 详情</string>
<string name="active_section_core_unchanged">当前 Hermes 连接保持不变。</string>
<string name="active_section_api_optional_direct">可选,用于直接聊天和集成</string>
<string name="active_section_configure_test">配置并测试</string>
<string name="active_section_relay_optional_bridge">可选的 Bridge 功能和远程访问</string>
<string name="active_section_configure_relay">配置 Relay</string>
<string name="active_section_pair_device_using_code">使用服务器生成的代码配对此设备。</string>
<string name="active_section_enter_pairing_code">输入配对码</string>
<string name="active_section_done">完成</string>
<string name="active_section_connection_behavior">连接行为</string>
<string name="active_section_manual_pairing">手动配对</string>
<string name="active_section_paired">已配对</string>
<string name="active_section_not_paired">未配对</string>
<string name="active_section_transport">传输方式</string>
<string name="active_section_hardware_backed">硬件级保护</string>
<string name="active_section_relay_session">Relay 会话</string>
<string name="active_section_access">访问权限</string>
<string name="active_section_paired_devices">已配对设备</string>
<string name="active_section_device_count">%1$d 台设备</string>
<string name="active_section_session_activity">会话活动</string>
<string name="active_section_last_checked_just_now">刚刚检查过</string>
<string name="active_section_protected">受保护</string>
<string name="active_section_not_encrypted">未加密</string>
<string name="active_section_no_security_issues">未检测到安全问题</string>
<string name="active_section_unencrypted_transport">此连接使用未加密的传输方式</string>
<string name="active_section_actions">操作</string>
<string name="active_section_revoke_relay">撤销 Relay 配对</string>
<string name="active_section_choose_how_phone_reaches_named">此手机如何连接到 %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · 家庭局域网</string>
<string name="cw_preparing_connection">正在准备连接…</string>
<string name="cw_preparing_connection_hint">正在设置安全的本地存储,片刻即可完成。</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">配对 Relay 以添加 Terminal、Bridge、设备工具、Relay 会话和权限。</string>
<string name="cw_relay_pair_qr">配对 Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">扫描 Relay 设置二维码</string>
<string name="cw_relay_enter_code">输入配对码</string>
<string name="cw_dashboard_connected_title">Hermes 已连接</string>
<string name="cw_dashboard_connected_body">Dashboard 和 Gateway 已就绪。你可以开始聊天或打开 Manage。</string>
<string name="cw_continue">继续</string>
<string name="cw_timeline_discovered">已发现 Hermes</string>
<string name="cw_timeline_discovered_detail">已验证 Dashboard 标识和状态端点</string>
<string name="cw_timeline_access">Dashboard 访问</string>
<string name="cw_timeline_access_ready">无需额外登录</string>
<string name="cw_timeline_authenticated">身份验证已确认</string>
<string name="cw_timeline_ready">连接已就绪</string>
<string name="cw_timeline_ready_detail">Chat、Manage 和 Voice 可以使用此 Dashboard</string>
<string name="active_section_optional_api_fallback">可选的直接 API 回退</string>
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时配置;该服务器需要可用密钥,并在此输入相同的值。</string>
<string name="active_section_scan_relay_qr">扫描 Relay 二维码</string>
<string name="active_section_other_relay_methods">其他配对方式</string>
<string name="endpoints_tailscale_setup_hint">两台设备都必须连接 Tailscale,并且 Hermes Dashboard 必须可通过端口 9119 访问。</string>
<string name="endpoints_setup_help">Tailscale 设置帮助</string>
<string name="voice_settings_provider_desc">语音生成的位置。可用选项来自此 Hermes 主机及其已安装的提供商。</string>
<string name="voice_settings_model_desc">Latest 会跟随提供商升级。选择带版本的模型可固定语音行为。</string>
<string name="voice_settings_voice_desc">选择回复的声音。预览语音不会保存选择。</string>
<string name="voice_settings_language_desc">自动会让提供商检测口语语言。仅在检测不可靠时选择语言。</string>
<string name="voice_settings_language_auto">自动</string>
<string name="voice_settings_provider_options_title">语音选项</string>
<string name="voice_settings_provider_options_desc">只显示所选提供商支持的设置。</string>
<string name="voice_settings_auto_speak">朗读回复</string>
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</string>
</resources>
+224 -26
View File
@@ -14,14 +14,16 @@
<string name="onboarding_next">Weiter</string>
<string name="onboarding_connect">Verbinden</string>
<string name="onboarding_welcome_title">Hermes-Relay für Android</string>
<string name="onboarding_welcome_description">Chatte mit Hermes und verwalte dein Dashboard vom Smartphone aus.</string>
<string name="onboarding_get_started">Loslegen</string>
<string name="onboarding_step_count">Schritt %1$d von %2$d</string>
<string name="onboarding_welcome_title">Hermes,\nin deiner Tasche</string>
<string name="onboarding_welcome_description">Chatte, verwalte und nutze Sprache\nmit deinem eigenen Hermes.</string>
<string name="onboarding_welcome_badge">Willkommen</string>
<string name="onboarding_hermes_logo">Hermes-Logo</string>
<string name="onboarding_chat_manage_label">Chat &amp; Verwaltung</string>
<string name="onboarding_chat_manage_description">Verbinde dich mit deinem laufenden Hermes-Dashboard und der API. Keine Relay-Installation oder -Kopplung erforderlich.</string>
<string name="onboarding_power_tools_label">Profiwerkzeuge</string>
<string name="onboarding_power_tools_description">Füge Hermes-Relay für Terminal, Bridge, Relay-Sitzungen und Kanalberechtigungen hinzu.</string>
<string name="onboarding_chat_manage_label">Dashboard zuerst</string>
<string name="onboarding_chat_manage_description">Kein Relay erforderlich</string>
<string name="onboarding_power_tools_label">Profiwerkzeuge später</string>
<string name="onboarding_power_tools_description">Kopple Relay für\nTerminal oder Bridge</string>
<string name="onboarding_setup_guide_hint">Die Einrichtungsanleitung enthält kopierbare Befehle, wenn du Hermes auf einem Computer oder Server starten möchtest.</string>
<string name="onboarding_setup_guide">Einrichtungsanleitung</string>
<string name="onboarding_hermes_docs">Hermes-Dokumentation</string>
@@ -54,6 +56,18 @@
<string name="onboarding_realtime_label">Echtzeit</string>
<string name="onboarding_realtime_description">Anbietereigener Echtzeit-Sprachagent und profilbezogene Sprachanbieter.</string>
<string name="onboarding_review_permissions">Berechtigungen prüfen</string>
<string name="onboarding_finish_setup_title">Einrichtung abschließen</string>
<string name="onboarding_finish_setup_description">Deine Hermes-Verbindung ist bereit. Wähle jetzt aus, was dieses Telefon tun darf, oder ändere es später in den Einstellungen.</string>
<string name="onboarding_chat_manage_ready">Bereit — keine Telefonberechtigung erforderlich.</string>
<string name="onboarding_chat_alerts">Chat-Benachrichtigungen</string>
<string name="onboarding_chat_alerts_description">Erlaube Android-Benachrichtigungen, damit aktivierte Hinweise dich im Hintergrund erreichen.</string>
<string name="onboarding_chat_alerts_ready">Benachrichtigungen sind für diese App aktiviert.</string>
<string name="onboarding_optional_features">Optionale Funktionen</string>
<string name="onboarding_optional_features_description">Kamera, Mikrofon, Benachrichtigungsbegleiter und verfügbare Gerätewerkzeuge bleiben aus, bis du sie auswählst.</string>
<string name="onboarding_review_optional_permissions">Optionale Berechtigungen prüfen</string>
<string name="onboarding_enable_chat_alerts">Chat-Benachrichtigungen aktivieren</string>
<string name="onboarding_not_now">Jetzt nicht</string>
<string name="onboarding_finish">Einrichtung abschließen</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">Live-Sprachgespräch</string>
@@ -68,7 +82,7 @@
<string name="chat_input_start_voice">Sprachgespräch starten</string>
<string name="chat_input_voice_setup_needed">Sprachgespräch — Einrichtung erforderlich</string>
<string name="chat_input_stop_streaming">Streaming stoppen</string>
<string name="chat_input_steer_response">Antwort steuern</string>
<string name="chat_input_steer_response">Antwort korrigieren</string>
<string name="chat_input_queue_message">Nachricht einreihen</string>
<!-- Bridge return labels -->
@@ -99,7 +113,7 @@
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">Nachricht bearbeiten…</string>
<string name="chat_placeholder_steer">Antwort steuern…</string>
<string name="chat_placeholder_steer">Antwort korrigieren…</string>
<string name="chat_placeholder_queue">Nachricht einreihen…</string>
<string name="chat_placeholder_message">Nachricht…</string>
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
@@ -274,6 +288,39 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Mit Hermes verbinden</string>
<string name="cw_nearby_description">Wir suchen in diesem Netzwerk nach Hermes. Dashboard und Gateway stellen die Standardverbindung für Chat, Verwaltung und Sprache bereit.</string>
<string name="cw_before_connecting">Vor dem Verbinden</string>
<string name="cw_connect_step_server_title">Hermes auf dem Computer starten</string>
<string name="cw_connect_step_server_body">Starte das Dashboard auf dem Hermes-Computer. Verwende für den ersten LAN-Zugriff die Anleitung unten, um es vom Smartphone erreichbar zu machen und die Anmeldung einzurichten.</string>
<string name="cw_connect_step_network_title">Server erreichbar machen</string>
<string name="cw_connect_step_network_body">Verwende dasselbe WLAN oder verbinde beide Geräte mit Tailscale.</string>
<string name="cw_connect_step_phone_title">Von diesem Smartphone verbinden</string>
<string name="cw_connect_step_phone_body">Suche unten oder gib die Dashboard-Adresse ein, z. B. 192.168.1.10:9119. Melde dich bei Aufforderung an – ein API-Schlüssel ist nicht erforderlich.</string>
<string name="cw_nearby_searching">Suche in der Nähe…</string>
<string name="cw_nearby_searching_hint">Das Smartphone muss sich im selben Netzwerk wie der Hermes-Server befinden.</string>
<string name="cw_nearby_heading">Hermes in der Nähe</string>
<string name="cw_nearby_empty">Keine Hermes-Server gefunden</string>
<string name="cw_nearby_failed">Dieses Netzwerk konnte nicht durchsucht werden</string>
<string name="cw_nearby_empty_hint">Prüfe, ob Hermes ausgeführt wird, und suche erneut oder gib die Adresse ein.</string>
<string name="cw_nearby_search_again">Erneut suchen</string>
<string name="cw_nearby_enter_address">Stattdessen Adresse eingeben</string>
<string name="cw_other_connection_methods">Andere Verbindungsmethoden</string>
<string name="cw_manual_hermes_title">Hermes-Adresse eingeben</string>
<string name="cw_manual_hermes_description">Gib die Dashboard-Adresse ein, die du im Browser öffnest. Wenn sie auf diesem Smartphone nicht geöffnet wird, starte hermes dashboard und prüfe WLAN oder Tailscale.</string>
<string name="cw_hermes_address">Hermes-Adresse</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 oder hermes.example.com</string>
<string name="cw_hermes_address_hint">Kein API-Schlüssel erforderlich. Ohne Port wird Dashboard-Port 9119 verwendet.</string>
<string name="cw_find_hermes">Hermes suchen</string>
<string name="cw_hermes_found">Hermes gefunden</string>
<string name="cw_ready_to_connect">Verbindungsbereit</string>
<string name="cw_ready">Bereit</string>
<string name="cw_available_after_signin">Nach der Anmeldung verfügbar</string>
<string name="cw_unavailable_server">Auf diesem Server nicht verfügbar</string>
<string name="cw_could_not_verify">Konnte nicht überprüft werden</string>
<string name="cw_choose_another">Andere auswählen</string>
<string name="cw_sign_in_to_hermes">Bei Hermes anmelden</string>
<string name="cw_semantics_hermes_available">Hermes verfügbar</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Starte die Hermes-API bzw. das Dashboard auf deinem Host und verbinde dann diese App. Die Relay-Kopplung ist optional und wird nur für Terminal, Bridge, Relay-Sitzungen und Kanalberechtigungen benötigt.</string>
<string name="cw_try_demo">Demo ausprobieren</string>
<string name="cw_try_demo_subtitle">Offline erkunden — kein Server erforderlich.</string>
@@ -318,8 +365,13 @@
<string name="cw_api_url_placeholder">192.168.1.10 oder http://dein-server:8642</string>
<string name="cw_api_url_supporting">Von Chat und Sitzungen verwendete Hermes-API — bei reinen Hosts werden API-Port 8642 und http:// angenommen (Dashboard-Port 9119 wird separat abgeleitet)</string>
<string name="cw_scan_message">Dieses LAN wird nach Hermes-Dashboard/API durchsucht…</string>
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel ist für Chat optional.</string>
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel gilt nur für den optionalen direkten API-Fallback.</string>
<string name="cw_pair_relay_section">Relay koppeln (optional)</string>
<string name="cw_pair_relay_for">Relay mit %1$s koppeln</string>
<string name="cw_pair_relay_scoped_desc">Füge dieser gespeicherten Hermes-Verbindung die optionale Relay-Erweiterung hinzu. Dadurch wird kein Server hinzugefügt oder ersetzt.</string>
<string name="cw_current_connection">Aktuelle Verbindung</string>
<string name="cw_current_hermes_connection">AKTUELLE HERMES-VERBINDUNG</string>
<string name="cw_existing_connection_unchanged">Chat, Verwaltung, Sprache und deine gespeicherten Routen bleiben unverändert.</string>
<string name="cw_pair_relay_section_desc">Das Relay-Plugin läuft bereits? Kopple es hier, um Terminal, Bridge und Kanalberechtigungen zu aktivieren.</string>
<string name="cw_pair_relay_url_label">Relay-URL</string>
<string name="cw_pair_relay_code_label">Kopplungscode</string>
@@ -441,7 +493,7 @@
<string name="endpoints_public">Öffentlich</string>
<string name="endpoints_custom">Benutzerdefiniert</string>
<string name="endpoints_route_name">Routenname</string>
<string name="endpoints_api_url_host">API-Server-URL oder Host</string>
<string name="endpoints_api_url_host">Dashboard-/Gateway-URL oder Host</string>
<string name="endpoints_saving">Speichern…</string>
<string name="endpoints_save">Speichern</string>
<string name="endpoints_close">Schließen</string>
@@ -580,7 +632,7 @@
<string name="settings_hermes_management">Hermes-Verwaltung</string>
<string name="settings_hermes_management_desc">Dashboard-Funktionen: Skills, Cron, MCP, Profile, Modelle</string>
<string name="settings_chat">Chat</string>
<string name="settings_chat_desc">API-Chatverhalten, Endpunkte, Werkzeuganzeige, Nachrichtenlänge</string>
<string name="settings_chat_desc">Chatverhalten, Gateway, API-Fallback, Werkzeuganzeige, Nachrichtenlänge</string>
<string name="settings_voice_mode">Sprachmodus</string>
<string name="settings_voice_mode_desc">Dashboard-Sprache, Echtzeit-Relay-Optionen, Anbieter</string>
<string name="settings_threads">Threads</string>
@@ -622,9 +674,9 @@
<string name="settings_persistent_connection">Dauerhafte Verbindung</string>
<string name="settings_persistent_connection_desc">Verbindung zu Hermes im Hintergrund offen halten</string>
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</string>
<string name="settings_turn_complete_alerts">Hinweise bei Abschluss</string>
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
<string name="settings_turn_complete_alerts_off">Kein Hinweis, wenn eine Antwort im Hintergrund abgeschlossen wird</string>
<string name="settings_turn_complete_alerts">Chat-Benachrichtigungen</string>
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
<string name="settings_keep_connected_deep_sleep">Im Tiefschlaf verbunden bleiben</string>
<string name="settings_keep_connected_battery_desc">Android kann die Verbindung weiterhin pausieren, nachdem der Bildschirm eine Weile ausgeschaltet war (Doze). Erlaube uneingeschränkte Akkunutzung, damit die dauerhafte Verbindung im Hintergrund funktioniert.</string>
<string name="settings_allow_unrestricted_battery">Uneingeschränkte Akkunutzung erlauben</string>
@@ -654,8 +706,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Letzte Nachrichten als antippbare Chips über dem Eingabefeld anzeigen, um sie erneut zu senden. Standardmäßig aus.</string>
<string name="chat_settings_keep_keyboard_open">Tastatur nach dem Senden geöffnet lassen</string>
<string name="chat_settings_keep_keyboard_open_desc">Nach dem Senden im Eingabefeld bleiben. Ausschalten, um die Tastatur nach jeder gesendeten Nachricht zu schließen.</string>
<string name="chat_settings_notify_when_finishes">Benachrichtigen, wenn Hermes fertig ist</string>
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigung senden, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
<string name="chat_settings_notify_when_finishes">Chat-Benachrichtigungen im Hintergrund</string>
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
<string name="chat_settings_share_phone_status">Smartphone-Status mit Agent teilen</string>
<string name="chat_settings_share_phone_status_desc">Bei jedem Chatdurchlauf eine kurze Systemnachricht über App und Smartphone einfügen</string>
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
@@ -737,6 +789,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">Optional</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">Chat</string>
<string name="conn_manage_label">Verwaltung</string>
<string name="conn_dashboard_label">Dashboard</string>
<string name="conn_voice_label">Sprache</string>
<string name="conn_relay_label">Relay</string>
@@ -761,6 +815,10 @@
<string name="detail_more_actions">Weitere Aktionen</string>
<string name="detail_rename">Umbenennen</string>
<string name="detail_pair_relay">Relay koppeln</string>
<string name="detail_dashboard_primary">Dashboard als primärer Zugang</string>
<string name="detail_core_ready">Hermes-Kern bereit</string>
<string name="detail_core_configured">Hermes-Kern konfiguriert</string>
<string name="detail_overview_summary">Chat, Verwaltung und Sprache nutzen die Standardfunktionen von Hermes. Relay ist eine optionale Erweiterung für erweiterte Gerätefunktionen.</string>
<string name="detail_repair">Erneut koppeln</string>
<string name="detail_revoke">Widerrufen</string>
<string name="detail_remove">Entfernen</string>
@@ -1430,7 +1488,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Sprachfunktion des Relay-Plugins — profilbezogene Anbieter und Streaming-Sprachausgabe.</string>
<string name="voice_settings_optional">Optional</string>
<string name="voice_settings_signin_route_hint">Du bist über die Route %1$s verbunden, und Dashboard-Anmeldungen gelten pro Host — eine Anmeldung aus deinem Heimnetz wird nicht übernommen. Melde dich unter Verwalten einmal über diese Route an, um auch hier Sprache freizuschalten.</string>
<string name="voice_settings_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Öffne Verwalten, um dich erneut anzumelden und Sprache freizuschalten.</string>
<string name="voice_settings_signin_default_hint">Dein Hermes-Dashboard erfordert eine Anmeldung, bevor Standardsprache transkribieren oder sprechen kann. Eine einmalige Anmeldung unter Verwalten schaltet sie für diese Verbindung frei.</string>
<string name="voice_settings_sign_in_via_manage">Über Verwalten anmelden</string>
<string name="voice_settings_unsupported_build_body">Dieser Hermes-Server-Build stellt die Dashboard-Audiorouten noch nicht bereit. Aktualisiere hermes-agent auf dem Server oder kopple Relay, um Relay-Sprache zu verwenden.</string>
@@ -1669,7 +1727,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">Dashboard-Anmeldung erforderlich</string>
<string name="dashboard_signin_required_body">Verwalten verwendet die Hermes-Dashboard-Sitzung unter %1$s.</string>
<string name="dashboard_signin_route_hint">Du verwendest die Route %1$s. Dashboard-Anmeldungen gelten pro Host, daher wird deine Anmeldung über die andere Route nicht übernommen — melde dich hier einmal an, dann behält die App beide Sitzungen.</string>
<string name="dashboard_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Melde dich erneut an, um sie für die vertrauenswürdigen Routen dieser Verbindung zu aktualisieren.</string>
<string name="dashboard_signin_with_provider">Mit %1$s anmelden</string>
<string name="dashboard_username_password">Benutzername &amp; Passwort</string>
<string name="dashboard_username">Benutzername</string>
@@ -1720,6 +1778,31 @@
<string name="dashboard_action_use">Verwenden</string>
<string name="dashboard_action_describe">Beschreiben</string>
<string name="dashboard_action_model">Modell</string>
<string name="dashboard_tab_custom_endpoints">Endpunkte</string>
<string name="dashboard_tab_custom_endpoints_lower">Endpunkte</string>
<string name="dashboard_tile_custom_endpoints_title">Benutzerdefinierte Endpunkte</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-kompatible Anbieter</string>
<string name="dashboard_action_authenticate">Authentifizieren</string>
<string name="dashboard_action_validate">Validieren</string>
<string name="dashboard_action_edit">Bearbeiten</string>
<string name="dashboard_mcp_oauth_title">%1$s authentifizieren</string>
<string name="dashboard_mcp_oauth_body">Hermes öffnet den Anbieter in Ihrem Browser. Kehren Sie nach der Genehmigung des Zugriffs hierher zurück; die Anmeldedaten verbleiben auf dem Hermes-Server.</string>
<string name="dashboard_mcp_oauth_approved">MCP-Authentifizierung genehmigt</string>
<string name="dashboard_mcp_oauth_failed">MCP-Authentifizierung fehlgeschlagen</string>
<string name="dashboard_mcp_oauth_no_browser">Zum Abschluss der MCP-Authentifizierung ist kein Browser verfügbar.</string>
<string name="dashboard_custom_endpoint_add">Endpunkt hinzufügen</string>
<string name="dashboard_custom_endpoint_edit">Endpunkt bearbeiten</string>
<string name="dashboard_custom_endpoint_name">Name</string>
<string name="dashboard_custom_endpoint_url">Basis-URL</string>
<string name="dashboard_custom_endpoint_model">Standardmodell</string>
<string name="dashboard_custom_endpoint_key">API-Schlüssel (optional)</string>
<string name="dashboard_custom_endpoint_key_help">Lassen Sie das Feld leer, um beim Speichern einen vorhandenen Schlüssel beizubehalten. Für die Validierung wird nur ein hier eingegebener Schlüssel verwendet; Hermes legt gespeicherte Schlüssel weder offen noch löscht es sie.</string>
<string name="dashboard_custom_endpoint_context">Kontextlänge (optional)</string>
<string name="dashboard_custom_endpoint_discover">Modelle über /models ermitteln</string>
<string name="dashboard_custom_endpoint_valid">Endpunkt erreichbar · %1$d Modell(e)</string>
<string name="dashboard_custom_endpoint_validate_failed">Endpunktvalidierung fehlgeschlagen</string>
<string name="dashboard_custom_endpoint_save_failed">Speichern des Endpunkts fehlgeschlagen</string>
<string name="dashboard_custom_endpoint_saved">Benutzerdefinierter Endpunkt gespeichert</string>
<string name="dashboard_action_completed">%1$s abgeschlossen</string>
<string name="dashboard_action_failed">%1$s fehlgeschlagen</string>
<string name="dashboard_more">Mehr</string>
@@ -2032,9 +2115,9 @@
<string name="active_section_allow_plain_connections">Unverschlüsselte Verbindungen erlauben</string>
<string name="active_section_api_hermes_voice_reachable">API erreichbar — Hermes-Sprache ist konfiguriert</string>
<string name="active_section_api_key_already_set">API-Schlüssel ist bereits festgelegt</string>
<string name="active_section_api_key_needed_hint">Gib einen Schlüssel ein, falls der API-Server einen erfordert.</string>
<string name="active_section_api_key_needed_hint">Verwende den auf deinem Hermes-Server erstellten API_SERVER_KEY. Die App stellt diesen Schlüssel nicht aus.</string>
<string name="active_section_api_key_not_configured">API-Schlüssel nicht konfiguriert</string>
<string name="active_section_api_key_optional">API-Schlüssel (optional)</string>
<string name="active_section_api_key_optional">API-Schlüssel für direkte API</string>
<string name="active_section_api_key_stored_hint">Schlüssel ist sicher gespeichert.</string>
<string name="active_section_api_reachable_voice_review">API erreichbar — Sprachkonfiguration prüfen</string>
<string name="active_section_api_relay_voice_reachable">API erreichbar — Relay-Sprache ist konfiguriert</string>
@@ -2048,6 +2131,8 @@
<string name="active_section_checking">Prüfung läuft…</string>
<string name="active_section_checking_routes">Routen werden geprüft…</string>
<string name="active_section_choose_how_phone_reaches">Wähle, wie dieses Smartphone den Server erreicht</string>
<string name="active_section_choose_how_phone_reaches_named">So erreicht dieses Smartphone %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · Heimnetzwerk</string>
<string name="active_section_command_copied">Befehl kopiert</string>
<string name="active_section_configured">Konfiguriert</string>
<string name="active_section_connect">Verbinden</string>
@@ -2142,6 +2227,7 @@
<string name="conn_info_api_server_title">API-Server</string>
<string name="conn_info_approvals_off">Genehmigungen sind AUS</string>
<string name="conn_info_auth">Authentifizierung</string>
<string name="conn_info_relay_auth">Relay-Authentifizierung</string>
<string name="conn_info_avg_ttft">Durchschn. TTFT</string>
<string name="conn_info_channel_grants">Kanalberechtigungen</string>
<string name="conn_info_checking">Prüfung läuft…</string>
@@ -2181,6 +2267,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (gekoppelt)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay gekoppelt</string>
<string name="conn_info_idle_suffix"> · Inaktiv</string>
<string name="conn_info_insecure_mode_allowed">Unsicherer Modus erlaubt</string>
<string name="conn_info_inspect_profile">%1$s prüfen</string>
@@ -2405,6 +2492,7 @@
<string name="tool_progress_status_completed">abgeschlossen</string>
<string name="tool_progress_status_failed">fehlgeschlagen</string>
<string name="tool_progress_status_running">läuft</string>
<string name="image_generation_rendering">Bild wird erstellt</string>
<string name="tool_progress_cd_collapse">Einklappen</string>
<string name="tool_progress_cd_expand">Ausklappen</string>
@@ -2511,7 +2599,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">Hermes-QR-Code scannen</string>
<string name="qr_scanner_instruction">Hermes-Einrichtungs-QR-Code scannen</string>
<string name="qr_scanner_subtext">Frage Hermes: &quot;Erzeuge einen QR-Code mit meiner API-URL und meinem API-Schlüssel.&quot;</string>
<string name="qr_scanner_subtext">Scanne einen Hermes-Einrichtungs- oder Relay-Kopplungs-QR-Code. Standard-Dashboard-Verbindungen benötigen keinen API-Schlüssel.</string>
<string name="qr_scanner_relay_title">Relay-QR scannen</string>
<string name="qr_scanner_relay_instruction">Einen Relay-Kopplungs-QR-Code scannen</string>
<string name="qr_scanner_relay_subtext">Öffne den Relay-Kopplungs-QR-Code auf deinem Hermes-Server. Deine bestehende Dashboard-Verbindung und Routen bleiben unverändert.</string>
<string name="qr_scanner_camera_error">Kamera kann auf diesem Gerät nicht gestartet werden.</string>
<string name="qr_scanner_fallback_message">Du kannst ohne Kamera koppeln.</string>
<string name="qr_scanner_pair_manual">Manuell koppeln</string>
@@ -2978,12 +3069,12 @@
<string name="diag_check_voice_relay">Sprache (Relay)</string>
<string name="diag_check_voice_standard">Sprache (Standard)</string>
<string name="endpoints_pin_title">Angeheftete Endpunkte</string>
<string name="endpoints_route_editor_desc">Route bearbeiten</string>
<string name="endpoints_route_editor_desc">Füge die Dashboard-/Gateway-Adresse hinzu, die dieses Telefon in diesem Netzwerk verwenden soll.</string>
<string name="endpoints_route_name_placeholder">Routenname</string>
<string name="endpoints_url_host_placeholder">Host</string>
<string name="endpoints_url_supporting_blank">Keine unterstützenden URLs</string>
<string name="endpoints_url_supporting_enter">URL eingeben</string>
<string name="endpoints_url_supporting_preview">Vorschau</string>
<string name="endpoints_url_host_placeholder">100.x.y.z oder host.ts.net</string>
<string name="endpoints_url_supporting_blank">Dashboard-Adresse des Servers eingeben</string>
<string name="endpoints_url_supporting_enter">Eine http://- oder https://-Dashboard-Adresse verwenden</string>
<string name="endpoints_url_supporting_preview">Wird getestet: %1$s</string>
<string name="image_viewer_error">Fehler beim Speichern des Bildes</string>
<string name="image_viewer_failed">Bild konnte nicht gespeichert werden</string>
<string name="image_viewer_failed_template">Bild konnte nicht gespeichert werden: %s</string>
@@ -3139,4 +3230,111 @@
<string name="tool_output_risk_a11y">, Ausgaberisiko: %1$s</string>
<string name="tool_output_risk_findings">Erkannte Ausgaberisiken</string>
<string name="tool_output_risk_redacted">Vertrauliche Textabschnitte wurden bereits serverseitig geschwärzt.</string>
<string name="conn_startup_title">Beim App-Start</string>
<string name="conn_startup_last_used">Zuletzt verwendet</string>
<string name="conn_startup_recommended">Empfohlen</string>
<string name="conn_startup_choose">Startverbindung auswählen</string>
<plurals name="conn_server_count">
<item quantity="one">%1$d Server</item>
<item quantity="other">%1$d Server</item>
</plurals>
<string name="conn_switch">Wechseln</string>
<string name="conn_switching">Wird gewechselt…</string>
<string name="conn_switched">Aktiv</string>
<string name="conn_connecting_to">Verbindung mit %1$s wird hergestellt…</string>
<string name="conn_last_used_now">Gerade eben verwendet</string>
<string name="conn_last_used_format">Zuletzt verwendet: %1$s</string>
<string name="conn_just_now">gerade eben</string>
<string name="conn_dashboard_only_route">Nur Dashboard</string>
<string name="conn_no_routes">Keine Routen konfiguriert</string>
<string name="active_section_primary_dashboard">Primäres Dashboard</string>
<string name="active_section_dashboard_reachable_signed_in">Erreichbar · Angemeldet</string>
<string name="active_section_dashboard_reachable">Erreichbar</string>
<string name="active_section_dashboard_not_checked">Noch nicht geprüft</string>
<string name="active_section_dashboard_unreachable">Nicht erreichbar</string>
<string name="active_section_no_fallback_routes">Noch keine Ausweichrouten</string>
<string name="active_section_no_fallback_routes_desc">Füge optional eine API-Route für direkten Chat-Ausweichbetrieb und Netzwerkwechsel hinzu.</string>
<string name="active_section_add_api_fallback">Ausweichroute hinzufügen</string>
<string name="active_section_security_authentication">Authentifizierung</string>
<string name="active_section_dashboard_session">Dashboard-Sitzung</string>
<string name="active_section_credential_storage">Anmeldedatenspeicher</string>
<string name="active_section_encrypted_storage">Verschlüsselter Speicher</string>
<string name="active_section_no_relay_credential">Keine Relay-Anmeldedaten</string>
<string name="active_section_sign_out_dashboard">Vom Dashboard abmelden</string>
<string name="active_section_credentials_encrypted">Anmeldedaten bleiben auf diesem Gerät verschlüsselt.</string>
<string name="active_section_reachable_badge">ERREICHBAR</string>
<string name="active_section_unchecked_badge">UNGEPRÜFT</string>
<string name="active_section_edit">Bearbeiten</string>
<string name="active_section_fallback_routes">Ausweichrouten</string>
<string name="active_section_route_selection">Routenauswahl</string>
<string name="active_section_automatic">Automatisch</string>
<string name="active_section_api_access">API-Zugriff</string>
<string name="active_section_core_hermes">Hermes-Kern</string>
<string name="active_section_optional_relay">Optionales Relay</string>
<string name="active_section_extend_connection">Diese Verbindung erweitern</string>
<string name="active_section_relay_connected_features">Relay-Erweiterungen</string>
<string name="active_section_relay_optional_summary">Füge Terminal, Bridge, Gerätewerkzeuge, Relay-Sitzungen und sichere Fernzugriffsrouten hinzu.</string>
<string name="active_section_view_relay_details">Relay-Details anzeigen</string>
<string name="active_section_core_unchanged">Deine aktuelle Hermes-Verbindung bleibt unverändert.</string>
<string name="active_section_api_optional_direct">Optional für direkten Chat und Integrationen</string>
<string name="active_section_configure_test">Konfigurieren &amp; testen</string>
<string name="active_section_relay_optional_bridge">Optionale Bridge-Funktionen und Fernzugriff</string>
<string name="active_section_configure_relay">Relay konfigurieren</string>
<string name="active_section_pair_device_using_code">Kopple dieses Gerät mit einem Code vom Server.</string>
<string name="active_section_enter_pairing_code">Kopplungscode eingeben</string>
<string name="active_section_done">Fertig</string>
<string name="active_section_connection_behavior">Verbindungsverhalten</string>
<string name="active_section_manual_pairing">Manuelle Kopplung</string>
<string name="active_section_paired">Gekoppelt</string>
<string name="active_section_not_paired">Nicht gekoppelt</string>
<string name="active_section_transport">Transport</string>
<string name="active_section_hardware_backed">Hardwaregestützt</string>
<string name="active_section_relay_session">Relay-Sitzung</string>
<string name="active_section_access">Zugriff</string>
<string name="active_section_paired_devices">Gekoppelte Geräte</string>
<string name="active_section_device_count">%1$d Geräte</string>
<string name="active_section_session_activity">Sitzungsaktivität</string>
<string name="active_section_last_checked_just_now">Gerade eben geprüft</string>
<string name="active_section_protected">Geschützt</string>
<string name="active_section_not_encrypted">Nicht verschlüsselt</string>
<string name="active_section_no_security_issues">Keine Sicherheitsprobleme erkannt</string>
<string name="active_section_unencrypted_transport">Diese Verbindung verwendet einen unverschlüsselten Transport</string>
<string name="active_section_actions">Aktionen</string>
<string name="active_section_revoke_relay">Relay-Kopplung widerrufen</string>
<string name="cw_preparing_connection">Verbindung wird vorbereitet…</string>
<string name="cw_preparing_connection_hint">Sicherer lokaler Speicher wird eingerichtet. Dies dauert nur einen Moment.</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Kopple Relay, um Terminal, Bridge, Gerätewerkzeuge, Relay-Sitzungen und Berechtigungen hinzuzufügen.</string>
<string name="cw_relay_pair_qr">Hermes Relay koppeln</string>
<string name="cw_relay_pair_qr_desc">Einen Relay-Einrichtungs-QR-Code scannen</string>
<string name="cw_relay_enter_code">Pairing-Code eingeben</string>
<string name="cw_dashboard_connected_title">Hermes ist verbunden</string>
<string name="cw_dashboard_connected_body">Dashboard und Gateway sind bereit. Du kannst chatten oder Manage öffnen.</string>
<string name="cw_continue">Weiter</string>
<string name="cw_timeline_discovered">Hermes gefunden</string>
<string name="cw_timeline_discovered_detail">Dashboard-Identität und Status-Endpunkt bestätigt</string>
<string name="cw_timeline_access">Dashboard-Zugriff</string>
<string name="cw_timeline_access_ready">Keine weitere Anmeldung erforderlich</string>
<string name="cw_timeline_authenticated">Authentifizierung bestätigt</string>
<string name="cw_timeline_ready">Verbindung bereit</string>
<string name="cw_timeline_ready_detail">Chat, Manage und Voice können dieses Dashboard verwenden</string>
<string name="active_section_optional_api_fallback">Optionaler direkter API-Fallback</string>
<string name="active_section_api_not_required">Nicht erforderlich, wenn diese Verbindung das Hermes Dashboard verwendet.</string>
<string name="active_section_where_api_key">Wo erhalte ich diesen Schlüssel?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY wird auf deinem Hermes-Server erstellt und nicht von dieser App bereitgestellt. Konfiguriere ihn nur für den optionalen API-Server, der einen verwendbaren Schlüssel verlangt, und gib hier denselben Wert ein.</string>
<string name="active_section_scan_relay_qr">Relay-QR scannen</string>
<string name="active_section_other_relay_methods">Andere Kopplungsmethoden</string>
<string name="endpoints_tailscale_setup_hint">Tailscale muss auf beiden Geräten verbunden und Hermes Dashboard auf Port 9119 erreichbar sein.</string>
<string name="endpoints_setup_help">Tailscale-Einrichtungshilfe</string>
<string name="voice_settings_provider_desc">Wo Sprache erzeugt wird. Verfuegbare Optionen kommen von diesem Hermes-Host und seinen installierten Anbietern.</string>
<string name="voice_settings_model_desc">Latest folgt Anbieter-Upgrades. Waehle ein versioniertes Modell, um das Sprachverhalten festzuhalten.</string>
<string name="voice_settings_voice_desc">Waehle, wie Antworten klingen. Eine Stimmvorschau speichert die Auswahl nicht.</string>
<string name="voice_settings_language_desc">Automatisch laesst den Anbieter die gesprochene Sprache erkennen. Waehle nur dann eine Sprache, wenn die Erkennung unzuverlaessig ist.</string>
<string name="voice_settings_language_auto">Automatisch</string>
<string name="voice_settings_provider_options_title">Sprachoptionen</string>
<string name="voice_settings_provider_options_desc">Nur Einstellungen, die der ausgewaehlte Anbieter unterstuetzt, werden angezeigt.</string>
<string name="voice_settings_auto_speak">Antworten vorlesen</string>
<string name="voice_settings_auto_speak_desc">Spricht Assistentenantworten automatisch auf Hermes-Oberflaechen, die diese Host-Einstellung beachten.</string>
<string name="voice_settings_realtime_model_desc">Steuert die Live-Sprachsitzung, nicht das Hermes-Chatmodell. Latest folgt Anbieter-Upgrades; ein versioniertes Modell bleibt festgelegt.</string>
<string name="voice_settings_realtime_voice_desc">Die Stimme innerhalb der Live-Sitzung. Eingebaute und benutzerdefinierte Stimmen erscheinen, wenn der Anbieter sie meldet.</string>
</resources>
+224 -26
View File
@@ -12,14 +12,16 @@
<string name="onboarding_back">Atrás</string>
<string name="onboarding_next">Siguiente</string>
<string name="onboarding_connect">Conectar</string>
<string name="onboarding_welcome_title">Hermes-Relay para Android</string>
<string name="onboarding_welcome_description">Chatea con Hermes y administra tu tablero desde tu teléfono.</string>
<string name="onboarding_get_started">Comenzar</string>
<string name="onboarding_step_count">Paso %1$d de %2$d</string>
<string name="onboarding_welcome_title">Hermes,\nen tu bolsillo</string>
<string name="onboarding_welcome_description">Chatea, administra y usa la voz\ncon tu propio Hermes.</string>
<string name="onboarding_welcome_badge">Bienvenido</string>
<string name="onboarding_hermes_logo">Logotipo de Hermes</string>
<string name="onboarding_chat_manage_label">Chatear y administrar</string>
<string name="onboarding_chat_manage_description">Conéctese a su tablero Hermes en ejecución y a API. No se requiere instalación ni emparejamiento de Relay.</string>
<string name="onboarding_power_tools_label">Herramientas avanzadas</string>
<string name="onboarding_power_tools_description">Agregue Hermes-Relay para sesiones de Terminal, Bridge, relay y concesiones de canales.</string>
<string name="onboarding_chat_manage_label">Primero el panel</string>
<string name="onboarding_chat_manage_description">No requiere Relay</string>
<string name="onboarding_power_tools_label">Herramientas después</string>
<string name="onboarding_power_tools_description">Empareja Relay para usar\nTerminal o Bridge</string>
<string name="onboarding_setup_guide_hint">La guía de configuración tiene comandos copy/paste cuando necesita iniciar Hermes en una computadora o servidor.</string>
<string name="onboarding_setup_guide">Guía de configuración</string>
<string name="onboarding_hermes_docs">Documentos Hermes</string>
@@ -49,6 +51,18 @@
<string name="onboarding_realtime_label">En tiempo real</string>
<string name="onboarding_realtime_description">Agente de voz en tiempo real nativo del proveedor y proveedores de voz con reconocimiento de perfil.</string>
<string name="onboarding_review_permissions">Revisar permisos</string>
<string name="onboarding_finish_setup_title">Finalizar configuración</string>
<string name="onboarding_finish_setup_description">Tu conexión con Hermes está lista. Elige ahora lo que puede hacer este teléfono o cámbialo más tarde en Ajustes.</string>
<string name="onboarding_chat_manage_ready">Listo — no se necesitan permisos del teléfono.</string>
<string name="onboarding_chat_alerts">Alertas de chat</string>
<string name="onboarding_chat_alerts_description">Permite las notificaciones de Android para que las alertas activadas te lleguen en segundo plano.</string>
<string name="onboarding_chat_alerts_ready">Las notificaciones están activadas para esta aplicación.</string>
<string name="onboarding_optional_features">Funciones opcionales</string>
<string name="onboarding_optional_features_description">La cámara, el micrófono, el asistente de notificaciones y las herramientas disponibles del dispositivo permanecen desactivados hasta que los elijas.</string>
<string name="onboarding_review_optional_permissions">Revisar permisos opcionales</string>
<string name="onboarding_enable_chat_alerts">Activar alertas de chat</string>
<string name="onboarding_not_now">Ahora no</string>
<string name="onboarding_finish">Finalizar configuración</string>
<string name="chat_input_live_voice_hint">Conversación de voz en vivo</string>
<string name="chat_input_add_attachment">Agregar archivo adjunto</string>
<string name="chat_input_browse_commands">Comandos de exploración</string>
@@ -61,7 +75,7 @@
<string name="chat_input_start_voice">Iniciar conversación de voz</string>
<string name="chat_input_voice_setup_needed">Conversación de voz: se necesita configuración</string>
<string name="chat_input_stop_streaming">dejar de transmitir</string>
<string name="chat_input_steer_response">Dirigir la respuesta</string>
<string name="chat_input_steer_response">Corregir la respuesta</string>
<string name="chat_input_queue_message">mensaje de cola</string>
<string name="bridge_return_chat_label">Charlar</string>
<string name="bridge_return_manage_label">Administrar</string>
@@ -82,7 +96,7 @@
<string name="status_profile_format">perfil: %1$s</string>
<string name="demo_feature_manage">Administrar</string>
<string name="chat_placeholder_edit">Edita tu mensaje...</string>
<string name="chat_placeholder_steer">Dirige la respuesta...</string>
<string name="chat_placeholder_steer">Corrige la respuesta...</string>
<string name="chat_placeholder_queue">Poner en cola un mensaje...</string>
<string name="chat_placeholder_message">Mensaje…</string>
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
@@ -236,6 +250,39 @@
<string name="error_classify_retry">Reintentar</string>
<string name="error_classify_open_settings">Abrir configuración</string>
<string name="cw_connect_to_hermes">Conéctese a Hermes</string>
<string name="cw_nearby_description">Buscaremos Hermes en esta red. El panel y la puerta de enlace proporcionan la conexión estándar para Chat, Administrar y Voz.</string>
<string name="cw_before_connecting">Antes de conectar</string>
<string name="cw_connect_step_server_title">Inicia Hermes en tu ordenador</string>
<string name="cw_connect_step_server_body">Inicia el panel en el ordenador con Hermes. Para el primer acceso por LAN, usa la guía inferior para hacerlo accesible desde el teléfono y habilitar el inicio de sesión.</string>
<string name="cw_connect_step_network_title">Haz que el servidor sea accesible</string>
<string name="cw_connect_step_network_body">Usa la misma red Wi-Fi o conecta ambos dispositivos a Tailscale.</string>
<string name="cw_connect_step_phone_title">Conecta desde este teléfono</string>
<string name="cw_connect_step_phone_body">Busca abajo o introduce la dirección del panel, como 192.168.1.10:9119. Inicia sesión si se solicita; no se necesita una clave de API.</string>
<string name="cw_nearby_searching">Buscando cerca…</string>
<string name="cw_nearby_searching_hint">Mantenga el teléfono en la misma red que el servidor de Hermes.</string>
<string name="cw_nearby_heading">Hermes cercanos</string>
<string name="cw_nearby_empty">No se encontraron servidores de Hermes</string>
<string name="cw_nearby_failed">No se pudo buscar en esta red</string>
<string name="cw_nearby_empty_hint">Compruebe que Hermes esté en ejecución y vuelva a buscar o introduzca su dirección.</string>
<string name="cw_nearby_search_again">Buscar de nuevo</string>
<string name="cw_nearby_enter_address">Introducir la dirección</string>
<string name="cw_other_connection_methods">Otros métodos de conexión</string>
<string name="cw_manual_hermes_title">Introduzca la dirección de Hermes</string>
<string name="cw_manual_hermes_description">Introduce la dirección del panel que abres en el navegador. Si no abre en este teléfono, inicia hermes dashboard y comprueba Wi-Fi o Tailscale.</string>
<string name="cw_hermes_address">Dirección de Hermes</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 o hermes.example.com</string>
<string name="cw_hermes_address_hint">No se necesita una clave de API. Se usa el puerto 9119 si no incluyes uno.</string>
<string name="cw_find_hermes">Buscar Hermes</string>
<string name="cw_hermes_found">Hermes encontrado</string>
<string name="cw_ready_to_connect">Listo para conectar</string>
<string name="cw_ready">Listo</string>
<string name="cw_available_after_signin">Disponible después de iniciar sesión</string>
<string name="cw_unavailable_server">No disponible en este servidor</string>
<string name="cw_could_not_verify">No se pudo verificar</string>
<string name="cw_choose_another">Elegir otro</string>
<string name="cw_sign_in_to_hermes">Iniciar sesión en Hermes</string>
<string name="cw_semantics_hermes_available">Hermes disponible</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Inicie Hermes API/dashboard en su host, luego conecte esta aplicación. El emparejamiento de Relay es opcional y solo es necesario para sesiones de Terminal, Bridge, relay y concesiones de canales.</string>
<string name="cw_try_demo">Pruebe la demostración</string>
<string name="cw_try_demo_subtitle">Explora sin conexión: no se necesita servidor.</string>
@@ -276,7 +323,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 o http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API utilizado por Chat y sesiones — API puerto 8642 y http:// asumido para hosts desnudos (el 9119 del tablero se deriva por separado)</string>
<string name="cw_scan_message">Escaneando esta LAN en busca de Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API es opcional para Chat.</string>
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API solo se usa para la alternativa directa opcional mediante API.</string>
<string name="cw_pair_relay_section">Emparejar Relay (opcional)</string>
<string name="cw_pair_relay_section_desc">¿Ya estás ejecutando el complemento Relay? Emparéjelo aquí para habilitar Terminal, Bridge y concesiones de canales.</string>
<string name="cw_pair_relay_url_label">URL de Relay</string>
@@ -397,7 +444,7 @@
<string name="endpoints_public">Público</string>
<string name="endpoints_custom">Costumbre</string>
<string name="endpoints_route_name">Nombre de la ruta</string>
<string name="endpoints_api_url_host">URL o host del servidor API</string>
<string name="endpoints_api_url_host">URL o host de Dashboard/Gateway</string>
<string name="endpoints_saving">Ahorro…</string>
<string name="endpoints_save">Guardar</string>
<string name="endpoints_close">Cerrar</string>
@@ -512,7 +559,7 @@
<string name="settings_hermes_management">Gestión Hermes</string>
<string name="settings_hermes_management_desc">Funciones del panel: habilidades, cron, MCP, perfiles, modelos</string>
<string name="settings_chat">Charlar</string>
<string name="settings_chat_desc">Comportamiento del chat API, puntos finales, visualización de herramientas, longitud del mensaje</string>
<string name="settings_chat_desc">Comportamiento del chat, Gateway, respaldo de API, visualización de herramientas y longitud del mensaje</string>
<string name="settings_voice_mode">Modo de voz</string>
<string name="settings_voice_mode_desc">Panel de voz, opciones relay en tiempo real, proveedores</string>
<string name="settings_threads">Threads</string>
@@ -554,9 +601,9 @@
<string name="settings_persistent_connection">Conexión persistente</string>
<string name="settings_persistent_connection_desc">Mantener abierta su conexión a Hermes en segundo plano</string>
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</string>
<string name="settings_turn_complete_alerts">Alertas de turno completo</string>
<string name="settings_turn_complete_alerts_desc">Notificar cuando finaliza una respuesta mientras la aplicación está en segundo plano</string>
<string name="settings_turn_complete_alerts_off">No hay alerta cuando finaliza una respuesta en segundo plano</string>
<string name="settings_turn_complete_alerts">Alertas de chat</string>
<string name="settings_turn_complete_alerts_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
<string name="settings_keep_connected_deep_sleep">Mantenlo conectado mientras duermes profundamente</string>
<string name="settings_keep_connected_battery_desc">Android todavía puede pausar la conexión una vez que la pantalla ha estado apagada por un tiempo (Doze). Permita la batería sin restricciones para que la conexión persistente siga funcionando en segundo plano.</string>
<string name="settings_allow_unrestricted_battery">Permitir batería sin restricciones</string>
@@ -584,8 +631,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Muestre sus mensajes recientes como chips que se pueden tocar encima del redactor para enviarlos nuevamente. Desactivado de forma predeterminada.</string>
<string name="chat_settings_keep_keyboard_open">Mantener el teclado abierto al enviar</string>
<string name="chat_settings_keep_keyboard_open_desc">Permanece en el compositor después del envío. Desactívelo para descartar el teclado después de cada mensaje enviado.</string>
<string name="chat_settings_notify_when_finishes">Notificar cuando finalice Hermes</string>
<string name="chat_settings_notify_when_finishes_desc">Publicar una notificación cuando se complete una respuesta mientras la aplicación está en segundo plano</string>
<string name="chat_settings_notify_when_finishes">Alertas de chat en segundo plano</string>
<string name="chat_settings_notify_when_finishes_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
<string name="chat_settings_share_phone_status">Compartir el estado del teléfono con el agente</string>
<string name="chat_settings_share_phone_status_desc">Incluya un breve mensaje del sistema sobre la aplicación y el teléfono en cada turno de chat.</string>
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
@@ -665,6 +712,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">Opcional</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">Chat</string>
<string name="conn_manage_label">Administrar</string>
<string name="conn_dashboard_label">Panel</string>
<string name="conn_voice_label">Voz</string>
<string name="conn_relay_label">Relay</string>
@@ -1315,7 +1364,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Complemento de voz Relay: proveedores con reconocimiento de perfiles y salida de voz en streaming.</string>
<string name="voice_settings_optional">Opcional</string>
<string name="voice_settings_signin_route_hint">Estás conectado a través de la ruta %1$s y los inicios de sesión en el panel de control son por anfitrión: un inicio de sesión desde tu red doméstica no se transfiere. Inicia sesión una vez en Gestionar mientras estás en esta ruta para desbloquear la voz aquí también.</string>
<string name="voice_settings_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Abre Gestionar para volver a iniciar sesión y desbloquear la voz.</string>
<string name="voice_settings_signin_default_hint">Su panel Hermes requiere iniciar sesión antes de que la voz estándar pueda transcribir o hablar. Iniciar sesión una vez en Administrar lo desbloquea para esta conexión.</string>
<string name="voice_settings_sign_in_via_manage">Iniciar sesión a través de Administrar</string>
<string name="voice_settings_unsupported_build_body">Esta compilación del servidor Hermes aún no expone las rutas de audio del panel. Actualice el agente hermes en el servidor o empareje Relay para usar la voz Relay.</string>
@@ -1534,7 +1583,7 @@
<string name="dashboard_hours_ago">Hace %1$dh</string>
<string name="dashboard_signin_required_title">Es necesario iniciar sesión en el panel</string>
<string name="dashboard_signin_required_body">Administrar utiliza la sesión del panel Hermes en %1$s.</string>
<string name="dashboard_signin_route_hint">Estás en la ruta %1$s. Los inicios de sesión en el panel de control son por anfitrión, por lo que tu inicio de sesión desde la otra ruta no se transfiere. Inicia sesión una vez aquí y la aplicación mantendrá ambas sesiones.</string>
<string name="dashboard_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Vuelve a iniciar sesión para actualizarla en las rutas de confianza de esta conexión.</string>
<string name="dashboard_signin_with_provider">Iniciar sesión con %1$s</string>
<string name="dashboard_username_password">Nombre de usuario y contraseña</string>
<string name="dashboard_username">Nombre de usuario</string>
@@ -1575,6 +1624,31 @@
<string name="dashboard_action_use">Usar</string>
<string name="dashboard_action_describe">Describir</string>
<string name="dashboard_action_model">Modelo</string>
<string name="dashboard_tab_custom_endpoints">Puntos de conexión</string>
<string name="dashboard_tab_custom_endpoints_lower">puntos de conexión</string>
<string name="dashboard_tile_custom_endpoints_title">Puntos de conexión personalizados</string>
<string name="dashboard_tile_custom_endpoints_sub">Proveedores compatibles con OpenAI</string>
<string name="dashboard_action_authenticate">Autenticar</string>
<string name="dashboard_action_validate">Validar</string>
<string name="dashboard_action_edit">Editar</string>
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes abrirá el proveedor en el navegador. Regrese aquí después de aprobar el acceso; las credenciales permanecen en el servidor de Hermes.</string>
<string name="dashboard_mcp_oauth_approved">Autenticación MCP aprobada</string>
<string name="dashboard_mcp_oauth_failed">Error de autenticación MCP</string>
<string name="dashboard_mcp_oauth_no_browser">No hay ningún navegador disponible para completar la autenticación MCP.</string>
<string name="dashboard_custom_endpoint_add">Agregar punto de conexión</string>
<string name="dashboard_custom_endpoint_edit">Editar punto de conexión</string>
<string name="dashboard_custom_endpoint_name">Nombre</string>
<string name="dashboard_custom_endpoint_url">URL base</string>
<string name="dashboard_custom_endpoint_model">Modelo predeterminado</string>
<string name="dashboard_custom_endpoint_key">Clave de API (opcional)</string>
<string name="dashboard_custom_endpoint_key_help">Déjelo en blanco para conservar una clave existente al guardar. La validación solo usa la clave introducida aquí; Hermes no muestra ni borra las claves guardadas.</string>
<string name="dashboard_custom_endpoint_context">Longitud de contexto (opcional)</string>
<string name="dashboard_custom_endpoint_discover">Detectar modelos desde /models</string>
<string name="dashboard_custom_endpoint_valid">Punto de conexión accesible · %1$d modelo(s)</string>
<string name="dashboard_custom_endpoint_validate_failed">Error al validar el punto de conexión</string>
<string name="dashboard_custom_endpoint_save_failed">Error al guardar el punto de conexión</string>
<string name="dashboard_custom_endpoint_saved">Punto de conexión personalizado guardado</string>
<string name="dashboard_action_completed">%1$s completado</string>
<string name="dashboard_action_failed">%1$s falló</string>
<string name="dashboard_more">Más</string>
@@ -1851,9 +1925,9 @@
<string name="active_section_allow_plain_connections">Permitir conexiones simples</string>
<string name="active_section_api_hermes_voice_reachable">API accesible: la voz Hermes está configurada</string>
<string name="active_section_api_key_already_set">La clave API ya está configurada</string>
<string name="active_section_api_key_needed_hint">Ingrese una clave si el servidor API la requiere.</string>
<string name="active_section_api_key_needed_hint">Use el API_SERVER_KEY creado en su servidor Hermes. La aplicación no proporciona esta clave.</string>
<string name="active_section_api_key_not_configured">Clave API no configurada</string>
<string name="active_section_api_key_optional">Clave API (opcional)</string>
<string name="active_section_api_key_optional">Clave API para API directa</string>
<string name="active_section_api_key_stored_hint">La clave se almacena de forma segura.</string>
<string name="active_section_api_reachable_voice_review">API accesible: revisar la configuración de voz</string>
<string name="active_section_api_relay_voice_reachable">API accesible: la voz Relay está configurada</string>
@@ -1959,6 +2033,7 @@
<string name="conn_info_api_server_title">Servidor API</string>
<string name="conn_info_approvals_off">Las aprobaciones están desactivadas</string>
<string name="conn_info_auth">autenticación</string>
<string name="conn_info_relay_auth">Autenticación de Relay</string>
<string name="conn_info_avg_ttft">Promedio TTFT</string>
<string name="conn_info_channel_grants">Subvenciones de canal</string>
<string name="conn_info_checking">De cheques…</string>
@@ -1992,6 +2067,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (emparejado)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay emparejado</string>
<string name="conn_info_idle_suffix"> · Inactivo</string>
<string name="conn_info_insecure_mode_allowed">Modo inseguro permitido</string>
<string name="conn_info_inspect_profile">Inspeccionar %1$s</string>
@@ -2190,6 +2266,7 @@
<string name="tool_progress_status_completed">terminado</string>
<string name="tool_progress_status_failed">fallido</string>
<string name="tool_progress_status_running">correr</string>
<string name="image_generation_rendering">Generando imagen</string>
<string name="tool_progress_cd_collapse">Colapsar</string>
<string name="tool_progress_cd_expand">Expandir</string>
<string name="agent_icon_title">Icono de agente</string>
@@ -2274,7 +2351,10 @@
<string name="crash_toast_copied">Informe de fallo copiado</string>
<string name="qr_scanner_title">Escanear Hermes QR</string>
<string name="qr_scanner_instruction">Escanear una configuración Hermes QR</string>
<string name="qr_scanner_subtext">Pregúntele a Hermes: "Genere un código QR con mi URL API y mi clave API".</string>
<string name="qr_scanner_subtext">Escanee un QR de configuración de Hermes o de vinculación con Relay. Las conexiones estándar mediante Dashboard no necesitan una clave API.</string>
<string name="qr_scanner_relay_title">Escanear QR de Relay</string>
<string name="qr_scanner_relay_instruction">Escanee un QR de vinculación con Relay</string>
<string name="qr_scanner_relay_subtext">Abra el QR de vinculación con Relay en su servidor Hermes. La conexión de Dashboard y las rutas existentes no cambiarán.</string>
<string name="qr_scanner_camera_error">No se puede iniciar la cámara en este dispositivo.</string>
<string name="qr_scanner_fallback_message">Puedes emparejar sin la cámara.</string>
<string name="qr_scanner_pair_manual">Emparejar manualmente</string>
@@ -2648,12 +2728,12 @@
<string name="diag_check_voice_relay">Voz (Relay)</string>
<string name="diag_check_voice_standard">Voz (Estándar)</string>
<string name="endpoints_pin_title">Puntos finales fijados</string>
<string name="endpoints_route_editor_desc">Editar ruta</string>
<string name="endpoints_route_editor_desc">Añada la dirección de Dashboard/Gateway que debe usar este teléfono en esa red.</string>
<string name="endpoints_route_name_placeholder">Nombre de la ruta</string>
<string name="endpoints_url_host_placeholder">Anfitrión</string>
<string name="endpoints_url_supporting_blank">No hay URL compatibles</string>
<string name="endpoints_url_supporting_enter">Introduce una URL</string>
<string name="endpoints_url_supporting_preview">Avance</string>
<string name="endpoints_url_host_placeholder">100.x.y.z o host.ts.net</string>
<string name="endpoints_url_supporting_blank">Introduzca la dirección de Dashboard del servidor</string>
<string name="endpoints_url_supporting_enter">Use una dirección de Dashboard http:// o https://</string>
<string name="endpoints_url_supporting_preview">Se probará: %1$s</string>
<string name="image_viewer_error">Error al guardar la imagen</string>
<string name="image_viewer_failed">No se pudo guardar la imagen</string>
<string name="image_viewer_failed_template">No se pudo guardar la imagen: %s</string>
@@ -2824,4 +2904,122 @@
<string name="conn_info_profile_offline_desc">Sin conexión; no se puede acceder a este perfil en este momento</string>
<string name="conn_info_profile_online">En línea</string>
<string name="conn_info_profile_online_desc">En línea; la puerta de enlace y los canales de mensajería están en funcionamiento</string>
<string name="cw_pair_relay_for">Vincular Relay con %1$s</string>
<string name="cw_pair_relay_scoped_desc">Añade la extensión opcional Relay a esta conexión Hermes guardada. Esto no añade ni sustituye ningún servidor.</string>
<string name="cw_current_connection">Conexión actual</string>
<string name="cw_current_hermes_connection">CONEXIÓN HERMES ACTUAL</string>
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice y tus rutas guardadas permanecen sin cambios.</string>
<string name="detail_dashboard_primary">Dashboard principal</string>
<string name="detail_core_ready">Núcleo de Hermes listo</string>
<string name="detail_core_configured">Núcleo de Hermes configurado</string>
<string name="detail_overview_summary">Chat, Manage y Voice usan Hermes estándar. Relay es una extensión opcional para funciones avanzadas del dispositivo.</string>
<string name="conn_startup_title">Al iniciar la aplicación</string>
<string name="conn_startup_last_used">Última usada</string>
<string name="conn_startup_recommended">Recomendado</string>
<string name="conn_startup_choose">Elegir conexión de inicio</string>
<plurals name="conn_server_count">
<item quantity="one">%1$d servidor</item>
<item quantity="other">%1$d servidores</item>
</plurals>
<string name="conn_switch">Cambiar</string>
<string name="conn_switching">Cambiando…</string>
<string name="conn_switched">Activa</string>
<string name="conn_connecting_to">Conectando con %1$s…</string>
<string name="conn_last_used_now">Usada por última vez ahora mismo</string>
<string name="conn_last_used_format">Usada por última vez %1$s</string>
<string name="conn_just_now">ahora mismo</string>
<string name="conn_dashboard_only_route">Solo Dashboard</string>
<string name="conn_no_routes">No hay rutas configuradas</string>
<string name="active_section_primary_dashboard">Dashboard principal</string>
<string name="active_section_dashboard_reachable_signed_in">Accesible · Sesión iniciada</string>
<string name="active_section_dashboard_reachable">Accesible</string>
<string name="active_section_dashboard_not_checked">Aún no comprobado</string>
<string name="active_section_dashboard_unreachable">No accesible</string>
<string name="active_section_no_fallback_routes">Aún no hay rutas alternativas</string>
<string name="active_section_no_fallback_routes_desc">Añade una ruta API opcional para chat directo alternativo y cambios de red.</string>
<string name="active_section_add_api_fallback">Añadir ruta alternativa</string>
<string name="active_section_security_authentication">Autenticación</string>
<string name="active_section_dashboard_session">Sesión del Dashboard</string>
<string name="active_section_credential_storage">Almacenamiento de credenciales</string>
<string name="active_section_encrypted_storage">Almacenamiento cifrado</string>
<string name="active_section_no_relay_credential">Sin credencial de Relay</string>
<string name="active_section_sign_out_dashboard">Cerrar sesión del Dashboard</string>
<string name="active_section_credentials_encrypted">Las credenciales permanecen cifradas en este dispositivo.</string>
<string name="active_section_reachable_badge">ACCESIBLE</string>
<string name="active_section_unchecked_badge">SIN COMPROBAR</string>
<string name="active_section_edit">Editar</string>
<string name="active_section_fallback_routes">Rutas alternativas</string>
<string name="active_section_route_selection">Selección de ruta</string>
<string name="active_section_automatic">Automática</string>
<string name="active_section_api_access">Acceso a la API</string>
<string name="active_section_core_hermes">Núcleo de Hermes</string>
<string name="active_section_optional_relay">Relay opcional</string>
<string name="active_section_extend_connection">Amplía esta conexión</string>
<string name="active_section_relay_connected_features">Extensiones de Relay</string>
<string name="active_section_relay_optional_summary">Añade Terminal, Bridge, herramientas del dispositivo, sesiones de Relay y rutas remotas seguras.</string>
<string name="active_section_view_relay_details">Ver detalles de Relay</string>
<string name="active_section_core_unchanged">Tu conexión Hermes actual permanece sin cambios.</string>
<string name="active_section_api_optional_direct">Opcional para chat directo e integraciones</string>
<string name="active_section_configure_test">Configurar y probar</string>
<string name="active_section_relay_optional_bridge">Funciones opcionales de Bridge y acceso remoto</string>
<string name="active_section_configure_relay">Configurar Relay</string>
<string name="active_section_pair_device_using_code">Vincula este dispositivo con un código del servidor.</string>
<string name="active_section_enter_pairing_code">Introducir código de vinculación</string>
<string name="active_section_done">Listo</string>
<string name="active_section_connection_behavior">Comportamiento de la conexión</string>
<string name="active_section_manual_pairing">Vinculación manual</string>
<string name="active_section_paired">Vinculado</string>
<string name="active_section_not_paired">No vinculado</string>
<string name="active_section_transport">Transporte</string>
<string name="active_section_hardware_backed">Protegido por hardware</string>
<string name="active_section_relay_session">Sesión de Relay</string>
<string name="active_section_access">Acceso</string>
<string name="active_section_paired_devices">Dispositivos vinculados</string>
<string name="active_section_device_count">%1$d dispositivos</string>
<string name="active_section_session_activity">Actividad de la sesión</string>
<string name="active_section_last_checked_just_now">Comprobado ahora mismo</string>
<string name="active_section_protected">Protegida</string>
<string name="active_section_not_encrypted">Sin cifrar</string>
<string name="active_section_no_security_issues">No se detectaron problemas de seguridad</string>
<string name="active_section_unencrypted_transport">Esta conexión usa un transporte sin cifrar</string>
<string name="active_section_actions">Acciones</string>
<string name="active_section_revoke_relay">Revocar vinculación de Relay</string>
<string name="cw_preparing_connection">Preparando la conexión…</string>
<string name="cw_preparing_connection_hint">Configurando el almacenamiento local seguro. Solo tardará un momento.</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Vincula Relay para añadir Terminal, Bridge, herramientas del dispositivo, sesiones de Relay y permisos.</string>
<string name="cw_relay_pair_qr">Vincular Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Escanea un QR de configuración de Relay</string>
<string name="cw_relay_enter_code">Introducir código de vinculación</string>
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
<string name="cw_dashboard_connected_body">Dashboard y Gateway están listos. Puedes empezar a chatear o abrir Manage.</string>
<string name="cw_continue">Continuar</string>
<string name="cw_timeline_discovered">Hermes encontrado</string>
<string name="cw_timeline_discovered_detail">Identidad del Dashboard y endpoint de estado verificados</string>
<string name="cw_timeline_access">Acceso al Dashboard</string>
<string name="cw_timeline_access_ready">No se requiere otro inicio de sesión</string>
<string name="cw_timeline_authenticated">Autenticación verificada</string>
<string name="cw_timeline_ready">Conexión lista</string>
<string name="cw_timeline_ready_detail">Chat, Manage y Voice pueden usar este Dashboard</string>
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional, que requiere una clave utilizable, e introduzca aquí el mismo valor.</string>
<string name="active_section_choose_how_phone_reaches_named">Cómo accede este teléfono a %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · Red LAN doméstica</string>
<string name="active_section_scan_relay_qr">Escanear QR de Relay</string>
<string name="active_section_other_relay_methods">Otros métodos de vinculación</string>
<string name="endpoints_tailscale_setup_hint">Tailscale debe estar conectado en ambos dispositivos y Hermes Dashboard debe ser accesible por el puerto 9119.</string>
<string name="endpoints_setup_help">Ayuda para configurar Tailscale</string>
<string name="voice_settings_provider_desc">Dónde se genera la voz. Las opciones disponibles vienen de este host Hermes y sus proveedores instalados.</string>
<string name="voice_settings_model_desc">Latest sigue las actualizaciones del proveedor. Elige un modelo con versión para mantener fija la voz.</string>
<string name="voice_settings_voice_desc">Elige cómo suenan las respuestas. La vista previa de una voz no guarda la selección.</string>
<string name="voice_settings_language_desc">Automático permite que el proveedor detecte el idioma hablado. Elige un idioma solo si la detección no es fiable.</string>
<string name="voice_settings_language_auto">Automático</string>
<string name="voice_settings_provider_options_title">Opciones de voz</string>
<string name="voice_settings_provider_options_desc">Solo se muestran los ajustes compatibles con el proveedor seleccionado.</string>
<string name="voice_settings_auto_speak">Leer respuestas en voz alta</string>
<string name="voice_settings_auto_speak_desc">Habla automáticamente las respuestas del asistente en las superficies de Hermes que respetan este ajuste del host.</string>
<string name="voice_settings_realtime_model_desc">Controla la sesión de voz en vivo, no el modelo de chat de Hermes. Latest sigue las actualizaciones del proveedor; un modelo con versión queda fijo.</string>
<string name="voice_settings_realtime_voice_desc">La voz usada dentro de la sesión en vivo. Las voces integradas y personalizadas aparecen cuando el proveedor las anuncia.</string>
</resources>
+224 -26
View File
@@ -14,14 +14,16 @@
<string name="onboarding_next">次</string>
<string name="onboarding_connect">接続する</string>
<string name="onboarding_welcome_title">Hermes-Relay 用 Android</string>
<string name="onboarding_welcome_description">Hermes とチャットし、携帯電話からダッシュボードを管理します。</string>
<string name="onboarding_get_started">始める</string>
<string name="onboarding_step_count">%2$d ステップ中 %1$d</string>
<string name="onboarding_welcome_title">Hermes を\nポケットに</string>
<string name="onboarding_welcome_description">自分の Hermes とチャット、管理、\n音声会話ができます。</string>
<string name="onboarding_welcome_badge">いらっしゃいませ</string>
<string name="onboarding_hermes_logo">Hermes ロゴ</string>
<string name="onboarding_chat_manage_label">チャット &amp; 管理</string>
<string name="onboarding_chat_manage_description">実行中の Hermes ダッシュボードと API に接続します。 Relay のインストールやペアリングは必要ありません。</string>
<string name="onboarding_power_tools_label">パワーツール</string>
<string name="onboarding_power_tools_description">ターミナル、Bridge、Relay セッション、およびチャネル許可に Hermes-Relay を追加します。</string>
<string name="onboarding_chat_manage_label">まずダッシュボード</string>
<string name="onboarding_chat_manage_description">Relay は不要です</string>
<string name="onboarding_power_tools_label">高度なツールは後から</string>
<string name="onboarding_power_tools_description">Terminal や Bridge が必要なら\nRelay をペアリング</string>
<string name="onboarding_setup_guide_hint">セットアップ ガイドには、コンピューターまたはサーバー上で Hermes を起動する必要がある場合のコピー/貼り付けコマンドが記載されています。</string>
<string name="onboarding_setup_guide">セットアップガイド</string>
<string name="onboarding_hermes_docs">Hermes ドキュメント</string>
@@ -54,6 +56,18 @@
<string name="onboarding_realtime_label">リアルタイム</string>
<string name="onboarding_realtime_description">プロバイダーネイティブのリアルタイム音声エージェントとプロファイル認識音声プロバイダー。</string>
<string name="onboarding_review_permissions">権限の確認</string>
<string name="onboarding_finish_setup_title">セットアップを完了</string>
<string name="onboarding_finish_setup_description">Hermes への接続準備ができました。このスマートフォンで許可する機能を選ぶか、後で設定から変更できます。</string>
<string name="onboarding_chat_manage_ready">準備完了 — スマートフォンの権限は必要ありません。</string>
<string name="onboarding_chat_alerts">チャット通知</string>
<string name="onboarding_chat_alerts_description">有効にした通知をバックグラウンドでも受け取れるよう、Android の通知を許可します。</string>
<string name="onboarding_chat_alerts_ready">このアプリの通知は有効です。</string>
<string name="onboarding_optional_features">オプション機能</string>
<string name="onboarding_optional_features_description">カメラ、マイク、通知コンパニオン、利用可能な端末ツールは、選択するまで無効のままです。</string>
<string name="onboarding_review_optional_permissions">オプション権限を確認</string>
<string name="onboarding_enable_chat_alerts">チャット通知を有効にする</string>
<string name="onboarding_not_now">今はしない</string>
<string name="onboarding_finish">セットアップを完了</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">ライブ音声会話</string>
@@ -68,7 +82,7 @@
<string name="chat_input_start_voice">音声会話を開始する</string>
<string name="chat_input_voice_setup_needed">音声会話 - 設定が必要です</string>
<string name="chat_input_stop_streaming">ストリーミングを停止する</string>
<string name="chat_input_steer_response">応答を制御する</string>
<string name="chat_input_steer_response">応答を修正</string>
<string name="chat_input_queue_message">キューメッセージ</string>
<!-- Bridge return labels -->
@@ -99,7 +113,7 @@
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">メッセージを編集してください…</string>
<string name="chat_placeholder_steer">応答を制御します…</string>
<string name="chat_placeholder_steer">応答を修正…</string>
<string name="chat_placeholder_queue">メッセージをキューに入れます…</string>
<string name="chat_placeholder_message">メッセージ…</string>
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
@@ -274,6 +288,39 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Hermes に接続します</string>
<string name="cw_nearby_description">このネットワーク上の Hermes を検索します。ダッシュボードとゲートウェイが、チャット、管理、音声の標準接続を提供します。</string>
<string name="cw_before_connecting">接続する前に</string>
<string name="cw_connect_step_server_title">パソコンで Hermes を起動</string>
<string name="cw_connect_step_server_body">Hermes のパソコンでダッシュボードを起動します。初めて LAN から接続する場合は、下のセットアップガイドでスマートフォンからアクセス可能にし、サインインを有効にしてください。</string>
<string name="cw_connect_step_network_title">サーバーに接続できる状態にする</string>
<string name="cw_connect_step_network_body">同じ Wi-Fi を使うか、両方のデバイスを Tailscale に接続します。</string>
<string name="cw_connect_step_phone_title">このスマートフォンから接続</string>
<string name="cw_connect_step_phone_body">下で検索するか、192.168.1.10:9119 のようなダッシュボードアドレスを入力します。表示された場合はサインインしてください。API キーは不要です。</string>
<string name="cw_nearby_searching">付近を検索中…</string>
<string name="cw_nearby_searching_hint">スマートフォンを Hermes サーバーと同じネットワークに接続してください。</string>
<string name="cw_nearby_heading">付近の Hermes</string>
<string name="cw_nearby_empty">Hermes サーバーが見つかりません</string>
<string name="cw_nearby_failed">このネットワークを検索できませんでした</string>
<string name="cw_nearby_empty_hint">Hermes が実行中であることを確認して再検索するか、アドレスを入力してください。</string>
<string name="cw_nearby_search_again">再検索</string>
<string name="cw_nearby_enter_address">アドレスを入力</string>
<string name="cw_other_connection_methods">その他の接続方法</string>
<string name="cw_manual_hermes_title">Hermes のアドレスを入力</string>
<string name="cw_manual_hermes_description">ブラウザで開くダッシュボードアドレスを入力します。このスマートフォンで開けない場合は hermes dashboard を起動し、Wi-Fi または Tailscale を確認してください。</string>
<string name="cw_hermes_address">Hermes アドレス</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 または hermes.example.com</string>
<string name="cw_hermes_address_hint">API キーは不要です。ポートを省略するとダッシュボードの 9119 番ポートを使用します。</string>
<string name="cw_find_hermes">Hermes を検索</string>
<string name="cw_hermes_found">Hermes が見つかりました</string>
<string name="cw_ready_to_connect">接続できます</string>
<string name="cw_ready">準備完了</string>
<string name="cw_available_after_signin">サインイン後に利用可能</string>
<string name="cw_unavailable_server">このサーバーでは利用不可</string>
<string name="cw_could_not_verify">確認できませんでした</string>
<string name="cw_choose_another">別のものを選択</string>
<string name="cw_sign_in_to_hermes">Hermes にサインイン</string>
<string name="cw_semantics_hermes_available">Hermes を利用できます</string>
<string name="cw_semantics_capability">%1$s:%2$s</string>
<string name="cw_connect_description">ホスト上で Hermes API/ダッシュボードを起動し、このアプリを接続します。 Relay ペアリングはオプションであり、端末、Bridge、Relay セッション、およびチャネル許可の場合にのみ必要です。</string>
<string name="cw_try_demo">デモを試してみる</string>
<string name="cw_try_demo_subtitle">オフラインで探索 — サーバーは必要ありません。</string>
@@ -318,7 +365,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 または http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API チャットとセッションで使用されます — API ポート 8642 および http:// はベア ホストとして想定されます (ダッシュボードの 9119 は個別に派生します)</string>
<string name="cw_scan_message">この LAN をスキャンして Hermes ダッシュボード/API を探しています…</string>
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。チャットでは API キーはオプションです。</string>
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。API キーは任意の直接 API フォールバックでのみ使います。</string>
<string name="cw_pair_relay_section">Relay のペア (オプション)</string>
<string name="cw_pair_relay_section_desc">すでに Relay プラグインを実行していますか?ここでペアリングすると、ターミナル、Bridge、およびチャネル許可が有効になります。</string>
<string name="cw_pair_relay_url_label">Relay URL</string>
@@ -337,6 +384,11 @@
<string name="cw_start_chat">チャットを開始する</string>
<string name="cw_sign_in_to_manage">サインインして管理する</string>
<string name="cw_pair_code_subtitle">これは、ホストが Relay ペアリング コードを登録した後にのみ使用してください。標準のチャットと管理ではこれは必要ありません。</string>
<string name="cw_pair_relay_for">%1$s と Relay をペアリング</string>
<string name="cw_pair_relay_scoped_desc">この保存済み Hermes 接続にオプションの Relay 拡張機能を追加します。サーバーが追加または置換されることはありません。</string>
<string name="cw_current_connection">現在の接続</string>
<string name="cw_current_hermes_connection">現在の HERMES 接続</string>
<string name="cw_existing_connection_unchanged">Chat、Manage、Voice、保存済みルートは変更されません。</string>
<string name="cw_api_url_label_field">API サーバー URL</string>
<string name="cw_api_url_field_placeholder">http://your-server:8642</string>
<string name="cw_api_url_field_supporting">Hermes API — チャットとセッション (デフォルトのポート 8642)</string>
@@ -441,7 +493,7 @@
<string name="endpoints_public">パブリック</string>
<string name="endpoints_custom">カスタム</string>
<string name="endpoints_route_name">路線名</string>
<string name="endpoints_api_url_host">API サーバー URL またはホスト</string>
<string name="endpoints_api_url_host">Dashboard/Gateway の URL またはホスト</string>
<string name="endpoints_saving">保存中…</string>
<string name="endpoints_save">保存</string>
<string name="endpoints_close">閉じる</string>
@@ -580,7 +632,7 @@
<string name="settings_hermes_management">Hermes 管理</string>
<string name="settings_hermes_management_desc">ダッシュボードの機能: スキル、cron、MCP、プロファイル、モデル</string>
<string name="settings_chat">チャット</string>
<string name="settings_chat_desc">API チャットの動作、エンドポイント、ツールの表示、メッセージの長さ</string>
<string name="settings_chat_desc">チャット動作、Gateway、API フォールバック、ツール表示、メッセージ長</string>
<string name="settings_voice_mode">ボイスモード</string>
<string name="settings_voice_mode_desc">ダッシュボード音声、リアルタイムRelayオプション、プロバイダー</string>
<string name="settings_threads">Threads</string>
@@ -622,9 +674,9 @@
<string name="settings_persistent_connection">永続的な接続</string>
<string name="settings_persistent_connection_desc">Hermes への接続をバッ​​クグラウンドで開いたままにします</string>
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
<string name="settings_turn_complete_alerts">ターン完了アラート</string>
<string name="settings_turn_complete_alerts_desc">アプリがバックグラウンドで動作しているときに返信が完了したときに通知する</string>
<string name="settings_turn_complete_alerts_off">バックグラウンドでの返信が終了してもアラートは表示されません</string>
<string name="settings_turn_complete_alerts">チャット通知</string>
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</string>
<string name="settings_keep_connected_deep_sleep">深い睡眠中も接続を維持する</string>
<string name="settings_keep_connected_battery_desc">Android は、画面がしばらくオフになった後でも接続を一時停止できます (Doze)。永続的な接続がバックグラウンドで動作し続けるように、無制限のバッテリーを許可します。</string>
<string name="settings_allow_unrestricted_battery">無制限のバッテリーを許可する</string>
@@ -654,8 +706,8 @@
<string name="chat_settings_recent_prompt_chips_desc">最近のメッセージをコンポーザーの上にタップ可能なチップとして表示し、再送信します。デフォルトではオフです。</string>
<string name="chat_settings_keep_keyboard_open">送信時にキーボードを開いたままにする</string>
<string name="chat_settings_keep_keyboard_open_desc">送信後はコンポーザー内に留まります。メッセージを送信するたびにキーボードを閉じるには、オフにします。</string>
<string name="chat_settings_notify_when_finishes">Hermes が終了したら通知する</string>
<string name="chat_settings_notify_when_finishes_desc">アプリがバックグラウンドで動作中に返信が完了したときに通知を投稿する</string>
<string name="chat_settings_notify_when_finishes">バックグラウンドのチャット通知</string>
<string name="chat_settings_notify_when_finishes_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
<string name="chat_settings_share_phone_status">電話のステータスをエージェントと共有する</string>
<string name="chat_settings_share_phone_status_desc">すべてのチャット ターンにアプリと電話に関する短いシステム メッセージを含めます</string>
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
@@ -737,6 +789,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">オプション</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">チャット</string>
<string name="conn_manage_label">管理</string>
<string name="conn_dashboard_label">ダッシュボード</string>
<string name="conn_voice_label">声</string>
<string name="conn_relay_label">Relay</string>
@@ -754,6 +808,19 @@
<item quantity="one">%1$d 日前にペアリングしました</item>
<item quantity="other">%1$d日前にペアリングしました</item>
</plurals>
<plurals name="conn_server_count">
<item quantity="one">%1$d 台のサーバー</item>
<item quantity="other">%1$d 台のサーバー</item>
</plurals>
<string name="conn_switch">切り替え</string>
<string name="conn_switching">切り替え中…</string>
<string name="conn_switched">使用中</string>
<string name="conn_connecting_to">%1$s に接続中…</string>
<string name="conn_last_used_now">たった今使用</string>
<string name="conn_last_used_format">最終使用: %1$s</string>
<string name="conn_just_now">たった今</string>
<string name="conn_dashboard_only_route">Dashboard のみ</string>
<string name="conn_no_routes">ルート未設定</string>
<!-- P0: ConnectionDetailScreen -->
<string name="detail_active">アクティブ</string>
@@ -783,6 +850,10 @@
<string name="detail_tab_routes">ルート</string>
<string name="detail_tab_advanced">高度な</string>
<string name="detail_tab_security">安全</string>
<string name="detail_dashboard_primary">Dashboard を優先</string>
<string name="detail_core_ready">Hermes コアは準備完了</string>
<string name="detail_core_configured">Hermes コアを設定済み</string>
<string name="detail_overview_summary">Chat、Manage、Voice では標準の Hermes を使用します。Relay は高度な端末機能を追加するオプションの拡張機能です。</string>
<!-- P0: SessionDrawer -->
<string name="drawer_filter_by_source">ソースによるフィルター</string>
@@ -1430,7 +1501,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Relay プラグイン音声 — プロファイル対応プロバイダーとストリーミング音声出力。</string>
<string name="voice_settings_optional">オプション</string>
<string name="voice_settings_signin_route_hint">%1$s ルート経由で接続されており、ダッシュボードのサインインはホストごとに行われます。ホーム ネットワークからのサインインは引き継がれません。このルートの途中で [管理] に一度サインインすると、ここでも音声のロックが解除されます。</string>
<string name="voice_settings_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。[管理] を開いて再度サインインし、音声を有効にしてください。</string>
<string name="voice_settings_signin_default_hint">Hermes ダッシュボードでは、標準音声を書き起こしたり話したりする前にサインインする必要があります。管理に一度サインインすると、この接続のロックが解除されます。</string>
<string name="voice_settings_sign_in_via_manage">「管理」経由でサインインする</string>
<string name="voice_settings_unsupported_build_body">この Hermes サーバー ビルドでは、ダッシュボードのオーディオ ルートがまだ公開されていません。サーバー上の hermes-agent を更新するか、Relay をペアにして Relay 音声を使用します。</string>
@@ -1669,7 +1740,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">ダッシュボードへのサインインが必要です</string>
<string name="dashboard_signin_required_body">管理は、%1$s で Hermes ダッシュボード セッションを使用します。</string>
<string name="dashboard_signin_route_hint">あなたは %1$s ルート上にいます。ダッシュボードのサインインはホストごとに行われるため、他のルートからのサインインは引き継がれません。ここで 1 回サインインすると、アプリは両方のセッションを維持します。</string>
<string name="dashboard_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。再度サインインして、この接続の信頼済みルート全体でセッションを更新してください。</string>
<string name="dashboard_signin_with_provider">%1$s でサインインします</string>
<string name="dashboard_username_password">ユーザー名 &amp; パスワード</string>
<string name="dashboard_username">ユーザー名</string>
@@ -1720,6 +1791,31 @@
<string name="dashboard_action_use">使用</string>
<string name="dashboard_action_describe">説明する</string>
<string name="dashboard_action_model">モデル</string>
<string name="dashboard_tab_custom_endpoints">エンドポイント</string>
<string name="dashboard_tab_custom_endpoints_lower">エンドポイント</string>
<string name="dashboard_tile_custom_endpoints_title">カスタムエンドポイント</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 互換プロバイダー</string>
<string name="dashboard_action_authenticate">認証</string>
<string name="dashboard_action_validate">検証</string>
<string name="dashboard_action_edit">編集</string>
<string name="dashboard_mcp_oauth_title">%1$s を認証</string>
<string name="dashboard_mcp_oauth_body">Hermes はブラウザでプロバイダーを開きます。アクセスを承認したら、この画面に戻ってください。認証情報は Hermes サーバーに保存されます。</string>
<string name="dashboard_mcp_oauth_approved">MCP 認証が承認されました</string>
<string name="dashboard_mcp_oauth_failed">MCP 認証に失敗しました</string>
<string name="dashboard_mcp_oauth_no_browser">MCP 認証を完了するためのブラウザを利用できません。</string>
<string name="dashboard_custom_endpoint_add">エンドポイントを追加</string>
<string name="dashboard_custom_endpoint_edit">エンドポイントを編集</string>
<string name="dashboard_custom_endpoint_name">名前</string>
<string name="dashboard_custom_endpoint_url">ベース URL</string>
<string name="dashboard_custom_endpoint_model">デフォルトモデル</string>
<string name="dashboard_custom_endpoint_key">API キー(任意)</string>
<string name="dashboard_custom_endpoint_key_help">保存時に既存のキーを保持するには空欄にしてください。検証にはここで入力したキーのみが使用されます。Hermes が保存済みのキーを表示または削除することはありません。</string>
<string name="dashboard_custom_endpoint_context">コンテキスト長(任意)</string>
<string name="dashboard_custom_endpoint_discover">/models からモデルを検出</string>
<string name="dashboard_custom_endpoint_valid">エンドポイントに接続できました · %1$d モデル</string>
<string name="dashboard_custom_endpoint_validate_failed">エンドポイントの検証に失敗しました</string>
<string name="dashboard_custom_endpoint_save_failed">エンドポイントの保存に失敗しました</string>
<string name="dashboard_custom_endpoint_saved">カスタムエンドポイントを保存しました</string>
<string name="dashboard_action_completed">%1$s 完了</string>
<string name="dashboard_action_failed">%1$s が失敗しました</string>
<string name="dashboard_more">もっと</string>
@@ -2032,9 +2128,9 @@
<string name="active_section_allow_plain_connections">プレーン接続を許可する</string>
<string name="active_section_api_hermes_voice_reachable">API 到達可能 — Hermes 音声が設定されています</string>
<string name="active_section_api_key_already_set">API キーはすでに設定されています</string>
<string name="active_section_api_key_needed_hint">API サーバーでキーが必要な場合は、キーを入力します。</string>
<string name="active_section_api_key_needed_hint">Hermes サーバーで作成した API_SERVER_KEY を使用します。このキーはアプリから発行されません。</string>
<string name="active_section_api_key_not_configured">API キーが構成されていません</string>
<string name="active_section_api_key_optional">API キー (オプション)</string>
<string name="active_section_api_key_optional">直接 API の API キー</string>
<string name="active_section_api_key_stored_hint">鍵は安全に保管されます。</string>
<string name="active_section_api_reachable_voice_review">API に到達可能 — 音声設定を確認してください</string>
<string name="active_section_api_relay_voice_reachable">API 到達可能 — Relay 音声が設定されています</string>
@@ -2142,6 +2238,7 @@
<string name="conn_info_api_server_title">API サーバー</string>
<string name="conn_info_approvals_off">承認はオフです</string>
<string name="conn_info_auth">認証</string>
<string name="conn_info_relay_auth">Relay 認証</string>
<string name="conn_info_avg_ttft">平均TTFT</string>
<string name="conn_info_channel_grants">チャンネル許可</string>
<string name="conn_info_checking">チェック中…</string>
@@ -2181,6 +2278,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (ペア)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay ペア済み</string>
<string name="conn_info_idle_suffix">・アイドル</string>
<string name="conn_info_insecure_mode_allowed">安全でないモードは許可されています</string>
<string name="conn_info_inspect_profile">%1$sを検査する</string>
@@ -2405,6 +2503,7 @@
<string name="tool_progress_status_completed">完成した</string>
<string name="tool_progress_status_failed">失敗した</string>
<string name="tool_progress_status_running">走っている</string>
<string name="image_generation_rendering">画像を生成中</string>
<string name="tool_progress_cd_collapse">崩壊</string>
<string name="tool_progress_cd_expand">拡大する</string>
@@ -2511,7 +2610,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">Hermes QR をスキャン</string>
<string name="qr_scanner_instruction">Hermes セットアップ QR をスキャンします</string>
<string name="qr_scanner_subtext">Hermes に質問します: &quot;私の API URL および API キーを使用して QR コードを生成します。&quot;</string>
<string name="qr_scanner_subtext">Hermes セットアップ QR または Relay ペアリング QR をスキャンします。標準の Dashboard 接続に API キーは必要ありません。</string>
<string name="qr_scanner_relay_title">Relay QR をスキャン</string>
<string name="qr_scanner_relay_instruction">Relay ペアリング QR をスキャンします</string>
<string name="qr_scanner_relay_subtext">Hermes サーバーで Relay ペアリング QR を開きます。既存の Dashboard 接続とルートは変更されません。</string>
<string name="qr_scanner_camera_error">このデバイスではカメラを起動できません。</string>
<string name="qr_scanner_fallback_message">カメラがなくてもペアリングできます。</string>
<string name="qr_scanner_pair_manual">手動でペアリングする</string>
@@ -2978,12 +3080,12 @@
<string name="diag_check_voice_relay">音声 (Relay)</string>
<string name="diag_check_voice_standard">音声(標準)</string>
<string name="endpoints_pin_title">固定されたエンドポイント</string>
<string name="endpoints_route_editor_desc">ルートを編集する</string>
<string name="endpoints_route_editor_desc">このネットワークで電話が使用する Dashboard/Gateway アドレスを追加します。</string>
<string name="endpoints_route_name_placeholder">路線名</string>
<string name="endpoints_url_host_placeholder">ホスト</string>
<string name="endpoints_url_supporting_blank">サポートする URL がありません</string>
<string name="endpoints_url_supporting_enter">URL を入力してください</string>
<string name="endpoints_url_supporting_preview">プレビュー</string>
<string name="endpoints_url_host_placeholder">100.x.y.z または host.ts.net</string>
<string name="endpoints_url_supporting_blank">サーバーの Dashboard アドレスを入力します</string>
<string name="endpoints_url_supporting_enter">http:// または https:// の Dashboard アドレスを使用します</string>
<string name="endpoints_url_supporting_preview">テスト対象: %1$s</string>
<string name="image_viewer_error">画像保存エラー</string>
<string name="image_viewer_failed">画像の保存に失敗しました</string>
<string name="image_viewer_failed_template">画像の保存に失敗しました: %s</string>
@@ -3139,4 +3241,100 @@
<string name="tool_output_risk_a11y">、%1$s 出力リスク</string>
<string name="tool_output_risk_findings">リスク所見を出力する</string>
<string name="tool_output_risk_redacted">機密性の高いスパンは上流で編集されました。</string>
<string name="conn_startup_title">アプリ起動時</string>
<string name="conn_startup_last_used">最後に使用した接続</string>
<string name="conn_startup_recommended">推奨</string>
<string name="conn_startup_choose">起動時の接続を選択</string>
<string name="active_section_primary_dashboard">メイン Dashboard</string>
<string name="active_section_dashboard_reachable_signed_in">接続可能 · サインイン済み</string>
<string name="active_section_dashboard_reachable">接続可能</string>
<string name="active_section_dashboard_not_checked">未確認</string>
<string name="active_section_dashboard_unreachable">接続できません</string>
<string name="active_section_no_fallback_routes">フォールバックルートはまだありません</string>
<string name="active_section_no_fallback_routes_desc">直接チャットのフォールバックやネットワーク切り替え用に、任意の API ルートを追加できます。</string>
<string name="active_section_add_api_fallback">フォールバックルートを追加</string>
<string name="active_section_security_authentication">認証</string>
<string name="active_section_dashboard_session">Dashboard セッション</string>
<string name="active_section_credential_storage">認証情報ストレージ</string>
<string name="active_section_encrypted_storage">暗号化ストレージ</string>
<string name="active_section_no_relay_credential">Relay 認証情報なし</string>
<string name="active_section_sign_out_dashboard">Dashboard からサインアウト</string>
<string name="active_section_credentials_encrypted">認証情報はこの端末上で暗号化されたまま保存されます。</string>
<string name="active_section_reachable_badge">接続可能</string>
<string name="active_section_unchecked_badge">未確認</string>
<string name="active_section_edit">編集</string>
<string name="active_section_fallback_routes">フォールバックルート</string>
<string name="active_section_route_selection">ルート選択</string>
<string name="active_section_automatic">自動</string>
<string name="active_section_api_access">API アクセス</string>
<string name="active_section_core_hermes">Hermes コア</string>
<string name="active_section_optional_relay">オプションの Relay</string>
<string name="active_section_extend_connection">この接続を拡張</string>
<string name="active_section_relay_connected_features">Relay 拡張機能</string>
<string name="active_section_relay_optional_summary">Terminal、Bridge、端末ツール、Relay セッション、安全なリモートルートを追加します。</string>
<string name="active_section_view_relay_details">Relay の詳細を表示</string>
<string name="active_section_core_unchanged">現在の Hermes 接続は変更されません。</string>
<string name="active_section_api_optional_direct">ダイレクトチャットと連携ではオプション</string>
<string name="active_section_configure_test">設定してテスト</string>
<string name="active_section_relay_optional_bridge">オプションの Bridge 機能とリモートアクセス</string>
<string name="active_section_configure_relay">Relay を設定</string>
<string name="active_section_pair_device_using_code">サーバーから取得したコードでこの端末をペアリングします。</string>
<string name="active_section_enter_pairing_code">ペアリングコードを入力</string>
<string name="active_section_done">完了</string>
<string name="active_section_connection_behavior">接続動作</string>
<string name="active_section_manual_pairing">手動ペアリング</string>
<string name="active_section_paired">ペアリング済み</string>
<string name="active_section_not_paired">未ペアリング</string>
<string name="active_section_transport">トランスポート</string>
<string name="active_section_hardware_backed">ハードウェア保護</string>
<string name="active_section_relay_session">Relay セッション</string>
<string name="active_section_access">アクセス</string>
<string name="active_section_paired_devices">ペアリング済み端末</string>
<string name="active_section_device_count">%1$d 台の端末</string>
<string name="active_section_session_activity">セッションのアクティビティ</string>
<string name="active_section_last_checked_just_now">たった今確認</string>
<string name="active_section_protected">保護済み</string>
<string name="active_section_not_encrypted">暗号化なし</string>
<string name="active_section_no_security_issues">セキュリティ上の問題は検出されませんでした</string>
<string name="active_section_unencrypted_transport">この接続では暗号化されていないトランスポートを使用しています</string>
<string name="active_section_actions">操作</string>
<string name="active_section_revoke_relay">Relay のペアリングを解除</string>
<string name="active_section_choose_how_phone_reaches_named">この端末から %1$s への接続方法</string>
<string name="active_section_dashboard_home_lan">Dashboard · ホーム LAN</string>
<string name="cw_preparing_connection">接続を準備しています…</string>
<string name="cw_preparing_connection_hint">安全なローカルストレージを設定しています。まもなく完了します。</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Relay をペアリングすると、Terminal、Bridge、端末ツール、Relay セッション、権限を追加できます。</string>
<string name="cw_relay_pair_qr">Hermes Relay をペアリング</string>
<string name="cw_relay_pair_qr_desc">Relay セットアップ QR をスキャン</string>
<string name="cw_relay_enter_code">ペアリングコードを入力</string>
<string name="cw_dashboard_connected_title">Hermes に接続しました</string>
<string name="cw_dashboard_connected_body">Dashboard と Gateway の準備ができました。チャットを開始するか Manage を開けます。</string>
<string name="cw_continue">続行</string>
<string name="cw_timeline_discovered">Hermes を検出</string>
<string name="cw_timeline_discovered_detail">Dashboard の識別情報とステータスエンドポイントを確認済み</string>
<string name="cw_timeline_access">Dashboard アクセス</string>
<string name="cw_timeline_access_ready">追加のサインインは不要です</string>
<string name="cw_timeline_authenticated">認証を確認済み</string>
<string name="cw_timeline_ready">接続準備完了</string>
<string name="cw_timeline_ready_detail">Chat、Manage、Voice でこの Dashboard を使用できます</string>
<string name="active_section_optional_api_fallback">任意の直接 API フォールバック</string>
<string name="active_section_api_not_required">この接続が Hermes Dashboard を使用する場合は必要ありません。</string>
<string name="active_section_where_api_key">このキーはどこで入手しますか?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY は Hermes サーバー上で作成します。このアプリからは発行されません。任意の API サーバーを有効にする場合のみ設定します。このサーバーには使用可能なキーが必要です。同じ値をここに入力してください。</string>
<string name="active_section_scan_relay_qr">Relay QR をスキャン</string>
<string name="active_section_other_relay_methods">その他のペアリング方法</string>
<string name="endpoints_tailscale_setup_hint">両方のデバイスで Tailscale に接続し、Hermes Dashboard がポート 9119 で到達可能である必要があります。</string>
<string name="endpoints_setup_help">Tailscale セットアップヘルプ</string>
<string name="voice_settings_provider_desc">音声を生成する場所です。利用可能な選択肢は、この Hermes ホストとインストール済みプロバイダーから取得されます。</string>
<string name="voice_settings_model_desc">Latest はプロバイダーの更新に追従します。音声の動作を固定するには、バージョン付きモデルを選択します。</string>
<string name="voice_settings_voice_desc">返信の声を選択します。音声をプレビューしても選択は保存されません。</string>
<string name="voice_settings_language_desc">自動では、プロバイダーが話し言葉を検出します。検出が不安定な場合だけ言語を選択します。</string>
<string name="voice_settings_language_auto">自動</string>
<string name="voice_settings_provider_options_title">音声オプション</string>
<string name="voice_settings_provider_options_desc">選択したプロバイダーが対応する設定だけを表示します。</string>
<string name="voice_settings_auto_speak">返信を読み上げる</string>
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</string>
</resources>
+248 -44
View File
@@ -13,15 +13,17 @@
<string name="onboarding_back">Back</string>
<string name="onboarding_next">Next</string>
<string name="onboarding_connect">Connect</string>
<string name="onboarding_get_started">Get started</string>
<string name="onboarding_step_count">Step %1$d of %2$d</string>
<string name="onboarding_welcome_title">Hermes-Relay for Android</string>
<string name="onboarding_welcome_description">Chat with Hermes and manage your dashboard from your phone.</string>
<string name="onboarding_welcome_title">Hermes,\nin your pocket</string>
<string name="onboarding_welcome_description">Chat, manage, and use voice\nwith your own Hermes.</string>
<string name="onboarding_welcome_badge">Welcome</string>
<string name="onboarding_hermes_logo">Hermes logo</string>
<string name="onboarding_chat_manage_label">Chat &amp; Manage</string>
<string name="onboarding_chat_manage_description">Connect to your running Hermes dashboard and API. No Relay install or pairing required.</string>
<string name="onboarding_power_tools_label">Power tools</string>
<string name="onboarding_power_tools_description">Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.</string>
<string name="onboarding_chat_manage_label">Dashboard first</string>
<string name="onboarding_chat_manage_description">No Relay required</string>
<string name="onboarding_power_tools_label">Power tools later</string>
<string name="onboarding_power_tools_description">Pair Relay when you need\nTerminal or Bridge</string>
<string name="onboarding_setup_guide_hint">The setup guide has copy/paste commands when you need to start Hermes on a computer or server.</string>
<string name="onboarding_setup_guide">Setup Guide</string>
<string name="onboarding_hermes_docs">Hermes Docs</string>
@@ -54,6 +56,18 @@
<string name="onboarding_realtime_label">Realtime</string>
<string name="onboarding_realtime_description">Provider-native realtime voice agent and profile-aware voice providers.</string>
<string name="onboarding_review_permissions">Review permissions</string>
<string name="onboarding_finish_setup_title">Finish setup</string>
<string name="onboarding_finish_setup_description">Your Hermes connection is ready. Choose what this phone can do now, or change it later in Settings.</string>
<string name="onboarding_chat_manage_ready">Ready — no phone permission needed.</string>
<string name="onboarding_chat_alerts">Chat alerts</string>
<string name="onboarding_chat_alerts_description">Allow Android notifications so enabled alerts can reach you in the background.</string>
<string name="onboarding_chat_alerts_ready">Notifications are enabled for this app.</string>
<string name="onboarding_optional_features">Optional features</string>
<string name="onboarding_optional_features_description">Camera, microphone, notification companion, and available device tools stay off until you choose them.</string>
<string name="onboarding_review_optional_permissions">Review optional permissions</string>
<string name="onboarding_enable_chat_alerts">Enable chat alerts</string>
<string name="onboarding_not_now">Not now</string>
<string name="onboarding_finish">Finish setup</string>
<!-- Chat input bar -->
<string name="chat_input_live_voice_hint">Live voice conversation</string>
@@ -68,7 +82,7 @@
<string name="chat_input_start_voice">Start voice conversation</string>
<string name="chat_input_voice_setup_needed">Voice conversation — setup needed</string>
<string name="chat_input_stop_streaming">Stop streaming</string>
<string name="chat_input_steer_response">Steer the response</string>
<string name="chat_input_steer_response">Correct the response</string>
<string name="chat_input_queue_message">Queue message</string>
<!-- Bridge return labels -->
@@ -99,7 +113,7 @@
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">Edit your message…</string>
<string name="chat_placeholder_steer">Steer the response…</string>
<string name="chat_placeholder_steer">Correct the response…</string>
<string name="chat_placeholder_queue">Queue a message…</string>
<string name="chat_placeholder_message">Message…</string>
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
@@ -274,12 +288,62 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Connect to Hermes</string>
<string name="cw_connect_description">Start the Hermes API/dashboard on your host, then connect this app. Relay pairing is optional and only needed for Terminal, Bridge, relay sessions, and channel grants.</string>
<string name="cw_nearby_description">We’ll look for Hermes on this network. Dashboard and Gateway provide the standard Chat, Manage, and Voice connection.</string>
<string name="cw_before_connecting">Before you connect</string>
<string name="cw_connect_step_server_title">Start Hermes on your computer</string>
<string name="cw_connect_step_server_body">Start the Dashboard on the Hermes computer. For first-time LAN access, use Setup Guide below to make it phone-reachable and enable sign-in.</string>
<string name="cw_connect_step_network_title">Make the server reachable</string>
<string name="cw_connect_step_network_body">Use the same Wi-Fi, or connect both devices to Tailscale.</string>
<string name="cw_connect_step_phone_title">Connect from this phone</string>
<string name="cw_connect_step_phone_body">Search below or enter the Dashboard address, such as 192.168.1.10:9119. Sign in if prompted—no API key is needed.</string>
<string name="cw_nearby_searching">Searching nearby…</string>
<string name="cw_nearby_searching_hint">Keep your phone on the same network as your Hermes server.</string>
<string name="cw_nearby_heading">Hermes nearby</string>
<string name="cw_nearby_empty">No Hermes servers found</string>
<string name="cw_nearby_failed">Couldn’t search this network</string>
<string name="cw_nearby_empty_hint">Check that Hermes is running, then search again or enter its address.</string>
<string name="cw_nearby_search_again">Search again</string>
<string name="cw_nearby_enter_address">Enter address or custom port</string>
<string name="cw_preparing_connection">Preparing connection…</string>
<string name="cw_preparing_connection_hint">Setting up secure local storage. This should only take a moment.</string>
<string name="cw_other_connection_methods">Advanced connection options</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Pair Relay to add Terminal, Bridge, device tools, Relay sessions, and grants.</string>
<string name="cw_relay_pair_qr">Pair Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Scan a Relay setup QR</string>
<string name="cw_relay_enter_code">Enter a Relay pairing code</string>
<string name="cw_manual_hermes_title">Enter your Hermes address</string>
<string name="cw_manual_hermes_description">Enter the Dashboard address you open in a browser. If it does not open on this phone, start hermes dashboard and check Wi-Fi or Tailscale.</string>
<string name="cw_hermes_address">Hermes address</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 or hermes.example.com</string>
<string name="cw_hermes_address_hint">No API key is needed. Dashboard port 9119 is used when no port is included.</string>
<string name="cw_find_hermes">Find Hermes</string>
<string name="cw_hermes_found">Hermes found</string>
<string name="cw_ready_to_connect">Ready to connect</string>
<string name="cw_ready">Ready</string>
<string name="cw_available_after_signin">Available after sign-in</string>
<string name="cw_unavailable_server">Unavailable on this server</string>
<string name="cw_could_not_verify">Couldn’t verify</string>
<string name="cw_choose_another">Choose another</string>
<string name="cw_sign_in_to_hermes">Sign in to Hermes</string>
<string name="cw_dashboard_connected_title">Hermes is connected</string>
<string name="cw_dashboard_connected_body">Dashboard and Gateway are ready. You can start chatting or open Manage.</string>
<string name="cw_continue">Continue</string>
<string name="cw_timeline_discovered">Hermes discovered</string>
<string name="cw_timeline_discovered_detail">Dashboard identity and status endpoint verified</string>
<string name="cw_timeline_access">Dashboard access</string>
<string name="cw_timeline_access_ready">No additional sign-in required</string>
<string name="cw_timeline_authenticated">Authentication verified</string>
<string name="cw_timeline_ready">Connection ready</string>
<string name="cw_timeline_ready_detail">Chat, Manage, and Voice can use this Dashboard</string>
<string name="cw_semantics_hermes_available">Hermes available</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Use these options for API-only compatibility, custom setup, or alternate Relay pairing.</string>
<string name="cw_try_demo">Try the demo</string>
<string name="cw_try_demo_subtitle">Explore offline — no server needed.</string>
<string name="cw_setup_guide">Setup Guide</string>
<string name="cw_hermes_api">Hermes API</string>
<string name="cw_advanced_relay_pairing">Advanced: Relay pairing</string>
<string name="cw_advanced_relay_pairing">Relay pairing options</string>
<string name="cw_advanced_relay_pairing_desc">Terminal, Bridge, Relay sessions, and grants require the Relay plugin.</string>
<string name="cw_relay_docs">Relay docs</string>
<string name="cw_skip_for_now">Skip for now — set up later in Settings</string>
@@ -305,12 +369,12 @@
<string name="cw_cancel">Cancel</string>
<!-- Method tiles -->
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">API/dashboard setup for Chat, Manage, Skills, Cron, MCP, Profiles, Models, and Settings</string>
<string name="cw_method_hermes_title">API-only connection</string>
<string name="cw_method_hermes_subtitle">Compatibility and fallback when Dashboard/Gateway is unavailable</string>
<string name="cw_method_scan_title">Scan setup QR</string>
<string name="cw_method_scan_subtitle">Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin</string>
<string name="cw_method_pair_code_title">Pair Relay by code</string>
<string name="cw_method_pair_code_subtitle">Power-user path for Terminal, Bridge, Relay sessions, and grants</string>
<string name="cw_method_pair_code_subtitle">Pair with a code created by your Relay server</string>
<string name="cw_method_show_code_title">Show Relay code</string>
<string name="cw_method_show_code_subtitle">No camera or QR? Register this phone\'s code on the host</string>
@@ -318,8 +382,13 @@
<string name="cw_api_url_placeholder">192.168.1.10 or http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API used by Chat and sessions — API port 8642 and http:// assumed for bare hosts (the dashboard\'s 9119 is derived separately)</string>
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is optional for Chat.</string>
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is only for the optional direct API fallback.</string>
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
<string name="cw_pair_relay_for">Pair Relay with %1$s</string>
<string name="cw_pair_relay_scoped_desc">Add the optional Relay extension to this saved Hermes connection. This does not add or replace a server.</string>
<string name="cw_current_connection">Current connection</string>
<string name="cw_current_hermes_connection">CURRENT HERMES CONNECTION</string>
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice, and your saved routes stay unchanged.</string>
<string name="cw_pair_relay_section_desc">Already running the Relay plugin? Pair here to enable Terminal, Bridge, and channel grants.</string>
<string name="cw_pair_relay_url_label">Relay URL</string>
<string name="cw_pair_relay_code_label">Pairing code</string>
@@ -441,7 +510,7 @@
<string name="endpoints_public">Public</string>
<string name="endpoints_custom">Custom</string>
<string name="endpoints_route_name">Route name</string>
<string name="endpoints_api_url_host">API server URL or host</string>
<string name="endpoints_api_url_host">Dashboard/Gateway URL or host</string>
<string name="endpoints_saving">Saving…</string>
<string name="endpoints_save">Save</string>
<string name="endpoints_close">Close</string>
@@ -580,7 +649,7 @@
<string name="settings_hermes_management">Hermes management</string>
<string name="settings_hermes_management_desc">Dashboard features: skills, cron, MCP, profiles, models</string>
<string name="settings_chat">Chat</string>
<string name="settings_chat_desc">API chat behavior, endpoints, tool display, message length</string>
<string name="settings_chat_desc">Chat behavior, Gateway, API fallback, tool display, message length</string>
<string name="settings_voice_mode">Voice mode</string>
<string name="settings_voice_mode_desc">Dashboard voice, realtime relay options, providers</string>
<string name="settings_threads">Threads</string>
@@ -622,9 +691,9 @@
<string name="settings_persistent_connection">Persistent connection</string>
<string name="settings_persistent_connection_desc">Keeping your connection to Hermes open in the background</string>
<string name="settings_connect_on_demand">Connect on demand only · saves battery</string>
<string name="settings_turn_complete_alerts">Turn-complete alerts</string>
<string name="settings_turn_complete_alerts_desc">Notify when a reply finishes while the app is in the background</string>
<string name="settings_turn_complete_alerts_off">No alert when a backgrounded reply finishes</string>
<string name="settings_turn_complete_alerts">Chat alerts</string>
<string name="settings_turn_complete_alerts_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
<string name="settings_keep_connected_deep_sleep">Keep it connected in deep sleep</string>
<string name="settings_keep_connected_battery_desc">Android can still pause the connection once the screen\'s been off a while (Doze). Allow unrestricted battery so Persistent connection keeps working in the background.</string>
<string name="settings_allow_unrestricted_battery">Allow unrestricted battery</string>
@@ -654,8 +723,8 @@
<string name="chat_settings_recent_prompt_chips_desc">Show your recent messages as tappable chips above the composer to send them again. Off by default.</string>
<string name="chat_settings_keep_keyboard_open">Keep keyboard open on send</string>
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
<string name="chat_settings_notify_when_finishes">Notify when Hermes finishes</string>
<string name="chat_settings_notify_when_finishes_desc">Post a notification when a reply completes while the app is in the background</string>
<string name="chat_settings_notify_when_finishes">Background chat alerts</string>
<string name="chat_settings_notify_when_finishes_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
<string name="chat_settings_share_phone_status">Share phone status with agent</string>
<string name="chat_settings_share_phone_status_desc">Include a short system message about the app and phone on every chat turn</string>
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
@@ -737,6 +806,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">Optional</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">Chat</string>
<string name="conn_manage_label">Manage</string>
<string name="conn_dashboard_label">Dashboard</string>
<string name="conn_voice_label">Voice</string>
<string name="conn_relay_label">Relay</string>
@@ -761,6 +832,10 @@
<string name="detail_more_actions">More actions</string>
<string name="detail_rename">Rename</string>
<string name="detail_pair_relay">Pair Relay</string>
<string name="detail_dashboard_primary">Dashboard primary</string>
<string name="detail_core_ready">Core Hermes ready</string>
<string name="detail_core_configured">Core Hermes configured</string>
<string name="detail_overview_summary">Chat, Manage, and Voice use standard Hermes. Relay is an optional extension for advanced device features.</string>
<string name="detail_repair">Re-pair</string>
<string name="detail_revoke">Revoke</string>
<string name="detail_remove">Remove</string>
@@ -783,6 +858,83 @@
<string name="detail_tab_routes">Routes</string>
<string name="detail_tab_advanced">Advanced</string>
<string name="detail_tab_security">Security</string>
<string name="conn_startup_title">On app start</string>
<string name="conn_startup_last_used">Last used</string>
<string name="conn_startup_recommended">Recommended</string>
<string name="conn_startup_choose">Choose startup connection</string>
<plurals name="conn_server_count">
<item quantity="one">%1$d server</item>
<item quantity="other">%1$d servers</item>
</plurals>
<string name="conn_switch">Switch</string>
<string name="conn_switching">Switching…</string>
<string name="conn_switched">Active</string>
<string name="conn_connecting_to">Connecting to %1$s…</string>
<string name="conn_last_used_now">Last used just now</string>
<string name="conn_last_used_format">Last used %1$s</string>
<string name="conn_just_now">just now</string>
<string name="conn_dashboard_only_route">Dashboard only</string>
<string name="conn_no_routes">No routes configured</string>
<string name="active_section_primary_dashboard">Primary Dashboard</string>
<string name="active_section_dashboard_reachable_signed_in">Reachable · Signed in</string>
<string name="active_section_dashboard_reachable">Reachable</string>
<string name="active_section_dashboard_not_checked">Not checked yet</string>
<string name="active_section_dashboard_unreachable">Not reachable</string>
<string name="active_section_no_fallback_routes">No fallback routes yet</string>
<string name="active_section_no_fallback_routes_desc">Add a LAN, Tailscale, or public address to keep this connection available when networks change.</string>
<string name="active_section_add_api_fallback">Add fallback route</string>
<string name="active_section_security_authentication">Authentication</string>
<string name="active_section_dashboard_session">Dashboard session</string>
<string name="active_section_credential_storage">Credential storage</string>
<string name="active_section_encrypted_storage">Encrypted storage</string>
<string name="active_section_no_relay_credential">No Relay credential</string>
<string name="active_section_sign_out_dashboard">Sign out of Dashboard</string>
<string name="active_section_credentials_encrypted">Credentials remain encrypted on this device.</string>
<string name="active_section_reachable_badge">REACHABLE</string>
<string name="active_section_unchecked_badge">UNCHECKED</string>
<string name="active_section_edit">Edit</string>
<string name="active_section_fallback_routes">Fallback routes</string>
<string name="active_section_route_selection">Route selection</string>
<string name="active_section_automatic">Automatic</string>
<string name="active_section_api_access">API access</string>
<string name="active_section_optional_api_fallback">Optional direct API fallback</string>
<string name="active_section_api_not_required">Not required when this connection uses the Hermes Dashboard.</string>
<string name="active_section_where_api_key">Where do I get this?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, which requires a usable key, then enter the same value here.</string>
<string name="active_section_core_hermes">Core Hermes</string>
<string name="active_section_optional_relay">Optional Relay</string>
<string name="active_section_extend_connection">Extend this connection</string>
<string name="active_section_relay_connected_features">Relay extensions</string>
<string name="active_section_relay_optional_summary">Add Terminal, Bridge, device tools, Relay sessions, and secure remote routes.</string>
<string name="active_section_view_relay_details">View Relay details</string>
<string name="active_section_core_unchanged">Your current Hermes connection stays unchanged.</string>
<string name="active_section_api_optional_direct">Optional for direct chat and integrations</string>
<string name="active_section_configure_test">Configure &amp; test</string>
<string name="active_section_relay_optional_bridge">Optional bridge features and remote access</string>
<string name="active_section_configure_relay">Configure Relay</string>
<string name="active_section_scan_relay_qr">Scan Relay QR</string>
<string name="active_section_other_relay_methods">Other pairing methods</string>
<string name="active_section_pair_device_using_code">Pair this device using a code from the server.</string>
<string name="active_section_enter_pairing_code">Enter pairing code</string>
<string name="active_section_done">Done</string>
<string name="active_section_connection_behavior">Connection behavior</string>
<string name="active_section_manual_pairing">Manual pairing</string>
<string name="active_section_paired">Paired</string>
<string name="active_section_not_paired">Not paired</string>
<string name="active_section_transport">Transport</string>
<string name="active_section_hardware_backed">Hardware-backed</string>
<string name="active_section_relay_session">Relay session</string>
<string name="active_section_access">Access</string>
<string name="active_section_paired_devices">Paired devices</string>
<string name="active_section_device_count">%1$d devices</string>
<string name="active_section_session_activity">Session activity</string>
<string name="active_section_last_checked_just_now">Last checked just now</string>
<string name="active_section_protected">Protected</string>
<string name="active_section_not_encrypted">Not encrypted</string>
<string name="active_section_no_security_issues">No security issues detected</string>
<string name="active_section_unencrypted_transport">This connection uses an unencrypted transport</string>
<string name="active_section_actions">Actions</string>
<string name="active_section_revoke_relay">Revoke Relay pairing</string>
<!-- P0: SessionDrawer -->
<string name="drawer_filter_by_source">Filter by source</string>
@@ -1400,17 +1552,17 @@
<string name="voice_settings_back">Back</string>
<string name="voice_settings_scope_label">Voice scope</string>
<string name="voice_settings_global_voice">Global voice</string>
<string name="voice_settings_standard_scope_body">This connection speaks through your Hermes server\'s configured TTS and STT (config.yaml on the server, or the dashboard\'s Audio settings) — it\'s host-wide, not per profile. Pair Relay to pick providers, models, and voices from the phone and carry them per profile.</string>
<string name="voice_settings_standard_scope_body">Standard voice follows this Hermes host\'s existing TTS, STT, and voice configuration. These settings are shared across profiles; Relay can add profile-specific voice choices without replacing the host setup.</string>
<string name="voice_settings_scope_resolving">Resolving the active profile\'s voice scope…</string>
<string name="voice_settings_label_profile">Profile</string>
<string name="voice_settings_label_scope">Scope</string>
<string name="voice_settings_relay_scope_footer">Engine, route, and voice picks below are saved for this profile.</string>
<string name="voice_settings_relay_scope_footer">Relay voice choices below override the host defaults for this profile only. Clear an override to follow the Hermes host again.</string>
<string name="voice_settings_for_profile_title">Voice for this profile</string>
<string name="voice_settings_engine_label">Voice engine</string>
<string name="voice_settings_engine_hermes">Hermes Chat + Voice Output</string>
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory; speech runs over the standard Hermes dashboard or Relay — whichever the STT/TTS route below picks.</string>
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory. Speech uses the host\'s Standard configuration unless this profile selects a Relay voice override.</string>
<string name="voice_settings_engine_realtime">Realtime Agent</string>
<string name="voice_settings_engine_realtime_desc">Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.</string>
<string name="voice_settings_engine_realtime_desc">A live provider session handles listening and speaking with its own voice model and voice. Hermes still handles tools, memory, and confirmations. Requires a paired Relay.</string>
<string name="voice_settings_experimental">Experimental</string>
<string name="voice_settings_realtime_no_relay">No Relay is configured for this connection, so the Realtime Agent can\'t start. Pair Relay in Settings → Connections, or switch back to Hermes Chat + Voice Output.</string>
<string name="voice_settings_route_label">STT/TTS route</string>
@@ -1424,19 +1576,19 @@
<string name="voice_settings_status_auto_hermes">Ready — using Hermes</string>
<string name="voice_settings_status_no_route">No route available yet</string>
<string name="voice_settings_route_auto">Auto</string>
<string name="voice_settings_route_auto_desc">Relay when paired; otherwise the Hermes dashboard. Recommended.</string>
<string name="voice_settings_route_auto_desc">Use this profile\'s Relay voice when available, then fall back to the Hermes host. Recommended.</string>
<string name="voice_settings_route_hermes">Hermes</string>
<string name="voice_settings_route_hermes_desc">The dashboard audio path Hermes Desktop uses — works on a Hermes install, no Relay plugin required.</string>
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Relay plugin voice — profile-aware providers and streaming voice output.</string>
<string name="voice_settings_route_relay_desc">Use the provider, model, and voice saved for this profile through Relay.</string>
<string name="voice_settings_optional">Optional</string>
<string name="voice_settings_signin_route_hint">You\'re connected over the %1$s route, and dashboard sign-ins are per-host — a sign-in from your home network doesn\'t carry over. Sign in once in Manage while on this route to unlock voice here too.</string>
<string name="voice_settings_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Open Manage to sign in again and unlock voice.</string>
<string name="voice_settings_signin_default_hint">Your Hermes dashboard requires sign-in before standard voice can transcribe or speak. Signing in once in Manage unlocks it for this connection.</string>
<string name="voice_settings_sign_in_via_manage">Sign in via Manage</string>
<string name="voice_settings_unsupported_build_body">This Hermes server build doesn\'t expose the dashboard audio routes yet. Update hermes-agent on the server, or pair Relay to use Relay voice.</string>
<string name="voice_settings_tts_title">Text-to-Speech</string>
<string name="voice_settings_streaming_output_label">Streaming output (/voice/output)</string>
<string name="voice_settings_streaming_output_desc">The provider, model, and voice the agent speaks with on the Relay path.</string>
<string name="voice_settings_streaming_output_desc">Streams spoken replies with the provider, model, and voice saved for this profile. It does not change the Hermes chat model.</string>
<string name="voice_settings_basic_synthesize_label">Basic synthesize fallback (/voice/synthesize)</string>
<string name="voice_settings_basic_synthesize_desc">Always available as the stable speech safety net when provider-native voice is unavailable.</string>
<string name="voice_settings_provider_unavailable">unavailable</string>
@@ -1444,14 +1596,17 @@
<string name="voice_settings_yes">yes</string>
<string name="voice_settings_no">no</string>
<string name="voice_settings_label_provider">Provider</string>
<string name="voice_settings_provider_desc">Where speech is generated. Available choices come from this Hermes host and its installed providers.</string>
<string name="voice_settings_label_enabled">Enabled</string>
<string name="voice_settings_label_model">Model</string>
<string name="voice_settings_model_desc">Latest follows provider upgrades. Choose a versioned model to keep voice behavior pinned.</string>
<string name="voice_settings_label_voice">Voice</string>
<string name="voice_settings_voice_desc">Choose how replies sound. Previewing a voice does not save the selection.</string>
<string name="voice_settings_provider_gemini">Gemini</string>
<string name="voice_settings_provider_xai">xAI</string>
<string name="voice_settings_hide_enhanced">Hide enhanced voice (%1$s)</string>
<string name="voice_settings_advanced_enhanced">Advanced: enhanced voice (%1$s)</string>
<string name="voice_settings_enhanced_body">Pick a voice and tone for the %1$s TTS provider. Leave a field on \"Server default\" to use the relay\'s saved config.</string>
<string name="voice_settings_enhanced_body">Choose the options advertised by %1$s for this profile. Leave a field on \"Server default\" to follow the Relay host configuration.</string>
<string name="voice_settings_server_default">Server default</string>
<string name="voice_settings_voice_blank_label">Voice (blank = server default)</string>
<string name="voice_settings_supports_tone_tags">supports tone tags</string>
@@ -1459,7 +1614,9 @@
<string name="voice_settings_audio_tags_unsupported">Requires a Gemini 3.1 TTS model — pick one above to enable.</string>
<string name="voice_settings_voice_direction_label">Voice direction (optional)</string>
<string name="voice_settings_voice_direction_placeholder">e.g. Warm, calm narrator; unhurried pace.</string>
<string name="voice_settings_language_label">Language (optional, e.g. en)</string>
<string name="voice_settings_language_label">Language</string>
<string name="voice_settings_language_desc">Automatic lets the provider detect the spoken language. Choose a language only when detection is unreliable.</string>
<string name="voice_settings_language_auto">Automatic</string>
<string name="voice_settings_status_active">active</string>
<string name="voice_settings_status_unavailable">unavailable</string>
<string name="voice_settings_status_disabled">disabled</string>
@@ -1480,7 +1637,11 @@
<string name="voice_settings_model_id">Model ID</string>
<string name="voice_settings_voice_id">Voice ID</string>
<string name="voice_settings_streaming_latency">Streaming latency: %1$d</string>
<string name="voice_settings_fallback_desc">Use legacy Hermes TTS if streaming output fails before audio starts</string>
<string name="voice_settings_fallback_desc">If Relay streaming cannot start, speak through the Hermes host\'s Standard voice.</string>
<string name="voice_settings_provider_options_title">Voice options</string>
<string name="voice_settings_provider_options_desc">Only settings supported by the selected provider are shown.</string>
<string name="voice_settings_auto_speak">Read replies aloud</string>
<string name="voice_settings_auto_speak_desc">Automatically speak assistant replies on Hermes surfaces that honor this host setting.</string>
<string name="voice_settings_expressive_tags">Expressive speech tags</string>
<string name="voice_settings_expressive_tags_desc">Let xAI add tone cues (whisper, laugh, sigh, pitch shifts) to streamed speech.</string>
<string name="voice_settings_saving">Saving...</string>
@@ -1488,7 +1649,9 @@
<string name="voice_settings_save_and_test">Save &amp; test</string>
<string name="voice_settings_sample_rate_must_be_number">Sample rate must be a number</string>
<string name="voice_settings_realtime_agent_title">Realtime Agent</string>
<string name="voice_settings_realtime_agent_desc">Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.</string>
<string name="voice_settings_realtime_agent_desc">Realtime uses its own provider model and voice for a live speech session. It does not replace the Hermes chat model; Hermes still owns tools and confirmations.</string>
<string name="voice_settings_realtime_model_desc">Controls the live speech session, not the Hermes chat model. Latest follows provider upgrades; a versioned model stays pinned.</string>
<string name="voice_settings_realtime_voice_desc">The voice used inside the live session. Built-in and custom voices appear when the provider advertises them.</string>
<string name="voice_settings_detailed_trace">Detailed trace</string>
<string name="voice_settings_detailed_trace_desc">Show compact Hermes status and result provenance in the timeline</string>
<string name="voice_settings_persistent_session">Persistent session</string>
@@ -1503,7 +1666,7 @@
<string name="voice_settings_delivery_speak">Speak</string>
<string name="voice_settings_delivery_notify">Notify</string>
<string name="voice_settings_delivery_show_only">Show only</string>
<string name="voice_settings_realtime_defaults_desc">Server-side realtime voice agent defaults for this profile</string>
<string name="voice_settings_realtime_defaults_desc">Realtime provider, model, and voice saved for this profile. These choices are separate from Standard Hermes voice.</string>
<string name="voice_settings_save_realtime_agent">Save realtime agent</string>
<string name="voice_settings_global_controls_title">Global Voice Controls</string>
<string name="voice_settings_global_controls_desc">These settings apply to both voice engines, on every profile.</string>
@@ -1551,7 +1714,7 @@
<string name="voice_settings_signin_required">Sign in required.</string>
<string name="voice_settings_server_config_title">Server voice config</string>
<string name="voice_settings_standard_badge">Standard</string>
<string name="voice_settings_server_config_desc">Edit the host\'s text-to-speech and speech-to-text settings (config.yaml tts.* / stt.*) — the same values the dashboard\'s Audio settings write. Applies to new voice turns.</string>
<string name="voice_settings_server_config_desc">Edit this host\'s Standard Hermes voice settings (config.yaml tts.*, stt.*, and voice.*), matching the dashboard\'s Audio configuration. Changes are host-wide and apply to new voice turns.</string>
<string name="voice_settings_no_elevenlabs_key">No ElevenLabs API key on the server — set ELEVENLABS_API_KEY in Manage → Keys to pick a voice from a list.</string>
<string name="voice_settings_saving_ellipsis">Saving…</string>
<string name="voice_settings_discard">Discard</string>
@@ -1598,6 +1761,7 @@
<string name="dashboard_tab_cron">Cron</string>
<string name="dashboard_tab_mcp">MCP</string>
<string name="dashboard_tab_catalog">Catalog</string>
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
<string name="dashboard_tab_profiles">Profiles</string>
<string name="dashboard_tab_models">Models</string>
<string name="dashboard_tab_keys">Keys</string>
@@ -1607,6 +1771,7 @@
<string name="dashboard_tab_cron_lower">cron</string>
<string name="dashboard_tab_mcp_lower">mcp</string>
<string name="dashboard_tab_catalog_lower">catalog</string>
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
<string name="dashboard_tab_profiles_lower">profiles</string>
<string name="dashboard_tab_models_lower">models</string>
<string name="dashboard_tab_keys_lower">keys</string>
@@ -1623,6 +1788,8 @@
<string name="dashboard_tile_mcp_sub">Servers, status, tools</string>
<string name="dashboard_tile_catalog_title">Catalog</string>
<string name="dashboard_tile_catalog_sub">Discover upstream servers</string>
<string name="dashboard_tile_custom_endpoints_title">Custom Endpoints</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-compatible providers</string>
<string name="dashboard_tile_models_title">Models</string>
<string name="dashboard_tile_models_sub">Pick provider + default model</string>
<string name="dashboard_tile_keys_title">Keys</string>
@@ -1669,7 +1836,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">Dashboard sign-in required</string>
<string name="dashboard_signin_required_body">Manage uses the Hermes dashboard session at %1$s.</string>
<string name="dashboard_signin_route_hint">You\'re on the %1$s route. Dashboard sign-ins are per host, so your sign-in from the other route doesn\'t carry over — sign in once here and the app keeps both sessions.</string>
<string name="dashboard_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Sign in again to refresh it across this connection\'s trusted routes.</string>
<string name="dashboard_signin_with_provider">Sign in with %1$s</string>
<string name="dashboard_username_password">Username &amp; Password</string>
<string name="dashboard_username">Username</string>
@@ -1714,12 +1881,33 @@
<string name="dashboard_action_enable">Enable</string>
<string name="dashboard_action_disable">Disable</string>
<string name="dashboard_action_test">Test</string>
<string name="dashboard_action_authenticate">Authenticate</string>
<string name="dashboard_action_validate">Validate</string>
<string name="dashboard_action_edit">Edit</string>
<string name="dashboard_action_remove">Remove</string>
<string name="dashboard_action_soul">SOUL</string>
<string name="dashboard_action_edit_soul">Edit SOUL</string>
<string name="dashboard_action_use">Use</string>
<string name="dashboard_action_describe">Describe</string>
<string name="dashboard_action_model">Model</string>
<string name="dashboard_mcp_oauth_title">Authenticate %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes will open the provider in your browser. Return here after approving access; credentials stay on the Hermes server.</string>
<string name="dashboard_mcp_oauth_approved">MCP authentication approved</string>
<string name="dashboard_mcp_oauth_failed">MCP authentication failed</string>
<string name="dashboard_mcp_oauth_no_browser">No browser is available to complete MCP authentication.</string>
<string name="dashboard_custom_endpoint_add">Add endpoint</string>
<string name="dashboard_custom_endpoint_edit">Edit endpoint</string>
<string name="dashboard_custom_endpoint_name">Name</string>
<string name="dashboard_custom_endpoint_url">Base URL</string>
<string name="dashboard_custom_endpoint_model">Default model</string>
<string name="dashboard_custom_endpoint_key">API key (optional)</string>
<string name="dashboard_custom_endpoint_key_help">Leave blank to preserve an existing key when saving. Validation uses only a key entered here; Hermes does not expose or clear saved keys.</string>
<string name="dashboard_custom_endpoint_context">Context length (optional)</string>
<string name="dashboard_custom_endpoint_discover">Discover models from /models</string>
<string name="dashboard_custom_endpoint_valid">Endpoint reachable · %1$d model(s)</string>
<string name="dashboard_custom_endpoint_validate_failed">Endpoint validation failed</string>
<string name="dashboard_custom_endpoint_save_failed">Endpoint save failed</string>
<string name="dashboard_custom_endpoint_saved">Custom endpoint saved</string>
<string name="dashboard_action_completed">%1$s completed</string>
<string name="dashboard_action_failed">%1$s failed</string>
<string name="dashboard_more">More</string>
@@ -2032,9 +2220,9 @@
<string name="active_section_allow_plain_connections">Allow plain connections</string>
<string name="active_section_api_hermes_voice_reachable">API reachable — Hermes voice is configured</string>
<string name="active_section_api_key_already_set">API key is already set</string>
<string name="active_section_api_key_needed_hint">Enter a key if the API server requires one.</string>
<string name="active_section_api_key_needed_hint">Use the API_SERVER_KEY created on your Hermes server. The app does not issue this key.</string>
<string name="active_section_api_key_not_configured">API key not configured</string>
<string name="active_section_api_key_optional">API key (optional)</string>
<string name="active_section_api_key_optional">API key for direct API</string>
<string name="active_section_api_key_stored_hint">Key is stored securely.</string>
<string name="active_section_api_reachable_voice_review">API reachable — review voice config</string>
<string name="active_section_api_relay_voice_reachable">API reachable — Relay voice is configured</string>
@@ -2048,6 +2236,8 @@
<string name="active_section_checking">Checking…</string>
<string name="active_section_checking_routes">Checking routes…</string>
<string name="active_section_choose_how_phone_reaches">Choose how this phone reaches the server</string>
<string name="active_section_choose_how_phone_reaches_named">How this phone reaches %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · Home LAN</string>
<string name="active_section_command_copied">Command copied</string>
<string name="active_section_configured">Configured</string>
<string name="active_section_connect">Connect</string>
@@ -2142,6 +2332,7 @@
<string name="conn_info_api_server_title">API Server</string>
<string name="conn_info_approvals_off">Approvals are OFF</string>
<string name="conn_info_auth">Auth</string>
<string name="conn_info_relay_auth">Relay auth</string>
<string name="conn_info_avg_ttft">Avg. TTFT</string>
<string name="conn_info_channel_grants">Channel grants</string>
<string name="conn_info_checking">Checking…</string>
@@ -2181,6 +2372,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (paired)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay paired</string>
<string name="conn_info_idle_suffix"> · Idle</string>
<string name="conn_info_insecure_mode_allowed">Insecure mode allowed</string>
<string name="conn_info_inspect_profile">Inspect %1$s</string>
@@ -2405,6 +2597,7 @@
<string name="tool_progress_status_completed">completed</string>
<string name="tool_progress_status_failed">failed</string>
<string name="tool_progress_status_running">running</string>
<string name="image_generation_rendering">Rendering image</string>
<string name="tool_progress_cd_collapse">Collapse</string>
<string name="tool_progress_cd_expand">Expand</string>
@@ -2511,7 +2704,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">Scan Hermes QR</string>
<string name="qr_scanner_instruction">Scan a Hermes setup QR</string>
<string name="qr_scanner_subtext">Ask Hermes: &quot;Generate a QR code with my API URL and API key.&quot;</string>
<string name="qr_scanner_subtext">Scan a Hermes setup or Relay pairing QR. Standard Dashboard connections do not require an API key.</string>
<string name="qr_scanner_relay_title">Scan Relay QR</string>
<string name="qr_scanner_relay_instruction">Scan a Relay pairing QR</string>
<string name="qr_scanner_relay_subtext">Open the Relay pairing QR on your Hermes server. Your existing Dashboard connection and routes will stay unchanged.</string>
<string name="qr_scanner_camera_error">Cannot start the camera on this device.</string>
<string name="qr_scanner_fallback_message">You can pair without the camera.</string>
<string name="qr_scanner_pair_manual">Pair manually</string>
@@ -2978,12 +3174,14 @@
<string name="diag_check_voice_relay">Voice (Relay)</string>
<string name="diag_check_voice_standard">Voice (Standard)</string>
<string name="endpoints_pin_title">Pinned endpoints</string>
<string name="endpoints_route_editor_desc">Edit route</string>
<string name="endpoints_route_editor_desc">Add the Dashboard/Gateway address this phone should use on that network.</string>
<string name="endpoints_route_name_placeholder">Route name</string>
<string name="endpoints_url_host_placeholder">Host</string>
<string name="endpoints_url_supporting_blank">No supporting URLs</string>
<string name="endpoints_url_supporting_enter">Enter a URL</string>
<string name="endpoints_url_supporting_preview">Preview</string>
<string name="endpoints_url_host_placeholder">100.x.y.z or host.ts.net</string>
<string name="endpoints_url_supporting_blank">Enter the server’s Dashboard address</string>
<string name="endpoints_url_supporting_enter">Use an http:// or https:// Dashboard address</string>
<string name="endpoints_url_supporting_preview">Will test: %1$s</string>
<string name="endpoints_tailscale_setup_hint">Tailscale must be connected on both devices, and Hermes Dashboard must be reachable on port 9119.</string>
<string name="endpoints_setup_help">Tailscale setup help</string>
<string name="image_viewer_error">Error saving image</string>
<string name="image_viewer_failed">Failed to save image</string>
<string name="image_viewer_failed_template">Failed to save image: %s</string>
@@ -3139,4 +3337,10 @@
<string name="tool_output_risk_a11y">, %1$s output risk</string>
<string name="tool_output_risk_findings">Output risk findings</string>
<string name="tool_output_risk_redacted">Sensitive spans were redacted upstream.</string>
<string name="diag_gateway_heartbeat" translatable="false">Gateway loop: %1$s%2$s</string>
<string name="diag_toolsets_inventory" translatable="false">API toolsets: %1$d of %2$d enabled · Relay tools visible: %3$s</string>
<string name="diag_yes" translatable="false">yes</string>
<string name="diag_no" translatable="false">no</string>
<string name="dashboard_nous_terminal_warning" translatable="false">The Nous provider login needs attention on the Hermes host. This is separate from Manage sign-in; chat can continue through another configured provider.</string>
<string name="dashboard_gateway_topology" translatable="false">Gateway: %1$s · Profiles: %2$s · Ports: %3$s</string>
</resources>
@@ -9,12 +9,15 @@
- Visible warning badge when connected over http:// or ws://
- Users are encouraged to set up TLS for production deployments
The app makes NO connections to external services — only user-configured endpoints.
System CAs and CAs deliberately installed in Android's user credential store
are accepted. This expands the available trust anchors without bypassing
certificate-chain, hostname, or Relay certificate-pin verification.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="system" overridePins="false" />
<certificates src="user" overridePins="false" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -52,6 +52,36 @@ class AgentDisplayTest {
assertEquals(defaultProfile, effective)
}
@Test
fun effectiveDisplayProfile_resolvesPinnedServerDefaultToNamedProfile() {
val pinned = Profile(
name = "pinned",
model = "gpt-pinned",
description = "Pinned profile",
)
val effective = AgentDisplay.effectiveDisplayProfile(
selectedProfile = null,
profiles = listOf(defaultProfile, pinned, mizu),
serverDefaultProfileName = "pinned",
)
assertEquals(pinned, effective)
}
@Test
fun effectiveDisplayProfile_keepsExplicitSelectionAheadOfPinnedDefault() {
val pinned = Profile(name = "pinned", model = "gpt-pinned")
val effective = AgentDisplay.effectiveDisplayProfile(
selectedProfile = mizu,
profiles = listOf(defaultProfile, pinned, mizu),
serverDefaultProfileName = "pinned",
)
assertEquals(mizu, effective)
}
@Test
fun agentName_usesProfileNameNotVerboseDescription() {
// The name slot shows the NAME, even when a (verbose) description exists.
@@ -0,0 +1,82 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionCapabilitiesTest {
@Test
fun dashboardOnlyConnection_exposesStandardHermesCapabilities() {
val connection = connection(
dashboardUrl = "https://hermes.example.com",
apiServerUrl = "",
relayUrl = "",
)
assertEquals("https://hermes.example.com", connection.primaryEndpointUrl)
assertEquals("hermes.example.com", connection.primaryHost)
assertTrue(connection.capabilities.dashboardGatewayConfigured)
assertTrue(connection.capabilities.gatewayChatAvailable)
assertTrue(connection.capabilities.manageAvailable)
assertTrue(connection.capabilities.standardVoiceAvailable)
assertTrue(connection.capabilities.chatConfigured)
assertFalse(connection.capabilities.apiChatFallbackAvailable)
assertFalse(connection.capabilities.relayFeaturesAvailable)
}
@Test
fun legacyApiRecord_retainsDerivedDashboardAndAllConfiguredSurfaces() {
val connection = connection(
dashboardUrl = null,
apiServerUrl = "http://192.168.1.25:8642",
relayUrl = "ws://192.168.1.25:8767",
)
assertEquals("http://192.168.1.25:9119", connection.primaryEndpointUrl)
assertTrue(connection.capabilities.dashboardGatewayConfigured)
assertTrue(connection.capabilities.apiServerConfigured)
assertTrue(connection.capabilities.relayConfigured)
}
@Test
fun apiOnlyConnection_canChatWithoutRelay() {
val connection = connection(
dashboardUrl = "",
apiServerUrl = "https://api.example.com",
relayUrl = "",
)
// A blank dashboard retains the legacy conventional derivation when
// an API endpoint is present.
assertTrue(connection.capabilities.dashboardGatewayConfigured)
assertTrue(connection.capabilities.apiChatFallbackAvailable)
assertFalse(connection.capabilities.relayFeaturesAvailable)
}
@Test
fun dashboardFirstLabel_usesDashboardWhenApiIsAbsent() {
assertEquals(
"hermes.example.com",
Connection.extractDefaultLabel(
dashboardUrl = "https://hermes.example.com:9119",
apiServerUrl = "",
relayUrl = "",
),
)
}
private fun connection(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
) = Connection(
id = "id-a",
label = "Hermes",
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
tokenStoreKey = "hermes_auth_id-a",
dashboardUrl = dashboardUrl,
)
}
@@ -42,6 +42,22 @@ class ConnectionDashboardFieldsTest {
assertNull(Connection.deriveDefaultDashboardUrl("ws://localhost:8767"))
}
@Test
fun deriveDefaultApiUrl_usesSameHostAndApiPort() {
assertEquals(
"http://100.75.1.2:8642",
Connection.deriveDefaultApiUrl("http://100.75.1.2:9119"),
)
}
@Test
fun deriveDefaultApiUrl_preservesHttpsAndIpv6Host() {
assertEquals(
"https://[fd7a:115c:a1e0::1]:8642",
Connection.deriveDefaultApiUrl("https://[fd7a:115c:a1e0::1]:9119"),
)
}
@Test
fun resolvedDashboardUrl_usesExplicitOverride() {
val connection = sampleConnection(
@@ -88,11 +104,39 @@ class ConnectionDashboardFieldsTest {
assertEquals(2, routes.size)
assertEquals("lan", routes[0].role)
assertEquals("192.168.1.25", routes[0].api.host)
assertEquals("ws://192.168.1.25:8767", routes[0].relay.url)
assertEquals("192.168.1.25", routes[0].api?.host)
assertEquals("ws://192.168.1.25:8767", routes[0].relay?.url)
assertEquals("tailscale", routes[1].role)
assertEquals("hermes.tail1234.ts.net", routes[1].api.host)
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay.url)
assertEquals("hermes.tail1234.ts.net", routes[1].api?.host)
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
}
@Test
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
val route = Connection.endpointCandidateFromDashboardUrl(
role = "",
priority = 1,
dashboardUrl = "100.75.1.2",
)
assertEquals("tailscale", route?.role)
assertEquals("http://100.75.1.2:9119", route?.dashboard?.url)
assertNull(route?.api)
assertNull(route?.relay)
}
@Test
fun dashboardRouteBuilder_preservesOptionalApiAndRelayWhenConfigured() {
val route = Connection.endpointCandidateFromDashboardUrl(
role = "tailscale",
priority = 1,
dashboardUrl = "hermes.tail1234.ts.net",
apiServerUrl = "https://hermes.tail1234.ts.net:8642",
relayUrl = "wss://hermes.tail1234.ts.net:8767",
)
assertEquals("https://hermes.tail1234.ts.net:8642", route?.api?.url)
assertEquals("wss://hermes.tail1234.ts.net:8767", route?.relay?.url)
}
@Test
@@ -102,6 +146,30 @@ class ConnectionDashboardFieldsTest {
assertEquals("public", Connection.inferRouteRole("https://hermes.example.com:8642"))
}
@Test
fun discoveredLabel_prefersHostnameForAnUncustomizedIpLabel() {
assertEquals(
"hermes-box.local",
Connection.chooseDiscoveredLabel(
currentLabel = "192.168.1.25",
primaryHost = "192.168.1.25",
discoveredHostname = "hermes-box.local",
),
)
}
@Test
fun discoveredLabel_preservesAUserLabel() {
assertEquals(
"Home Hermes",
Connection.chooseDiscoveredLabel(
currentLabel = "Home Hermes",
primaryHost = "192.168.1.25",
discoveredHostname = "hermes-box.local",
),
)
}
private fun sampleConnection(
dashboardUrl: String? = null,
): Connection = Connection(
@@ -43,7 +43,7 @@ class ConnectionRouteCandidateMergeTest {
assertEquals(2, merged.size)
assertEquals("lan", merged[0].role)
assertEquals("tailscale", merged[1].role)
assertEquals("100.64.0.7", merged[1].api.host)
assertEquals("100.64.0.7", merged[1].api?.host)
}
@Test
@@ -66,7 +66,7 @@ class ConnectionRouteCandidateMergeTest {
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay.url)
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay?.url)
}
@Test
@@ -90,9 +90,9 @@ class ConnectionRouteCandidateMergeTest {
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(2, merged.size)
val tailscale = merged.first { it.api.host == "100.64.0.7" }
val tailscale = merged.first { it.api?.host == "100.64.0.7" }
assertEquals("tailscale", tailscale.role)
assertEquals("ws://100.64.0.7:8767", tailscale.relay.url)
assertEquals("ws://100.64.0.7:8767", tailscale.relay?.url)
}
@Test
@@ -111,10 +111,10 @@ class ConnectionRouteCandidateMergeTest {
assertEquals(2, merged.size)
assertTrue(
"old priority-0 host must be replaced by the edited URL",
merged.none { it.api.host == "192.168.1.50" },
merged.none { it.api?.host == "192.168.1.50" },
)
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api.host)
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api.host)
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api?.host)
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api?.host)
}
@Test
@@ -0,0 +1,59 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ConnectionStoreStartupTest {
@Test
fun pinnedStartupOverridesLastUsedOnlyDuringHydration() = runTest {
val dataStore = InMemoryPreferencesDataStore()
val store = ConnectionStore(dataStore, backgroundScope)
store.isHydrated.first { it }
val a = sampleConnection("connection-a", "A")
val b = sampleConnection("connection-b", "B")
store.addConnection(a)
store.addConnection(b)
store.setActiveConnection(b.id)
store.setStartupConnection(a.id)
assertEquals(b.id, store.activeConnectionId.value)
assertEquals(a.id, store.startupConnectionId.value)
val reloaded = ConnectionStore(dataStore, backgroundScope)
reloaded.isHydrated.first { it }
assertEquals(a.id, reloaded.activeConnectionId.value)
assertEquals(a.id, reloaded.startupConnectionId.value)
reloaded.setStartupConnection(null)
assertNull(reloaded.startupConnectionId.value)
}
private fun sampleConnection(id: String, label: String) = Connection(
id = id,
label = label,
apiServerUrl = "http://localhost:8642",
relayUrl = "ws://localhost:8767",
tokenStoreKey = Connection.buildTokenStoreKey(id),
)
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val next = transform(state.value)
state.value = next
return next
}
}
}

Some files were not shown because too many files have changed in this diff Show More