Compare commits

...
Author SHA1 Message Date
Bailey Dixon c85f42c51c Merge pull request #279 from Codename-11/dev
release(server): server-v1.5.0
2026-08-02 18:58:20 -04:00
Bailey Dixon 8c570b214d Merge pull request #281 from Codename-11/fix/mobile-plugin-path-containment
fix(plugins): contain generated page paths
2026-08-02 18:42:04 -04:00
Bailey Dixon 52f19ca028 fix(plugins): contain generated page paths 2026-08-02 18:40:47 -04:00
Bailey Dixon 9ca42e3e6c Merge pull request #280 from Codename-11/chore/reconcile-main-before-server-1.5.0
chore: reconcile main release history into dev
2026-08-02 18:28:11 -04:00
Bailey Dixon 2f949c7d15 chore: reconcile main release history into dev 2026-08-02 18:17:50 -04:00
Bailey Dixon c77fd057bb Merge pull request #278 from Codename-11/release/server-1.5.0
release(server): server-v1.5.0
2026-08-02 18:16:25 -04:00
Bailey Dixon ed1c47f47d release(server): server-v1.5.0 2026-08-02 18:14:51 -04:00
Bailey Dixon f5c2a2b888 feat(plugins): add live Android plugin surfaces 2026-08-02 18:14:42 -04:00
Bailey Dixon 9187d8e77c Merge pull request #277 from Codename-11/feature/russian-localization-salvage
feat(android): add Russian localization
2026-08-02 13:11:28 -04:00
Bailey DixonandDScoNOIZ 4efc52dd5b feat(android): add Russian localization
Salvaged from #276 by @DScoNOIZ.

Co-authored-by: DScoNOIZ <212546794+DScoNOIZ@users.noreply.github.com>
2026-08-02 13:02:57 -04:00
Bailey Dixon f2b92b2755 Merge pull request #274 from Codename-11/fix/android-1.5.3-voice-transcript-keys
release(android): Android 1.5.3 duplicate-key hotfix
2026-07-31 19:03:40 -04:00
Bailey Dixon 8651656899 release(android): android-v1.5.3 2026-07-31 18:50:21 -04:00
Bailey Dixon b458d83fcc fix(android): stabilize voice transcript keys 2026-07-31 18:27:59 -04:00
Bailey Dixon ca0c5b2a54 Merge pull request #266 from Codename-11/fix/android-https-gateway-route
fix(android): preserve secure gateway routes
2026-07-28 21:04:31 -04:00
Bailey Dixon 77e34c2c02 fix(android): preserve secure gateway routes 2026-07-28 19:35:14 -04:00
Bailey Dixon f4ee409106 docs(devlog): record Android 1.5.2 release 2026-07-28 18:20:37 -04:00
Bailey Dixon aa26f7c9b6 Merge pull request #265 from Codename-11/dev
release(android): android-v1.5.2
2026-07-28 17:58:37 -04:00
Bailey Dixon bfb608bea6 release(android): android-v1.5.2 2026-07-28 17:30:59 -04:00
Bailey Dixon 95a95fe7d2 Merge pull request #264 from Codename-11/fix/android-nous-native-auth
fix(android): support Nous system-browser sign-in
2026-07-28 17:28:56 -04:00
Bailey Dixon 1bdf2ae71b fix(android): support Nous system-browser sign-in 2026-07-28 17:15:26 -04:00
Bailey Dixon f9e7a2f320 Merge pull request #263 from Codename-11/fix/android-duplicate-compose-keys
fix(android): coalesce replayed chat message ids
2026-07-27 11:38:06 -04:00
Bailey Dixon 988fac8522 Merge pull request #262 from Codename-11/fix/android-oidc-manage-callback
fix(android): keep dashboard OIDC on cookie flow
2026-07-27 11:37:43 -04:00
Bailey Dixon d4832a6a38 fix(android): coalesce replayed chat message ids 2026-07-27 09:30:39 -04:00
Bailey Dixon f9c8736e5b fix(android): keep dashboard OIDC on cookie flow 2026-07-27 08:57:17 -04:00
dependabot[bot] a815dd33fa build(deps): bump com.android.library from 9.3.0 to 9.3.1 (#261)
Bumps com.android.library from 9.3.0 to 9.3.1.

---
updated-dependencies:
- dependency-name: com.android.library
  dependency-version: 9.3.1
  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-27 11:56:57 +00:00
dependabot[bot] cc9c75a636 build(deps): bump androidx.browser:browser from 1.9.0 to 1.10.0 (#260)
Bumps androidx.browser:browser from 1.9.0 to 1.10.0.

---
updated-dependencies:
- dependency-name: androidx.browser:browser
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:55:03 +00:00
dependabot[bot] 43179e03c0 build(deps): bump com.android.application from 9.3.0 to 9.3.1 (#259)
Bumps com.android.application from 9.3.0 to 9.3.1.

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-version: 9.3.1
  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-27 11:55:00 +00:00
Bailey Dixon 325b8e5670 Merge pull request #257 from Codename-11/dev
release(android): promote android-v1.5.1
2026-07-26 16:11:49 -04:00
Bailey Dixon 693ac4ed64 Merge pull request #256 from Codename-11/release/android-1.5.1-patch
release(android): android-v1.5.1
2026-07-26 15:44:19 -04:00
Bailey Dixon f94d663ac4 release(android): android-v1.5.1 2026-07-26 15:35:21 -04:00
Bailey Dixon d1a21bd42e fix(android): use Compose resources for sign-in copy 2026-07-26 15:35:20 -04:00
Bailey Dixon 7ee2d73010 fix(android): preserve formatted chat completion position 2026-07-26 15:10:48 -04:00
Bailey Dixon 682bde84fe fix(android): merge API 36 target 2026-07-26 09:38:22 -04:00
Bailey Dixon 16bdbe5f44 fix(android): target API 36 2026-07-26 09:38:00 -04:00
Bailey Dixon f43fba9fed feat(android): merge chat readability and final-only voice 2026-07-26 09:13:57 -04:00
Bailey Dixon d1745413fd fix(android): release realtime background foreground turns 2026-07-26 08:57:00 -04:00
Bailey Dixon 1b7a8025c3 fix(android): restore standard voice narration 2026-07-26 08:56:42 -04:00
Bailey Dixon d92a87483e feat(android): enhance voice conversation experience 2026-07-26 08:56:23 -04:00
Bailey Dixon e9da59cf40 Merge pull request #254 from Codename-11/dev
fix(android): repair immutable release dispatch
2026-07-25 18:30:53 -04:00
Bailey Dixon 0bea626ed8 Merge pull request #253 from Codename-11/fix/android-release-dispatch
fix(android): repair immutable release dispatch
2026-07-25 18:30:27 -04:00
Bailey Dixon f453dd27f3 fix(android): repair immutable release dispatch 2026-07-25 18:29:32 -04:00
Bailey Dixon c9a03c9ed7 Merge pull request #252 from Codename-11/dev
release(android): android-v1.5.0
2026-07-25 18:26:34 -04:00
Bailey Dixon 36546c2712 Merge pull request #251 from Codename-11/release/android-1.5.1
release(android): android-v1.5.0
2026-07-25 17:54:01 -04:00
Bailey Dixon fbe3fc3e05 release(android): android-v1.5.0 2026-07-25 17:34:26 -04:00
Bailey Dixon a77cebec43 fix(android): refine agent passport interactions 2026-07-25 17:28:46 -04:00
Bailey Dixon 8167f93705 fix(android): repair developer options and data actions 2026-07-25 16:33:03 -04:00
Bailey Dixon 52dc46072e feat(android): redesign agent passport drawer 2026-07-25 16:32:15 -04:00
Bailey Dixon c18ab4cce8 fix(android): complete new localized strings 2026-07-25 14:39:03 -04:00
Bailey Dixon 52b28e5b48 Merge pull request #246 from Codename-11/fix/reconcile-dev-delivery
fix(android): reconcile completed dev work
2026-07-25 14:35:13 -04:00
Bailey Dixon 575fd82c59 Merge branch 'fix/android-cold-start-api-toast' into dev 2026-07-25 14:32:28 -04:00
Bailey Dixon ead3f5fd4f fix(android): suppress cold-start API fallback 2026-07-25 14:24:56 -04:00
Bailey Dixon 36a922be64 Merge branch 'feature/image-generation-progress' into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/MessageBubble.kt
2026-07-25 14:11:57 -04:00
Bailey Dixon 530a1c9591 feat(android): refine image generation progress 2026-07-25 14:10:28 -04:00
Bailey Dixon 353faa9da5 Merge branch 'chore/android-ci-feedback' into dev
# Conflicts:
#	DEVLOG.md
2026-07-25 14:07:10 -04:00
Bailey Dixon 76fdbcfd70 Merge branch 'fix/android-gateway-card-routing' into dev 2026-07-25 12:57:08 -04:00
Bailey Dixon 5365e22fff fix(android): keep gateway card actions on gateway 2026-07-25 12:56:42 -04:00
Bailey Dixon 1e3974fd88 feat(android): complete native dashboard sign-in 2026-07-25 12:33:01 -04:00
Bailey Dixon 72854d58b1 Merge origin/dev into dev before native sign-in completion 2026-07-25 11:45:47 -04:00
Bailey Dixon b63e0e726d Merge pull request #245 from Codename-11/fix/android-active-turn-notifications
feat(android): retain active turns in background
2026-07-25 11:42:44 -04:00
Bailey Dixon fff3ba8d91 feat(android): retain active turns in background 2026-07-25 11:41:41 -04:00
Bailey Dixon 7c155e3693 test(android): stabilize integrated UX fixtures 2026-07-25 11:38:45 -04:00
Bailey Dixon d15d3b594c fix(android): restore routing integration imports 2026-07-25 11:26:40 -04:00
Bailey Dixon 915b2ebe54 Merge branch 'feature/android-relay-ux-batch' into dev 2026-07-25 11:24:00 -04:00
Bailey Dixon cf3634ee2b Merge branch 'feature/hrui-059-072-routing' into feature/android-relay-ux-batch
# Conflicts:
#	TODO.md
2026-07-25 11:23:37 -04:00
Bailey Dixon 88b11856d3 test(android): fix recovery ack fixture 2026-07-25 11:23:07 -04:00
Bailey Dixon aede6c8cb3 fix(android): harden fallback model locks 2026-07-25 11:16:13 -04:00
Bailey Dixon c1187f0f2f Merge branch 'feature/hrui-030-approval' into feature/android-relay-ux-batch 2026-07-25 11:16:07 -04:00
Bailey Dixon 6ff473820c Merge branch 'feature/hrui-069-moa' into feature/android-relay-ux-batch
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayEventMapper.kt
2026-07-25 11:15:57 -04:00
Bailey Dixon 847a21cc0c Merge branch 'feature/hrui-063-065-voice' into feature/android-relay-ux-batch 2026-07-25 11:15:04 -04:00
Bailey Dixon 8ebd97643d Merge attachment test fix into feature/android-relay-ux-batch 2026-07-25 11:15:03 -04:00
Bailey Dixon 8aded16da9 test(android): fix attachment assertion imports 2026-07-25 11:14:28 -04:00
Bailey Dixon a35c0b34f8 Merge branch 'feature/hrui-074-040-recovery' into feature/android-relay-ux-batch 2026-07-25 11:14:27 -04:00
Bailey Dixon 877cfad88a Merge branch 'feature/hrui-060-native-pkce' into feature/android-relay-ux-batch 2026-07-25 11:14:06 -04:00
Bailey Dixon fb0b8deef7 feat(android): expose profile approval modes 2026-07-25 11:10:48 -04:00
Bailey Dixon 1a710f071c fix(android): serialize gateway resume events 2026-07-25 11:07:59 -04:00
Bailey Dixon 89b1461431 fix(android): scope and serialize dashboard auth 2026-07-25 11:07:00 -04:00
Bailey Dixon ce72f7790e Merge branch 'feature/android-collapsible-attachments' into feature/android-relay-ux-batch 2026-07-25 11:05:57 -04:00
Bailey Dixon 51f4d29ebb Merge branch 'feature/hrui-073-resume-media' into feature/android-relay-ux-batch 2026-07-25 11:05:56 -04:00
Bailey Dixon 97a2dac96d Merge branch 'feature/hrui-manage-ux' into feature/android-relay-ux-batch 2026-07-25 11:05:55 -04:00
Bailey Dixon a569361d43 fix(android): reconcile MoA advisor state 2026-07-25 11:02:34 -04:00
Bailey Dixon 35dfc8c051 fix(android): narrate complete assistant runs 2026-07-25 11:01:09 -04:00
Bailey Dixon 4a1ece7ad0 fix(android): restore persisted image attachments 2026-07-25 10:58:38 -04:00
Bailey Dixon 41b6fb4f84 feat(android): add collapsible attachment groups 2026-07-25 10:56:19 -04:00
Bailey Dixon a15b247d1a feat(android): improve Manage diagnostics and model discovery 2026-07-25 10:53:18 -04:00
Bailey Dixon 2d486dd395 fix(android): preserve API fallback model routing 2026-07-25 10:51:08 -04:00
Bailey Dixon 0fcb833c83 feat(android): surface MoA advisor progress 2026-07-25 10:49:40 -04:00
Bailey Dixon 4507b2270a fix(android): recover failed gateway turns 2026-07-25 10:49:10 -04:00
Bailey Dixon 1cd3da5ac6 feat(android): add native dashboard auth foundation 2026-07-25 10:46:16 -04:00
Bailey Dixon afe2be9304 Merge pull request #242 from Codename-11/fix/android-interaction-notifications
fix(android): notify for blocked gateway interactions
2026-07-25 09:47:58 -04:00
Bailey Dixon 68f8b58a0b Merge pull request #232 from Codename-11/dev
release(android): android-v1.4.9
2026-07-19 21:27:28 -04:00
Bailey Dixon 2bfe21eaff chore(ci): shorten Android validation feedback 2026-07-19 20:50:36 -04:00
157 changed files with 17392 additions and 1147 deletions
@@ -89,7 +89,7 @@ jobs:
VERSION: ${{ steps.metadata.outputs.version }}
run: |
gh workflow run release-android.yml \
--ref="android-v${VERSION}" \
--ref=main \
-f version="$VERSION"
- name: Approval summary
@@ -97,4 +97,4 @@ jobs:
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
+3 -2
View File
@@ -38,10 +38,11 @@ on:
- ".github/workflows/approve-release-android.yml"
- ".github/workflows/release-android.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
# commit must finish its independent validation.
concurrency:
group: ci-android-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
# ──────────────────────────────────────────────
+18 -10
View File
@@ -12,8 +12,9 @@ on:
tags:
- "android-v*"
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
# does not recursively start workflows. It explicitly dispatches this file
# at that tag instead. Manual tag pushes continue to use the push trigger.
# does not recursively start workflows. It dispatches the current workflow
# definition from main, while every job checks out the immutable tag. Manual
# tag pushes continue to use the push trigger.
workflow_dispatch:
inputs:
version:
@@ -37,19 +38,22 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Extract version from tag
id: version
env:
DISPATCHED_VERSION: ${{ inputs.version }}
run: |
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
if [ -n "$DISPATCHED_VERSION" ]; then
REF_VERSION="$DISPATCHED_VERSION"
fi
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
exit 1
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
exit 1
fi
else
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
fi
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
@@ -67,8 +71,8 @@ jobs:
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
exit 1
fi
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
exit 1
fi
@@ -111,6 +115,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v5
@@ -147,6 +153,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v5
+49 -8
View File
@@ -8,17 +8,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### 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.
- **Android can be used in Russian.** The main and sideload builds include a complete Russian catalog, an in-app language option, localized chat and voice surfaces, and Russian plural handling.
### 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.
## [1.5.0] - 2026-08-02
### Added
- **Realtime Agent sessions can speak only settled answers.** Clients may enable an optional per-session `final_answer_only` policy that suppresses routine acknowledgements, progress narration, and intermediate commentary while preserving spoken approvals, confirmation questions, blocking failures, and the final Hermes answer.
- **Agents can draft native Android plugin pages through Relay.** New tools store bounded declarative JSON pages under the authenticated Relay plugin namespace, while Android retains control of enablement, publication, write grants, and persistent removal. Generated pages cannot include executable code, arbitrary network calls, Android intents, or backend action requests.
## [Android 1.5.3] - 2026-07-31
### Fixed
- **Voice transcripts retain stable rows after chat-history reconciliation.** Focus mode uses the same stable Compose identity as the main conversation, preventing duplicate-key crashes when live rows adopt persisted server IDs.
## [Android 1.5.2] - 2026-07-28
### Fixed
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
## [Android 1.5.1] - 2026-07-26
### Added
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
### Changed
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
### Fixed
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
## [Server 1.4.3] - 2026-07-22
### Added
@@ -31,14 +64,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **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
## [Android 1.5.0] - 2026-07-25
### 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.
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
- **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; native Gateway lifecycle events remain authoritative and Relay remains optional.
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
### Fixed
@@ -47,6 +84,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **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.
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
## [1.4.9] - 2026-07-19
+9
View File
@@ -168,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
## Testing
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
checks, Google Play debug lint, and the same focused unit-test shard used by
CI in one cached Gradle invocation. Run it before pushing Android PR updates
to catch common hosted failures without waiting for another full Actions
cycle; hosted CI remains the exhaustive all-variant gate.
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
runs are never canceled because each release-branch commit must complete its
independent validation.
## Questions?
+130
View File
@@ -1,5 +1,122 @@
# Hermes-Relay — Dev Log
## 2026-08-02 — Android Russian localization
Android now ships complete Russian catalogs for the main and sideload builds.
The in-app language picker, Android locale configuration, chat and voice labels,
tool and status presentation, diagnostics, onboarding, and plural resources are
registered against the canonical English catalog. Existing non-English catalogs
were refreshed to retain exact resource and format-argument parity.
The integration preserves PR #276 as the source contribution while excluding
unrelated recovery, routing, and test-stability changes from the localization
scope. The localization registry records Android coverage only; Russian public
documentation and marketing pages continue to use the canonical English
fallback until those surfaces are translated separately.
## 2026-07-30 — Voice transcript identity alignment
Android voice Focus mode now keys transcript rows with the same stable UI
identity as the main Chat list. A live row may adopt its persisted server
message ID during history reconciliation while retaining its original Compose
identity; using the mutable domain ID in the voice overlay could otherwise
collide during that transition and close the app.
Focused JVM coverage recreates two visible rows with a shared reconciled server
ID and verifies distinct stable transcript keys.
## 2026-07-28 — Android 1.5.2 production release
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
the advertised native PKCE system-browser flow, while compatible self-hosted
providers retain cookie-backed full-page Dashboard authentication. Callback
origin discovery remains server-driven, private-network HTTP compatibility is
preserved, and arbitrary public HTTP redirects remain rejected.
The private Play preflight validated the exact application tree before release
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
`main` tip, the production workflow promoted versionCode 35 to the completed
Google Play production track, and the public GitHub release contains the
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
checksum was independently verified; replacing the debug-signed phone build
with the release-signed artifact requires an uninstall because Android
correctly rejects cross-signature in-place updates.
## 2026-07-27 — Android replayed-message identity reconciliation
Android history reconciliation now collapses reconnect/rejoin replays of the
same persisted message ID before publishing the transcript to Compose. The
latest repeated snapshot replaces the value at the message's first transcript
position, preserving stable ordering, distinct messages, and the LazyColumn
identity contract without index- or random-key fallbacks.
Focused coverage reproduces the duplicate UUID condition and verifies that the
authoritative final content wins while every rendered message keeps a unique
stable UI key.
## 2026-07-26 — Android 1.5.1 patch reconciliation
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
34. Voice now offers compact Focus and full Conversation presentation,
Standard narration preserves valid completed replies, and promoted Realtime
tasks release foreground voice controls while retaining progress and results.
Completed streamed answers promote from the stable live text node to full
Markdown only after completion. The measured Markdown row is then positioned
by its trailing edge until deferred code and attachment measurement settles,
preventing the LazyColumn from restoring the start of a tall response.
The release also targets Android API level 36. Release notes, in-app What's
New assets, localized Play notes, and the Play listing reference were updated
for Android 1.5.1.
## 2026-07-25 — Immutable Android release dispatch repair
Android approval now dispatches the current release workflow definition from
`main`, while every release job explicitly checks out the immutable
`android-v*` tag. Validation confirms the dispatched version resolves to that
checked-out commit and accepts the repository's surface-qualified
`[Android x.y.z]` changelog heading. Existing tags remain unchanged, and a
workflow-only correction can resume a failed publication without rebuilding
from a different application tree.
Audited `.github/workflows/approve-release-android.yml`,
`.github/workflows/release-android.yml`, `RELEASE.md`, and `DEVLOG.md`.
## 2026-07-25 — Android 1.5.0 final release reconciliation
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
voice, attachment, image-generation, security, localization, and Developer
Options work into one public release narrative. Android remains version 1.5.0
with monotonic versionCode 33 because that prepared version was not previously
tagged or uploaded to production.
Release notes, the in-app What's New assets, localized Play release notes, and
the Play listing reference now describe the final tree rather than the earlier
voice-focused candidate. The release train is gated by the exact-tree private
Play preflight before the `dev` to `main` release merge and public tag.
## 2026-07-25 — Active-turn retention and actionable interaction alerts
Android now promotes user-started chat work to foreground execution until every
connection/profile/session-scoped turn settles. Independent leases preserve
concurrent detached Gateway sessions, track sessions paused for input, and
prevent one completion from stopping protection for siblings. The existing
Persistent connection switch now extends retention only to idle periods.
The foreground notification reports active and waiting session counts.
Interaction alerts add privacy-safe expanded profile/session context and an
explicit review, answer, or secure-response action that deep-links to the exact
conversation; commands, questions, passwords, secrets, and environment-variable
names remain confined to the authenticated chat surface.
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
manifest, Android settings copy, chat user documentation, and Play foreground
service declaration guidance.
## 2026-07-24 — Post-connect permission setup
Android onboarding now finishes with a layered permission step after a
@@ -81,6 +198,19 @@ 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-20 — Faster Android validation feedback
Android contributors now have one cross-platform pre-push command for locale,
documentation, collection-API, and version checks plus primary Play-variant
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
Android SDK without writing worktree-local configuration. Hosted CI retains
the exhaustive all-variant lint gate.
Android CI now cancels a superseded run on `dev` or a pull-request ref while
preserving every `main` run. A newer integration commit therefore stops paying
for an older release smoke that can no longer become the tested release tip.
## 2026-07-19 — Android 1.4.9 release preparation
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
+7 -12
View File
@@ -1,22 +1,17 @@
# Hermes-Relay-Plugin v__VERSION__
# Hermes-Relay-Server v__VERSION__
**Release Date:** July 22, 2026
**Release Date:** August 2, 2026
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
This release adds Realtime Agent final-answer-only speech and Relay-hosted declarative plugin pages for Android.
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.
Android clients can keep voice progress visual until the settled answer and review agent-created native plugin pages before keeping them. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Added
- **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
- **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.
- **Realtime Agent final-answer-only speech.** Session creation accepts an optional `final_answer_only` flag. When enabled, the provider skips routine acknowledgements, spoken progress, service updates, and intermediate commentary, then speaks the settled Hermes answer. Approval and confirmation prompts, along with blocking failures, remain audible so required user action is not hidden.
- **Agent-created declarative Android plugin pages.** New Relay tools create bounded JSON-only drafts and expose them through authenticated plugin routes. Android owns enablement, write grants, exact-revision approval, publication, and persistent removal. Generated pages reject executable code, arbitrary network requests, Android intents, traversal, symlink entries, oversized documents, and backend action requests.
## Install / update
@@ -24,7 +19,7 @@ It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and
hermes plugins install Codename-11/hermes-relay/plugin --enable
# Classic install / update on a systemd host:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/server-v__VERSION__/install.sh | bash
# or, if already installed:
hermes-relay-update
+1 -1
View File
@@ -159,7 +159,7 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
</table>
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
**日本語**, **Português (Brasil)**, **Русский**, and **简体中文**. Choose a language from
**Settings → Appearance → Language**; translation status and fluent review are
tracked independently so community corrections remain easy to contribute.
+6 -2
View File
@@ -610,8 +610,12 @@ git push origin dev
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
`main`, and enter the version. Starting the workflow is the release approval. It
verifies that `main` has the exact preflighted tree and creates the
`android-v<version>` tag. Manual stable tags are still guarded by the same
preflight proof in the tag workflow.
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
another workflow, approval dispatches the current release workflow definition
from `main`; every release job explicitly checks out and verifies the immutable
`android-v<version>` tag. This lets release-workflow fixes apply without moving
an existing tag or changing its artifact tree. Manual stable tags are still
guarded by the same preflight proof in the tag workflow.
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
artifacts, changes the existing Play Production draft to `completed` (submitting
+6 -17
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.5.0
# Hermes-Relay-Android v1.5.3
**Release Date:** July 22, 2026
**Release Date:** July 31, 2026
## Download
> 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).
> Installing on your phone? Download `hermes-relay-1.5.3-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,24 +12,13 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
## Added
- 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.
This patch prevents Voice Focus from closing when live chat rows receive their persisted server identities during history reconciliation.
## Fixed
- 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.
- Voice Focus now uses the same stable row identity as the main conversation, preventing duplicate-key crashes while live messages reconcile with persisted history.
## Install / Verify
- App version: **1.5.0** (versionCode **33**).
- App version: **1.5.3** (versionCode **36**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
+36 -7
View File
@@ -6,6 +6,24 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Verify Android native dashboard sign-in on device
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
untrusted callback noise, and closes on completion, cancellation, navigation,
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
prewarm, and standard voice; sign-out clears both cookie and native sessions.
Older gateways retain the identified WebView cookie fallback.
Before release, device-test the real Custom Tab → provider → loopback return,
configuration/background transitions, Manage reload, Gateway chat ticket,
standard voice, sign-out, and process relaunch. Native bearer exchange remains
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
using the native flow.
---
## Active — Remove temporary GitHub Pages docs redirects
PR #210 moved current source and production documentation to
@@ -78,6 +96,19 @@ intentionally remain outside that code batch:
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.
- Keep persistent approval-mode writes for multiplexed non-launch profiles
read-only until upstream `config.get` / `config.set` bind an explicit
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
accepts `approvals.mode` but resolves it against the gateway process home;
Android may reconcile a selected profile's `session.info.approval_mode`, but
must not claim a profile-scoped write that upstream ignores.
- Keep gateway `model.options` profile scoping blocked until the supported
upstream RPC accepts an explicit `profile` and documents that the returned
provider inventory was built inside that profile's runtime scope. Android
now keys picker results to its active profile context and rejects late
responses after a profile switch, but it deliberately does not send an
invented `profile` parameter. API-server fallback can use the separate,
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
- 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
@@ -686,9 +717,10 @@ Deferred:
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
`markdownTypography()` ramp (headings were falling through to M3 display roles —
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
`markdownTypography()` ramp (headings were falling through to M3 display roles —
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
conversation gets its own beat; long-press haptic on the action menu; streaming dots
@@ -700,10 +732,6 @@ gated to pre-first-token. Deferred:
parses one full CommonMark document so global link references, indentation, and
nested containers remain correct; the viewport now anchors that same remeasure.
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
@@ -1170,6 +1198,7 @@ Follow-ups:
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
+2 -1
View File
@@ -37,7 +37,7 @@ android {
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
applicationId = "com.axiomlabs.hermesrelay"
minSdk = 26
targetSdk = 35
targetSdk = 36
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -245,6 +245,7 @@ dependencies {
// Activity
implementation(libs.activity.compose)
implementation(libs.browser)
implementation(libs.appcompat)
// Core
+4
View File
@@ -5,5 +5,9 @@
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
android:exported="true"
android:screenOrientation="portrait" />
<activity
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
android:exported="true"
android:screenOrientation="portrait" />
</application>
</manifest>
@@ -0,0 +1,196 @@
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.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
/**
* Debug-build-only live host for fast image-generation motion tuning.
*
* Launch directly:
* adb shell am start -n <applicationId>/
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
*/
class ImageGenerationDesignQaActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val themePreference = intent.getStringExtra("theme") ?: "auto"
setContent {
HermesRelayTheme(themePreference = themePreference) {
ImageGenerationDesignQaScene(onBack = ::finish)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
var restartKey by remember { mutableIntStateOf(0) }
var durationMillis by remember { mutableIntStateOf(4_800) }
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Image generation lab") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Live debug preview · no generation request",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
ImageGenerationVisualStyle.LatentGrid to "Grid",
ImageGenerationVisualStyle.ParticleOrb to "Orb",
ImageGenerationVisualStyle.Constellation to "Nodes",
).forEach { (style, label) ->
FilterChip(
selected = visualStyle == style,
onClick = { visualStyle = style },
label = { Text(label) },
)
}
}
key(restartKey, durationMillis, visualStyle) {
val startedAtMillis = remember { System.currentTimeMillis() }
ImageGenerationResultTransition(
generating = !showResult,
startedAtMillis = startedAtMillis,
animationDurationMillis = durationMillis,
visualStyle = visualStyle,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Image(
painter = painterResource(R.drawable.image_generation_transition_preview),
contentDescription = "Generated landscape preview",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Generated image",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "12.4s",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Text(
text = "Cycle speed",
style = MaterialTheme.typography.labelMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
7_200 to "Slow",
4_800 to "Normal",
3_200 to "Fast",
).forEach { (duration, label) ->
FilterChip(
selected = durationMillis == duration,
onClick = { durationMillis = duration },
label = { Text(label) },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
showResult = true
},
enabled = !showResult,
) {
Text("Reveal result")
}
Button(
onClick = {
showResult = false
restartKey++
},
) {
Text("Restart")
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -1 +1 @@
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.
Voice Focus now keeps transcript rows stable while live messages reconcile with persisted chat history, preventing the duplicate-key crash that could close the app.
@@ -1 +1 @@
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
语音专注模式现在会在实时消息与已保存的聊天记录同步时保持稳定的列表标识,避免重复键导致应用关闭。
+5 -7
View File
@@ -90,12 +90,10 @@
android:name=".notifications.ProactiveReplyReceiver"
android:exported="false" />
<!-- Opt-in "Persistent connection" — holds the user's connection to
Hermes open while backgrounded so messages and live features stay
responsive (relay-paired setups also keep device control +
notification mirroring reachable). In main so BOTH flavors ship it
(Home-Assistant-class persistent connection). Off by default; only
runs while the user has explicitly enabled the toggle. specialUse
<!-- Protects user-started active turns automatically; the optional
"Persistent connection" setting extends the same foreground
protection to idle/background connectivity (and relay-paired
device features). In main so BOTH flavors ship it. specialUse
needs a Play Console foreground-service declaration at submission. -->
<service
android:name=".network.upstream.GatewayKeepAliveService"
@@ -103,7 +101,7 @@
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
</service>
</application>
+80 -12
View File
@@ -1,29 +1,97 @@
{
"versions": [
{
"version": "1.5.0",
"title": "Voice that keeps pace",
"date": "2026-07-22",
"version": "1.5.3",
"title": "Voice stays open",
"date": "2026-07-31",
"sections": [
{
"header": "A clearer voice studio",
"header": "Stable voice transcripts",
"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."
"Voice Focus keeps stable transcript rows while live messages reconcile with persisted chat history, preventing duplicate-key crashes that could close the app."
]
}
]
},
{
"version": "1.5.2",
"title": "Sign in without detours",
"date": "2026-07-28",
"sections": [
{
"header": "Provider-compatible sign-in",
"bullets": [
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
]
},
{
"header": "Natural streaming speech",
"header": "Stable conversation updates",
"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."
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
]
}
]
},
{
"version": "1.5.1",
"title": "Voice and chat stay in place",
"date": "2026-07-26",
"sections": [
{
"header": "Voice at the right depth",
"bullets": [
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
]
},
{
"header": "More upstream-aware controls",
"header": "Reliable narration and background work",
"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."
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
]
},
{
"header": "Formatted answers stay readable",
"bullets": [
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
]
}
]
},
{
"version": "1.5.0",
"title": "Hermes, always in reach",
"date": "2026-07-25",
"sections": [
{
"header": "One secure Hermes connection",
"bullets": [
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
]
},
{
"header": "Active work stays reachable",
"bullets": [
"Multiple user-started chats remain active in the background until every session settles.",
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
]
},
{
"header": "Richer chat and voice",
"bullets": [
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
]
},
{
"header": "Setup without surprises",
"bullets": [
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
]
}
]
+3 -4
View File
@@ -1,5 +1,4 @@
v1.5.0 - Voice that keeps pace
v1.5.3 - Voice stays open
* 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.
* Prevent Voice Focus from closing while live messages reconcile with chat history.
* Keep transcript rows stable when they receive persisted server identities.
@@ -12,6 +12,7 @@ enum class AppLanguage(val languageTag: String) {
JAPANESE("ja"),
SIMPLIFIED_CHINESE("zh-Hans"),
SPANISH("es"),
RUSSIAN("ru"),
;
fun toLocaleList(): LocaleListCompat = if (languageTag.isEmpty()) {
@@ -35,6 +36,7 @@ enum class AppLanguage(val languageTag: String) {
"es" -> SPANISH
"ja" -> JAPANESE
"pt" -> BRAZILIAN_PORTUGUESE
"ru" -> RUSSIAN
"zh" -> {
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
locale.script.isEmpty() ||
@@ -142,6 +142,21 @@ data class ChatMessage(
* through `copy`, while [id] remains the authoritative lookup/wire id.
*/
val uiKey: String = id,
/**
* Mixture-of-Agents advisor responses surfaced during the live turn.
* Unavailable advisors retain only neutral state, never their raw failure
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
* but server history never owns these presentation blocks.
*/
val moaReferences: List<MoaReference> = emptyList(),
)
data class MoaReference(
val index: Int,
val count: Int?,
val label: String,
val text: String,
val available: Boolean = true,
)
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
@@ -392,6 +407,8 @@ data class ChatSession(
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
*/
val source: String? = null,
/** Server reports a persisted session runtime/model binding. */
val hasModelConfig: Boolean = false,
) {
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
val cardDispatches: List<HermesCardDispatch> = emptyList(),
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
)
@Serializable
data class ChatTurnMoaReferenceCheckpoint(
val index: Int,
val count: Int? = null,
val label: String,
val text: String = "",
val available: Boolean = true,
)
@Serializable
@@ -247,6 +247,7 @@ data class Connection(
apiServerUrl: String,
relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(),
dashboardUrl: String? = null,
): List<EndpointCandidate> {
val routes = buildList {
endpointCandidateFromApiUrl(
@@ -255,6 +256,7 @@ data class Connection(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add)
extraApiUrls
@@ -266,6 +268,7 @@ data class Connection(
priority = index + 1,
apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
dashboardUrl = dashboardUrl,
)?.let(::add)
}
}
@@ -340,6 +343,7 @@ data class Connection(
priority: Int,
apiServerUrl: String,
relayUrl: String,
dashboardUrl: String? = null,
): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null
@@ -363,12 +367,62 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
dashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
?.let { DashboardEndpoint(url = it) }
?: deriveDefaultDashboardUrl(apiServerUrl)
?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
/**
* Reconcile stored API-derived routes with the Dashboard origin that
* was actually verified during setup. Older app versions synthesized
* `:9119` for every API route, even when the same host was reached
* through an HTTPS reverse proxy on 443. Replace only that conventional
* synthesized value (or a missing value); preserve explicit and
* different-host LAN/Tailscale routes.
*/
fun reconcileDashboardRoutes(
dashboardUrl: String?,
candidates: List<EndpointCandidate>,
): List<EndpointCandidate> {
val explicitDashboard = dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: return candidates
return candidates.map { candidate ->
val apiUrl = candidate.api?.url ?: return@map candidate
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
val currentDashboard = candidate.dashboard?.url
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
val canReplace = currentDashboard.isNullOrBlank() ||
(
derivedDashboard != null &&
currentDashboard.trim().trimEnd('/')
.equals(derivedDashboard, ignoreCase = true)
)
if (canReplace) {
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
} else {
candidate
}
}
}
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
return !leftHost.isNullOrBlank() &&
!rightHost.isNullOrBlank() &&
leftHost.equals(rightHost, ignoreCase = true)
}
/**
* De-duplication identity for rebuilding stored routes. Prefer the
* legacy API authority when present so an older API-only candidate and
@@ -543,29 +543,6 @@ class ConnectionStore private constructor(
}
}
private fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val normalizedRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
}
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
companion object {
private const val TAG = "ConnectionStore"
@@ -585,3 +562,40 @@ class ConnectionStore private constructor(
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
}
}
/**
* Restore route defaults after loading a serialized connection. This remains
* internal so focused persistence tests can exercise the same normalization
* path used by [ConnectionStore].
*/
internal fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
dashboardUrl = effectiveDashboardUrl,
)
}
val normalizedRoutes = Connection.reconcileDashboardRoutes(
dashboardUrl = effectiveDashboardUrl,
candidates = storedOrDefaultRoutes,
)
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
dashboardUrl != effectiveDashboardUrl ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = effectiveDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
@@ -255,9 +255,11 @@ class DataManager(
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(backup.toByteArray(Charsets.UTF_8))
outputStream.flush()
val outputStream = context.contentResolver.openOutputStream(uri)
?: return@withContext false
outputStream.use {
it.write(backup.toByteArray(Charsets.UTF_8))
it.flush()
}
true
} catch (e: Exception) {
@@ -288,9 +290,10 @@ class DataManager(
* - Clear DataStore preferences
* - Clear EncryptedSharedPreferences (auth tokens)
* - Clear any cached data
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
* should show onboarding again.
*/
suspend fun resetAppData() {
suspend fun resetAppData(): Boolean =
try {
// Preserve onboarding state before clearing
val onboarding = isOnboardingCompleted()
@@ -320,10 +323,11 @@ class DataManager(
}
Log.d(TAG, "App data reset complete")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to reset app data", e)
false
}
}
private suspend fun deleteSensitivePreferenceFiles() {
withContext(Dispatchers.IO) {
@@ -384,16 +388,17 @@ class DataManager(
* Reset only the onboarding completion flag.
* Next app launch will show onboarding again.
*/
suspend fun resetOnboarding() {
suspend fun resetOnboarding(): Boolean =
try {
context.relayDataStore.edit { preferences ->
preferences.remove(KEY_ONBOARDING_COMPLETED)
}
Log.d(TAG, "Onboarding flag reset")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to reset onboarding flag", e)
false
}
}
/**
* Check if onboarding has been completed.
@@ -412,13 +417,14 @@ class DataManager(
/**
* Mark onboarding as completed.
*/
suspend fun setOnboardingCompleted(completed: Boolean) {
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
try {
context.relayDataStore.edit { preferences ->
preferences[KEY_ONBOARDING_COMPLETED] = completed
}
true
} catch (e: Exception) {
Log.e(TAG, "Failed to set onboarding completed", e)
false
}
}
}
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
/**
* Feature flags with compile-time defaults and runtime overrides.
*
* In debug builds, all features are unlocked by default.
* In debug builds, Developer Options are unlocked by default until the user
* explicitly locks them.
* In release builds, experimental features are hidden unless the user
* enables Developer Options (tap version 7 times in Settings > About).
*
@@ -21,7 +22,6 @@ object FeatureFlags {
// DataStore keys
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
/** Whether the app is running a debug build. */
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
@@ -29,13 +29,7 @@ object FeatureFlags {
/** Observe whether Developer Options have been unlocked. */
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
}
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
fun relayEnabled(context: Context): Flow<Boolean> =
context.relayDataStore.data.map { prefs ->
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
}
/** Unlock Developer Options. */
@@ -45,18 +39,10 @@ object FeatureFlags {
}
}
/** Lock Developer Options and disable all experimental features. */
/** Lock Developer Options, including in debug builds. */
suspend fun lockDevOptions(context: Context) {
context.relayDataStore.edit { prefs ->
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
prefs[KEY_RELAY_ENABLED] = false
}
}
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
context.relayDataStore.edit { prefs ->
prefs[KEY_RELAY_ENABLED] = enabled
}
}
@@ -36,6 +36,14 @@ data class VoiceSettings(
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
val interactionMode: String = "tap",
val silenceThresholdMs: Long = 1250L,
/**
* When true, voice keeps progress visual and waits for the settled Hermes
* answer before speaking. Tool status, service updates, and intermediate
* assistant commentary are not narrated.
*/
val finalAnswerOnly: Boolean = false,
/** Presentation only; changing this never restarts or interrupts voice. */
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
val realtimeTraceDetails: Boolean = false,
/**
* When true (default), Realtime Agent keeps one provider session/socket open
@@ -122,6 +130,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
}
}
enum class VoicePresentationMode(val storageValue: String) {
Focus("focus"),
Conversation("conversation");
companion object {
fun fromStorage(value: String?): VoicePresentationMode =
values().firstOrNull { it.storageValue == value } ?: Focus
}
}
/**
* Active scope for per-profile voice prefs.
*
@@ -182,6 +200,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
// un-namespaced means switching profiles never churns these.
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
private val KEY_REALTIME_PERSISTENT_SESSION =
booleanPreferencesKey("voice_realtime_persistent_session")
@@ -191,6 +211,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
const val DEFAULT_INTERACTION_MODE = "tap"
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
const val DEFAULT_FINAL_ANSWER_ONLY = false
const val DEFAULT_PRESENTATION_MODE = "focus"
const val DEFAULT_REALTIME_TRACE_DETAILS = false
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
// --- global (shared across profiles) ---
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
presentationMode = VoicePresentationMode.fromStorage(
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
).storageValue,
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
@@ -367,6 +393,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
}
suspend fun setFinalAnswerOnly(enabled: Boolean) {
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
}
suspend fun setPresentationMode(mode: VoicePresentationMode) {
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
}
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
}
@@ -1278,6 +1278,7 @@ class RelayVoiceClient(
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
finalAnswerOnly: Boolean = false,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
@@ -1309,6 +1310,7 @@ class RelayVoiceClient(
model = model,
voice = voice,
sampleRate = sampleRate,
finalAnswerOnly = finalAnswerOnly,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
@@ -1822,6 +1824,28 @@ class RelayVoiceClient(
if (event.type == "hermes.run.promoted") {
longRunningTurn.set(true)
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
if (persistent &&
event.spokenHandoff == false &&
activeTurn.compareAndSet(true, false)
) {
Log.i(
TAG,
"Realtime agent foreground turn ended at silent background promotion",
)
onTurnComplete(
RealtimeVoiceSummary(
provider = event.provider ?: session.provider,
model = event.model ?: session.model,
voice = event.voice ?: session.voice,
sampleRate = session.sampleRate,
audioChunks = audioChunks,
audioBytes = audioBytes,
firstAudioMs = event.firstAudioMs,
responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath,
)
)
}
}
if (event.isAudioDelta) {
audioChunks += 1
@@ -1847,13 +1871,12 @@ class RelayVoiceClient(
responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath,
)
if (persistent) {
if (persistent && activeTurn.compareAndSet(true, false)) {
// Turn boundary, not session boundary: keep the socket
// open for the next utterance.
activeTurn.set(false)
longRunningTurn.set(false)
onTurnComplete(summary)
} else {
} else if (!persistent) {
if (claimTerminalSocket(
webSocket,
generation,
@@ -2679,6 +2702,7 @@ class RelayVoiceClient(
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
finalAnswerOnly: Boolean = false,
): Result<RealtimeSessionResponse> {
val body = buildJsonObject {
putProfile()
@@ -2694,6 +2718,9 @@ class RelayVoiceClient(
sampleRate?.takeIf { it > 0 }?.let {
put("sample_rate", JsonPrimitive(it))
}
if (finalAnswerOnly) {
put("final_answer_only", JsonPrimitive(true))
}
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
put("chat_session_id", JsonPrimitive(it))
}
@@ -2980,6 +3007,9 @@ class RelayVoiceClient(
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
?.contentOrNull
?.toBooleanStrictOrNull(),
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
@@ -3374,6 +3404,7 @@ data class RealtimeVoiceEvent(
// ADR 33: background-run promotion fields.
val tier: String? = null,
val floor: String? = null,
val spokenHandoff: Boolean? = null,
// hermes.run.progress extras — drive the live background-run chip.
val activeToolName: String? = null,
val completedToolCount: Int? = null,
@@ -0,0 +1,76 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-local ownership of background protection for work the user already
* started. Keys are connection/profile/session scoped, so detached sibling
* sessions retain independent leases and one completion cannot release
* another session's protection.
*/
object ActiveTurnKeepAliveRegistry {
data class Snapshot(
val activeTurnCount: Int = 0,
val waitingSessionCount: Int = 0,
) {
val required: Boolean get() = activeTurnCount > 0
}
private val lock = Any()
private val leases = linkedMapOf<String, Boolean>()
private val _snapshot = MutableStateFlow(Snapshot())
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
fun acquire(key: String) {
synchronized(lock) {
leases[key] = leases[key] ?: false
publishLocked()
}
}
fun setWaiting(key: String, waiting: Boolean) {
synchronized(lock) {
if (key in leases) {
leases[key] = waiting
publishLocked()
}
}
}
fun rename(oldKey: String, newKey: String) {
if (oldKey == newKey) return
synchronized(lock) {
val waiting = leases.remove(oldKey) ?: return
leases[newKey] = waiting
publishLocked()
}
}
fun release(key: String) {
synchronized(lock) {
if (leases.remove(key) != null) publishLocked()
}
}
fun releaseAll() {
synchronized(lock) {
if (leases.isNotEmpty()) {
leases.clear()
publishLocked()
}
}
}
internal fun resetForTest() {
releaseAll()
}
private fun publishLocked() {
_snapshot.value = Snapshot(
activeTurnCount = leases.size,
waitingSessionCount = leases.count { it.value },
)
}
}
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.MessageDeliveryStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.MoaReference
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -43,6 +43,9 @@ class ChatHandler {
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
internal const val MAX_MESSAGES = 500
private const val MAX_MOA_REFERENCES = 32
private const val MAX_MOA_LABEL_CHARS = 120
private const val MAX_MOA_REFERENCE_CHARS = 16_000
private fun timestampToMillis(timestamp: Double?): Long {
val value = timestamp ?: return 0L
@@ -155,6 +158,15 @@ class ChatHandler {
*/
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
/**
* Fired for a canonical `@image:<path>` directive found on a persisted
* USER history row. This is intentionally separate from free-form
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
* can reach this callback.
*/
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
{ _, _ -> }
/**
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
@@ -512,6 +524,42 @@ class ChatHandler {
}
}
/**
* Collapse a provisional post-interim segment back into its sealed
* assistant bubble when the terminal text proves they are one response.
* Tool/card state accumulated after the interim remains attached.
*/
fun reconcileInterimMessage(
interimMessageId: String,
currentMessageId: String,
content: String,
) {
_messages.update { messages ->
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
val current = messages.firstOrNull { it.id == currentMessageId }
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
val merged = interim.copy(
content = content,
isStreaming = true,
toolCalls = mergedTools,
thinkingContent = current?.thinkingContent
?.takeIf { it.isNotBlank() }
?: interim.thinkingContent,
isThinkingStreaming = current?.isThinkingStreaming
?: interim.isThinkingStreaming,
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
)
messages
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
.map { if (it.id == interimMessageId) merged else it }
}
}
/** Remove a provisional client-side message that never became a real turn. */
fun removeMessage(messageId: String) {
_messages.update { messages -> messages.filterNot { it.id == messageId } }
@@ -973,6 +1021,27 @@ class ChatHandler {
startedAt = task.startedAt,
)
}
val checkpointMoaReferences = assistant.moaReferences
.filter { it.index in 1..MAX_MOA_REFERENCES }
.distinctBy { it.index }
.sortedBy { it.index }
.take(MAX_MOA_REFERENCES)
.map { reference ->
MoaReference(
index = reference.index,
count = reference.count,
label = reference.label.take(MAX_MOA_LABEL_CHARS),
text = if (reference.available) {
reference.text.take(MAX_MOA_REFERENCE_CHARS)
} else {
""
},
available = reference.available,
)
}
val restoredMoaReferences = currentAssistant?.moaReferences
?.takeIf { it.isNotEmpty() }
?: checkpointMoaReferences
val restoredAssistant = ChatMessage(
id = assistant.id,
role = MessageRole.ASSISTANT,
@@ -996,6 +1065,7 @@ class ChatHandler {
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
?: assistant.cardDispatches,
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
moaReferences = restoredMoaReferences,
)
activeAgentName = restoredAssistant.agentName ?: activeAgentName
@@ -1153,11 +1223,20 @@ class ChatHandler {
// so we can attach results back to the originating assistant message's ToolCall
val toolResults = items.filter { it.role == "tool" }
.associateBy { it.toolCallId }
// A reconnect/rejoin history response can repeat a persisted message row.
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
// so allowing both copies through would crash Compose before either copy
// could be reconciled. A domain id identifies one persisted message: retain
// its first transcript position while adopting the latest repeated snapshot.
// Rows without ids remain independent, and tool/hidden rows keep their
// separate handling above/below.
val renderedItems = coalesceRenderedHistoryItems(items)
// Accumulator for media markers we find in loaded content — fired AFTER
// the wholesale `_messages.value = ...` assignment so the ViewModel's
// mutateMessage lookups find the newly-loaded messages.
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
// building the carry map, so the id-keyed delta-merge updates rows in
@@ -1169,8 +1248,8 @@ class ChatHandler {
// silently misses those rows, so a gateway turn's tokens/badges survived
// only if a content match happened to cover them. See
// [reconcileLiveIdsToServer].
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
// RECONCILED message id. The server transcript (MessageItem) rebuilds
@@ -1207,12 +1286,13 @@ class ChatHandler {
// clientOnly bubbles (same exchange, pre-sync copy).
val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = items.mapNotNull { item ->
val loaded = renderedItems.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val role = when {
displayKind == "model_switch" ||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
displayKind == "async_delegation_complete" ||
displayKind == "auto_continue" -> MessageRole.SYSTEM
item.role == "user" -> MessageRole.USER
item.role == "assistant" -> MessageRole.ASSISTANT
item.role == "system" ->
@@ -1250,12 +1330,18 @@ class ChatHandler {
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
val rawContent = rawServerContent
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
PersistedImageReferenceParser.parse(rawContent)
} else {
PersistedImageReferences(rawContent, emptyList())
}
// Run the media marker parser on assistant content; strip matched
// lines and queue hits for post-assignment dispatch.
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
} else {
rawContent
persistedImages.cleanedText
}
// Cards are synchronous (no async fetch) so we attach them
@@ -1293,7 +1379,14 @@ class ChatHandler {
// content-keyed queue. Inbound attachments are intentionally
// excluded — they come back via the marker re-dispatch.
val carriedAttachments = run {
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
val persistedImagePaths = persistedImages.paths.toHashSet()
val byId = prior?.attachments.orEmpty().filter { attachment ->
attachment.relayToken == null ||
(
role == MessageRole.USER &&
attachment.relayToken in persistedImagePaths
)
}
when {
byId.isNotEmpty() -> byId
role == MessageRole.USER ->
@@ -1301,6 +1394,15 @@ class ChatHandler {
else -> emptyList()
}
}
if (
role == MessageRole.USER &&
carriedAttachments.isEmpty() &&
persistedImages.paths.isNotEmpty()
) {
persistedImages.paths.forEach { path ->
pendingPersistedUserImages += messageId to path
}
}
// Server reasoning is authoritative when present; absent, keep the
// live-streamed thinking rather than blanking it on reload.
val serverThinking =
@@ -1345,6 +1447,10 @@ class ChatHandler {
} else {
prior.badges
},
// Keep sanitized advisor state while reconciling a still-live
// row, but clear it once completion made history authoritative.
// The server transcript never becomes the source of these blocks.
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
)
} else {
// INSERT — a server message with no local row yet. Built from
@@ -1437,6 +1543,37 @@ class ChatHandler {
}
}
}
for ((messageId, path) in pendingPersistedUserImages) {
onPersistedUserImageRequested(messageId, path)
}
}
/**
* Collapse replayed visible history rows by their authoritative message id.
*
* Replacing the value at its first-seen slot preserves transcript ordering;
* the last repeated value wins so a later, more complete snapshot is not lost.
* Null ids cannot be proven identical and therefore remain separate rows.
*/
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
val firstSlotById = HashMap<String, Int>()
val coalesced = ArrayList<MessageItem>(items.size)
for (item in items) {
if (renderedRoleOf(item) == null) continue
val id = item.id
if (id == null) {
coalesced += item
continue
}
val existingSlot = firstSlotById[id]
if (existingSlot == null) {
firstSlotById[id] = coalesced.size
coalesced += item
} else {
coalesced[existingSlot] = item
}
}
return coalesced
}
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
@@ -1505,7 +1642,7 @@ class ChatHandler {
private fun renderedRoleOf(item: MessageItem): MessageRole? =
when (item.displayKind?.trim()?.lowercase()) {
"hidden" -> null
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
else -> when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
@@ -1539,6 +1676,7 @@ class ChatHandler {
else -> "$count background tasks completed"
}
}
"auto_continue" -> "Continued after an interrupted turn"
else -> null
}
@@ -1565,6 +1703,7 @@ class ChatHandler {
val t = line.trim()
if (t.isEmpty()) continue
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
if (cardMarkerRegex.containsMatchIn(t)) continue
if (sb.isNotEmpty()) sb.append('\n')
sb.append(t)
@@ -1753,6 +1892,7 @@ class ChatHandler {
// SessionItem; the other ChatSession() call sites are local optimistic
// rows (default source). (ADR 12 — Threads surface, slice 1.)
source = item.source,
hasModelConfig = item.hasModelConfig,
)
}.sortedByDescending { it.activityTimestamp }
// Preserve the active session's optimistic row when the server list
@@ -2593,6 +2733,44 @@ class ChatHandler {
// --- Gateway subagent lanes ---
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
_messages.update { messages ->
val targetIndex = messages.indexOfLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
}
if (targetIndex < 0) return@update messages
_isStreaming.value = true
val message = messages[targetIndex]
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
val reference = MoaReference(
index = nextIndex,
count = event.count,
label = event.label.take(MAX_MOA_LABEL_CHARS),
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
available = event.available,
)
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
val exactReplay = existingAtIndex == reference
val base = if (nextIndex == 1 && !exactReplay) {
emptyList()
} else {
message.moaReferences
}
if (exactReplay) {
messages
} else {
val upserted = (base.filterNot { it.index == nextIndex } + reference)
.sortedBy(MoaReference::index)
.take(MAX_MOA_REFERENCES)
messages.toMutableList().also {
it[targetIndex] = message.copy(moaReferences = upserted)
}
}
}
}
/**
* Lane labels by task index, captured from `subagent.start` (goal
* truncated to 60 chars) and stamped onto every child ToolCall so
@@ -50,6 +50,7 @@ data class DashboardStatus(
val authRequired: Boolean,
val authProviders: List<String> = emptyList(),
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
val version: String? = null,
val message: String? = null,
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
@@ -158,6 +159,7 @@ data class DashboardCustomEndpointDraft(
val name: String,
val baseUrl: String,
val model: String,
val models: List<String> = emptyList(),
val apiKey: String? = null,
val contextLength: Int? = null,
val discoverModels: Boolean = true,
@@ -1136,23 +1138,42 @@ class DashboardApiClient(
put("name", draft.name)
put("base_url", draft.baseUrl)
put("model", draft.model)
draft.models
.asSequence()
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
.take(MAX_CUSTOM_ENDPOINT_MODELS)
.map(::JsonPrimitive)
.toList()
.takeIf { it.isNotEmpty() }
?.let { put("models", JsonArray(it)) }
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)
}
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
fun defaultClient(
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
): OkHttpClient = OkHttpClient.Builder()
.cookieJar(DashboardCookieJar(cookieStore))
.connectTimeout(10, TimeUnit.SECONDS)
// Skills-hub search fans out server-side with a 30s overall
// timeout; keep the read window above it so a slow-but-successful
// search doesn't die client-side at the edge.
.readTimeout(45, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
bearerAuth: DashboardBearerAuth? = null,
): OkHttpClient {
val builder = OkHttpClient.Builder()
.cookieJar(DashboardCookieJar(cookieStore))
.connectTimeout(10, TimeUnit.SECONDS)
// Skills-hub search fans out server-side with a 30s overall
// timeout; keep the read window above it so a slow-but-successful
// search doesn't die client-side at the edge.
.readTimeout(45, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
bearerAuth?.let {
builder.addInterceptor(it)
builder.authenticator(it)
}
return builder.build()
}
fun parseStatus(root: JsonObject): DashboardStatus {
val authObject = root["auth"] as? JsonObject
@@ -1180,6 +1201,9 @@ class DashboardApiClient(
?: false,
authProviders = providers.map { it.name },
authProviderDetails = providers,
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull
},
version = root.stringField("version"),
message = root.stringField("message") ?: root.stringField("detail"),
nousSessionValid = root.stringField("nous_session_valid"),
@@ -1338,6 +1362,35 @@ class DashboardApiClient(
}
}
/**
* Bearer credentials are scoped to the exact saved dashboard base, including
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
* not sufficient authority to receive the active connection's token.
*/
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return candidateUrl.scheme == trustedUrl.scheme &&
candidateUrl.host == trustedUrl.host &&
candidateUrl.port == trustedUrl.port &&
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
candidateUrl.query == null &&
trustedUrl.query == null
}
fun trustedDashboardBearerAuthOrNull(
candidate: String,
trusted: String,
tokenStoreProvider: () -> NativeDashboardTokenStore,
): DashboardBearerAuth? =
if (isNativeDashboardTransportEligible(candidate) &&
sameDashboardBase(candidate, trusted)
) {
DashboardBearerAuth(candidate, tokenStoreProvider())
} else {
null
}
interface DashboardCookieStore {
fun load(): List<StoredDashboardCookie>
fun save(cookies: List<StoredDashboardCookie>)
@@ -164,6 +164,7 @@ class GatewayChatClient(
private const val CONNECT_ATTEMPTS = 2
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
private const val MAX_RECOVERY_BUFFERED_EVENTS = 256
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
private const val MAX_TURN_REJOINS = 4
@@ -284,6 +285,14 @@ class GatewayChatClient(
private val _serverYolo = MutableStateFlow<Boolean?>(null)
val serverYolo: StateFlow<Boolean?> = _serverYolo.asStateFlow()
private val _serverApprovalMode = MutableStateFlow<GatewayApprovalMode?>(null)
val serverApprovalMode: StateFlow<GatewayApprovalMode?> = _serverApprovalMode.asStateFlow()
private val _approvalModeCapability =
MutableStateFlow(GatewayApprovalModeCapability.Unknown)
val approvalModeCapability: StateFlow<GatewayApprovalModeCapability> =
_approvalModeCapability.asStateFlow()
private val _serverFast = MutableStateFlow<Boolean?>(null)
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
@@ -362,6 +371,15 @@ class GatewayChatClient(
@Volatile
private var activeTurn: GatewayTurn? = null
private data class RecoveryEvent(
val sessionId: String,
val type: String,
val payload: JsonObject?,
)
private val recoveryEventLock = Any()
private var recoveryEvents: MutableList<RecoveryEvent>? = null
/**
* Turns deliberately detached when the user switches profile/session.
* Upstream continues them server-side; retain the live→durable binding so
@@ -820,24 +838,33 @@ class GatewayChatClient(
}
if (response == null) {
response = rpc(
"session.resume",
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
).getOrElse { error ->
preferredLiveId?.let { liveId ->
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
synchronized(recoveryEventLock) {
recoveryEvents = mutableListOf()
}
response = try {
rpc(
"session.resume",
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
).getOrElse { error ->
preferredLiveId?.let { liveId ->
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
}
throw error
}
} catch (error: Throwable) {
synchronized(recoveryEventLock) { recoveryEvents = null }
throw error
}
}
val recoveredLiveId = response.stringField("session_id")
?: run {
synchronized(recoveryEventLock) { recoveryEvents = null }
if (!preferredLiveId.isNullOrBlank()) {
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
}
@@ -854,6 +881,9 @@ class GatewayChatClient(
user = value.stringField("user").orEmpty(),
assistant = value.stringField("assistant").orEmpty(),
streaming = value.booleanField("streaming") == true,
status = value.stringField("status"),
error = value.stringField("error"),
recoverable = value.booleanField("recoverable") == true,
)
}
val queued = (response["queued"] as? JsonObject)?.let { value ->
@@ -862,8 +892,20 @@ class GatewayChatClient(
?.let(::GatewayQueuedTurn)
}
val running = response.booleanField("running") == true || inflight?.streaming == true
val autoContinue = (response["auto_continue"] as? JsonObject)?.let { value ->
val attempt = value.stringField("attempt")?.toIntOrNull()
?: (value["attempt"] as? JsonPrimitive)?.intOrNull
if (attempt != null && attempt > 0) {
GatewayAutoContinue(
attempt = attempt,
interruptedAt = value.stringField("interrupted_at")?.toDoubleOrNull(),
)
} else {
null
}
}
if (running) {
if (running || autoContinue != null) {
if (boundTurn == null || boundTurn.ended) {
boundTurn = GatewayTurn(
callbacks = dispatchOn(callbacks),
@@ -873,6 +915,13 @@ class GatewayChatClient(
activeTurn = turn
}
}
val buffered = synchronized(recoveryEventLock) {
recoveryEvents
?.filter { it.sessionId == recoveredLiveId }
.orEmpty()
.also { recoveryEvents = null }
}
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
queued?.let { queuedTurn ->
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
boundTurn.installQueuedSuccessor(registration)
@@ -884,6 +933,7 @@ class GatewayChatClient(
}
boundTurn.armWatchdog()
} else if (queued != null) {
synchronized(recoveryEventLock) { recoveryEvents = null }
// A queued-only snapshot belongs to the NEXT turn. Never let
// its events flow through the completed checkpoint's mapper.
val priorBoundTurn = boundTurn
@@ -914,6 +964,7 @@ class GatewayChatClient(
priorBoundTurn?.detach()
}
} else {
synchronized(recoveryEventLock) { recoveryEvents = null }
if (boundTurn != null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.discardDeferredEvents()
@@ -929,6 +980,7 @@ class GatewayChatClient(
status = response.stringField("status"),
inflight = inflight,
queued = queued,
autoContinue = autoContinue,
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
?.takeUnless { it.ended },
)
@@ -1377,6 +1429,84 @@ class GatewayChatClient(
}
}
/**
* Read the profile-persisted approval policy added in gateway contract v3.
* Older gateways either reject the key or return no recognized value; both
* downgrade this optional control without affecting chat or per-session YOLO.
*/
suspend fun getApprovalMode(): Result<GatewayApprovalMode> {
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
return Result.failure(approvalModeUnsupported())
}
if (currentSessionProfile() != null) {
return Result.failure(approvalModeRequiresLaunchProfile())
}
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
val response = rpc(
"config.get",
buildJsonObject { put("key", "approvals.mode") },
)
response.exceptionOrNull()?.let { error ->
if (error.isApprovalModeUnsupported()) {
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
}
return Result.failure(error)
}
val mode = GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
?: run {
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
return Result.failure(approvalModeUnsupported())
}
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
_serverApprovalMode.value = mode
return Result.success(mode)
}
/** Persist the selected approval policy for the active gateway profile. */
suspend fun setApprovalMode(mode: GatewayApprovalMode): Result<GatewayApprovalMode> {
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
return Result.failure(approvalModeUnsupported())
}
if (currentSessionProfile() != null) {
return Result.failure(approvalModeRequiresLaunchProfile())
}
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
val response = rpc(
"config.set",
buildJsonObject {
put("key", "approvals.mode")
put("value", mode.wireValue)
},
)
response.exceptionOrNull()?.let { error ->
if (error.isApprovalModeUnsupported()) {
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
}
return Result.failure(error)
}
val authoritative =
GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
?: run {
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
return Result.failure(approvalModeUnsupported())
}
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
_serverApprovalMode.value = authoritative
return Result.success(authoritative)
}
/**
* Toggle fast mode (priority service tier) via `config.set {key:"fast"}` —
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
@@ -1452,6 +1582,7 @@ class GatewayChatClient(
private suspend fun connectOnce() {
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
@@ -1541,6 +1672,14 @@ class GatewayChatClient(
* session can paint its real model up front rather than waiting for a turn.
*/
private fun applySessionInfo(info: JsonObject) {
val contract = (info["desktop_contract"] as? JsonPrimitive)?.intOrNull
if (contract != null && contract < 3) {
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
}
GatewayApprovalMode.fromWire(info.stringField("approval_mode"))?.let { mode ->
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
_serverApprovalMode.value = mode
}
if (info.containsKey("personality")) {
_serverPersonality.value =
(info.stringField("personality") ?: "").ifBlank { "none" }
@@ -1845,6 +1984,25 @@ class GatewayChatClient(
return
}
// A cold session.resume may schedule auto-continue before its RPC
// response reaches Android. The recovery buffer is an ownership gate,
// not an observational copy: an event is either claimed here for
// replay or routed live below, never both. The resume response drains
// and closes the gate under this same lock, so later frames route live.
// Already-owned sibling sessions retain their background routing.
val claimedByRecovery = !eventSessionId.isNullOrBlank() &&
!backgroundTurns.containsKey(eventSessionId) &&
synchronized(recoveryEventLock) {
recoveryEvents?.let { buffered ->
if (buffered.size >= MAX_RECOVERY_BUFFERED_EVENTS) {
buffered.removeAt(0)
}
buffered += RecoveryEvent(eventSessionId, type, payload)
true
} ?: false
}
if (claimedByRecovery) 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
@@ -1916,7 +2074,7 @@ class GatewayChatClient(
// `/personality`, desktop, or TUI change keeps the app in sync. Falls
// through to the turn dispatch below so an in-flight turn still sees it.
if (type == "session.info" &&
(eventSessionId == null || liveSessionId == null || eventSessionId == liveSessionId)
(eventSessionId == null || eventSessionId == liveSessionId)
) {
// Connection-level session info (model / provider / effort / persona /
// yolo / fast / usage) — shared with the session.resume result via
@@ -2046,6 +2204,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
pendingRpcs.values.forEach {
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
@@ -2224,6 +2383,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
}
@@ -2705,6 +2865,9 @@ class GatewayChatClient(
onInterimMessage = { text, alreadyStreamed ->
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
},
onInterimReconciled = { text ->
callbackDispatcher { callbacks.onInterimReconciled(text) }
},
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
@@ -2717,6 +2880,7 @@ class GatewayChatClient(
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
@@ -2798,6 +2962,29 @@ private fun Throwable?.isMethodNotFound(): Boolean {
msg.contains("unknown method", ignoreCase = true)
}
private fun Throwable?.isApprovalModeUnsupported(): Boolean {
val rpcError = this as? GatewayRpcException ?: return false
val message = rpcError.message.orEmpty()
return rpcError.code == JSONRPC_METHOD_NOT_FOUND ||
rpcError.code == 4002 ||
message.contains("approval mode", ignoreCase = true) &&
(
message.contains("unknown", ignoreCase = true) ||
message.contains("unsupported", ignoreCase = true)
)
}
private fun approvalModeUnsupported(): GatewayRpcException =
GatewayRpcException(
"profile approval modes are not supported by this gateway",
JSONRPC_METHOD_NOT_FOUND,
)
private fun approvalModeRequiresLaunchProfile(): GatewayRpcException =
GatewayRpcException(
"profile approval mode is read-only for multiplexed non-launch profiles",
)
private fun JsonObject.stringField(key: String): String? =
(get(key) as? JsonPrimitive)?.contentOrNull
@@ -48,6 +48,7 @@ class GatewayEventMapper(
private var syntheticToolCounter = 0
private var providerWaitStatusActive = false
private var compactionStatusActive = false
private var moaStatusActive = false
private var pendingInteraction: GatewayAsk? = null
/**
@@ -216,13 +217,18 @@ class GatewayEventMapper(
"message.complete" -> {
// Non-streaming servers (or error turns) deliver everything
// here; backfill whatever never streamed.
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
val error = payload.string("error")
val text = payload.string("text")
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 &&
?: error?.takeIf { failed }?.let { "Error: $it" }
val reconcilesInterim = !text.isNullOrEmpty() &&
previewedText?.let { preview ->
preview.isNotEmpty() &&
(text.startsWith(preview) || preview.startsWith(text))
} == true
if (reconcilesInterim) {
callbacks.onInterimReconciled(text)
} else if (!text.isNullOrEmpty() &&
!isIntentionalSilenceMarker(text) &&
(!sawTextDelta || previewedText != null)
) {
@@ -233,6 +239,12 @@ class GatewayEventMapper(
callbacks.onThinkingDelta(reasoning)
}
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
if (failed) {
callbacks.onStatusUpdate(
ERROR_STATUS_KIND,
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
)
}
turnEnded = true
callbacks.onComplete()
}
@@ -290,9 +302,57 @@ class GatewayEventMapper(
}
}
// MoA activity proves auto-compaction has resumed even though
// Android does not currently render these upstream events.
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
"moa.reference" -> {
clearProviderWaitAndCompaction()
val text = payload.string("text")?.trim().orEmpty()
if (text.isNotEmpty()) {
val available = !isFailedMoaReference(text)
callbacks.onMoaReference(
GatewayMoaReference(
index = payload.int("index")?.takeIf { it > 0 },
count = payload.int("count")
?.takeIf { it > 0 },
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
.ifBlank { "Advisor" },
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
available = available,
),
)
}
}
"moa.progress" -> {
clearProviderWaitAndCompaction()
val total = payload.int("refs_total")
?.takeIf { it > 0 }
val done = payload.int("refs_done")
if (total != null && done != null) {
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
}
}
"moa.phase" -> {
clearProviderWaitAndCompaction()
when (payload.string("phase")?.lowercase()) {
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
"reference", "references" -> {
val total = payload.int("refs_total")
?.takeIf { it > 0 }
val done = payload.int("refs_done")
if (total != null && done != null) {
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
}
}
}
}
// Legacy phase marker retained by upstream for older consumers.
"moa.aggregating" -> {
clearProviderWaitAndCompaction()
setMoaStatus("MoA: aggregating…")
}
"tool.progress" -> clearActivityStatuses()
"status.update" -> {
val text = payload.string("text")
@@ -324,15 +384,31 @@ class GatewayEventMapper(
}
private fun clearActivityStatuses() {
clearProviderWaitAndCompaction()
if (!moaStatusActive) return
moaStatusActive = false
callbacks.onStatusClear(MOA_STATUS_KIND)
}
private fun clearProviderWaitAndCompaction() {
clearProviderWaitStatus()
if (!compactionStatusActive) return
compactionStatusActive = false
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
}
private fun setMoaStatus(text: String) {
moaStatusActive = true
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
}
companion object {
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting"
const val ERROR_STATUS_KIND = "error"
const val MOA_STATUS_KIND = "moa"
private const val MAX_MOA_LABEL_CHARS = 120
private const val MAX_MOA_REFERENCE_CHARS = 16_000
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
private val INTERACTION_RESUME_EVENTS = setOf(
"reasoning.delta",
@@ -348,6 +424,11 @@ class GatewayEventMapper(
"error",
)
internal fun isFailedMoaReference(text: String): Boolean {
val normalized = text.trimStart().lowercase()
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
}
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Opt-in foreground service that holds the app process up so the app's
* connection to Hermes survives Android's background-freeze / Doze — i.e.
* Foreground service that holds the app process up so work the user already
* started survives Android's background-freeze / Doze. It runs automatically
* while one or more turns are active, or continuously when the user enables
* "persistent connection". Concretely it keeps the gateway chat WebSocket
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
* connection use case Google Play permits. The `specialUse` type is honest for
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
* SDK 35) but requires a one-time Play Console foreground-service declaration
* at submission. Off by default; only runs while the user enables the toggle.
* at submission. Continuous idle retention is off by default; active work is
* protected automatically and releases its lease on terminal settlement.
*
* # It does NOT own the socket
*
* The service's only job is to hold the process in the foreground. The socket
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
* idle-close timer while the toggle is on. On task removal (user swipes the app
* idle-close timer while retention is required. On task removal (user swipes the app
* away) the ViewModel + socket die with the process, so the service stops
* itself rather than leave a notification that lies about being connected.
*
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
private const val CHANNEL_NAME = "Persistent connection"
const val NOTIFICATION_ID = 4713
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
private const val EXTRA_PERSISTENT = "persistent"
private const val EXTRA_ACTIVE_TURNS = "active_turns"
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
@Volatile private var runningInstance: GatewayKeepAliveService? = null
fun start(context: Context) {
fun update(
context: Context,
persistent: Boolean,
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
) {
if (!persistent && !activeTurns.required) {
stop(context)
return
}
runningInstance?.let { service ->
service.applyState(persistent, activeTurns)
service.startForegroundNotification()
return
}
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
.setAction(ACTION_REFRESH)
.putExtra(EXTRA_PERSISTENT, persistent)
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var persistent = false
private var activeTurns = 0
private var waitingSessions = 0
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
runningInstance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_REFRESH) {
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
.coerceIn(0, activeTurns)
}
startForegroundNotification()
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
// Flip the pref off so ConnectionViewModel's collector won't
// restart us on the next foreground.
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
persistent = false
if (activeTurns == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
startForegroundNotification()
}
return START_NOT_STICKY
}
return START_STICKY
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
// The socket lives in the ViewModel, which dies when the task is
// removed — keeping the notification would be a lie. Stop cleanly.
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
ActiveTurnKeepAliveRegistry.releaseAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
if (runningInstance === this) runningInstance = null
scope.cancel()
super.onDestroy()
}
private fun applyState(
persistent: Boolean,
turns: ActiveTurnKeepAliveRegistry.Snapshot,
) {
this.persistent = persistent
activeTurns = turns.activeTurnCount
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
}
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
// are all declared in the main manifest (both flavors), so the type is
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
return NotificationCompat.Builder(this, CHANNEL_ID)
val (title, body) = when {
waitingSessions > 0 -> {
val title = if (waitingSessions == 1) {
"Hermes is waiting for input"
} else {
"$waitingSessions Hermes sessions need input"
}
title to if (activeTurns > waitingSessions) {
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
} else {
"Open the requested session to review and continue."
}
}
activeTurns > 0 -> {
val title = if (activeTurns == 1) {
"Hermes is finishing a turn"
} else {
"Hermes is finishing $activeTurns turns"
}
title to "The connection stays active until this work completes."
}
else -> getString(R.string.gateway_keepalive_title) to
getString(R.string.gateway_keepalive_body)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.gateway_keepalive_title))
.setContentText(getString(R.string.gateway_keepalive_body))
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(tapPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.addAction(0, "Turn off", stopPending)
.build()
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
return builder.build()
}
private fun ensureChannel() {
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
Ready,
}
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
enum class GatewayApprovalMode(val wireValue: String) {
Manual("manual"),
Smart("smart"),
Off("off");
companion object {
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
"manual" -> Manual
"smart" -> Smart
"off" -> Off
else -> null
}
}
}
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
enum class GatewayApprovalModeCapability {
Unknown,
Supported,
Unsupported,
}
/**
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
* is unit-testable without an AndroidViewModel. ConnectionViewModel
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
*
* Manual picks pass through untouched (ChatViewModel handles per-turn
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
* otherwise it falls back to the capability-preferred SSE endpoint.
* gateway while the dashboard probe is unresolved or ready. A capability-
* preferred SSE fallback is selected only after a definitive unavailable,
* unsupported, or sign-in-required verdict.
*/
fun resolveStreamingEndpointPreference(
preference: String,
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
capabilities: ServerCapabilities,
): String = when (preference) {
"sessions", "completions", "runs", "gateway" -> preference
else -> if (gateway == GatewayAvailability.Ready) {
else -> if (
gateway == GatewayAvailability.Ready ||
gateway == GatewayAvailability.Unknown
) {
"gateway"
} else {
capabilities.preferredChatEndpoint()
@@ -95,6 +122,9 @@ data class GatewayInflightTurn(
val user: String,
val assistant: String,
val streaming: Boolean,
val status: String? = null,
val error: String? = null,
val recoverable: Boolean = false,
)
/** A next-turn prompt accepted by upstream while the current turn was busy. */
@@ -102,6 +132,12 @@ data class GatewayQueuedTurn(
val user: String,
)
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
data class GatewayAutoContinue(
val attempt: Int,
val interruptedAt: Double?,
)
/** Optional project identity attached to newer upstream session metadata. */
data class GatewaySessionProject(
val id: String?,
@@ -120,10 +156,11 @@ data class GatewaySessionRecovery(
val queued: GatewayQueuedTurn?,
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
val handle: ActiveTurnHandle?,
val autoContinue: GatewayAutoContinue? = null,
) {
/** Whether upstream still owes this client live turn events. */
val hasPendingWork: Boolean
get() = running || queued != null
get() = running || queued != null || autoContinue != null
}
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
@@ -310,6 +347,14 @@ data class GatewayModelProvider(
val totalModels: Int = 0,
)
data class GatewayMoaReference(
val index: Int?,
val count: Int?,
val label: String,
val text: String,
val available: Boolean = true,
)
/** Result of the gateway `model.options` RPC. */
data class GatewayModelOptions(
val providers: List<GatewayModelProvider>,
@@ -317,6 +362,15 @@ data class GatewayModelOptions(
val currentProvider: String,
)
/** Reject provider catalogs that completed after a profile/context switch. */
internal fun isCurrentModelOptionsResponse(
requestGeneration: Long,
currentGeneration: Long,
requestProfileKey: String,
currentProfileKey: String,
): Boolean =
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
/**
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
* new session's PER-SESSION overrides. Matches the upstream desktop client,
@@ -374,6 +428,12 @@ class GatewayTurnCallbacks(
* sealing the current assistant segment.
*/
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
/**
* The terminal text is equal/prefix-related to the sealed interim, so the
* existing segment should be replaced in place instead of opening a second
* assistant bubble.
*/
val onInterimReconciled: (text: String) -> Unit = { _ -> },
val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
@@ -398,6 +458,8 @@ class GatewayTurnCallbacks(
val onToolGenerating: (toolName: String?) -> Unit,
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
/** Successful MoA advisor output for a transient labelled reference block. */
val onMoaReference: (GatewayMoaReference) -> Unit,
/**
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
* the turn until answered via the matching respond RPC or the turn is
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@@ -77,6 +78,10 @@ data class ServerCapabilities(
val portable: Boolean,
/** `/health` — basic reachability. */
val healthy: Boolean,
/** Authenticated provider/model inventory at `/api/model/options`. */
val modelOptions: Boolean = false,
/** Backend-acknowledged per-session model lock. */
val sessionModelLock: Boolean = false,
) {
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
fun preferredChatEndpoint(): String = when {
@@ -100,6 +105,8 @@ data class ServerCapabilities(
runs = false,
portable = false,
healthy = false,
modelOptions = false,
sessionModelLock = false,
)
}
}
@@ -139,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
feature("chat_completions") ||
endpoint("chat_completions"),
healthy = true,
modelOptions = feature("model_options") || endpoint("model_options"),
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
)
}
@@ -190,6 +199,129 @@ data class ApiModelOption(
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
}
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
data class ApiProviderModelOptions(
val providers: List<GatewayModelProvider>,
val currentModel: String,
val currentProvider: String,
)
internal fun parseApiProviderModelOptionsBody(
json: Json,
body: String,
): ApiProviderModelOptions? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val rows = root["providers"] as? JsonArray ?: return null
val providers = rows.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
GatewayModelProvider(
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull() ?: 0,
)
}
return ApiProviderModelOptions(
providers = providers,
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
)
}
enum class ApiModelRoutingErrorCode {
INVENTORY_UNSUPPORTED,
INVENTORY_UNAVAILABLE,
PROVIDER_NOT_AUTHENTICATED,
MODEL_NOT_AVAILABLE,
MODEL_NOT_AVAILABLE_ON_PLAN,
LOCK_CAPABILITY_INCOMPLETE,
LOCK_REJECTED,
LOCK_ACK_MISMATCH,
LEGACY_PROVIDER_UNSUPPORTED,
}
class ApiModelRoutingException(
val code: ApiModelRoutingErrorCode,
message: String,
) : IOException(message)
sealed interface ApiModelSelectionAck {
data object ServerDefault : ApiModelSelectionAck
data class Locked(
val sessionId: String,
val model: String,
val provider: String?,
val effectiveModel: String = model,
val effectiveProvider: String? = provider,
) : ApiModelSelectionAck
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
}
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
when {
capabilities.sessionModelLock && capabilities.modelOptions ->
ApiModelRoutingStrategy.LOCKED
capabilities.sessionModelLock ->
ApiModelRoutingStrategy.INCOMPLETE
else ->
ApiModelRoutingStrategy.LEGACY_HINT
}
internal fun sessionTurnModelHint(
acknowledgement: ApiModelSelectionAck,
requestedModel: String?,
): String? =
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
internal data class ParsedApiModelLockAck(
val sessionId: String?,
val model: String?,
val provider: String?,
val state: String?,
val effectiveModel: String?,
val effectiveProvider: String?,
)
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val runtime = root["runtime"] as? JsonObject ?: return null
val requested = runtime["requested"] as? JsonObject
val effective = runtime["effective"] as? JsonObject
return ParsedApiModelLockAck(
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
)
}
internal fun confirmedRuntimeMatches(
runtime: JsonObject?,
expected: ApiModelSelectionAck.Locked,
): Boolean {
runtime ?: return false
val effective = runtime["effective"] as? JsonObject ?: return false
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
}
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
val data = try {
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
@@ -315,6 +447,8 @@ class HermesApiClient(
isLenient = true
}
) {
@Volatile
private var lastCapabilities: ServerCapabilities? = null
private val baseUrl: String = baseUrl.trimEnd('/')
companion object {
@@ -634,6 +768,205 @@ class HermesApiClient(
/** Compatibility view for callers that only need request ids. */
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
suspend fun getProviderModelOptions(
refresh: Boolean = false,
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
try {
val suffix = if (refresh) "?refresh=true" else ""
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(
ApiModelRoutingException(
if (response.code == 404) {
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
} else {
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
},
if (response.code == 401 || response.code == 403) {
"Model inventory authorization failed (HTTP ${response.code})."
} else {
"Model inventory unavailable (HTTP ${response.code})."
},
),
)
}
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
?: return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory returned an invalid response.",
),
)
Result.success(parsed)
}
} catch (e: Exception) {
Result.failure(
if (e is ApiModelRoutingException) e else {
ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
)
},
)
}
}
/**
* Validate and, on capable servers, persist a model/provider lock before a
* session turn is submitted. This never writes global config.
*/
suspend fun acknowledgeSessionModelSelection(
sessionId: String,
model: String?,
provider: String?,
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
val selectedModel = AgentDisplay.requestModelName(model)
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
// Capability snapshots can be populated by a disconnected startup
// probe. Re-probe at the lock boundary instead of trusting a stale
// false forever after the connection recovers.
val capabilities = probeCapabilities()
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
val inventory = getProviderModelOptions().getOrElse {
return@withContext Result.failure(it)
}
val aliases = getModelOptions()
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
?.takeIf { it.isNotBlank() }
val providerModel = selectedRoot ?: selectedModel
val providerRow = when {
selectedProvider != null ->
inventory.providers.firstOrNull { it.slug == selectedProvider }
else -> inventory.providers.singleOrNull { providerModel in it.models }
?: inventory.providers.firstOrNull {
it.isCurrent && providerModel in it.models
}
} ?: return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
"The selected model is not in the API server's authenticated inventory.",
),
)
if (!providerRow.authenticated) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
"The selected provider is not authenticated on this profile.",
),
)
}
if (providerModel !in providerRow.models) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
"The selected model is not available from ${providerRow.name}.",
),
)
}
if (providerModel in providerRow.unavailableModels) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
"The selected model is not available on the authenticated account.",
),
)
}
val body = kotlinx.serialization.json.buildJsonObject {
put("model", selectedModel)
put("provider", providerRow.slug)
}
try {
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
.build()
client.newCall(request).execute().use { response ->
val responseBody = response.body.string()
if (!response.isSuccessful) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_REJECTED,
streamHttpFailureMessage(
response.code,
response.message,
response.header("Retry-After"),
responseBody,
json,
),
),
)
}
val ack = parseApiModelLockAck(json, responseBody)
if (
ack?.sessionId != sessionId ||
ack?.model != selectedModel ||
ack?.provider != providerRow.slug ||
ack?.state != "accepted" ||
ack?.effectiveModel.isNullOrBlank() ||
ack?.effectiveProvider.isNullOrBlank()
) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
"Server did not acknowledge the requested model lock.",
),
)
}
val confirmedAck = requireNotNull(ack)
Result.success(
ApiModelSelectionAck.Locked(
sessionId = sessionId,
model = selectedModel,
provider = providerRow.slug,
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
effectiveProvider = confirmedAck.effectiveProvider,
),
)
}
} catch (e: Exception) {
Result.failure(
if (e is ApiModelRoutingException) e else {
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_REJECTED,
"Model lock request failed before the message was sent.",
)
},
)
}
} else {
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
"Server advertises an incomplete model-routing contract.",
),
)
}
if (selectedProvider != null) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
"This Hermes version cannot safely preserve a provider selection on API fallback.",
),
)
}
val advertised = getModelOptions().map { it.id }
if (selectedModel !in advertised) {
return@withContext Result.failure(
ApiModelRoutingException(
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
"This Hermes version did not advertise the selected model for API fallback.",
),
)
}
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
}
}
// --- Server personalities ---
/**
@@ -756,6 +1089,7 @@ class HermesApiClient(
onError: (String) -> Unit,
modelOverride: String? = null,
profileName: String? = null,
expectedModelLock: ApiModelSelectionAck.Locked? = null,
): EventSource {
if (!modelOverride.isNullOrBlank()) {
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
@@ -786,6 +1120,7 @@ class HermesApiClient(
}
val completeCalled = AtomicBoolean(false)
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
@@ -806,7 +1141,13 @@ class HermesApiClient(
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
mainHandler.post {
if (runtimeConfirmed.get()) {
onComplete()
} else {
onError("Server ended the turn without confirming the selected model route.")
}
}
}
return
}
@@ -882,9 +1223,17 @@ class HermesApiClient(
}
// assistant.completed — one turn finished, but run may continue with tool calls
"assistant.completed" -> {
val runtimeMatches = expectedModelLock?.let {
confirmedRuntimeMatches(event.runtime, it)
} ?: true
if (runtimeMatches) runtimeConfirmed.set(true)
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
if (!runtimeMatches) {
if (completeCalled.compareAndSet(false, true)) {
onError("Server response did not confirm the selected model route.")
}
} else if (event.interrupted == true) {
if (completeCalled.compareAndSet(false, true)) {
onError("Response interrupted")
}
@@ -896,9 +1245,15 @@ class HermesApiClient(
// run.completed — the entire agent loop is done (all turns + tool calls)
"run.completed" -> {
if (completeCalled.compareAndSet(false, true)) {
val runtimeMatches = expectedModelLock?.let {
confirmedRuntimeMatches(event.runtime, it)
} ?: true
if (runtimeMatches) runtimeConfirmed.set(true)
mainHandler.post {
onUsage(event.usage)
if (event.interrupted == true) {
if (!runtimeMatches) {
onError("Server response did not confirm the selected model route.")
} else if (event.interrupted == true) {
onError("Run interrupted")
} else {
onComplete()
@@ -908,7 +1263,13 @@ class HermesApiClient(
}
"done" -> {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
mainHandler.post {
if (runtimeConfirmed.get()) {
onComplete()
} else {
onError("Server ended the turn without confirming the selected model route.")
}
}
}
}
"error" -> {
@@ -987,7 +1348,13 @@ class HermesApiClient(
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
mainHandler.post {
if (runtimeConfirmed.get()) {
onComplete()
} else {
onError("Server closed the turn without confirming the selected model route.")
}
}
}
}
}
@@ -1530,7 +1897,10 @@ class HermesApiClient(
} catch (_: Exception) {
false
}
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
if (!healthy) {
lastCapabilities = ServerCapabilities.DISCONNECTED
return@withContext ServerCapabilities.DISCONNECTED
}
val advertisedCapabilities = try {
val req = authRequest("$baseUrl/v1/capabilities").get().build()
@@ -1544,7 +1914,10 @@ class HermesApiClient(
} catch (_: Exception) {
null
}
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
if (advertisedCapabilities != null) {
lastCapabilities = advertisedCapabilities
return@withContext advertisedCapabilities
}
// Reusable HEAD probe — returns true if the route is registered
// (any status except 404 + network errors). Already inside the
@@ -1589,7 +1962,7 @@ class HermesApiClient(
runs = runs,
portable = portable,
healthy = true,
)
).also { lastCapabilities = it }
}
// --- Lifecycle ---
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
*
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
* consumes `message` (or `input`) and `system_message` (or
* `instructions`, string only). `message` accepts either a plain string
* or OpenAI-style content parts (text + `image_url`) via
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
* `model`, and `profile` are NOT parsed.
* `instructions`, string only). Newer servers also parse per-request model
* fields and reuse a backend-acknowledged session model lock when those
* fields are omitted. Android therefore acknowledges a lock first and
* omits `model` on that turn; the builder's model field remains only for
* older-server compatibility. Top-level `messages`, `attachments`, and
* `profile` are not parsed.
*
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
* array), `instructions`, `conversation_history` (array of
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
* entries are silently skipped and `tool_calls` fields are stripped.
* Top-level `attachments` and `profile` are NOT parsed.
*
* Legacy hint fields we deliberately keep sending although current native
* upstream ignores them: `model` + `profile` on the sessions path,
* `profile` on runs/completions, and `stream` on runs. They are
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
* configuration hints (never user content, so they cannot mask data
* loss) honored by legacy fork builds — the runs path in particular only
* activates against servers that explicitly advertise SSE-on-POST, which
@@ -0,0 +1,478 @@
package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.buildRawTokenStore
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.Route
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okio.ByteString.Companion.toByteString
private const val NATIVE_PKCE_FLOW = "native_pkce"
private const val CALLBACK_PATH = "/callback"
private const val TOKEN_KEY = "dashboard_native_tokens_json"
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
@Serializable
data class NativeDashboardTokens(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String = "",
@SerialName("expires_at") val expiresAt: Long = 0L,
val provider: String = "",
@SerialName("user_id") val userId: String = "",
)
interface NativeDashboardTokenStore {
/** Stable, non-secret identity used to serialize refresh-token rotation. */
val coordinationKey: String
fun load(): NativeDashboardTokens?
fun save(tokens: NativeDashboardTokens)
fun clear()
}
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
NativeTokenRefreshCoordinator.clear(store)
}
class EncryptedNativeDashboardTokenStore(
context: Context,
tokenStoreKey: String,
private val json: Json = Json { ignoreUnknownKeys = true },
) : NativeDashboardTokenStore {
override val coordinationKey: String = tokenStoreKey
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
buildRawTokenStore(context.applicationContext, tokenStoreKey)
}
override fun load(): NativeDashboardTokens? =
store.getString(TOKEN_KEY)?.let { raw ->
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
}
override fun save(tokens: NativeDashboardTokens) {
store.putString(TOKEN_KEY, json.encodeToString(tokens))
}
override fun clear() {
store.remove(TOKEN_KEY)
}
}
/**
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
* its verifier and CSRF state must never be persisted, logged, or copied into
* Compose/SavedState UI state.
*/
class NativeDashboardAuthorization internal constructor(
val authorizationUrl: String,
internal val verifier: String,
internal val state: String,
internal val generation: Long,
)
class NativeDashboardAuthClient(
baseUrl: String,
private val tokenStore: NativeDashboardTokenStore,
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build(),
private val json: Json = Json { ignoreUnknownKeys = true },
private val random: SecureRandom = SecureRandom(),
) {
private val baseUrl = baseUrl.trim().trimEnd('/')
fun supportsNativePkce(status: DashboardStatus): Boolean =
NATIVE_PKCE_FLOW in status.authFlows
fun beginAuthorization(
redirectUri: String,
provider: String? = null,
): NativeDashboardAuthorization {
requireStrictLoopbackRedirect(redirectUri)
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
// trailing "=", which makes Hermes' standards-compliant S256
// comparison fail even though both sides hashed the same bytes.
val verifier = randomBytes(32).base64Url().trimEnd('=')
val challenge = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
.toByteString()
.base64Url()
.trimEnd('=')
val state = randomBytes(24).base64Url()
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address")
val url = root.newBuilder()
.addQueryParameter("code_challenge", challenge)
.addQueryParameter("code_challenge_method", "S256")
.addQueryParameter("redirect_uri", redirectUri)
.addQueryParameter("state", state)
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
.build()
.toString()
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
tokenStore.coordinationKey,
)
return NativeDashboardAuthorization(url, verifier, state, generation)
}
/**
* A private-route dashboard may be configured with a canonical HTTPS
* callback origin for its provider. Starting the browser on the private
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
* discover the provider's declared callback and start native auth there.
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
* to the active connection route.
*/
private fun resolveAuthorizationBaseUrl(provider: String?): String {
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
if (
!provider.equals("nous", ignoreCase = true) ||
configured.scheme != "http" ||
!isPrivateNetworkLiteral(configured.host)
) {
return baseUrl
}
val loginUrl = configured.newBuilder()
.addPathSegments("auth/login")
.addQueryParameter("provider", provider)
.addQueryParameter("next", "/")
.build()
val discoveryClient = client.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.build()
val location = discoveryClient.newCall(
Request.Builder().url(loginUrl).get().build(),
).execute().use { response ->
if (response.code !in 300..399) null else response.header("Location")
}
return canonicalDashboardBaseFromNousRedirect(location)
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
}
fun exchangeCallback(
authorization: NativeDashboardAuthorization,
callbackTarget: String,
commitAllowed: () -> Boolean = { true },
): NativeDashboardTokens {
val callback = callbackTarget.toHttpUrlOrNull()
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
throw NativeDashboardCallbackException(
"Native sign-in callback did not use the expected loopback path",
)
}
if (callback.queryParameter("state") != authorization.state) {
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
}
callback.queryParameter("error")?.let {
throw NativeDashboardCallbackException(
message = "Gateway rejected native sign-in",
retryable = false,
)
}
val code = callback.queryParameter("code")
?.takeIf(String::isNotBlank)
?: throw NativeDashboardCallbackException(
"Native sign-in callback did not include an authorization code",
)
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
return postTokens(
path = "/auth/native/token",
payload = json.encodeToString(payload),
clearOnAuthFailure = false,
expectedGeneration = authorization.generation,
commitAllowed = commitAllowed,
)
}
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
NativeTokenRefreshCoordinator.cancelAuthorization(
tokenStore.coordinationKey,
authorization.generation,
)
}
fun clearStoredSession() {
clearNativeDashboardTokens(tokenStore)
}
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
val current = tokenStore.load()
?: tokens
?: throw IOException("No native dashboard session is stored")
// A sibling client may already have rotated the single-use refresh
// token while this caller was waiting. Adopt that winner instead
// of replaying the stale token.
if (tokens != null && current != tokens) return@synchronized current
if (current.refreshToken.isBlank()) {
clearIfUnchanged(current)
throw IOException("Native dashboard session cannot be refreshed")
}
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
val generation = NativeTokenRefreshCoordinator.currentGeneration(
tokenStore.coordinationKey,
)
try {
postTokens(
path = "/auth/native/refresh",
payload = json.encodeToString(payload),
clearOnAuthFailure = false,
expectedGeneration = generation,
)
} catch (error: NativeDashboardAuthHttpException) {
if (error.statusCode == 400 || error.statusCode == 401) {
clearIfUnchanged(current)
}
throw error
}
}
}
private fun postTokens(
path: String,
payload: String,
clearOnAuthFailure: Boolean,
expectedGeneration: Long,
commitAllowed: () -> Boolean = { true },
): NativeDashboardTokens {
val url = "$baseUrl$path".toHttpUrlOrNull()
?: throw IOException("Dashboard URL is not a valid http(s) address")
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody(JSON_MEDIA))
.build()
val tokens = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
tokenStore.clear()
}
throw NativeDashboardAuthHttpException(response.code)
}
val body = response.body?.string().orEmpty()
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
.also {
if (it.accessToken.isBlank()) {
throw IOException("Dashboard token response did not include an access token")
}
}
}
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
if (!commitAllowed() ||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
expectedGeneration
) {
throw IOException("Dashboard sign-in is no longer active")
}
tokenStore.save(tokens)
}
return tokens
}
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
if (tokenStore.load() == expected) tokenStore.clear()
}
companion object {
fun requireStrictLoopbackRedirect(redirectUri: String) {
val url = redirectUri.toHttpUrlOrNull()
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
require(url.scheme == "http" && url.host == "127.0.0.1") {
"Native redirect must use the 127.0.0.1 loopback address"
}
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
"Native redirect must use an ephemeral port and the exact /callback path"
}
}
}
}
internal class NativeDashboardCallbackException(
message: String,
val retryable: Boolean = true,
) : IOException(message)
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return url.scheme == "https" ||
(
url.scheme == "http" &&
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
)
}
/**
* Hermes already permits explicitly configured HTTP dashboard sessions on
* local routes. The brokered flow is no less protected than that cookie flow,
* but remains unavailable to arbitrary cleartext Internet hosts.
*/
private fun isPrivateNetworkLiteral(host: String): Boolean {
val octets = host.split('.').mapNotNull(String::toIntOrNull)
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
val first = octets[0]
val second = octets[1]
return first == 10 ||
(first == 172 && second in 16..31) ||
(first == 192 && second == 168) ||
(first == 100 && second in 64..127)
}
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
val providerUrl = location?.toHttpUrlOrNull() ?: return null
if (
providerUrl.scheme != "https" ||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
) {
return null
}
val callback = providerUrl.queryParameter("redirect_uri")
?.toHttpUrlOrNull()
?: return null
if (callback.scheme != "https") return null
val callbackSuffix = "/auth/callback"
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
val basePath = callback.encodedPath
.removeSuffix(callbackSuffix)
.ifBlank { "/" }
return callback.newBuilder()
.encodedPath(basePath)
.query(null)
.fragment(null)
.build()
.toString()
.trimEnd('/')
}
/**
* Adds the native bearer to dashboard REST calls and rotates it before expiry
* or after one 401. Refresh requests use a separate bare client, so neither a
* stale bearer nor the authenticator can recurse into token rotation.
*/
class DashboardBearerAuth(
baseUrl: String,
private val tokenStore: NativeDashboardTokenStore,
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
) : Interceptor, Authenticator {
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
override fun intercept(chain: Interceptor.Chain): Response {
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
val request = tokens?.let {
chain.request().newBuilder()
.header("Authorization", "Bearer ${it.accessToken}")
.build()
} ?: chain.request()
return chain.proceed(request)
}
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
val previous = response.request.header("Authorization") ?: return null
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
val tokens = usableTokens(
forceRefresh = true,
failedAccessToken = failedAccessToken,
) ?: return null
val next = "Bearer ${tokens.accessToken}"
if (next == previous) return null
return response.request.newBuilder().header("Authorization", next).build()
}
private fun usableTokens(
forceRefresh: Boolean,
failedAccessToken: String?,
): NativeDashboardTokens? =
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
val current = tokenStore.load() ?: return@synchronized null
// A request can receive its 401 after another client already
// rotated the token. Retry with the winner; do not rotate again.
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
return@synchronized current
}
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
if (!forceRefresh && !nearExpiry) return@synchronized current
runCatching { authClient.refresh(current) }.getOrNull()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count += 1
prior = prior.priorResponse
}
return count
}
}
private class NativeDashboardAuthHttpException(
val statusCode: Int,
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
private object NativeTokenRefreshCoordinator {
private val locks = ConcurrentHashMap<String, Any>()
private val generations = ConcurrentHashMap<String, Long>()
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
fun currentGeneration(key: String): Long =
synchronized(lockFor(key)) { generations[key] ?: 0L }
fun beginAuthorization(key: String): Long =
synchronized(lockFor(key)) {
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
}
fun cancelAuthorization(key: String, expectedGeneration: Long) {
synchronized(lockFor(key)) {
if ((generations[key] ?: 0L) == expectedGeneration) {
generations[key] = expectedGeneration + 1L
}
}
}
fun clear(store: NativeDashboardTokenStore) {
synchronized(lockFor(store.coordinationKey)) {
generations[store.coordinationKey] =
(generations[store.coordinationKey] ?: 0L) + 1L
store.clear()
}
}
}
@Serializable
private data class NativeTokenExchange(
val code: String,
@SerialName("code_verifier") val codeVerifier: String,
)
@Serializable
private data class NativeTokenRefresh(
@SerialName("refresh_token") val refreshToken: String,
val provider: String,
)
@@ -0,0 +1,266 @@
package com.hermesandroid.relay.network.upstream
import java.io.IOException
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
private const val CALLBACK_PATH = "/callback"
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
private const val MAX_HEADER_BYTES = 16 * 1024
private const val ACCEPT_POLL_MILLIS = 500
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
internal enum class DashboardRedirectAuthMode {
NativePkce,
WebView,
}
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
if ("native_pkce" in authFlows) {
DashboardRedirectAuthMode.NativePkce
} else {
DashboardRedirectAuthMode.WebView
}
/**
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
* gateway's brokered system-browser flow for Nous when it is advertised.
*/
internal fun androidDashboardRedirectAuthMode(
providerName: String,
authFlows: List<String>,
): DashboardRedirectAuthMode =
if (
providerName.equals("nous", ignoreCase = true) &&
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
) {
DashboardRedirectAuthMode.NativePkce
} else {
DashboardRedirectAuthMode.WebView
}
/**
* Owns one native dashboard sign-in attempt.
*
* The listener and PKCE authorization are both local to [signIn], so leaving
* the screen, cancellation, timeout, or callback completion closes the port
* and discards verifier/state. Nothing secret enters Compose or saved state.
*/
class NativeDashboardSignInCoordinator(
private val authClient: NativeDashboardAuthClient,
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
) {
suspend fun signIn(
provider: String?,
launchAuthorization: suspend (String) -> Unit,
): NativeDashboardTokens =
try {
withContext(Dispatchers.IO) {
withTimeout(timeoutMillis) {
serverSocketFactory().use { server ->
server.reuseAddress = false
server.bind(
InetSocketAddress(
InetAddress.getByName("127.0.0.1"),
0,
),
1,
)
server.soTimeout = ACCEPT_POLL_MILLIS
check(server.inetAddress.hostAddress == "127.0.0.1") {
"Native sign-in listener did not bind to IPv4 loopback"
}
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
val authorization = authClient.beginAuthorization(redirectUri, provider)
val attemptContext = currentCoroutineContext()
var completed = false
try {
launchAuthorization(authorization.authorizationUrl)
awaitValidCallback(
server = server,
authorization = authorization,
commitAllowed = { attemptContext.isActive },
).also { completed = true }
} finally {
if (!completed) {
authClient.cancelAuthorization(authorization)
}
}
}
}
}
} catch (_: TimeoutCancellationException) {
throw IOException("Dashboard sign-in timed out")
}
private suspend fun awaitValidCallback(
server: ServerSocket,
authorization: NativeDashboardAuthorization,
commitAllowed: () -> Boolean,
): NativeDashboardTokens {
while (true) {
val callback = acceptCallback(server)
val tokens = callback.use { socket ->
if (socket.inetAddress.hostAddress != "127.0.0.1") {
writeResponse(
socket,
status = "403 Forbidden",
body = "This sign-in callback was not accepted.",
)
return@use null
}
val target = try {
readCallbackTarget(
input = socket.getInputStream(),
expectedPort = server.localPort,
)
} catch (_: IOException) {
writeResponse(
socket,
status = "400 Bad Request",
body = "This sign-in callback was not accepted.",
)
return@use null
}
try {
authClient.exchangeCallback(
authorization,
target,
commitAllowed = commitAllowed,
).also {
writeResponse(
socket,
status = "200 OK",
body = "Sign-in complete. You can return to Hermes Relay.",
)
}
} catch (error: NativeDashboardCallbackException) {
if (error.retryable) {
writeResponse(
socket,
status = "400 Bad Request",
body = "This sign-in callback was not accepted.",
)
return@use null
}
writeResponse(
socket,
status = "400 Bad Request",
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
)
throw error
} catch (error: Exception) {
writeResponse(
socket,
status = "400 Bad Request",
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
)
throw error
}
}
if (tokens != null) return tokens
}
}
private suspend fun acceptCallback(server: ServerSocket): Socket {
while (true) {
currentCoroutineContext().ensureActive()
try {
return server.accept().apply { soTimeout = 5_000 }
} catch (_: SocketTimeoutException) {
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
}
}
}
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
?: throw IOException("Native sign-in callback was empty")
val requestParts = requestLine.split(' ')
if (requestParts.size != 3 || requestParts[0] != "GET" ||
!requestParts[1].startsWith("/") ||
!requestParts[2].startsWith("HTTP/1.")
) {
throw IOException("Native sign-in callback request was malformed")
}
var headerBytes = 0
var host: String? = null
while (true) {
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
?: throw IOException("Native sign-in callback headers were incomplete")
headerBytes += line.length + 2
if (line.isEmpty()) break
if (line.startsWith("Host:", ignoreCase = true)) {
host = line.substringAfter(':').trim()
}
if (headerBytes >= MAX_HEADER_BYTES) {
throw IOException("Native sign-in callback headers were too large")
}
}
if (host != "127.0.0.1:$expectedPort") {
throw IOException("Native sign-in callback host was not accepted")
}
return requestParts[1]
}
private fun readAsciiLine(input: InputStream, limit: Int): String? {
if (limit <= 0) throw IOException("Native sign-in callback was too large")
val bytes = ArrayList<Byte>(minOf(limit, 128))
var previous = -1
while (bytes.size < limit) {
val current = input.read()
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
"Native sign-in callback ended unexpectedly",
)
if (previous == '\r'.code && current == '\n'.code) {
bytes.removeAt(bytes.lastIndex)
return bytes.toByteArray().toString(Charsets.US_ASCII)
}
bytes += current.toByte()
previous = current
}
throw IOException("Native sign-in callback line was too large")
}
private fun writeResponse(socket: Socket, status: String, body: String) {
val html = """
<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body><p>${escapeHtml(body)}</p></body></html>
""".trimIndent().toByteArray(Charsets.UTF_8)
val headers = buildString {
append("HTTP/1.1 ").append(status).append("\r\n")
append("Content-Type: text/html; charset=utf-8\r\n")
append("Content-Length: ").append(html.size).append("\r\n")
append("Cache-Control: no-store\r\n")
append("Connection: close\r\n\r\n")
}.toByteArray(Charsets.US_ASCII)
runCatching {
socket.getOutputStream().apply {
write(headers)
write(html)
flush()
}
}
}
private fun escapeHtml(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
@@ -0,0 +1,93 @@
package com.hermesandroid.relay.network.upstream
/**
* Parsed form of upstream's persisted user-image directives.
*
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
* Unknown or malformed directives stay visible as text. Valid directives are
* removed from the bubble so a host-local path is never exposed in the UI.
*/
internal data class PersistedImageReferences(
val cleanedText: String,
val paths: List<String>,
)
internal object PersistedImageReferenceParser {
private const val MAX_INPUT_CHARS = 256 * 1024
private const val MAX_PATH_CHARS = 2_048
private const val MAX_ATTACHMENTS = 8
private val imageExtensions = setOf(
"avif",
"bmp",
"gif",
"heic",
"heif",
"jpeg",
"jpg",
"png",
"webp",
)
fun parse(content: String): PersistedImageReferences {
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
return PersistedImageReferences(content, emptyList())
}
val keptLines = ArrayList<String>()
val paths = ArrayList<String>()
for (line in content.lines()) {
val path = parseDirectiveLine(line)
if (path == null) {
keptLines += line
} else if (paths.size < MAX_ATTACHMENTS) {
paths += path
}
// Recognized refs beyond the attachment cap are still removed:
// exposing a server-local path is worse than omitting an excessive
// attachment from a deliberately bounded gallery.
}
return PersistedImageReferences(
cleanedText = keptLines.joinToString("\n").trim(),
paths = paths,
)
}
private fun parseDirectiveLine(line: String): String? {
if (!line.startsWith("@image:")) return null
val rawValue = line.removePrefix("@image:")
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
val path = unwrapCanonicalValue(rawValue) ?: return null
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
return path
}
private fun unwrapCanonicalValue(value: String): String? {
val first = value.first()
if (first !in charArrayOf('`', '"', '\'')) {
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
}
if (value.length < 3 || value.last() != first) return null
val inner = value.substring(1, value.lastIndex)
return inner.takeIf { first !in it }
}
private fun isAbsolutePath(path: String): Boolean =
path.startsWith("/") ||
(
path.length >= 3 &&
path[0].isLetter() &&
path[1] == ':' &&
(path[2] == '\\' || path[2] == '/')
)
private fun hasImageExtension(path: String): Boolean {
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
return extension in imageExtensions
}
}
@@ -41,15 +41,15 @@ import java.util.concurrent.TimeUnit
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
* on the API server (:8642) — current upstream api_server advertises
* `audio_api: false` and registers no audio routes. Auth is the dashboard
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
* same per-connection cookie jar the Manage tab signs in with; an API bearer
* header is meaningless on this surface. Revisit when upstream PR #8199
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
* must carry the same exact-origin cookie or native bearer session used by
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
* (No glob spellings in block comments — Kotlin block comments nest.)
*/
class StandardHermesVoiceClient(
private val context: Context,
private val okHttpClient: OkHttpClient,
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
private val dashboardUrlProvider: () -> String?,
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
@@ -66,12 +66,10 @@ class StandardHermesVoiceClient(
) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
private val callClient: OkHttpClient =
standardHermesDashboardAudioClient(okHttpClient)
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
val callClient = callClient(baseUrl)
if (!audioFile.exists() || audioFile.length() == 0L) {
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
}
@@ -102,7 +100,7 @@ class StandardHermesVoiceClient(
.header("Accept", "application/json")
.build()
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
val transcript = root.stringField("transcript")
?: root.stringField("text")
?: root.stringField("message")
@@ -116,6 +114,7 @@ class StandardHermesVoiceClient(
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
val callClient = callClient(baseUrl)
val cleanText = text.trim()
if (cleanText.isBlank()) {
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
@@ -138,7 +137,7 @@ class StandardHermesVoiceClient(
.header("Accept", "application/json")
.build()
executeJson(request, "Hermes audio speak").mapCatching { root ->
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
if (dataUrl.isNullOrBlank()) {
throw IOException("Hermes audio speak returned no audio")
@@ -162,13 +161,14 @@ class StandardHermesVoiceClient(
try {
val baseUrl = dashboardBaseUrl()
?: throw IllegalStateException("Hermes dashboard URL not configured")
val callClient = callClient(baseUrl)
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")
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
.getOrThrow()
.stringField("ticket")
?: throw IOException("Dashboard websocket ticket response missing ticket")
@@ -195,7 +195,14 @@ class StandardHermesVoiceClient(
private fun dashboardBaseUrl(): String? =
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
private fun callClient(baseUrl: String): OkHttpClient =
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
private fun executeJson(
request: Request,
operation: String,
callClient: OkHttpClient,
): Result<JsonObject> {
return try {
callClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
@@ -163,7 +163,8 @@ data class SessionItem(
@SerialName("message_count") val messageCount: Int? = null,
@SerialName("tool_call_count") val toolCallCount: Int? = null,
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
) {
val resolvedLastActivity: Double?
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
@@ -391,7 +392,9 @@ data class HermesSseEvent(
@SerialName("thinking_delta") val thinkingDelta: String? = null,
val text: String? = null, // /v1/runs reasoning.available text
// Usage/token fields (on assistant.completed / run.completed)
val usage: UsageInfo? = null
val usage: UsageInfo? = null,
// Native session routing proof on run.started and terminal events.
val runtime: JsonObject? = null,
) {
/** Resolve the event type from whichever field is populated. */
val resolvedType: String?
@@ -33,6 +33,7 @@ object InteractionRequestNotifier {
private const val TAG = "InteractionNotifier"
internal const val CHANNEL_ID = "chat_interactions"
private const val CHANNEL_NAME = "Hermes needs input"
private const val GROUP_KEY = "gateway-interactions"
internal const val NOTIFICATION_ID = 3823
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
@@ -72,6 +73,31 @@ object InteractionRequestNotifier {
-> "Open Hermes to respond securely."
}
internal fun safeExpandedBody(
sessionId: String,
ask: GatewayAsk,
profile: String? = null,
): String {
val action = when (ask.kind) {
GatewayAsk.Kind.APPROVAL ->
"Review the requested action. Nothing is approved from the notification."
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
}
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
val sessionLabel = sessionId.takeLast(12)
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
}
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> "Review approval"
GatewayAsk.Kind.CLARIFY -> "Answer"
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
-> "Respond securely"
}
@SuppressLint("MissingPermission", "NotificationPermission")
fun notify(
context: Context,
@@ -106,6 +132,7 @@ object InteractionRequestNotifier {
)
val title = safeTitle(ask)
val body = safeBody(ask)
val expandedBody = safeExpandedBody(sessionId, ask, profile)
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes needs your input")
@@ -117,6 +144,7 @@ object InteractionRequestNotifier {
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
.setContentIntent(tapPending)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
@@ -124,6 +152,8 @@ object InteractionRequestNotifier {
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.setGroup(GROUP_KEY)
.addAction(0, actionLabel(ask), tapPending)
.build()
return runCatching {
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -108,6 +109,7 @@ 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.VoicePresentationMode
import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel
@@ -154,7 +156,6 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
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
@@ -183,8 +184,8 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
showSnackbar(
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
return showSnackbar(
message = err.body,
actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
@@ -599,15 +600,9 @@ fun RelayApp() {
val standardVoiceClient = remember {
StandardHermesVoiceClient(
context = mediaContext,
okHttpClient = okhttp3.OkHttpClient.Builder()
.cookieJar(
DynamicDashboardCookieJar {
connectionViewModel.activeDashboardCookieStore()
},
)
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.build(),
dashboardHttpClientProvider = { dashboardUrl ->
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
},
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
// Live read (null for the default profile) — sent defensively on
// /api/audio/speak; upstream ignores it, so standard voice stays the
@@ -1476,10 +1471,10 @@ fun RelayApp() {
// The VM's cached per-connection store — the prewarm must NOT
// construct its own (each instance lazily pays a multi-second
// Keystore keyset build under a process-global Tink lock).
val cookieStore = connectionViewModel.activeDashboardCookieStore()
?: return@LaunchedEffect
prewarmDashboardManage(
cookieStore = cookieStore,
clientFactory = {
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
},
connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl,
effectiveProfileName = effectiveManageProfile,
@@ -1926,6 +1921,14 @@ fun RelayApp() {
voiceViewModel = voiceViewModel,
voiceClient = voiceClient,
maxBubbleWidth = maxBubbleWidth,
voicePresentationMode = VoicePresentationMode.fromStorage(
voiceSettings.presentationMode,
),
onVoicePresentationModeChange = { mode ->
connectionSwitchScope.launch {
voicePreferences.setPresentationMode(mode)
}
},
openAgentSheetOnEntry = openAgentSheetArg,
onAgentSheetArgConsumed = {
backStackEntry.arguments?.putBoolean(
@@ -1943,6 +1946,16 @@ fun RelayApp() {
launchSingleTop = true
}
},
onRepairConnection = {
navController.navigate(
Screen.Pair.route(
connectionId = activeConnectionId,
autoStart = "relay",
),
) {
launchSingleTop = true
}
},
// Empty-chat "needs connection" card also offers the offline
// demo, so a skipped / never-connected first run can explore
// without leaving Chat. Safe here — this state only shows when
@@ -2301,8 +2314,8 @@ fun RelayApp() {
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
relayVoiceReady = relayVoiceReady,
dashboardUrl = voiceDashboardUrl,
dashboardCookieStoreProvider = {
connectionViewModel.activeDashboardCookieStore()
dashboardClientProvider = { dashboardUrl ->
connectionViewModel.dashboardClientForActive(dashboardUrl)
},
onOpenManage = {
navController.navigate(Screen.Manage.route) {
@@ -2640,6 +2653,17 @@ fun RelayApp() {
onNavigateToRealtimeVoice = {
navController.navigate(Screen.RealtimeVoiceTest.route)
},
onNavigateToImageGenerationLab = {
terminalAppContext.startActivity(
android.content.Intent().apply {
setClassName(
terminalAppContext,
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
},
)
}
composable(Screen.RealtimeVoiceTest.route) {
@@ -268,7 +268,6 @@ fun ActiveCardRelayStatusSection(
@Composable
fun ActiveCardFeaturesSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
onOpenApiInfo: () -> Unit,
onOpenDashboard: () -> Unit,
onOpenRelayInfo: () -> Unit,
@@ -349,21 +348,19 @@ fun ActiveCardFeaturesSection(
}
val relayValue = when {
!relayEnabled -> stringResource(R.string.active_section_disabled)
!relayConfigured -> stringResource(R.string.active_section_optional)
relayReady -> stringResource(R.string.active_section_ready)
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
else -> stringResource(R.string.active_section_configured)
}
val relayTone = when {
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
!relayConfigured -> CapabilityTone.Neutral
relayReady -> CapabilityTone.Good
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
else -> CapabilityTone.Info
}
val terminalValue = when {
!relayEnabled -> stringResource(R.string.active_section_disabled)
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
relayConfigured -> stringResource(R.string.active_section_pair_relay)
else -> stringResource(R.string.active_section_optional)
@@ -614,7 +611,6 @@ private fun CapabilityRow(
@Composable
fun ActiveCardAdvancedSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
onPairRelay: () -> Unit,
onInsecureAckRequested: () -> Unit,
@@ -654,7 +650,7 @@ fun ActiveCardAdvancedSection(
Text(stringResource(R.string.active_section_done))
}
}
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
}
}
} else {
@@ -731,7 +727,7 @@ fun ActiveCardAdvancedSection(
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
Text(stringResource(R.string.active_section_other_relay_methods))
}
if (relayEnabled && relayEditorOpen) {
if (relayEditorOpen) {
Surface(
color = Color.Transparent,
shape = RoundedCornerShape(12.dp),
@@ -746,7 +742,7 @@ fun ActiveCardAdvancedSection(
Text(stringResource(R.string.active_section_done))
}
}
ManualUrlSubsection(connectionViewModel, relayEnabled = true, showApi = false, showRelay = true)
ManualUrlSubsection(connectionViewModel, showApi = false, showRelay = true)
}
}
}
@@ -798,7 +794,6 @@ fun ActiveCardAdvancedSection(
@Composable
private fun ManualUrlSubsection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
showApi: Boolean,
showRelay: Boolean,
) {
@@ -960,7 +955,7 @@ private fun ManualUrlSubsection(
}
}
if (relayEnabled && showRelay) {
if (showRelay) {
HorizontalDivider()
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
@@ -261,8 +262,12 @@ fun AgentTextFlow(
motionEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
lineHeight = 21.sp,
)
val flowColor = MaterialTheme.colorScheme.onSurface
// Readable, non-faded mirror of the visible tail — used as the live-region
// text on both paths so assistive tech hears the words.
@@ -0,0 +1,172 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
/**
* Stable, presentation-only summary for a message's attachment group.
*
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
* by the existing attachment pipeline; this helper only chooses the compact
* label shown while that pipeline is folded away.
*/
internal data class AttachmentGroupSummary(
val count: Int,
val firstName: String?,
val firstType: AttachmentRenderMode,
val remainingCount: Int,
)
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
val first = attachments.firstOrNull() ?: return null
return AttachmentGroupSummary(
count = attachments.size,
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
firstType = first.renderMode,
remainingCount = (attachments.size - 1).coerceAtLeast(0),
)
}
/**
* Slack-style disclosure for all attachments belonging to one message.
*
* The fold state is saveable and keyed by the message's stable Compose
* identity, so attachment lifecycle updates do not unexpectedly reopen a
* group the user collapsed. The compact header always remains available,
* making preview, retry, download, and file actions recoverable with one tap.
*/
@Composable
internal fun CollapsibleAttachmentGroup(
messageKey: String,
attachments: List<Attachment>,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val summary = attachmentGroupSummary(attachments) ?: return
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
val stateLabel = stringResource(
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
)
val actionLabel = stringResource(
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
)
val typeLabel = stringResource(summary.firstType.labelResource())
val detail = when {
summary.firstName != null && summary.remainingCount > 0 ->
stringResource(
R.string.attachment_group_named_more,
summary.firstName,
typeLabel,
summary.remainingCount,
)
summary.firstName != null ->
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
summary.remainingCount > 0 ->
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
else -> typeLabel
}
Column(
modifier = modifier
.fillMaxWidth()
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Surface(
onClick = { expanded = !expanded },
modifier = Modifier
.fillMaxWidth()
.testTag("attachment-group-toggle-$messageKey")
.semantics(mergeDescendants = true) {
contentDescription = actionLabel
role = Role.Button
stateDescription = stateLabel
},
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = pluralStringResource(
R.plurals.attachment_group_count,
summary.count,
summary.count,
),
style = MaterialTheme.typography.labelLarge,
)
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
}
AnimatedVisibility(visible = expanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.testTag("attachment-group-content-$messageKey"),
) {
content()
}
}
}
}
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
}
File diff suppressed because it is too large Load Diff
@@ -100,7 +100,6 @@ import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionValidation
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
@@ -197,8 +196,6 @@ fun ConnectionWizard(
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
@@ -621,7 +618,6 @@ fun ConnectionWizard(
WizardStep.RelayChoice -> RelayChoiceStep(
connectionLabel = activeConnection?.label.orEmpty(),
dashboardUrl = currentDashboardUrl,
relayEnabled = relayEnabled,
onPickScan = {
chosenMethod = PairMethod.Scan
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
@@ -638,7 +634,6 @@ fun ConnectionWizard(
)
WizardStep.Method -> MethodStep(
relayEnabled = relayEnabled,
onPickStandard = {
chosenMethod = PairMethod.Standard
standardError = null
@@ -1701,7 +1696,6 @@ private fun FoundCapabilityLine(label: String, value: String, ready: Boolean) {
private fun RelayChoiceStep(
connectionLabel: String,
dashboardUrl: String,
relayEnabled: Boolean,
onPickScan: () -> Unit,
onPickEnterCode: () -> Unit,
onPickShowCode: () -> Unit,
@@ -1769,14 +1763,12 @@ private fun RelayChoiceStep(
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
onClick = onPickEnterCode,
)
if (relayEnabled) {
MethodTile(
icon = Icons.Filled.PhonelinkLock,
title = stringResource(R.string.cw_method_show_code_title),
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
onClick = onPickShowCode,
)
}
MethodTile(
icon = Icons.Filled.PhonelinkLock,
title = stringResource(R.string.cw_method_show_code_title),
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
onClick = onPickShowCode,
)
TextButton(
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
modifier = Modifier.fillMaxWidth(),
@@ -1794,7 +1786,6 @@ private fun RelayChoiceStep(
@Composable
private fun MethodStep(
relayEnabled: Boolean,
onPickStandard: () -> Unit,
onPickScan: () -> Unit,
onPickEnterCode: () -> Unit,
@@ -1931,14 +1922,12 @@ private fun MethodStep(
onClick = onPickEnterCode,
)
if (relayEnabled) {
MethodTile(
icon = Icons.Filled.PhonelinkLock,
title = stringResource(R.string.cw_method_show_code_title),
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
onClick = onPickShowCode,
)
}
MethodTile(
icon = Icons.Filled.PhonelinkLock,
title = stringResource(R.string.cw_method_show_code_title),
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
onClick = onPickShowCode,
)
if (onSkip != null) {
TextButton(
@@ -63,6 +63,8 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
val context = LocalContext.current
val plainText = remember(entry) { entry.toPlainText() }
val severityName = entry.severity.name
val copiedToast = stringResource(R.string.diag_copied)
val exportChooserTitle = stringResource(R.string.diag_export)
// Info-severity pre-flight: routine log lines only become GitHub issues once
// the reporter says what they expected instead (that answer replaces the
@@ -165,7 +167,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Diagnostic copied")
toast(context, copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
@@ -174,7 +176,7 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = "Export diagnostic",
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
@@ -246,7 +248,7 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
}
Surface(shape = RoundedCornerShape(50), color = bg) {
Text(
text = severity.name.uppercase(),
text = stringResource(severityLabelRes(severity)).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = fg,
@@ -99,7 +99,7 @@ fun DiagnosticsLogPanel(
FilterChip(
selected = severityFilter == sev,
onClick = { severityFilter = if (severityFilter == sev) null else sev },
label = { Text(sev.name) },
label = { Text(stringResource(severityLabelRes(sev))) },
)
}
}
@@ -224,3 +224,10 @@ private fun DiagnosticLogEntry.detailLine(): String? {
)
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
}
/** String resource for a [DiagnosticSeverity] label (shared with the detail dialog). */
internal fun severityLabelRes(severity: DiagnosticSeverity): Int = when (severity) {
DiagnosticSeverity.Info -> R.string.diag_severity_info
DiagnosticSeverity.Warning -> R.string.diag_severity_warning
DiagnosticSeverity.Error -> R.string.diag_severity_error
}
@@ -78,9 +78,9 @@ fun GatewayBackgroundProcessStrip(
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
val displayedCount = if (running > 0) running else processes.size
val status = when {
running > 0 -> "$running running"
failed > 0 -> "$failed failed"
else -> "Complete"
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
failed > 0 -> "$failed ${stringResource(R.string.task_status_failed)}"
else -> stringResource(R.string.task_status_complete)
}
Surface(
@@ -94,7 +94,7 @@ fun GatewayBackgroundProcessStrip(
stateDescription = status
}
.clickable(
onClickLabel = "Open background processes",
onClickLabel = stringResource(R.string.bg_processes_open),
onClick = onClick,
),
shape = RoundedCornerShape(14.dp),
@@ -208,7 +208,7 @@ fun GatewayBackgroundProcessSheet(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"No background processes in this chat",
stringResource(R.string.bg_processes_empty),
modifier = Modifier.padding(top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -220,7 +220,7 @@ fun GatewayBackgroundProcessSheet(
.heightIn(max = 560.dp),
) {
if (running.isNotEmpty()) {
item { ProcessSectionLabel("Running", running.size) }
item { ProcessSectionLabel(stringResource(R.string.bg_processes_running), running.size) }
items(running, key = { it.id }) { process ->
GatewayProcessRow(
process = process,
@@ -234,7 +234,7 @@ fun GatewayBackgroundProcessSheet(
item { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) }
}
if (recent.isNotEmpty()) {
item { ProcessSectionLabel("Recent", recent.size) }
item { ProcessSectionLabel(stringResource(R.string.bg_processes_recent), recent.size) }
items(recent, key = { it.id }) { process ->
GatewayProcessRow(
process = process,
@@ -278,7 +278,7 @@ private fun GatewayProcessRow(
?.trim()
.orEmpty()
.ifBlank {
"Background process"
stringResource(R.string.bg_processes_title)
}
Column(
@@ -287,7 +287,7 @@ private fun GatewayProcessRow(
.animateContentSize()
.clickable(
enabled = output.isNotBlank(),
onClickLabel = if (expanded) "Collapse process output" else "Expand process output",
onClickLabel = if (expanded) stringResource(R.string.bg_collapse_output) else stringResource(R.string.bg_expand_output),
) { expanded = !expanded }
.padding(horizontal = 20.dp, vertical = 10.dp),
) {
@@ -332,7 +332,7 @@ private fun GatewayProcessRow(
if (output.isNotBlank()) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse output" else "Expand output",
contentDescription = if (expanded) stringResource(R.string.tool_progress_cd_collapse) else stringResource(R.string.tool_progress_cd_expand),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -396,11 +396,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
}
}
@Composable
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
val state = when {
process.isRunning -> "Running"
failed -> "Failed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
else -> "Completed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
process.isRunning -> stringResource(R.string.bg_processes_running)
failed -> stringResource(R.string.task_status_failed) +
process.exitCode?.let { " · exit $it" }.orEmpty()
else -> stringResource(R.string.task_status_complete) +
process.exitCode?.let { " · exit $it" }.orEmpty()
}
return "$state · ${formatElapsed(process.uptimeSeconds)}" +
if (process.detached) " · recovered" else ""
@@ -502,7 +502,7 @@ private fun CardInputSlot(
input.holdToConfirm -> {
Spacer(Modifier.height(10.dp))
HoldToConfirmButton(
label = "Hold to confirm",
label = stringResource(R.string.hermes_card_hold_confirm),
enabled = !input.masked || answerText.isNotEmpty(),
onConfirmed = {
onSubmit(
@@ -511,6 +511,13 @@ private fun CardInputSlot(
)
},
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.hermes_card_hold_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
)
}
input.masked -> {
Spacer(Modifier.height(10.dp))
@@ -155,7 +155,7 @@ private fun LoadingCard(
val sizeHint = attachment.fileSize?.takeIf { it > 0 }
?.let { " · ${formatBytes(it)}" } ?: ""
Text(
text = if (isManualCta) stringResource(R.string.inbound_attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
text = if (isManualCta) stringResource(R.string.attach_tap_download) else stringResource(R.string.inbound_attach_downloading, sizeHint),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -0,0 +1,117 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
/**
* Returns the string resource for a tool name (toolCall.name), or null when the
* name does not map to a known tool. Pure mapping so it can be unit-tested.
*/
fun localizeToolNameKey(name: String): Int? = when {
name.contains("terminal") -> R.string.tool_name_terminal
name.contains("execute") -> R.string.tool_name_execute_code
name.contains("read_file") -> R.string.tool_name_read_file
name.contains("write_file") || name.contains("patch") -> R.string.tool_name_write_file
name.contains("session_search") -> R.string.tool_name_session
name.contains("android") || name.contains("phone") -> R.string.tool_name_android
name.contains("web_search") || name.contains("search") -> R.string.tool_name_web_search
name.contains("web_extract") -> R.string.tool_name_web_extract
name.contains("memory") || name.contains("mnemosyne") -> R.string.tool_name_memory
name.contains("skill") -> R.string.tool_name_skill
name.contains("delegate") -> R.string.tool_name_delegate
name.contains("cron") -> R.string.tool_name_cron
name.contains("todo") -> R.string.tool_name_todo
name.contains("process") -> R.string.tool_name_process
name.contains("vision") || name.contains("image") -> R.string.tool_name_vision
name.contains("computer_use") -> R.string.tool_name_computer
name.contains("speech") || name.contains("tts") -> R.string.tool_name_tts
name.contains("file") -> R.string.tool_name_file
else -> null
}
/**
* Returns the string resource for a badge label (message.badges entries), or
* null when the badge does not map to a known resource. Pure mapping so it can
* be unit-tested.
*/
fun localizeBadgeKey(badge: String): Int? = when (badge) {
"Tool failed" -> R.string.badge_tool_failed
"Memory" -> R.string.badge_memory
"Skill" -> R.string.badge_skill
"Artifact" -> R.string.badge_artifact
"Response interrupted" -> R.string.badge_response_interrupted
"Unknown error" -> R.string.badge_unknown_error
"Error" -> R.string.badge_error
"Stopped" -> R.string.badge_stopped
"Model changed" -> R.string.badge_model_changed
"Background work completed" -> R.string.badge_bg_work_completed
"Continued after an interrupted turn" -> R.string.badge_continued
"Realtime Agent" -> R.string.bubble_realtime_agent
"Voice" -> R.string.bubble_voice
else -> null
}
/**
* Returns the string resource for an agent name (message.agentName), or null
* when the name does not map to a known resource. Pure mapping so it can be
* unit-tested.
*/
fun localizeAgentNameKey(name: String): Int? = when (name) {
"Send SMS" -> R.string.agent_send_sms
"Call" -> R.string.agent_call
"Search Contacts" -> R.string.agent_search_contacts
"Open App" -> R.string.agent_open_app
"Return to Hermes" -> R.string.agent_return_hermes
"Screenshot" -> R.string.agent_screenshot
"Key Press" -> R.string.agent_key_press
"Bridge Setup" -> R.string.agent_bridge_setup
else -> null
}
/** Localizes a tool name (toolCall.name) for display. Unknown names are returned as-is. */
@Composable
fun localizeToolName(name: String): String = localizeToolNameKey(name)?.let {
stringResource(it)
} ?: name
/** Localizes a badge (message.badges entries) for display. Unknown badges are returned as-is. */
@Composable
fun localizeBadge(badge: String): String = localizeBadgeKey(badge)?.let {
stringResource(it)
} ?: badge
/** Localizes an agent name (message.agentName) for display. Unknown names are returned as-is. */
@Composable
fun localizeAgentName(name: String): String = localizeAgentNameKey(name)?.let {
stringResource(it)
} ?: name
/**
* Splits a timeline event title of the form "toolName · status" and returns the
* resource key for the tool-name segment when it is known. Returns null when
* the title has no separator or the name is not mapped, so the render site
* falls back to the original title.
*/
fun localizeTimelineTitleName(title: String): Pair<String, Int?>? {
val sep = " · "
val idx = title.indexOf(sep)
if (idx <= 0) return null
val name = title.substring(0, idx)
return name to localizeToolNameKey(name)
}
/**
* Localizes a timeline event title of the form "toolName · status" at the
* render site (TimelineRow). The data layer (buildTimelineEvents) keeps the
* English keys.
*/
@Composable
fun localizeTimelineTitle(title: String): String {
val parts = localizeTimelineTitleName(title) ?: return title
val (name, key) = parts
if (key == null) return title
val sep = " · "
val idx = title.indexOf(sep)
return stringResource(key) + title.substring(idx)
}
@@ -67,6 +67,11 @@ fun MarkdownContent(
modifier: Modifier = Modifier
) {
val isDarkTheme = LocalBrand.current.isDark
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
color = textColor,
)
val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
}
@@ -87,7 +92,8 @@ fun MarkdownContent(
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
// live font-picker still applies) and is capped so the largest heading is
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings.
// proportionate to the 15sp body, matching Discord / GitHub-mobile
// in-message headings.
typography = markdownTypography(
h1 = MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
@@ -108,16 +114,17 @@ fun MarkdownContent(
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
color = textColor.copy(alpha = 0.85f),
),
// Prose, list items, and quotes all sit at the 14sp body size so a
// paragraph and the bullet list under it share one rhythm — the library
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
quote = MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
// The library default 'text'/list role is bodyLarge (16sp), while
// bodyMedium was previously 14sp and unnecessarily small for long chat.
paragraph = chatBodyStyle,
text = chatBodyStyle,
bullet = chatBodyStyle,
ordered = chatBodyStyle,
list = chatBodyStyle,
quote = chatBodyStyle.copy(
fontStyle = FontStyle.Italic,
color = textColor.copy(alpha = 0.9f),
),
// Inline + fenced code at 13sp (one step under body, not two): monospace
// + the tinted chip already signal "code" without also shrinking it, and
@@ -125,7 +132,7 @@ fun MarkdownContent(
code = MaterialTheme.typography.bodySmall.copy(
fontSize = 13.sp, letterSpacing = 0.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = textColor,
),
inlineCode = MaterialTheme.typography.bodyMedium.copy(
fontSize = 13.sp, letterSpacing = 0.sp,
@@ -302,7 +309,10 @@ fun StreamingMarkdownContent(
// code and deliberately spaced prose are not altered.
text = content.withoutLeadingBlankLines(),
modifier = modifier,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
),
color = textColor,
)
} else {
@@ -1,6 +1,5 @@
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
@@ -139,6 +138,8 @@ fun MessageBubble(
* happening.
*/
recoveringAnswer: Boolean = false,
imageGenerationStylePreference: String = "rotate",
imageGenerationRotationIndex: Int = 0,
) {
val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM
@@ -173,7 +174,7 @@ fun MessageBubble(
val textColor = when (message.role) {
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
}
@@ -211,6 +212,23 @@ fun MessageBubble(
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
val hasImageGenerationCall = remember(message.toolCalls) {
message.toolCalls.any {
it.name.trim().lowercase() == "image_generate"
}
}
val imageGenerationStartMillis = remember(message.toolCalls) {
imageGenerationStartedAt(message.toolCalls)
}
val imageGenerationVisualStyle = remember(
imageGenerationStylePreference,
imageGenerationRotationIndex,
) {
resolveImageGenerationVisualStyle(
preference = imageGenerationStylePreference,
rotationIndex = imageGenerationRotationIndex,
)
}
// Provide the sensitive-media blur mode to the attachment / inline-image
// renderers below, sourced as locally as possible (here, not threaded
@@ -253,7 +271,7 @@ fun MessageBubble(
)
}
Text(
text = message.agentName,
text = localizeAgentName(message.agentName),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -270,7 +288,7 @@ fun MessageBubble(
) {
message.badges.take(4).forEach { badge ->
MessagePathBadge(
text = badge,
text = localizeBadge(badge),
// Speaker glyph = the shared "spoken" modality marker.
// Both the standard voice-mode chip ("Voice") and the
// realtime engine chip ("Realtime Agent") are spoken
@@ -297,6 +315,30 @@ fun MessageBubble(
)
}
if (!isUser && !isSystem && showThinking) {
message.moaReferences.forEach { reference ->
ThinkingBlock(
thinkingContent = if (reference.available) {
reference.text
} else {
stringResource(R.string.bubble_advisor_unavailable)
},
isStreaming = false,
headerText = buildString {
append(stringResource(R.string.bubble_advisor_prefix))
append(reference.index)
reference.count?.let { append("/").append(it) }
append(" · ")
append(reference.label)
},
accessibilityLabel = stringResource(R.string.bubble_moa_advisor),
modifier = Modifier
.widthIn(max = maxBubbleWidth)
.padding(bottom = 4.dp),
)
}
}
// Message bubble.
//
// Action bubbles (voice/phone origin) wrap the existing Surface in
@@ -427,7 +469,10 @@ fun MessageBubble(
// Plain text for user and system messages
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 15.sp,
lineHeight = 21.sp,
),
color = textColor
)
} else {
@@ -485,44 +530,78 @@ fun MessageBubble(
}
}
// 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),
)
// Image generation owns the bubble's progress slot. Keep the
// selected progress treatment mounted under the real result,
// then reveal the same collapsible attachment surface without
// rebuilding the surrounding message bubble.
if (hasImageGenerationCall) {
Spacer(modifier = Modifier.height(4.dp))
ImageGenerationResultTransition(
generating = showImageGeneration,
startedAtMillis = imageGenerationStartMillis,
visualStyle = imageGenerationVisualStyle,
) {
if (message.attachments.isNotEmpty()) {
CollapsibleAttachmentGroup(
messageKey = message.uiKey,
attachments = message.attachments,
) {
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),
)
}
}
}
}
}
}
} 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))
CollapsibleAttachmentGroup(
messageKey = message.uiKey,
attachments = message.attachments,
) {
// Two or more loaded images collapse into one grid +
// swipe-across gallery. Every other item stays on the
// unified attachment path, retaining original indices.
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),
)
}
}
}
}
}
// Streaming indicator — only while awaiting the first token. Once
@@ -83,8 +83,9 @@ fun ModelPickerSheet(
}
// groupBy preserves key insertion order, so providers stay current-first
// (the caller sorts is_current to the front).
val grouped = remember(filtered) {
filtered.groupBy { it.group?.takeIf { g -> g.isNotBlank() } ?: "Other" }
val otherGroupLabel = stringResource(R.string.model_picker_other)
val grouped = remember(filtered, otherGroupLabel) {
filtered.groupBy { it.group?.takeIf { g -> g.isNotBlank() } ?: otherGroupLabel }
}
ModalBottomSheet(
@@ -106,7 +107,7 @@ fun ModelPickerSheet(
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = "Model", style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(R.string.model_picker_model), style = MaterialTheme.typography.titleMedium)
Text(
text = "${modelOptions.size} models",
style = MaterialTheme.typography.labelMedium,
@@ -131,7 +132,7 @@ fun ModelPickerSheet(
)
}
Spacer(modifier = Modifier.size(6.dp))
Text(if (refreshing) "Refreshing" else "Refresh")
Text(if (refreshing) stringResource(R.string.model_picker_refreshing) else stringResource(R.string.model_picker_refresh))
}
}
}
@@ -202,8 +202,8 @@ fun PowerFeatureGateCard(
private fun PowerFeatureGatePreview() {
HermesRelayTheme {
PowerFeatureGateCard(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
title = stringResource(R.string.power_terminal),
summary = stringResource(R.string.power_terminal_desc),
status = PowerFeatureGateStatus.RequiresPairing,
onPrimaryAction = {},
)
@@ -215,8 +215,8 @@ private fun PowerFeatureGatePreview() {
private fun PowerFeatureGateExpiredPreview() {
HermesRelayTheme {
PowerFeatureGateCard(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
title = stringResource(R.string.power_bridge),
summary = stringResource(R.string.power_bridge_desc),
status = PowerFeatureGateStatus.PairingExpired,
onPrimaryAction = {},
)
@@ -28,6 +28,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -39,6 +41,8 @@ fun ThinkingBlock(
modifier: Modifier = Modifier,
/** Message timestamp shown right-aligned in the header (null hides it). */
timestamp: Long? = null,
headerText: String? = null,
accessibilityLabel: String = stringResource(R.string.thinking_thinking_short),
) {
var expanded by remember { mutableStateOf(isStreaming) }
val locale = LocalLocale.current.platformLocale
@@ -65,13 +69,13 @@ fun ThinkingBlock(
) {
Icon(
imageVector = Icons.Filled.Psychology,
contentDescription = "Thinking",
contentDescription = accessibilityLabel,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isStreaming) "Thinking..." else "Thought process",
text = headerText ?: if (isStreaming) stringResource(R.string.thinking_thinking) else stringResource(R.string.thinking_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary
)
@@ -86,7 +90,7 @@ fun ThinkingBlock(
}
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) stringResource(R.string.tool_progress_cd_collapse) else stringResource(R.string.tool_progress_cd_expand),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -38,6 +38,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -102,7 +103,7 @@ fun TimelineView(
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "${events.size} events",
text = pluralStringResource(R.plurals.timeline_events_count, events.size, events.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -161,11 +162,11 @@ private fun TimelineLegend() {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
LegendEntry("Chat", TimelineEventKind.ChatMessage.color())
LegendEntry("Tool", TimelineEventKind.ToolCall.color())
LegendEntry("Voice", TimelineEventKind.VoiceTurn.color())
LegendEntry("Profile", TimelineEventKind.ProfileSwitch.color())
LegendEntry("Conn", TimelineEventKind.ConnectionEvent.color())
LegendEntry(stringResource(R.string.timeline_legend_chat), TimelineEventKind.ChatMessage.color())
LegendEntry(stringResource(R.string.timeline_legend_tool), TimelineEventKind.ToolCall.color())
LegendEntry(stringResource(R.string.timeline_legend_voice), TimelineEventKind.VoiceTurn.color())
LegendEntry(stringResource(R.string.timeline_legend_profile), TimelineEventKind.ProfileSwitch.color())
LegendEntry(stringResource(R.string.timeline_legend_conn), TimelineEventKind.ConnectionEvent.color())
}
}
@@ -376,10 +377,10 @@ private fun StatusCheckRow(
private fun StatusPill(status: CheckStatus) {
val color = status.statusColor()
val label = when (status) {
CheckStatus.Pass -> "PASS"
CheckStatus.Warn -> "WARN"
CheckStatus.Fail -> "FAIL"
CheckStatus.Unknown -> "UNKNOWN"
CheckStatus.Pass -> stringResource(R.string.timeline_pass)
CheckStatus.Warn -> stringResource(R.string.timeline_warn)
CheckStatus.Fail -> stringResource(R.string.timeline_fail)
CheckStatus.Unknown -> stringResource(R.string.timeline_status_unknown)
}
Surface(
shape = RoundedCornerShape(50),
@@ -396,15 +397,16 @@ private fun StatusPill(status: CheckStatus) {
}
/** One-line "N failing · N warning · N passing" summary for the header. */
@Composable
private fun statusSummary(checks: List<StatusCheck>): String {
if (checks.isEmpty()) return "no checks"
if (checks.isEmpty()) return stringResource(R.string.timeline_no_checks)
val fail = checks.count { it.status == CheckStatus.Fail }
val warn = checks.count { it.status == CheckStatus.Warn }
val pass = checks.count { it.status == CheckStatus.Pass }
return buildList {
if (fail > 0) add("$fail failing")
if (warn > 0) add("$warn warning")
add("$pass passing")
if (fail > 0) add(pluralStringResource(R.plurals.timeline_summary_fail, fail, fail))
if (warn > 0) add(pluralStringResource(R.plurals.timeline_summary_warn, warn, warn))
add(pluralStringResource(R.plurals.timeline_summary_pass, pass, pass))
}.joinToString(" · ")
}
@@ -459,7 +461,7 @@ private fun TimelineRow(
modifier = Modifier.width(56.dp),
)
Text(
text = primary.title,
text = localizeTimelineTitle(primary.title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -509,7 +511,7 @@ private fun TimelineRow(
.background(sibling.kind.color()),
)
Text(
text = sibling.title,
text = localizeTimelineTitle(sibling.title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -144,7 +144,7 @@ fun ToolProgressCard(
val riskDescription = toolCall.outputRisk?.let {
stringResource(R.string.tool_output_risk_a11y, it)
}.orEmpty()
val toolDescription = stringResource(R.string.tool_a11y, toolCall.name, statusText, durationDescription) +
val toolDescription = stringResource(R.string.tool_a11y, localizeToolName(toolCall.name), statusText, durationDescription) +
riskDescription
Card(
@@ -177,7 +177,7 @@ fun ToolProgressCard(
// Tool name — tool.generating may arrive nameless
Text(
text = if (isPreparing) toolCall.name.ifBlank { "Preparing tool…" } else toolCall.name,
text = if (isPreparing) localizeToolName(toolCall.name).ifBlank { stringResource(R.string.tool_preparing) } else localizeToolName(toolCall.name),
style = MaterialTheme.typography.labelMedium,
color = if (isPreparing) MaterialTheme.colorScheme.onSurfaceVariant
else androidx.compose.ui.graphics.Color.Unspecified,
@@ -211,7 +211,7 @@ fun ToolProgressCard(
// Expand/collapse
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
contentDescription = if (expanded) stringResource(R.string.tool_progress_cd_collapse) else stringResource(R.string.tool_progress_cd_expand),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
@@ -73,8 +74,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.LocalSnackbarHost
@@ -146,7 +149,8 @@ fun VoiceModeOverlay(
voiceOutputEnabled: Boolean? = null,
voiceOutputFallbackEnabled: Boolean? = null,
onOverlayRequest: () -> Unit = {},
onCompactModeChange: (Boolean) -> Unit = {},
presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
// === END PHASE3-voice-mode-transcript ===
// === v0.4.1 JIT permission-denied chip ===
// Tapped when the user clicks the permission-denied chip. Default no-op
@@ -159,22 +163,21 @@ fun VoiceModeOverlay(
onBackgroundRunCancel: () -> Unit = {},
onBackgroundRunTap: () -> Unit = {},
onHermesConfirmationAnswer: (String) -> Unit = {},
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit =
{ _, _, _ -> },
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit =
{ _, _, _ -> },
// === END v0.4.1 ===
) {
val surface = MaterialTheme.colorScheme.surface
val haptic = LocalHapticFeedback.current
var controlsExpanded by remember { mutableStateOf(false) }
var focusMode by remember { mutableStateOf(true) }
val focusMode = presentationMode == VoicePresentationMode.Focus
val setFocusMode: (Boolean) -> Unit = { focused ->
focusMode = focused
onCompactModeChange(!focused)
}
LaunchedEffect(uiState.voiceMode) {
if (!uiState.voiceMode) {
setFocusMode(true)
}
onPresentationModeChange(
if (focused) VoicePresentationMode.Focus else VoicePresentationMode.Conversation,
)
}
// Voice errors surface ONLY on the overlay's own inline top banner
@@ -193,7 +196,7 @@ fun VoiceModeOverlay(
// chips, pill) didn't handle so stray taps/swipes don't fall
// through to the chat + session drawer behind it. Children run on
// the same Main pass leaf-first, so this only catches the gaps.
// In compact mode the overlay is intentionally transparent and the
// In Conversation the overlay is intentionally transparent and the
// chat stays interactive, so no scrim is installed.
.then(
if (focusMode) {
@@ -399,11 +402,21 @@ fun VoiceModeOverlay(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(visibleTranscriptMessages, key = { it.id }) { msg ->
// Match ChatScreen's identity contract. A history
// reconcile can adopt the same authoritative server
// id into a live row while Compose still holds the
// pre-reconcile row for a frame. uiKey remains stable
// and unique across that transition.
items(visibleTranscriptMessages, key = ::voiceTranscriptItemKey) { msg ->
CompactTranscriptRow(
message = msg,
showThinking = showThinking,
expanded = msg.id == latestId || msg.isStreaming,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
if (pendingTranscriptText != null) {
@@ -418,6 +431,11 @@ fun VoiceModeOverlay(
),
showThinking = showThinking,
expanded = true,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
}
@@ -449,7 +467,7 @@ fun VoiceModeOverlay(
}
}
// Compact mode: the background-run chip must survive outside focus
// Conversation: the background-run chip must survive outside focus
// mode too — a running task with no visible presence reads as lost
// (the chip previously existed ONLY in the focus layout).
AnimatedVisibility(
@@ -497,7 +515,7 @@ fun VoiceModeOverlay(
// retrying, so a failed/timed-out turn never traps the user
// on a retry-only banner.
TextButton(onClick = { onClearError() }) {
Text("Dismiss")
Text(stringResource(R.string.common_dismiss))
}
TextButton(
onClick = {
@@ -716,6 +734,8 @@ internal fun pendingVoiceTranscriptText(
return if (alreadyRendered) null else transcribed
}
internal fun voiceTranscriptItemKey(message: ChatMessage): String = message.uiKey
@Composable
private fun InteractionMode.label(): String = when (this) {
InteractionMode.TapToTalk -> stringResource(R.string.voice_overlay_tap_to_talk)
@@ -821,6 +841,15 @@ private fun VoiceSessionPill(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (!focusMode) {
ConversationVoiceMicButton(
uiState = uiState,
onMicTap = onMicTap,
onMicRelease = onMicRelease,
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
}
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
@@ -855,10 +884,9 @@ private fun VoiceSessionPill(
.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Moved out of the collapsed header (4a): the current
// interaction-mode pill plus the inline mic control. The
// compact mic only appears in compact mode, where the
// full-size bottom mic button is hidden.
// The expanded body keeps the current interaction mode
// visible; Conversation's persistent mic stays in the
// collapsed header so it never disappears.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -866,15 +894,6 @@ private fun VoiceSessionPill(
) {
StatusPill(uiState.interactionMode.label())
Spacer(Modifier.weight(1f))
if (!focusMode) {
CompactVoiceMicButton(
uiState = uiState,
onMicTap = onMicTap,
onMicRelease = onMicRelease,
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
}
}
Row(
@@ -915,7 +934,11 @@ private fun VoiceSessionPill(
modifier = Modifier.weight(1f),
) {
Text(
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
if (focusMode) {
stringResource(R.string.voice_overlay_conversation)
} else {
stringResource(R.string.voice_overlay_focus)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -958,7 +981,7 @@ private fun VoiceSessionPill(
}
@Composable
private fun CompactVoiceMicButton(
private fun ConversationVoiceMicButton(
uiState: VoiceUiState,
onMicTap: () -> Unit,
onMicRelease: () -> Unit,
@@ -1224,6 +1247,9 @@ private fun CompactTranscriptRow(
message: ChatMessage,
showThinking: Boolean,
expanded: Boolean,
onViewConversation: () -> Unit,
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
) {
if (message.role == MessageRole.SYSTEM) return
@@ -1248,6 +1274,13 @@ private fun CompactTranscriptRow(
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondary
}
val (markdownBody, inlineImages) = remember(message.content, message.role) {
if (message.role == MessageRole.ASSISTANT) {
extractChatInlineImages(message.content)
} else {
message.content to emptyList()
}
}
Column(
modifier = Modifier.fillMaxWidth(),
@@ -1285,7 +1318,7 @@ private fun CompactTranscriptRow(
}
when {
isVoiceActionBubble && hasText -> MarkdownContent(
content = message.content,
content = markdownBody,
textColor = MaterialTheme.colorScheme.onSurface,
)
message.role == MessageRole.USER && hasText -> Text(
@@ -1295,12 +1328,107 @@ private fun CompactTranscriptRow(
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
)
hasText -> Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
hasText -> MarkdownContent(
content = markdownBody,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
message.cards.forEachIndexed { index, card ->
if (card.actions.isNotEmpty() || card.input != null) {
Spacer(Modifier.height(6.dp))
val cardKey = card.id ?: "idx:$index"
HermesCardBubble(
card = card,
cardKey = cardKey,
dispatches = message.cardDispatches,
onActionTap = { key, action ->
onCardAction(message.id, key, action)
},
onInputSubmit = { key, value ->
onCardInput(message.id, key, value)
},
maxWidth = 360.dp,
)
}
}
if (
message.attachments.isNotEmpty() ||
message.cards.any { it.actions.isEmpty() && it.input == null } ||
inlineImages.isNotEmpty()
) {
Spacer(Modifier.height(6.dp))
VoiceRichResultAffordance(
message = message,
onViewConversation = onViewConversation,
)
}
}
}
@Composable
private fun VoiceRichResultAffordance(
message: ChatMessage,
onViewConversation: () -> Unit,
) {
val inlineImages = remember(message.content) {
extractChatInlineImages(message.content).second
}
val previewModel = remember(message.attachments, inlineImages) {
message.attachments.firstOrNull { it.isImage && !it.cachedUri.isNullOrBlank() }?.cachedUri
?: inlineImages.firstOrNull {
it.src.startsWith("https://") || it.src.startsWith("http://")
}?.src
}
val label = when {
message.attachments.size + inlineImages.size > 1 ->
stringResource(
R.string.voice_overlay_rich_results_count,
message.attachments.size + inlineImages.size,
)
message.attachments.isNotEmpty() || inlineImages.isNotEmpty() ->
stringResource(R.string.voice_overlay_image_ready)
else -> stringResource(R.string.voice_overlay_rich_result_ready)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onViewConversation),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.48f),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (previewModel != null) {
AsyncImage(
model = previewModel,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp)),
)
} else {
Icon(
imageVector = Icons.Filled.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 6,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.voice_overlay_view_conversation),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
@@ -1631,7 +1759,7 @@ private fun BackgroundRunChip(
imageVector = Icons.Filled.Close,
// The VM treats ✕ on a DONE chip as a local dismiss,
// never a cancel — label it accordingly for TalkBack.
contentDescription = if (done) "Dismiss" else "Cancel background task",
contentDescription = if (done) stringResource(R.string.common_dismiss) else stringResource(R.string.voice_overlay_cancel_task_cd),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp),
)
@@ -29,10 +29,12 @@ 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.res.stringResource
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.R
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
@Composable
@@ -144,8 +146,8 @@ private fun OnboardingPagePreview() {
HermesRelayTheme {
OnboardingPage(
icon = Icons.AutoMirrored.Filled.Chat,
title = "Talk to Your Agent",
description = "Stream conversations with any Hermes profile. Ask questions, run tasks, and collaborate in real time."
title = stringResource(R.string.onboarding_talk_to_agent),
description = stringResource(R.string.onboarding_stream_desc)
)
}
}
@@ -156,11 +158,11 @@ private fun OnboardingPageWithContentPreview() {
HermesRelayTheme {
OnboardingPage(
icon = Icons.AutoMirrored.Filled.Chat,
title = "Let's Connect",
description = "Enter your relay server URL to get started."
title = stringResource(R.string.onboarding_lets_connect),
description = stringResource(R.string.onboarding_server_url)
) {
Text(
text = "Custom content slot",
text = stringResource(R.string.onboarding_custom_slot),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -206,10 +206,16 @@ fun AboutScreen(
val remaining = 7 - versionTapCount
when {
remaining <= 0 -> {
scope.launch { FeatureFlags.unlockDevOptions(context) }
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
versionTapCount = 0
onUnlockDeveloperOptions()
scope.launch {
FeatureFlags.unlockDevOptions(context)
Toast.makeText(
context,
devUnlockedMsg,
Toast.LENGTH_SHORT,
).show()
onUnlockDeveloperOptions()
}
}
remaining <= 3 -> {
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
@@ -249,6 +249,7 @@ fun AppearanceSettingsScreen(
AppLanguage.JAPANESE to stringResource(R.string.appearance_language_japanese),
AppLanguage.SIMPLIFIED_CHINESE to stringResource(R.string.appearance_language_simplified_chinese),
AppLanguage.SPANISH to stringResource(R.string.appearance_language_spanish),
AppLanguage.RUSSIAN to stringResource(R.string.appearance_language_russian),
)
FlowRow(
@@ -460,6 +461,7 @@ fun AppearanceSettingsScreen(
) {
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
Column(
modifier = Modifier.padding(16.dp),
@@ -526,6 +528,48 @@ fun AppearanceSettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
Text(
text = stringResource(R.string.appearance_image_generation_style),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.appearance_image_generation_style_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val imageStyleOptions = listOf(
"rotate" to stringResource(R.string.appearance_image_generation_rotate),
"grid" to stringResource(R.string.appearance_image_generation_grid),
"sphere" to stringResource(R.string.appearance_image_generation_sphere),
"nodes" to stringResource(R.string.appearance_image_generation_nodes),
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
imageStyleOptions.forEach { (id, label) ->
FilterChip(
selected = imageGenerationStyle == id,
onClick = { connectionViewModel.setImageGenerationStyle(id) },
label = { Text(label) },
leadingIcon = if (imageGenerationStyle == id) {
{
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else {
null
},
)
}
}
}
}
@@ -139,6 +139,7 @@ import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.content.ClipData
import android.content.Intent
import android.net.Uri
@@ -156,7 +157,9 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
@@ -204,6 +207,7 @@ 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
import com.hermesandroid.relay.util.HumanErrorAction
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.abs
import com.hermesandroid.relay.ui.theme.relayGridTexture
@@ -280,6 +284,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
internal fun releaseRetainedLiveTail(
retainedUiKey: String?,
completedUiKey: String?,
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
internal fun tailEndScrollOffset(
tailSizePx: Int,
footerSizePx: Int,
viewportSizePx: Int,
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null,
)
@@ -439,6 +454,8 @@ fun ChatScreen(
voiceViewModel: VoiceViewModel,
voiceClient: RelayVoiceClient? = null,
maxBubbleWidth: Dp = 300.dp,
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
// Deep-link nudge from Settings → Active Agent card: when `true`, the
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
// fires so the host can clear the nav arg (prevents re-open on tab
@@ -451,6 +468,7 @@ fun ChatScreen(
// don't wire navigation.
onNavigateToConnections: () -> Unit = {},
onNavigateToConnect: () -> Unit = onNavigateToConnections,
onRepairConnection: () -> Unit = onNavigateToConnect,
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
// skipped / never-connected first run can explore without a server. null hides it.
onTryDemo: (() -> Unit)? = null,
@@ -465,9 +483,10 @@ fun ChatScreen(
) {
val voiceUiState by voiceViewModel.uiState.collectAsState()
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
var voiceCompactMode by remember { mutableStateOf(false) }
val chatAlpha by animateFloatAsState(
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f,
targetValue = if (
voiceUiState.voiceMode && voicePresentationMode == VoicePresentationMode.Focus
) 0.4f else 1f,
animationSpec = tween(300),
label = "chatAlpha",
)
@@ -477,7 +496,13 @@ fun ChatScreen(
val snackbarHost = LocalSnackbarHost.current
LaunchedEffect(chatViewModel) {
chatViewModel.errorEvents.collect { err ->
snackbarHost.showHumanError(err)
val result = snackbarHost.showHumanError(err)
if (
result == SnackbarResult.ActionPerformed &&
err.action == HumanErrorAction.Repair
) {
onRepairConnection()
}
}
}
@@ -663,9 +688,24 @@ fun ChatScreen(
// Animation settings
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
val imageGenerationOrdinals = remember(messages) {
var nextOrdinal = 0
buildMap {
messages.forEach { message ->
val generationCount = message.toolCalls.count {
it.name.trim().equals("image_generate", ignoreCase = true)
}
if (generationCount > 0) {
put(message.uiKey, nextOrdinal + generationCount - 1)
nextOrdinal += generationCount
}
}
}
}
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
// empty / new-chat view (no messages) — it teaches the long-press entry
@@ -773,6 +813,25 @@ fun ChatScreen(
val clipboard = LocalClipboard.current
val haptic = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() }
val handleCardAction: (String, String, HermesCardAction) -> Unit =
remember(chatViewModel, context) {
{ messageId, cardKey, action ->
if (action.mode == HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(messageId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context,
action,
)
} else {
chatViewModel.dispatchCardAction(messageId, cardKey, action)
}
}
}
val handleCardInput: (String, String, String) -> Unit = remember(chatViewModel) {
{ messageId, cardKey, value ->
chatViewModel.answerAsk(messageId, cardKey, value)
}
}
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
// transient snackbar, never a chat bubble.
@@ -870,7 +929,6 @@ fun ChatScreen(
if (!voiceUiState.voiceMode) {
voiceOverlayHost.hide()
pendingVoiceOverlayPermission = false
voiceCompactMode = false
}
}
@@ -1115,15 +1173,49 @@ fun ChatScreen(
derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
val settlingVisibleTail = completionSettlingUiKey != null &&
messages.lastOrNull()?.uiKey == completionSettlingUiKey
messages.isNotEmpty() &&
!isAtBottom &&
!programmaticBottomScroll &&
!((isStreaming || retainingVisibleTail) &&
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
smoothAutoScroll &&
!userScrolledAway)
}
}
// Slash command descriptions (resolved in composable scope — the
// remember/derivedStateOf block below is NOT composable, so stringResource
// must be called here, not inside it).
val slashDescNew = stringResource(R.string.slash_new_session)
val slashDescRetry = stringResource(R.string.slash_retry_last)
val slashDescUndo = stringResource(R.string.slash_remove_exchange)
val slashDescTitle = stringResource(R.string.slash_set_title)
val slashDescCompress = stringResource(R.string.slash_compress)
val slashDescRollback = stringResource(R.string.slash_checkpoints)
val slashDescStop = stringResource(R.string.slash_kill_bg)
val slashDescResume = stringResource(R.string.slash_resume)
val slashDescBackground = stringResource(R.string.slash_background_prompt)
val slashDescBtw = stringResource(R.string.slash_side_question)
val slashDescQueue = stringResource(R.string.slash_queue_prompt)
val slashDescApprove = stringResource(R.string.slash_approve)
val slashDescDeny = stringResource(R.string.slash_deny)
val slashDescModel = stringResource(R.string.slash_switch_model)
val slashDescProvider = stringResource(R.string.slash_providers)
val slashDescPersonality = stringResource(R.string.slash_personality)
val slashDescVerbose = stringResource(R.string.slash_tool_progress)
val slashDescYolo = stringResource(R.string.slash_auto_approve)
val slashDescReasoning = stringResource(R.string.slash_reasoning)
val slashDescVoice = stringResource(R.string.slash_voice_mode)
val slashDescReloadMcp = stringResource(R.string.slash_reload_mcp)
val slashDescHelp = stringResource(R.string.slash_commands)
val slashDescStatus = stringResource(R.string.slash_session_info)
val slashDescUsage = stringResource(R.string.slash_token_usage)
val slashDescInsights = stringResource(R.string.slash_analytics)
val slashDescCommands = stringResource(R.string.slash_browse)
val slashDescProfile = stringResource(R.string.slash_active_profile)
val slashDescClearPersonality = stringResource(R.string.slash_clear_personality)
// Build all commands dynamically: built-in + personalities + server
// skills + (gateway) the server's commands.catalog
val allCommands by remember(availableSkills, personalityNames, serverCommands) {
@@ -1132,36 +1224,36 @@ fun ChatScreen(
// Only includes commands available via gateway (not cli_only)
val builtIn = listOf(
// Session
SlashCommand("/new", "Start a new session", "session"),
SlashCommand("/retry", "Retry the last message", "session"),
SlashCommand("/undo", "Remove the last exchange", "session"),
SlashCommand("/title", "Set a title for this session", "session"),
SlashCommand("/new", slashDescNew, "session"),
SlashCommand("/retry", slashDescRetry, "session"),
SlashCommand("/undo", slashDescUndo, "session"),
SlashCommand("/title", slashDescTitle, "session"),
SlashCommand("/branch", "Branch/fork the current session", "session"),
SlashCommand("/compress", "Compress conversation context", "session"),
SlashCommand("/rollback", "List or restore checkpoints", "session"),
SlashCommand("/stop", "Kill running background processes", "session"),
SlashCommand("/resume", "Resume a previous session", "session"),
SlashCommand("/background", "Run a prompt in the background", "session"),
SlashCommand("/btw", "Side question using session context", "session"),
SlashCommand("/queue", "Queue a prompt for the next turn", "session"),
SlashCommand("/approve", "Approve a pending command", "session"),
SlashCommand("/deny", "Deny a pending command", "session"),
SlashCommand("/compress", slashDescCompress, "session"),
SlashCommand("/rollback", slashDescRollback, "session"),
SlashCommand("/stop", slashDescStop, "session"),
SlashCommand("/resume", slashDescResume, "session"),
SlashCommand("/background", slashDescBackground, "session"),
SlashCommand("/btw", slashDescBtw, "session"),
SlashCommand("/queue", slashDescQueue, "session"),
SlashCommand("/approve", slashDescApprove, "session"),
SlashCommand("/deny", slashDescDeny, "session"),
// Configuration
SlashCommand("/model", "Switch model for this session", "configuration"),
SlashCommand("/provider", "Show available providers", "configuration"),
SlashCommand("/personality", "Set a predefined personality", "configuration"),
SlashCommand("/verbose", "Cycle tool progress display", "configuration"),
SlashCommand("/yolo", "Toggle auto-approve mode", "configuration"),
SlashCommand("/reasoning", "Set reasoning effort level", "configuration"),
SlashCommand("/voice", "Toggle voice mode", "configuration"),
SlashCommand("/reload-mcp", "Reload MCP servers", "configuration"),
SlashCommand("/model", slashDescModel, "configuration"),
SlashCommand("/provider", slashDescProvider, "configuration"),
SlashCommand("/personality", slashDescPersonality, "configuration"),
SlashCommand("/verbose", slashDescVerbose, "configuration"),
SlashCommand("/yolo", slashDescYolo, "configuration"),
SlashCommand("/reasoning", slashDescReasoning, "configuration"),
SlashCommand("/voice", slashDescVoice, "configuration"),
SlashCommand("/reload-mcp", slashDescReloadMcp, "configuration"),
// Info
SlashCommand("/help", "Show available commands", "info"),
SlashCommand("/status", "Show session info", "info"),
SlashCommand("/usage", "Show token usage", "info"),
SlashCommand("/insights", "Usage analytics", "info"),
SlashCommand("/commands", "Browse all commands", "info"),
SlashCommand("/profile", "Show active profile", "info"),
SlashCommand("/help", slashDescHelp, "info"),
SlashCommand("/status", slashDescStatus, "info"),
SlashCommand("/usage", slashDescUsage, "info"),
SlashCommand("/insights", slashDescInsights, "info"),
SlashCommand("/commands", slashDescCommands, "info"),
SlashCommand("/profile", slashDescProfile, "info"),
)
// Dynamic personality commands from server, plus the upstream
@@ -1169,7 +1261,7 @@ fun ChatScreen(
val personalities = listOf(
SlashCommand(
command = "/personality none",
description = "Clear the personality overlay",
description = slashDescClearPersonality,
category = "personality"
)
) + personalityNames.map { name ->
@@ -1316,34 +1408,99 @@ fun ChatScreen(
) {
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
// Retention is only a completion-transition aid. Never leave the
// finalized tail on the plain streaming renderer just because the
// user disabled follow-scroll or is reading above the bottom.
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null
return@LaunchedEffect
}
var settledFrames = 0
repeat(6) {
var previousMarkdownTailSize: Int? = null
var previousMarkdownFooterSize: Int? = null
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
repeat(60) completionFrame@{
withFrameNanos { }
if (messages.lastOrNull()?.uiKey != settlingKey) {
completionSettlingUiKey = null
return@LaunchedEffect
}
if (listState.canScrollForward) {
settledFrames = 0
val viewportHeight = listState.layoutInfo.viewportSize.height
if (viewportHeight > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
if (listState.canScrollForward) {
settledFrames = 0
val viewportHeight = listState.layoutInfo.viewportSize.height
if (viewportHeight > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
}
}
return@completionFrame
}
} else {
settledFrames += 1
if (settledFrames >= 2) {
completionSettlingUiKey = null
return@LaunchedEffect
}
if (settledFrames < 2) return@completionFrame
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
settledFrames = 0
return@completionFrame
}
// Once Markdown owns the row, position its measured trailing edge
// explicitly. `canScrollForward` is insufficient here: LazyColumn
// may preserve the leading edge of a tall item while reporting an
// otherwise valid item anchor. Repeating catches deferred parsing,
// highlighted code, and attachment measurement without competing
// with the ordinary streaming-growth coroutine.
val layout = listState.layoutInfo
val tailIndex = messages.size // header item + zero-based messages
val footerIndex = tailIndex + 1
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
if (tailInfo == null) {
listState.scrollToItem(tailIndex)
settledFrames = 0
return@completionFrame
}
val viewportHeight = layout.viewportSize.height
if (viewportHeight <= 0) return@completionFrame
val desiredOffset = tailEndScrollOffset(
tailSizePx = tailInfo.size,
footerSizePx = footerInfo?.size ?: 0,
viewportSizePx = viewportHeight,
)
if (desiredOffset == 0) {
listState.scrollToItem(footerIndex)
} else {
listState.scrollToItem(tailIndex, desiredOffset)
}
val footerSize = footerInfo?.size ?: 0
settledFrames = if (
previousMarkdownTailSize == tailInfo.size &&
previousMarkdownFooterSize == footerSize
) {
settledFrames + 1
} else {
0
}
previousMarkdownTailSize = tailInfo.size
previousMarkdownFooterSize = footerSize
if (settledFrames >= 12) {
completionSettlingUiKey = null
return@LaunchedEffect
}
}
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null
}
@@ -2284,30 +2441,17 @@ fun ChatScreen(
isLastInGroup = isLastInGroup,
retainStreamingLayout = retainLiveLayout,
recoveringAnswer = recoveringAnswer,
imageGenerationStylePreference = imageGenerationStyle,
imageGenerationRotationIndex =
imageGenerationOrdinals[message.uiKey] ?: 0,
onAttachmentRetry = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = { msgId, cardKey, action ->
// OPEN_URL is resolved at the UI layer
// because launching ACTION_VIEW needs a
// Context. Record the dispatch first so
// the card collapses even if launch fails.
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context,
action,
)
} else {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onCardInput = { msgId, cardKey, value ->
chatViewModel.answerAsk(msgId, cardKey, value)
},
onCardAction = handleCardAction,
onCardInput = handleCardInput,
onEditMessage = if (
isGatewayTransport &&
!isStreaming &&
@@ -2745,8 +2889,8 @@ fun ChatScreen(
}
val inputCaption = when {
isStreaming && hasContent && steerableTurn ->
"↳ sends now — Hermes adjusts mid-turn"
isStreaming && hasContent -> "↳ delivered after this turn finishes"
stringResource(R.string.chat_sends_now)
isStreaming && hasContent -> stringResource(R.string.chat_delivered_after_turn)
isStreaming && steerNotice != null -> steerNotice
else -> null
}
@@ -2783,7 +2927,7 @@ fun ChatScreen(
val serverDefaultModelDetail = AgentDisplay.displayModelName(serverModelName)
?: AgentDisplay.displayModelName(effectiveProfile?.model)
val hasModelChoices = modelProviders.any { it.models.isNotEmpty() } || sseModelOptions.isNotEmpty()
val serverDefaultLabel = stringResource(R.string.chat_server_default_sessions)
val serverDefaultLabel = stringResource(R.string.chat_server_default)
val notOnPlanLabel = stringResource(R.string.chat_not_on_plan)
val needsSetupLabel = stringResource(R.string.chat_needs_setup)
val modelDefaultLabel = stringResource(R.string.chat_model_label)
@@ -2836,6 +2980,18 @@ fun ChatScreen(
)
}
}
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
add(
ChatInputPickerOption(
label = AgentDisplay.displayModelName(model.id) ?: model.id,
value = model.id,
group = "Routes",
secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
),
)
}
} else {
sseModelOptions.forEach { model ->
add(
@@ -2949,7 +3105,7 @@ fun ChatScreen(
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
else ->
"Voice needs a reachable Hermes dashboard or Relay voice route"
context.getString(R.string.chat_voice_needs_route)
},
Toast.LENGTH_SHORT,
).show()
@@ -3154,14 +3310,13 @@ fun ChatScreen(
voiceConfigScope = activeVoiceScope,
voiceOutputEnabled = activeVoiceEnabled,
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
presentationMode = voicePresentationMode,
onPresentationModeChange = onVoicePresentationModeChange,
onOverlayRequest = showVoiceSystemOverlay,
// Gear button in the overlay's expanded controls. The overlay
// exits voice mode before invoking this, so navigation lands
// on Voice Settings with no overlay left on top.
onOpenSettings = onNavigateToVoiceSettings,
onCompactModeChange = { compact ->
voiceCompactMode = compact
},
// === v0.4.1 JIT permission-denied chip ===
// Tap deep-links to Settings → Apps → Hermes-Relay →
// Permissions for the running package. Use BuildConfig
@@ -3185,6 +3340,8 @@ fun ChatScreen(
onHermesConfirmationAnswer = { answer ->
voiceViewModel.answerHermesConfirmation(answer)
},
onCardAction = handleCardAction,
onCardInput = handleCardInput,
// === END v0.4.1 ===
)
}
@@ -3296,6 +3453,7 @@ private fun ChatColdStartLoadingState(
onNavigateToConnections: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val commands = remember(
connectionLabel,
chatMode,
@@ -3305,6 +3463,7 @@ private fun ChatColdStartLoadingState(
isLoadingSessions,
) {
buildChatLoadingCommands(
context = context,
connectionLabel = connectionLabel,
chatMode = chatMode,
apiReachable = apiReachable,
@@ -3404,6 +3563,7 @@ private fun ChatColdStartLoadingState(
}
private fun buildChatLoadingCommands(
context: android.content.Context,
connectionLabel: String?,
chatMode: ChatMode,
apiReachable: Boolean,
@@ -3413,15 +3573,15 @@ private fun buildChatLoadingCommands(
): List<ChatLoadingCommand> {
val hasConnection = !connectionLabel.isNullOrBlank()
val chatModeDetail = when (chatMode) {
ChatMode.ENHANCED_HERMES -> "sessions stream"
ChatMode.PORTABLE -> "portable stream"
ChatMode.ENHANCED_HERMES -> context.getString(R.string.chat_stream_sessions)
ChatMode.PORTABLE -> context.getString(R.string.chat_stream_portable)
ChatMode.DISCONNECTED -> "waiting"
}
return listOf(
ChatLoadingCommand(
state = if (hasConnection) ChatLoadingCommandState.Done else ChatLoadingCommandState.Active,
command = "/state restore",
detail = if (hasConnection) "active config loaded" else "loading config",
detail = if (hasConnection) context.getString(R.string.chat_config_active) else context.getString(R.string.chat_config_loading),
),
ChatLoadingCommand(
state = when {
@@ -3429,7 +3589,7 @@ private fun buildChatLoadingCommands(
else -> ChatLoadingCommandState.Pending
},
command = "/route resolve",
detail = connectionLabel?.takeIf { it.isNotBlank() } ?: "selecting route",
detail = connectionLabel?.takeIf { it.isNotBlank() } ?: context.getString(R.string.chat_selecting_route),
),
ChatLoadingCommand(
state = when {
@@ -3438,7 +3598,7 @@ private fun buildChatLoadingCommands(
else -> ChatLoadingCommandState.Pending
},
command = "/hermes ping",
detail = if (apiReachable) "online" else "contacting server",
detail = if (apiReachable) "online" else context.getString(R.string.chat_contacting_server),
),
ChatLoadingCommand(
state = when {
@@ -3683,7 +3843,7 @@ private fun ingestAttachmentFromUri(
)
)
} catch (e: Exception) {
Toast.makeText(context, "Failed to read file", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
}
}
@@ -54,7 +54,6 @@ 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
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
@@ -110,9 +109,6 @@ fun ConnectionDetailScreen(
val connections by connectionViewModel.connections.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val connection = connections.firstOrNull { it.id == connectionId }
// Connection was removed (e.g. via the overflow menu) — leave the screen.
LaunchedEffect(connection == null) {
@@ -275,7 +271,6 @@ fun ConnectionDetailScreen(
connectionViewModel = connectionViewModel,
connection = connection,
relayUiState = relayUiState,
relayEnabled = relayEnabled,
onReconnect = onReconnect,
onRepair = { onRepair(connectionId) },
onOpenApiInfo = { showApiInfoSheet = true },
@@ -303,7 +298,6 @@ fun ConnectionDetailScreen(
DetailTab.Advanced -> ActiveCardAdvancedSection(
connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
isDarkTheme = isDarkTheme,
onPairRelay = { onRepair(connectionId) },
onInsecureAckRequested = { showInsecureAckDialog = true },
@@ -423,7 +417,6 @@ private fun ActiveOverview(
connectionViewModel: ConnectionViewModel,
connection: Connection,
relayUiState: RelayUiState,
relayEnabled: Boolean,
onReconnect: () -> Unit,
onRepair: () -> Unit,
onOpenApiInfo: () -> Unit,
@@ -498,7 +491,6 @@ private fun ActiveOverview(
ActiveCardFeaturesSection(
connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
onOpenApiInfo = onOpenApiInfo,
onOpenDashboard = onOpenDashboard,
onOpenRelayInfo = onOpenRelayInfo,
@@ -107,6 +107,7 @@ import com.hermesandroid.relay.network.upstream.McpOAuthFlowCoordinator
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
import com.hermesandroid.relay.network.upstream.DashboardStatus
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
@@ -464,15 +465,8 @@ fun DashboardManagementScreen(
)
}
}
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
{
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = cookieStoreFactory(),
),
)
}
val clientFactory = remember(dashboardUrl, connectionViewModel) {
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
}
suspend fun loadDashboardSection(
@@ -1548,6 +1542,41 @@ private fun ManageOverviewBody(
)
}
}
status?.componentHealth
?.takeIf { it.supported }
?.let { health ->
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
RelaySectionCaption(
title = stringResource(R.string.dashboard_metric_dashboard),
meta = health.overall?.replaceFirstChar(Char::uppercase)
?: stringResource(R.string.conn_label_status),
)
dashboardComponentHealthLines(
health = health,
connectedLabel = stringResource(R.string.dashboard_component_connected),
serverErrorsLabel = stringResource(R.string.dashboard_component_server_errors_5m),
).forEach { line ->
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
val signInStatus = status
if (signInStatus?.authRequired == true && authenticated != true) {
item {
@@ -1916,7 +1945,7 @@ private suspend fun fetchDashboardSectionStateWith(
* start lands on an already-populated Manage tab.
*/
internal suspend fun prewarmDashboardManage(
cookieStore: DashboardCookieStore,
clientFactory: () -> DashboardApiClient,
connectionId: String,
dashboardUrl: String,
effectiveProfileName: String? = null,
@@ -1945,12 +1974,7 @@ internal suspend fun prewarmDashboardManage(
// encrypted cookie store per section: 8 Keystore keyset builds, each
// holding Tink's process-global lock for seconds on StrongBox devices,
// which starved main-thread keystore users and froze the UI at startup.
val client = withContext(Dispatchers.IO) {
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(cookieStore = cookieStore),
)
}
val client = withContext(Dispatchers.IO) { clientFactory() }
try {
val preamble = try {
fetchDashboardPreamble(client)
@@ -2598,6 +2622,7 @@ private fun DashboardOAuthSignInDialog(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
clientFactory: () -> DashboardApiClient,
onDismiss: () -> Unit,
onAuthenticated: (DashboardAuthSession) -> Unit,
onError: (String) -> Unit,
@@ -2634,16 +2659,7 @@ private fun DashboardOAuthSignInDialog(
statusText = context.getString(R.string.dashboard_oauth_verifying)
scope.launch {
try {
val session = withDashboardClient(
clientFactory = {
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = cookieStoreFactory(),
),
)
},
) { client ->
val session = withDashboardClient(clientFactory = clientFactory) { client ->
client.currentSession().getOrNull()
}
if (session?.authenticated == true) {
@@ -3466,6 +3482,7 @@ private fun CustomEndpointDialog(
var discoverModels by remember(existing) {
mutableStateOf(existing?.meta?.contains("discover=off") != true)
}
var validatedModels by remember(existing) { mutableStateOf(emptyList<String>()) }
var busy by remember(existing) { mutableStateOf(false) }
var message by remember(existing) { mutableStateOf<String?>(null) }
@@ -3474,6 +3491,7 @@ private fun CustomEndpointDialog(
name = name.trim(),
baseUrl = baseUrl.trim(),
model = model.trim(),
models = validatedModels,
apiKey = apiKey.takeIf { it.isNotBlank() },
contextLength = contextLength.toIntOrNull(),
discoverModels = discoverModels,
@@ -3488,8 +3506,24 @@ private fun CustomEndpointDialog(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) }, enabled = !busy)
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
OutlinedTextField(
baseUrl,
{
baseUrl = it
validatedModels = emptyList()
},
label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) },
enabled = !busy,
)
OutlinedTextField(
model,
{
model = it
validatedModels = emptyList()
},
label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) },
enabled = !busy,
)
OutlinedTextField(
apiKey,
{ apiKey = it },
@@ -3518,6 +3552,11 @@ private fun CustomEndpointDialog(
busy = false
message = result.fold(
onSuccess = { validation ->
validatedModels = validation.models
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
.take(256)
validation.message.ifBlank {
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
}
@@ -3837,6 +3876,23 @@ internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryI
)
} ?: emptyList()
internal fun dashboardComponentHealthLines(
health: DashboardComponentHealthRollup,
connectedLabel: String = "connected",
serverErrorsLabel: String = "server errors / 5m",
): List<String> = health.components.map { component ->
buildList {
add("${component.name}: ${component.status}")
component.message?.takeIf(String::isNotBlank)?.let(::add)
if (component.configured != null || component.connected != null) {
add("${component.connected ?: 0}/${component.configured ?: 0} $connectedLabel")
}
component.unhandled5xxCount5m
?.takeIf { it > 0 }
?.let { add("$it $serverErrorsLabel") }
}.joinToString(" · ")
}
private fun summarizeRoot(root: JsonElement): String {
return when (root) {
is JsonObject -> {
@@ -1,18 +1,19 @@
package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
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
@@ -21,12 +22,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -37,11 +40,11 @@ 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.platform.LocalResources
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
@@ -49,10 +52,19 @@ 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.DashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
@@ -66,7 +78,9 @@ fun DashboardSignInScreen(
onBack: () -> Unit,
onAuthenticated: () -> Unit,
) {
val context = LocalContext.current.applicationContext
val context = LocalContext.current
val resources = LocalResources.current
val appContext = context.applicationContext
val scope = rememberCoroutineScope()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
@@ -78,22 +92,22 @@ fun DashboardSignInScreen(
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
var actionInFlight by remember { mutableStateOf(false) }
var actionMessage by remember { mutableStateOf<String?>(null) }
var actionIsError by remember { mutableStateOf(false) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var authFlows by remember(dashboardUrl, connectionId) {
mutableStateOf<List<String>>(emptyList())
}
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
var authenticationComplete by remember { mutableStateOf(false) }
val cookieStoreFactory = remember(context, connectionId) {
val cookieStoreFactory = remember(appContext, connectionId) {
{
connectionViewModel.activeDashboardCookieStore()
?: EncryptedDashboardCookieStore(context, connectionId)
?: EncryptedDashboardCookieStore(appContext, connectionId)
}
}
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
{
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
)
}
val clientFactory = remember(dashboardUrl, connectionViewModel) {
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
}
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
@@ -115,7 +129,7 @@ fun DashboardSignInScreen(
fun finishAuthentication() {
scope.launch {
invalidateDashboardManageCache(context.cacheDir)
invalidateDashboardManageCache(appContext.cacheDir)
connectionViewModel.refreshStandardVoice()
connectionViewModel.refreshDashboardProfiles()
authenticationComplete = true
@@ -125,18 +139,20 @@ fun DashboardSignInScreen(
LaunchedEffect(dashboardUrl, connectionId) {
if (dashboardUrl.isBlank()) {
loading = false
actionMessage = context.getString(R.string.dashboard_no_url_configured)
actionMessage = resources.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)
actionMessage = it.message ?: resources.getString(R.string.dashboard_request_failed)
actionIsError = true
return@LaunchedEffect
}
providers = status.authProviderDetails.ifEmpty {
client.getAuthProviders().getOrNull().orEmpty()
}
providers = client.getAuthProviders().getOrNull()
?.takeIf { it.isNotEmpty() }
?: status.authProviderDetails
authFlows = status.authFlows
val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus(
status = status,
@@ -157,6 +173,7 @@ fun DashboardSignInScreen(
if (actionInFlight || dashboardUrl.isBlank()) return
actionInFlight = true
actionMessage = null
actionIsError = false
scope.launch {
val client = clientFactory()
try {
@@ -166,10 +183,12 @@ fun DashboardSignInScreen(
finishAuthentication()
} else {
actionMessage = result.exceptionOrNull()?.message
?: context.getString(R.string.dashboard_signin_no_session)
?: resources.getString(R.string.dashboard_signin_no_session)
actionIsError = true
}
} catch (e: Exception) {
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
actionMessage = e.message ?: resources.getString(R.string.dashboard_signin_failed)
actionIsError = true
} finally {
actionInFlight = false
client.shutdown()
@@ -177,11 +196,76 @@ fun DashboardSignInScreen(
}
}
fun startRedirectSignIn(provider: DashboardAuthProvider) {
if (actionInFlight || dashboardUrl.isBlank()) return
if (
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
DashboardRedirectAuthMode.WebView
) {
oauthProvider = provider
return
}
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
actionIsError = true
return
}
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
if (authClient == null) {
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
actionIsError = true
return
}
actionInFlight = true
actionIsError = false
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
nativeSignInJob = scope.launch {
try {
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
withContext(Dispatchers.Main.immediate) {
launchNativeDashboardAuthorization(context, authorizationUrl)
}
}
val client = clientFactory()
val session = try {
verifyAndRecord(client)
} finally {
client.shutdown()
}
if (session?.authenticated == true) {
actionMessage = session.provider?.let {
resources.getString(R.string.dashboard_signed_in_with, it)
} ?: resources.getString(R.string.dashboard_signed_in)
actionIsError = false
finishAuthentication()
} else {
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
actionIsError = true
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
actionMessage = error.message
?: resources.getString(R.string.dashboard_signin_failed)
actionIsError = true
} finally {
actionInFlight = false
nativeSignInJob = null
}
}
}
DisposableEffect(dashboardUrl, connectionId) {
onDispose { nativeSignInJob?.cancel() }
}
oauthProvider?.let { provider ->
DashboardOAuthDialog(
DashboardOAuthScreen(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
clientFactory = clientFactory,
onDismiss = { oauthProvider = null },
onAuthenticated = { session ->
oauthProvider = null
@@ -193,13 +277,17 @@ fun DashboardSignInScreen(
client.shutdown()
}
actionMessage = session.provider?.let {
context.getString(R.string.dashboard_signed_in_with, it)
} ?: context.getString(R.string.dashboard_signed_in)
resources.getString(R.string.dashboard_signed_in_with, it)
} ?: resources.getString(R.string.dashboard_signed_in)
finishAuthentication()
}
},
onError = { actionMessage = it },
onError = {
actionMessage = it
actionIsError = true
},
)
return
}
Scaffold(
@@ -235,8 +323,15 @@ fun DashboardSignInScreen(
providers = providers,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
actionIsError = actionIsError,
nativeSignInInFlight = nativeSignInJob != null,
onSignIn = ::submitPassword,
onOAuthSignIn = { oauthProvider = it },
onOAuthSignIn = ::startRedirectSignIn,
onCancelNativeSignIn = {
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
actionIsError = false
nativeSignInJob?.cancel()
},
)
}
}
@@ -301,8 +396,11 @@ private fun DashboardSignInForm(
providers: List<DashboardAuthProvider>,
actionInFlight: Boolean,
actionMessage: String?,
actionIsError: Boolean,
nativeSignInInFlight: Boolean,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onCancelNativeSignIn: () -> Unit,
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
@@ -334,6 +432,14 @@ private fun DashboardSignInForm(
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
}
}
if (nativeSignInInFlight) {
Button(
onClick = onCancelNativeSignIn,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_cancel))
}
}
if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider()
OutlinedTextField(
@@ -360,15 +466,25 @@ private fun DashboardSignInForm(
}
}
actionMessage?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (actionIsError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DashboardOAuthDialog(
private fun DashboardOAuthScreen(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
clientFactory: () -> DashboardApiClient,
onDismiss: () -> Unit,
onAuthenticated: (DashboardAuthSession) -> Unit,
onError: (String) -> Unit,
@@ -381,6 +497,8 @@ private fun DashboardOAuthDialog(
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
var checking by remember { mutableStateOf(false) }
var pageProgress by remember { mutableStateOf(0) }
var webView by remember { mutableStateOf<WebView?>(null) }
val loginUrl = remember(dashboardUrl, provider.name) {
DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl,
@@ -389,14 +507,17 @@ private fun DashboardOAuthDialog(
)
}
fun maybeVerify(url: String?) {
fun handleNavigation(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
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
DashboardWebViewAuthNavigation.Continue -> return
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
statusText = notAcceptedStatus
onError(notAcceptedStatus)
return
}
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
}
val manager = CookieManager.getInstance()
manager.flush()
val imported = importDashboardCookieHeader(
@@ -408,10 +529,7 @@ private fun DashboardOAuthDialog(
checking = true
statusText = verifyingStatus
scope.launch {
val client = DashboardApiClient(
dashboardUrl,
DashboardApiClient.defaultClient(cookieStoreFactory()),
)
val client = clientFactory()
try {
val session = client.currentSession().getOrNull()
if (session?.authenticated == true) onAuthenticated(session) else {
@@ -429,39 +547,162 @@ private fun DashboardOAuthDialog(
}
}
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
BackHandler(onBack = onDismiss)
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
maybeVerify(url)
}
}
loadUrl(loginUrl)
}
},
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(
R.string.dashboard_signin_with_provider,
provider.displayName ?: provider.name,
),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = statusText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
},
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
if (pageProgress in 0..99) {
LinearProgressIndicator(
progress = { pageProgress / 100f },
modifier = Modifier.fillMaxWidth(),
)
}
AndroidView(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
pageProgress = newProgress
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val target = request.url.toString()
if (
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
DashboardWebViewAuthNavigation.RejectLoopbackCallback
) {
handleNavigation(target)
return true
}
return false
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
super.onReceivedError(view, request, error)
if (request.isForMainFrame) {
val message = error.description?.toString()
?.takeIf { it.isNotBlank() }
?: verifyFailedStatus
statusText = message
onError(message)
}
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
handleNavigation(url)
}
}
webView = this
loadUrl(loginUrl)
}
},
)
}
}
}
internal enum class DashboardWebViewAuthNavigation {
Continue,
ImportAndVerify,
RejectLoopbackCallback,
}
/**
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
* loopback callback belongs to the desktop native-PKCE contract and must never
* be followed, imported, or treated as an authenticated Android return.
*/
internal fun dashboardWebViewAuthNavigation(
dashboardUrl: String,
loadedUrl: String,
): DashboardWebViewAuthNavigation {
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val loaded = loadedUrl.trim().toHttpUrlOrNull()
?: return DashboardWebViewAuthNavigation.Continue
val sameOrigin = dashboard.scheme == loaded.scheme &&
dashboard.host.equals(loaded.host, ignoreCase = true) &&
dashboard.port == loaded.port
if (!sameOrigin) {
val foreignLoopback = loaded.scheme == "http" &&
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
loaded.encodedPath == "/callback"
return if (foreignLoopback) {
DashboardWebViewAuthNavigation.RejectLoopbackCallback
} else {
DashboardWebViewAuthNavigation.Continue
}
}
val basePath = dashboard.encodedPath.trimEnd('/')
val relativePath = loaded.encodedPath
.removePrefix(basePath)
.ifBlank { "/" }
return if (
relativePath.equals("/login", ignoreCase = true) ||
relativePath.equals("/auth/login", ignoreCase = true)
) {
DashboardWebViewAuthNavigation.Continue
} else {
// Includes the public /auth/callback response: import its cookies at
// root scope, then verify the resulting session through /api/auth/me.
DashboardWebViewAuthNavigation.ImportAndVerify
}
}
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
/**
* Dedicated Developer Options screen. Gated behind the tap-version-7x
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
* (clear session / wipe caches), and any experimental toggles.
* unlock. Hosts experimental labs, test tools, and data-management utilities.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onNavigateToRealtimeVoice: () -> Unit = {},
onNavigateToImageGenerationLab: () -> Unit = {},
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
// Data management local state — unfolded from the private
// DataManagementSection helper in the old SettingsScreen.
var showResetDialog by remember { mutableStateOf(false) }
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
)
}
IconButton(onClick = {
connectionViewModel.resetOnboarding()
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
connectionViewModel.resetOnboarding { success ->
Toast.makeText(
context,
if (success) {
context.getString(R.string.dev_settings_onboarding_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}
}) {
Icon(
imageVector = Icons.Filled.RestartAlt,
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Relay features toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Text(
text = stringResource(R.string.dev_settings_relay_features),
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = stringResource(R.string.dev_settings_relay_features_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = relayEnabled,
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
HorizontalDivider()
if (FeatureFlags.isDevBuild) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary,
)
Text(
text = stringResource(R.string.dev_settings_image_generation_lab),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onNavigateToImageGenerationLab) {
Icon(
imageVector = Icons.Filled.Science,
contentDescription = stringResource(
R.string.dev_settings_open_image_generation_lab_cd
),
)
}
}
HorizontalDivider()
}
// Lock developer options
Row(
modifier = Modifier.fillMaxWidth(),
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
)
}
IconButton(onClick = {
scope.launch { FeatureFlags.lockDevOptions(context) }
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
onBack()
scope.launch {
FeatureFlags.lockDevOptions(context)
Toast.makeText(
context,
context.getString(R.string.dev_settings_locked_toast),
Toast.LENGTH_SHORT,
).show()
onBack()
}
}) {
Icon(
imageVector = Icons.Filled.Lock,
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
onClick = {
showExportDialog = false
connectionViewModel.exportSettings { json ->
backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json")
if (json == null) {
Toast.makeText(
context,
context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT,
).show()
} else {
backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json")
}
}
}
) {
@@ -577,8 +601,17 @@ fun DeveloperSettingsScreen(
TextButton(
onClick = {
showResetDialog = false
connectionViewModel.resetAppData()
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
connectionViewModel.resetAppData { success ->
Toast.makeText(
context,
if (success) {
context.getString(R.string.dev_settings_app_data_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}
}
) {
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
@@ -0,0 +1,33 @@
package com.hermesandroid.relay.ui.screens
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
internal fun launchNativeDashboardAuthorization(
context: Context,
authorizationUrl: String,
) {
val uri = Uri.parse(authorizationUrl)
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
.also {
if (context !is Activity) {
it.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
try {
customTab.launchUrl(context, uri)
} catch (_: ActivityNotFoundException) {
context.startActivity(
Intent(Intent.ACTION_VIEW, uri).apply {
if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}
@@ -112,9 +112,7 @@ import com.hermesandroid.relay.network.relay.VoiceProviderValidationResponse
import com.hermesandroid.relay.network.upstream.ConfigFieldType
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.ElevenLabsVoices
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.applyConfigEdits
import com.hermesandroid.relay.network.upstream.configValueAt
import com.hermesandroid.relay.network.upstream.parseConfigSchema
@@ -184,12 +182,12 @@ fun VoiceSettingsScreen(
*/
connectionId: String? = null,
/**
* Dashboard base URL + per-connection cookie store provider for the
* standard-path server voice-config editor (`/api/config`, cookie auth).
* Dashboard base URL + trusted per-connection client provider for the
* standard-path server voice-config editor (`/api/config`, session auth).
* Null on connections with no dashboard — the editor card is then hidden.
*/
dashboardUrl: String? = null,
dashboardCookieStoreProvider: (() -> DashboardCookieStore?)? = null,
dashboardClientProvider: ((String) -> DashboardApiClient)? = null,
onOpenManage: (() -> Unit)? = null,
onBack: () -> Unit,
settingsViewModel: VoiceSettingsViewModel = viewModel(),
@@ -208,17 +206,12 @@ fun VoiceSettingsScreen(
// just observes it; the editor cards push saves back through the VM.
val configState by settingsViewModel.configState.collectAsState()
// Standard-path server voice-config editor client (dashboard cookie auth).
// Standard-path server voice-config editor client (cookie or native bearer).
// Built once per (dashboardUrl, connection); shut down on dispose. Null when
// the connection has no dashboard, which hides the card entirely.
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
DashboardApiClient(
baseUrl = url,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreProvider?.invoke() ?: InMemoryDashboardCookieStore(),
),
)
dashboardClientProvider?.invoke(url)
}
DisposableEffect(dashboardConfigClient) {
onDispose { dashboardConfigClient?.shutdown() }
@@ -314,7 +307,12 @@ fun VoiceSettingsScreen(
else -> InteractionMode.TapToTalk
},
)
snackbarHost.showSnackbar("${preset.displayName} preset applied")
snackbarHost.showSnackbar(
context.getString(
R.string.voice_preset_applied,
context.getString(presetDisplayNameRes(preset)),
),
)
} finally {
presetApplying = false
}
@@ -679,6 +677,22 @@ private fun RealtimeVoicePromotion.toPresetSettings(): VoicePresetPromotionSetti
// Mode presets — compact bundles over controls already present on this screen.
// ---------------------------------------------------------------------------
/** Display-name resource per preset (shown as the active-preset caption). */
private fun presetDisplayNameRes(preset: VoiceModePreset): Int = when (preset) {
VoiceModePreset.HandsFree -> R.string.voice_preset_hands_free
VoiceModePreset.LowLatency -> R.string.voice_preset_low_latency
VoiceModePreset.CarefulTools -> R.string.voice_preset_careful_tools
VoiceModePreset.QuietVisualOnly -> R.string.voice_preset_quiet_visual
}
/** Compact segmented-button label resource per preset. */
private fun presetShortLabelRes(preset: VoiceModePreset): Int = when (preset) {
VoiceModePreset.HandsFree -> R.string.voice_preset_hands_free
VoiceModePreset.LowLatency -> R.string.voice_preset_fast
VoiceModePreset.CarefulTools -> R.string.voice_preset_careful
VoiceModePreset.QuietVisualOnly -> R.string.voice_preset_quiet
}
@Composable
private fun VoiceModePresetCard(
activePreset: VoiceModePreset?,
@@ -686,9 +700,9 @@ private fun VoiceModePresetCard(
applying: Boolean,
onSelect: (VoiceModePreset) -> Unit,
) {
SectionCard(title = "Mode preset") {
SectionCard(title = stringResource(R.string.voice_settings_mode_preset_title)) {
Text(
text = "Tune interaction, interruption, trace, and long-task delivery together.",
text = stringResource(R.string.voice_settings_mode_preset_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -711,7 +725,7 @@ private fun VoiceModePresetCard(
enabled = enabled && !applying,
) {
Text(
text = preset.shortLabel,
text = stringResource(presetShortLabelRes(preset)),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -724,13 +738,14 @@ private fun VoiceModePresetCard(
Spacer(Modifier.height(8.dp))
Text(
text = activePreset?.displayName ?: "Custom",
text = activePreset?.let { stringResource(presetDisplayNameRes(it)) }
?: stringResource(R.string.voice_preset_custom),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = activePreset?.description
?: "Your manual values do not exactly match a preset.",
?: stringResource(R.string.voice_preset_manual_values),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -748,7 +763,7 @@ private fun VoiceModePresetCard(
}
Spacer(Modifier.height(4.dp))
Text(
text = "Engine, route, provider, model, voice, and credentials stay unchanged.",
text = stringResource(R.string.voice_preset_keep_unchanged),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1620,7 +1635,7 @@ private fun StreamingVoiceOutputEditor(
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(16.dp),
) {
Text("Discard", fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.voice_settings_discard), fontWeight = FontWeight.SemiBold)
}
}
}
@@ -1674,7 +1689,7 @@ internal fun VoiceProviderGroupCard(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("Provider", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.voice_settings_label_provider), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
stringResource(R.string.voice_settings_provider_desc),
style = MaterialTheme.typography.bodySmall,
@@ -1736,10 +1751,10 @@ internal fun VoiceProviderGroupCard(
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Refreshes from the provider", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.voice_settings_refreshes_from_provider), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
TextButton(onClick = { pickerOpen = true }, enabled = controlsEnabled) {
Text("Change provider")
Text(stringResource(R.string.voice_settings_change_provider))
Icon(Icons.Filled.ChevronRight, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
@@ -1748,7 +1763,7 @@ internal fun VoiceProviderGroupCard(
if (pickerOpen) {
AlertDialog(
onDismissRequest = { pickerOpen = false },
title = { Text("Choose provider") },
title = { Text(stringResource(R.string.voice_settings_choose_provider_title)) },
text = {
Column(
modifier = Modifier.heightIn(max = 480.dp).verticalScroll(rememberScrollState()),
@@ -1759,8 +1774,8 @@ internal fun VoiceProviderGroupCard(
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Voice output", style = MaterialTheme.typography.titleSmall)
Text(if (enabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.voice_settings_voice_output_label), style = MaterialTheme.typography.titleSmall)
Text(if (enabled) stringResource(R.string.voice_settings_enabled_status) else stringResource(R.string.voice_settings_disabled_status), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = enabled, onCheckedChange = onEnabledChange, enabled = controlsEnabled)
}
@@ -1789,7 +1804,7 @@ internal fun VoiceProviderGroupCard(
}
}
},
confirmButton = { TextButton(onClick = { pickerOpen = false }) { Text("Done") } },
confirmButton = { TextButton(onClick = { pickerOpen = false }) { Text(stringResource(R.string.settings_done)) } },
)
}
}
@@ -1823,7 +1838,7 @@ internal fun ModelAndVoiceGroupCard(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Model & voice", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.voice_settings_model_and_voice_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
stringResource(R.string.voice_settings_voice_desc),
style = MaterialTheme.typography.bodySmall,
@@ -1843,7 +1858,7 @@ internal fun ModelAndVoiceGroupCard(
Icon(Icons.Filled.ViewInAr, contentDescription = null, modifier = Modifier.padding(9.dp))
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Model", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.model_picker_title), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(selectedModel?.label ?: modelValue, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
selectedModel?.detail?.let { detail ->
Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -1902,7 +1917,7 @@ internal fun ModelAndVoiceGroupCard(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
contentColor = MaterialTheme.colorScheme.primary,
) {
Text("Recommended", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp))
Text(stringResource(R.string.voice_settings_recommended), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp))
}
}
}
@@ -1949,7 +1964,7 @@ internal fun ModelAndVoiceGroupCard(
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (voiceListExpanded) "Show fewer voices" else "View all ${allVoices.size} voices")
Text(if (voiceListExpanded) stringResource(R.string.voice_settings_show_fewer_voices) else stringResource(R.string.voice_settings_view_all_voices, allVoices.size))
Icon(
if (voiceListExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = null,
@@ -1961,7 +1976,7 @@ internal fun ModelAndVoiceGroupCard(
}
if (modelPickerOpen) {
ChoicePickerDialog(
title = "Choose a model",
title = stringResource(R.string.voice_settings_choose_model),
choices = modelChoices,
selected = modelValue,
onSelected = {
@@ -2062,7 +2077,7 @@ private fun ChoicePickerDialog(
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.pair_close)) } },
)
}
@@ -2187,7 +2202,7 @@ private fun StaticProviderCard(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Provider", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.voice_settings_label_provider), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
modifier = Modifier.size(44.dp),
@@ -2212,7 +2227,7 @@ private fun StaticProviderCard(
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Refresh, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Uses host configuration", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.voice_settings_uses_host_config), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
TextButton(onClick = onAction) {
Text(actionLabel)
@@ -2238,7 +2253,7 @@ private fun StaticModelVoiceCard(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Model & voice", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.voice_settings_model_and_voice_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
modifier = Modifier.size(42.dp),
@@ -2247,7 +2262,7 @@ private fun StaticModelVoiceCard(
contentColor = MaterialTheme.colorScheme.primary,
) { Icon(Icons.Filled.ViewInAr, contentDescription = null, modifier = Modifier.padding(10.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text("Model", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.model_picker_title), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(model, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
PreviewCircleButton(active = false, loading = false, enabled = enabled, contentDescription = "Preview standard voice", onClick = onPreview)
@@ -2266,7 +2281,7 @@ private fun StaticModelVoiceCard(
) { Icon(Icons.Filled.Person, contentDescription = null, modifier = Modifier.padding(9.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text(voice, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text("Configured in Standard Hermes", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.voice_settings_configured_in_standard), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
PreviewCircleButton(active = false, loading = false, enabled = enabled, contentDescription = "Preview $voice", onClick = onPreview)
}
@@ -2295,13 +2310,13 @@ private fun VoiceSaveActions(
enabled = enabled && !saving,
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(16.dp),
) { Text("Discard", fontWeight = FontWeight.SemiBold) }
) { Text(stringResource(R.string.voice_settings_discard), fontWeight = FontWeight.SemiBold) }
Button(
onClick = onSave,
enabled = enabled && !saving,
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(16.dp),
) { Text(if (saving) "Saving…" else "Save changes", fontWeight = FontWeight.SemiBold) }
) { Text(if (saving) stringResource(R.string.voice_settings_saving) else stringResource(R.string.voice_settings_save_changes), fontWeight = FontWeight.SemiBold) }
}
}
}
@@ -2424,7 +2439,7 @@ private fun RealtimeBehaviorSettingsCard(
}
},
)
Text("When the answer is ready", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
Text(stringResource(R.string.voice_settings_when_answer_ready), style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
val modes = listOf(
"speak_verbatim" to "Exact",
"speak_when_idle" to "Summary",
@@ -3170,7 +3185,7 @@ private fun LegacyRealtimeAgentCard(
private fun DeliveryModeInfoDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Answer delivery") },
title = { Text(stringResource(R.string.voice_settings_answer_delivery_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
DeliveryModeInfoRow(
@@ -3193,7 +3208,7 @@ private fun DeliveryModeInfoDialog(onDismiss: () -> Unit) {
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Got it")
Text(stringResource(R.string.paired_devices_got_it))
}
},
)
@@ -3231,6 +3246,17 @@ private fun GlobalVoiceControlsCard(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
SettingSwitchRow(
title = stringResource(R.string.voice_settings_final_answer_only),
detail = stringResource(R.string.voice_settings_final_answer_only_desc),
checked = voiceSettings.finalAnswerOnly,
onCheckedChange = { enabled ->
scope.launch { prefsRepo.setFinalAnswerOnly(enabled) }
},
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = stringResource(R.string.voice_settings_interaction_mode),
style = MaterialTheme.typography.labelLarge,
@@ -3710,9 +3736,9 @@ private fun StandardVoiceServerConfigCard(
val behaviorFields = fields.filter { field -> field.key.startsWith("voice.") }
if (behaviorFields.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text("Hermes host behavior", style = MaterialTheme.typography.labelLarge)
Text(stringResource(R.string.voice_settings_host_behavior_title), style = MaterialTheme.typography.labelLarge)
Text(
text = "These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.",
text = stringResource(R.string.voice_settings_host_behavior_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -4410,7 +4436,7 @@ private fun VoiceProfileSummaryCard(
val displayedVoiceSummary = if (profileScoped) {
voiceSummary
} else {
"Standard Hermes · Host configuration"
stringResource(R.string.voice_standard_host_config)
}
Card(
modifier = Modifier
@@ -4468,7 +4494,7 @@ private fun VoiceProfileSummaryCard(
contentColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = if (profileScoped) "Profile" else "Host",
text = if (profileScoped) stringResource(R.string.voice_settings_label_profile) else stringResource(R.string.voice_settings_label_host),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
@@ -4489,7 +4515,7 @@ private fun VoiceModePickerDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Voice mode") },
title = { Text(stringResource(R.string.settings_voice_mode)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(
@@ -4517,10 +4543,10 @@ private fun VoiceModePickerDialog(
}
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text("Output route", style = MaterialTheme.typography.labelLarge)
Text(stringResource(R.string.voice_settings_output_route_label), style = MaterialTheme.typography.labelLarge)
listOf(
VoiceAudioRoute.Auto to "Automatic",
VoiceAudioRoute.Standard to "Standard Hermes",
VoiceAudioRoute.Standard to stringResource(R.string.voice_provider_standard),
VoiceAudioRoute.Relay to "Relay voice output",
).forEach { (route, label) ->
val available = route != VoiceAudioRoute.Relay || relayVoiceReady
@@ -4541,7 +4567,7 @@ private fun VoiceModePickerDialog(
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_done)) } },
)
}
@@ -4552,6 +4578,7 @@ private fun StandardVoiceOutputOverview(
onOpenManage: (() -> Unit)?,
voiceViewModel: VoiceViewModel,
) {
val context = LocalContext.current
var values by remember(client) { mutableStateOf<JsonObject?>(null) }
var loading by remember(client) { mutableStateOf(client != null) }
LaunchedEffect(client) {
@@ -4584,23 +4611,23 @@ private fun StandardVoiceOutputOverview(
}
val ready = availability == StandardVoiceAvailability.Ready
StaticProviderCard(
provider = provider ?: "Standard Hermes",
provider = provider ?: stringResource(R.string.voice_provider_standard),
detail = when {
loading -> "Reading host voice configuration…"
values == null -> "Open Manage to configure the host voice provider"
else -> "Uses this Hermes host’s existing configuration"
loading -> stringResource(R.string.voice_reading_host_config)
values == null -> stringResource(R.string.voice_open_manage_provider)
else -> stringResource(R.string.voice_uses_host_config)
},
ready = ready,
actionLabel = if (onOpenManage == null) null else "Manage provider",
actionLabel = if (onOpenManage == null) null else context.getString(R.string.voice_settings_manage_provider),
onAction = onOpenManage,
)
StaticModelVoiceCard(
model = model ?: "Server default",
voice = voice ?: "Server default",
model = model ?: stringResource(R.string.settings_server_default),
voice = voice ?: stringResource(R.string.settings_server_default),
enabled = ready,
onPreview = { voiceViewModel.testVoice() },
)
LanguageQualitySummaryCard(summary = "Host-wide Standard Hermes settings")
LanguageQualitySummaryCard(summary = context.getString(R.string.voice_settings_host_wide_standard_desc))
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -22,11 +22,16 @@ import javax.net.ssl.SSLPeerUnverifiedException
* showHumanError in RelayApp.kt.
*/
enum class HumanErrorAction {
Repair,
}
data class HumanError(
val title: String,
val body: String,
val retryable: Boolean = false,
val actionLabel: String? = null,
val action: HumanErrorAction? = null,
)
private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c ->
@@ -96,7 +101,7 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
"realtime oauth refresh failed" in msg ||
"realtime provider credentials" in msg -> HumanError(
title = ctx?.getString(R.string.error_classify_realtime_auth) ?: "Realtime provider auth unavailable",
body = "The realtime voice provider is missing or rejected server-side auth. Refresh provider auth on the relay or choose another provider.",
body = ctx?.getString(R.string.error_classify_realtime_auth_body) ?: "The realtime voice provider is missing or rejected server-side auth. Refresh provider auth on the relay or choose another provider.",
retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_voice_settings) ?: "Voice settings",
)
@@ -116,6 +121,7 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
body = "Your session is no longer valid — re-pair this device",
retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
action = HumanErrorAction.Repair,
)
"403" in msg || "forbidden" in msg -> HumanError(
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
@@ -271,6 +277,7 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
body = "The server certificate changed since you paired — re-pair to trust it",
retryable = false,
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
action = HumanErrorAction.Repair,
)
is SecurityException -> HumanError(
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
File diff suppressed because it is too large Load Diff
@@ -68,9 +68,11 @@ import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardStatus
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
import com.hermesandroid.relay.network.upstream.ToolsetInfo
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
@@ -210,12 +212,28 @@ internal fun resolveEffectiveDashboardUrl(
endpoint?.dashboard?.url
?.takeIf { it.isNotBlank() }
?.let { return it }
endpoint?.api?.url
?.let(Connection::deriveDefaultDashboardUrl)
?.let { return it }
endpoint?.api?.url?.let { apiUrl ->
connection.dashboardUrl
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
?.let { return it }
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
}
return connection.resolvedDashboardUrl
}
/**
* Resolve the runtime API route only after the optional fallback was explicitly
* configured. Discovery may attach a conventional same-host API candidate to a
* Dashboard route, but that candidate alone must not enable API traffic.
*/
internal fun resolveEffectiveApiServerUrl(
savedUrl: String,
endpoint: EndpointCandidate?,
): String {
if (savedUrl.isBlank()) return ""
return endpoint?.api?.url?.takeIf { it.isNotBlank() } ?: savedUrl
}
/**
* Add Relay transport metadata to the connection's existing standard routes
* without adopting the Relay QR's API/Dashboard identity.
@@ -390,6 +408,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// Animation
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
private val KEY_IMAGE_GENERATION_STYLE = stringPreferencesKey("image_generation_style")
private val KEY_CHAT_RECENT_PROMPTS = booleanPreferencesKey("chat_recent_prompts")
// Chat scroll behavior
@@ -623,7 +642,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
context = application,
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
dashboardUrlProvider = { activeDashboardUrl() },
gatewayKeepAliveProvider = { gatewayKeepAlive.value },
gatewayKeepAliveProvider = {
gatewayKeepAlive.value || ActiveTurnKeepAliveRegistry.snapshot.value.required
},
// Lets the dashboard cookie store ride the connection's token keyset
// (one keyset build instead of two on cold start).
tokenStoreKeyProvider = { cid ->
@@ -770,12 +791,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
extraApiUrls = extraApiUrls,
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
),
existing = activeConnection.value?.routeCandidates.orEmpty(),
)
private fun effectiveApiServerUrlSnapshot(): String =
connectionManager.activeEndpoint.value?.api?.url ?: _apiServerUrl.value
resolveEffectiveApiServerUrl(
savedUrl = _apiServerUrl.value,
endpoint = connectionManager.activeEndpoint.value,
)
private fun effectiveRelayUrlSnapshot(): String =
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
@@ -851,7 +876,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
// --- API Server state ---
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
// Blank is the unhydrated sentinel. Seeding this with the legacy localhost
// default made a discovered remote API candidate look explicitly configured
// during the first DataStore frame, briefly building an unauthenticated
// Sessions client before a Dashboard-only connection restored its saved
// blank URL.
private val _apiServerUrl = MutableStateFlow("")
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
private val _apiServerReachable = MutableStateFlow(false)
@@ -958,6 +988,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun activeDashboardCookieStore(): DashboardCookieStore? =
upstreamTransport.activeDashboardCookieStore()
/** Trusted active-connection clients used by the shared dashboard sign-in route. */
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient =
upstreamTransport.dashboardClientForActive(dashboardUrl)
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient =
upstreamTransport.dashboardHttpClientForActive(dashboardUrl)
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
val connectionId = connectionStore.activeConnectionId.value ?: return null
@@ -1017,17 +1057,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
/**
* Runtime route for chat/API traffic. The persisted API URL remains the
* connection's base config; a resolver-selected endpoint temporarily wins
* so paired devices can roam between LAN, Tailscale, and operator VPN
* routes without rewriting stored settings.
* Runtime route for chat/API traffic. Once API fallback is explicitly
* configured, a resolver-selected endpoint temporarily wins so paired
* devices can roam without rewriting stored settings. Discovery alone
* never enables the optional API surface.
*/
val effectiveApiServerUrl: StateFlow<String> = combine(
_apiServerUrl,
connectionManager.activeEndpoint,
) { savedUrl, endpoint ->
endpoint?.api?.url ?: savedUrl
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
resolveEffectiveApiServerUrl(savedUrl, endpoint)
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
/**
* Whether a chat turn is currently streaming — mirrored from
@@ -1671,17 +1711,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
init {
// Drive the keep-alive: flip the active client's no-background-close
// flag and start/stop the foreground service. Both flavors — the
// Drive the keep-alive from either the user's always-on preference or
// work the user already started. Active-turn leases are session scoped,
// so sibling turns release independently. Both flavors — the
// GatewayKeepAliveService is declared in the main manifest (Play permits
// this Home-Assistant-class persistent-connection use case). Mirrors
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
viewModelScope.launch {
gatewayKeepAlive.collect { enabled ->
upstreamTransport.applyGatewayKeepAlive(enabled)
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
persistent to turns
}.distinctUntilChanged().collect { (persistent, turns) ->
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
val ctx = getApplication<Application>()
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
else runCatching { GatewayKeepAliveService.stop(ctx) }
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
}
}
}
@@ -1740,6 +1782,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
val imageGenerationStyle: StateFlow<String> = application.relayDataStore.data
.map { it[KEY_IMAGE_GENERATION_STYLE] ?: "rotate" }
.stateIn(viewModelScope, SharingStarted.Eagerly, "rotate")
fun setAnimationEnabled(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
@@ -1772,6 +1818,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
fun setImageGenerationStyle(value: String) {
val normalized = value.takeIf {
it in setOf("rotate", "grid", "sphere", "nodes")
} ?: "rotate"
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_IMAGE_GENERATION_STYLE] = normalized
}
}
}
// Smooth auto-scroll during chat streaming.
// When enabled, the chat list smoothly follows new tokens, tool cards, and
// reasoning deltas as they stream in — but only while the user is at the
@@ -4633,17 +4690,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
} else {
current.dashboardUrl
}
val newRouteCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = newDashboardUrl,
candidates = payload.endpoints.orEmpty(),
)
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
current.relayUrl != newRelayUrl ||
current.dashboardUrl != newDashboardUrl ||
current.routeCandidates != payload.endpoints.orEmpty()
current.routeCandidates != newRouteCandidates
if (needsUpdate) {
connectionStore.updateConnection(
current.copy(
apiServerUrl = payload.serverUrl,
relayUrl = newRelayUrl,
dashboardUrl = newDashboardUrl,
routeCandidates = payload.endpoints.orEmpty(),
routeCandidates = newRouteCandidates,
preferredRouteRole = current.preferredRouteRole
?.takeIf { preferred ->
payload.endpoints.orEmpty().any {
@@ -4874,6 +4935,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
current.copy(
label = nextLabel,
dashboardUrl = normalized,
routeCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = normalized,
candidates = current.routeCandidates.ifEmpty {
listOfNotNull(
Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(normalized),
priority = 0,
dashboardUrl = normalized,
apiServerUrl = current.apiServerUrl
.takeIf { it.isNotBlank() },
relayUrl = current.relayUrl
.takeIf { it.isNotBlank() },
),
)
},
),
),
)
probeStandardVoice()
@@ -4922,7 +4999,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
viewModelScope.launch {
withContext(Dispatchers.IO) {
dashboardCookieStoreFor(connectionId).clear()
upstreamTransport.clearDashboardAuthentication(connectionId)
}
connectionStore.setDashboardStatus(
connectionId = connectionId,
@@ -6063,16 +6140,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
) {
val activeId = connectionStore.activeConnectionId.value ?: return
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return
val nextRouteCandidates = routeCandidates ?: current.routeCandidates
val nextPreferredRouteRole = when {
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
routeCandidates != null &&
current.preferredRouteRole != null &&
nextRouteCandidates.none {
it.role.equals(current.preferredRouteRole, ignoreCase = true)
} -> null
else -> current.preferredRouteRole
}
val nextDashboardUrl = when {
dashboardUrlOverride != null -> {
dashboardUrlOverride
@@ -6086,6 +6153,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
else -> current.dashboardUrl
}
val nextRouteCandidates = Connection.reconcileDashboardRoutes(
dashboardUrl = nextDashboardUrl,
candidates = routeCandidates ?: current.routeCandidates,
)
val nextPreferredRouteRole = when {
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
routeCandidates != null &&
current.preferredRouteRole != null &&
nextRouteCandidates.none {
it.role.equals(current.preferredRouteRole, ignoreCase = true)
} -> null
else -> current.preferredRouteRole
}
if (
current.apiServerUrl == apiServerUrl &&
current.relayUrl == relayUrl &&
@@ -6318,50 +6398,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
fun resetOnboarding() {
fun resetOnboarding(onResult: (Boolean) -> Unit = {}) {
viewModelScope.launch {
dataManager.resetOnboarding()
_onboardingCompleted.value = false
val success = dataManager.resetOnboarding()
if (success) {
_onboardingCompleted.value = false
}
onResult(success)
}
}
fun resetAppData() {
fun resetAppData(onResult: (Boolean) -> Unit = {}) {
viewModelScope.launch {
disconnectRelay()
authManager.clearSession()
authManager.clearApiKey()
dataManager.resetAppData()
profileController.profileSelectionStore.clearAll()
profileController.profileLockStore.clearAll()
profileController.profilePresentationStore.clearAll()
profileController.profileSessionStore.clearAll()
_apiServerUrl.value = ""
_relayUrl.value = ""
rebuildApiClient()
shutdownClientOffMain(profileChatApiClient)
profileChatApiClient = null
profileChatApiClientUrl = null
profileChatApiClientKey = null
profileController.clearSelectionState()
_lastSessionId.value = null
val success = runCatching {
disconnectRelay()
authManager.clearSession()
authManager.clearApiKey()
check(dataManager.resetAppData()) { "App data store reset failed" }
profileController.profileSelectionStore.clearAll()
profileController.profileLockStore.clearAll()
profileController.profilePresentationStore.clearAll()
profileController.profileSessionStore.clearAll()
_apiServerUrl.value = ""
_relayUrl.value = ""
rebuildApiClient()
shutdownClientOffMain(profileChatApiClient)
profileChatApiClient = null
profileChatApiClientUrl = null
profileChatApiClientKey = null
profileController.clearSelectionState()
_lastSessionId.value = null
}.onFailure {
android.util.Log.e("ConnectionVM", "Failed to reset app data", it)
}.isSuccess
onResult(success)
}
}
fun exportSettings(onResult: (String) -> Unit) {
fun exportSettings(onResult: (String?) -> Unit) {
viewModelScope.launch {
val json = dataManager.exportSettings(
serverUrl = _relayUrl.value,
theme = theme.value,
onboardingCompleted = _onboardingCompleted.value,
// Pass 2: AuthManager.sessionLabels is gone — replaced by
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
// written to the backup anyway, so an empty list keeps the
// signature stable until the param is removed in a later pass.
sessionLabels = emptyList(),
apiServerUrl = _apiServerUrl.value,
relayUrl = _relayUrl.value
)
val json = runCatching {
dataManager.exportSettings(
serverUrl = _relayUrl.value,
theme = theme.value,
onboardingCompleted = _onboardingCompleted.value,
// Pass 2: AuthManager.sessionLabels is gone — replaced by
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
// written to the backup anyway, so an empty list keeps the
// signature stable until the param is removed in a later pass.
sessionLabels = emptyList(),
apiServerUrl = _apiServerUrl.value,
relayUrl = _relayUrl.value
)
}.onFailure {
android.util.Log.e("ConnectionVM", "Failed to prepare settings backup", it)
}.getOrNull()
onResult(json)
}
}
@@ -6383,24 +6475,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
onResult(false)
return@launch
}
// Apply imported settings
if (backup.connections.isNotEmpty()) {
val success = runCatching {
val importedRelayUrl = if (backup.connections.isEmpty()) {
backup.relayUrl ?: backup.serverUrl
} else {
null
}
// A backup is a replacement snapshot, including when it
// intentionally contains zero connections.
dataManager.restoreConnectionBackup(backup)
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_THEME] = backup.theme
importedRelayUrl?.let { preferences[KEY_RELAY_URL] = it }
backup.apiServerUrl
?.takeIf { backup.connections.isEmpty() }
?.let { preferences[KEY_API_SERVER_URL] = it }
}
connectionStore.activeConnection.value?.let { restored ->
restorePersistedActiveConnectionContext(restored)
}
} else {
// Prefer v2 fields, fall back to v1 serverUrl for relay
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
importedRelayUrl?.let { updateRelayUrl(it) }
backup.apiServerUrl?.let { updateApiServerUrl(it) }
}
setTheme(backup.theme)
if (backup.onboardingCompleted) {
dataManager.setOnboardingCompleted(true)
_onboardingCompleted.value = true
}
onResult(true)
if (backup.connections.isEmpty()) {
// Preserve v1/v2 compatibility after clearing the current
// multi-connection snapshot.
importedRelayUrl?.let { updateRelayUrl(it) }
backup.apiServerUrl?.let { updateApiServerUrl(it) }
}
check(dataManager.setOnboardingCompleted(backup.onboardingCompleted)) {
"Failed to restore onboarding state"
}
_onboardingCompleted.value = backup.onboardingCompleted
}.onFailure {
android.util.Log.e("ConnectionVM", "Failed to import settings backup", it)
}.isSuccess
onResult(success)
}
}
@@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
@@ -99,6 +100,113 @@ private enum class StandardSpeechStreamState {
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
internal data class AssistantSpeechDelta(
val message: ChatMessage,
val text: String,
val startsNewBubble: Boolean,
)
internal data class AssistantSpeechBatch(
val deltas: List<AssistantSpeechDelta>,
val assistantMessages: List<ChatMessage>,
val aggregateText: String,
val hasTurnAssistant: Boolean,
) {
/** Last non-empty assistant bubble: the settled answer after any tool commentary. */
val finalAnswerText: String
get() = assistantMessages
.asReversed()
.firstOrNull { it.content.isNotBlank() }
?.content
?.trim()
.orEmpty()
}
/**
* Per-voice-turn cursor over every assistant bubble created after the user
* submits the turn. Tool-using Hermes runs may finalize an interim assistant
* bubble and then append a second bubble with the actual answer; tracking only
* the last bubble drops one of those segments.
*
* Existing stable UI keys are fenced at construction so StateFlow replay and
* later history reconciliation cannot narrate an older session after adopting
* a server message ID. Content rewrites are adopted silently unless they
* preserve the exact prior prefix: only genuine suffix growth is speech.
*/
internal class AssistantSpeechCursor(
baselineMessages: List<ChatMessage>,
) {
private val baselineAssistantKeys = baselineMessages.asSequence()
.filter { it.role == MessageRole.ASSISTANT }
.mapTo(mutableSetOf()) { it.uiKey }
private val observedContent = mutableMapOf<String, String>()
fun poll(messages: List<ChatMessage>): AssistantSpeechBatch {
val turnAssistants = messages.filter {
it.role == MessageRole.ASSISTANT && it.uiKey !in baselineAssistantKeys
}
val deltas = buildList {
turnAssistants.forEach { message ->
val key = message.uiKey
val firstObservation = key !in observedContent
val hasPriorBubbleSpeech = observedContent.values.any { it.isNotEmpty() }
val previous = observedContent[key].orEmpty()
val current = message.content
if (current.length > previous.length && current.startsWith(previous)) {
add(
AssistantSpeechDelta(
message = message,
text = current.substring(previous.length),
startsNewBubble = firstObservation && hasPriorBubbleSpeech,
),
)
}
observedContent[key] = current
}
}
return AssistantSpeechBatch(
deltas = deltas,
assistantMessages = turnAssistants,
aggregateText = turnAssistants
.map { it.content.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n\n"),
hasTurnAssistant = turnAssistants.isNotEmpty(),
)
}
}
/**
* Binds one voice request to its chat session. Existing-session turns are
* fixed immediately. A brand-new chat starts with no session id, so the first
* server id is accepted only while the locally submitted user row is still in
* that session's message list; switching to another existing session while
* creation is pending therefore fails closed.
*/
internal class VoiceTurnSessionFence(initialSessionId: String?) {
private var boundSessionId: String? = initialSessionId
private val startedWithoutSession = initialSessionId == null
private var submittedUserUiKey: String? = null
fun bindSubmittedUser(uiKey: String?) {
submittedUserUiKey = uiKey
}
fun accepts(sessionId: String?, messages: List<ChatMessage>): Boolean {
boundSessionId?.let { return sessionId == it }
if (!startedWithoutSession) return false
if (sessionId == null) return true
val userKey = submittedUserUiKey ?: return false
val ownsSubmittedTurn = messages.any {
it.role == MessageRole.USER && it.uiKey == userKey
}
if (!ownsSubmittedTurn) return false
boundSessionId = sessionId
return true
}
}
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
@@ -224,9 +332,8 @@ enum class BackgroundRunPhase {
DONE,
}
internal fun realtimeTurnActiveAfterResponseDone(backgroundPhase: BackgroundRunPhase?): Boolean =
backgroundPhase == BackgroundRunPhase.RUNNING ||
backgroundPhase == BackgroundRunPhase.RECONNECTING
internal fun realtimeTurnActiveAfterPromotion(spokenHandoff: Boolean?): Boolean =
spokenHandoff != false
internal fun preserveRealtimeTurnOnStop(backgroundPhase: BackgroundRunPhase?): Boolean =
backgroundPhase == BackgroundRunPhase.RUNNING ||
@@ -372,7 +479,7 @@ data class VoiceStats(
* ### Sentence-boundary streaming TTS
* The SSE stream emits text one token at a time, but TTS wants whole
* sentences to sound natural. We observe [ChatViewModel.messages],
* extract deltas from the currently-streaming assistant message, and
* extract deltas from every assistant message created by the active run, and
* feed each completed sentence into a bounded [ttsQueue]. A dedicated
* consumer coroutine pulls from the queue, synthesizes each sentence,
* and plays them back-to-back via [VoicePlayer.awaitCompletion].
@@ -380,10 +487,9 @@ data class VoiceStats(
* ### Integration note (V2a → V2b cleanup)
* This first version uses the public [ChatViewModel.messages] StateFlow
* to observe streaming deltas rather than adding a `// VOICE HOOK`
* callback inside ChatViewModel. It's clean but depends on the
* "last message with isStreaming=true" invariant — if ChatViewModel
* ever streams multiple assistant messages concurrently this will need
* a dedicated per-turn flow. See `DEVLOG.md` and V2b ticket.
* callback inside ChatViewModel. A per-turn cursor fences pre-existing
* history and follows all assistant bubbles until ChatViewModel reports
* that the complete Hermes run has ended.
*/
class VoiceViewModel(application: Application) : AndroidViewModel(application) {
@@ -550,6 +656,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var voicePreferences: VoicePreferencesRepository? = null
private var voicePreferencesJob: Job? = null
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
private var finalAnswerOnly: Boolean = false
private var realtimeTraceDetails: Boolean = false
private var realtimePersistentSession: Boolean = true
private var realtimeModel: String = ""
@@ -672,8 +779,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
/** Tracks which assistant-message IDs have already been consumed so
* we don't re-process older turns when the history list updates. */
private var lastObservedMessageId: String? = null
private var lastObservedContentLength: Int = 0
private var assistantSpeechCursor: AssistantSpeechCursor? = null
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
private var sentenceBuffer: StringBuilder = StringBuilder()
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
private val brokeredToolSpeechKeys = mutableSetOf<String>()
@@ -776,15 +883,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var ttsChunksThisResponse: Int = 0
private var lastTtsChunkFinishedAtMs: Long = 0L
/**
* Assistant-message-id that already existed BEFORE the current turn's
* [chatVm.sendMessage] call. The stream observer ignores any emission
* whose `lastAssistant.id` equals this, so StateFlow's initial replay
* of the previous turn's response doesn't get spoken as a reply to
* the current voice input.
*/
private var ignoreAssistantId: String? = null
/** MP3 files produced by synthesize — trimmed to [TTS_CACHE_CAP]. */
private val ttsFileHistory = ArrayDeque<File>()
@@ -1279,10 +1377,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
*/
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
val realtimeSelectionChanged =
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
if (
voiceEngineMode != nextEngineMode ||
finalAnswerPolicyChanged ||
realtimeTraceDetails != settings.realtimeTraceDetails ||
realtimeSelectionChanged
) {
@@ -1290,6 +1390,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
TAG,
"Voice prefs updated engine=${nextEngineMode.storageValue} " +
"interaction=${settings.interactionMode} " +
"finalAnswerOnly=${settings.finalAnswerOnly} " +
"realtimeTraceDetails=${settings.realtimeTraceDetails} " +
"realtimeModel=${settings.realtimeModel.ifBlank { "relay-default" }} " +
"realtimeVoice=${settings.realtimeVoice.ifBlank { "relay-default" }}",
@@ -1301,11 +1402,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
(realtimePersistentSession && !settings.realtimePersistentSession) ||
finalAnswerPolicyChanged ||
realtimeSelectionChanged
) {
closeRealtimeSession()
}
voiceEngineMode = nextEngineMode
finalAnswerOnly = settings.finalAnswerOnly
realtimeTraceDetails = settings.realtimeTraceDetails
realtimePersistentSession = settings.realtimePersistentSession
realtimeModel = settings.realtimeModel
@@ -1737,8 +1840,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
sentenceBuffer = StringBuilder()
resetRealtimeSpeechCoalescer()
pendingRawDelta = StringBuilder()
lastObservedMessageId = null
lastObservedContentLength = 0
assistantSpeechCursor = null
voiceTurnSessionFence = null
streamComplete = false
currentTurnPcm = ByteArray(0)
resetBrokeredToolSpeechState()
@@ -2144,8 +2247,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
sentenceBuffer = StringBuilder()
resetRealtimeSpeechCoalescer()
pendingRawDelta = StringBuilder()
lastObservedMessageId = null
lastObservedContentLength = 0
assistantSpeechCursor = null
voiceTurnSessionFence = null
streamComplete = false
currentTurnPcm = ByteArray(0)
resetBrokeredToolSpeechState()
@@ -3061,8 +3164,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// Reset sentence buffering state for the new turn.
sentenceBuffer = StringBuilder()
pendingRawDelta = StringBuilder()
lastObservedMessageId = null
lastObservedContentLength = 0
assistantSpeechCursor = AssistantSpeechCursor(chatVm.messages.value)
voiceTurnSessionFence = VoiceTurnSessionFence(chatVm.currentSessionId.value)
streamComplete = false
idleFlushJob?.cancel()
idleFlushJob = null
@@ -3073,23 +3176,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
resumeWatchdog?.cancel(); resumeWatchdog = null
clearSpokenChunksState()
// Capture the id of the assistant message that currently sits at
// the end of history. StateFlow.collect replays the current value
// to new subscribers, so without this guard the observer would
// treat the previous turn's full response as one giant delta for
// the new turn and TTS the wrong answer.
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)
if (!finalAnswerOnly) {
prepareStandardSpeechStream()
}
// Route the transcribed text through the normal chat pipeline.
// This will create a user message + kick off the SSE stream.
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
// This creates the user row synchronously before kicking off the
// transport, so bind the turn before observing the replaying StateFlows.
// Starting the observer first leaves a small window where a legitimate
// session adoption can be rejected before the submitted user key exists.
// StateFlow replay preserves any assistant text that arrives before the
// observer starts.
val submittedUserUiKey =
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
startStreamObserver(chatVm)
}
private suspend fun runVoiceRelayPreflight(engineLabel: String): Boolean {
@@ -3147,8 +3248,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
sentenceBuffer = StringBuilder()
pendingRawDelta = StringBuilder()
lastObservedMessageId = null
lastObservedContentLength = 0
assistantSpeechCursor = null
voiceTurnSessionFence = null
streamComplete = false
idleFlushJob?.cancel()
idleFlushJob = null
@@ -3216,7 +3317,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
)
}
}
if (speak && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
if (speak && !finalAnswerOnly && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
// W3: per-turn throttle independent of the per-key dedupe above.
// Suppress the TTS enqueue (UI state + diagnostics already
// applied) when spoken status is too frequent or has hit the
@@ -3288,6 +3389,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
conversationContext = conversationContext,
model = realtimeModel,
voice = realtimeVoice,
finalAnswerOnly = finalAnswerOnly,
onHandoff = { event -> recordRealtimeVoiceHandoff(sessionGeneration, event) },
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
onTurnComplete = { summary ->
@@ -3332,9 +3434,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
if (suppressCommandResponse) {
if (event.type == "voice.response.done") {
providerRealtimeAgentTurnActive.set(
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase),
)
providerRealtimeAgentTurnActive.set(false)
suppressLocalCommandResponse = false
realtimeAudioSuppressed = false
}
@@ -3555,10 +3655,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
"hermes.run.promoted" -> {
providerRealtimeAgentTurnActive.set(true)
// The run detached to the background; the provider speaks the
// handoff. Surface a persistent chip so the user knows a long
// task is still in flight (ADR 33 Tier B/C).
providerRealtimeAgentTurnActive.set(
realtimeTurnActiveAfterPromotion(event.spokenHandoff),
)
// The run detached to the background. A spoken handoff keeps
// the foreground turn active until response.done; a silent
// handoff ends it here. The task chip remains either way.
val tier = event.tier ?: "promoted"
Log.i(
TAG,
@@ -3762,9 +3864,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
hermesConfirmation = null,
)
}
providerRealtimeAgentTurnActive.set(
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase)
)
providerRealtimeAgentTurnActive.set(false)
}
"voice.error" -> {
realtimeConfirmationControl = null
@@ -3846,7 +3946,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (rtAssistantMessageId.isNotBlank()) {
chatVm.failRealtimeAgentTurn(
rtAssistantMessageId,
"Voice connection was interrupted. Tap the mic to try again.",
getApplication<Application>().getString(R.string.voice_connection_interrupted),
)
}
surfaceError(err, context = "voice_config")
@@ -3976,7 +4076,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (!assistantMessageId.isNullOrBlank()) {
chatVm.failRealtimeAgentTurn(
assistantMessageId,
"Voice connection was interrupted. Tap the mic to try again.",
getApplication<Application>().getString(R.string.voice_connection_interrupted),
)
}
closeRealtimeSession()
@@ -4223,64 +4323,116 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
/**
* Observe [ChatViewModel.messages]. When the last assistant message
* grows (isStreaming=true), diff the content against our last snapshot,
* push the new delta into [sentenceBuffer], and flush completed
* sentences into [ttsQueue]. On isStreaming=false, flush the remaining
* buffer and end the turn.
* Observe every assistant bubble created by the active Hermes run. A tool
* turn can finalize one bubble while the run is still active and later
* append the final answer in another bubble, so completion is keyed to
* [ChatViewModel.isStreaming], not an individual message flag.
*/
private fun startStreamObserver(chatVm: ChatViewModel) {
streamObserverJob?.cancel()
streamObserverJob = viewModelScope.launch {
chatVm.messages.collect { messages ->
val lastAssistant = messages.lastOrNull {
it.role == MessageRole.ASSISTANT
} ?: return@collect
// Skip the assistant message that existed BEFORE the current
// turn's sendMessage. Without this, StateFlow's replay of the
// current list (containing the PREVIOUS turn's response) gets
// treated as a delta and the agent voices the old answer.
if (lastAssistant.id == ignoreAssistantId) return@collect
val msgId = lastAssistant.id
if (lastObservedMessageId == null) {
lastObservedMessageId = msgId
lastObservedContentLength = 0
} else if (lastObservedMessageId != msgId) {
// A new assistant turn appeared — flush whatever's left
// from the previous one, then switch tracking.
if (!finishStandardSpeechStream()) flushRemainingBuffer()
resetBrokeredToolSpeechState()
lastObservedMessageId = msgId
lastObservedContentLength = 0
}
observeHermesToolLoopForSpeech(lastAssistant)
val content = lastAssistant.content
if (content.length > lastObservedContentLength) {
val delta = content.substring(lastObservedContentLength)
lastObservedContentLength = content.length
onStreamDelta(delta, content)
}
if (!lastAssistant.isStreaming && lastObservedContentLength > 0) {
// Stream ended — mark complete so the chunker stops
// holding short trailing sentences, cancel the idle
// timer (we know exactly when the stream is done), and
// flush any trailing buffer.
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
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.
streamObserverJob?.cancel()
scheduleAgentAudioCompletionCheck()
}
val cursor = assistantSpeechCursor ?: AssistantSpeechCursor(chatVm.messages.value).also {
assistantSpeechCursor = it
}
val sessionFence = voiceTurnSessionFence
?: VoiceTurnSessionFence(chatVm.currentSessionId.value).also {
voiceTurnSessionFence = it
}
streamObserverJob = viewModelScope.launch {
combine(
chatVm.messages,
chatVm.isStreaming,
chatVm.currentSessionId,
) { messages, runActive, sessionId -> Triple(messages, runActive, sessionId) }
.collect { (messages, runActive, sessionId) ->
if (!sessionFence.accepts(sessionId, messages)) {
// Session id and message history are independent flows.
// During session creation/adoption, combine can briefly
// pair the new id with the old history (or vice versa).
// Skip that inconsistent snapshot without permanently
// killing narration; the next coherent emission is still
// fenced by the submitted user row/session identity.
return@collect
}
val batch = cursor.poll(messages)
if (finalAnswerOnly) {
if (batch.deltas.isNotEmpty()) {
onVisualStreamDelta(batch.aggregateText)
}
} else {
batch.deltas.forEach { update ->
if (update.startsNewBubble) {
beginAssistantSpeechBubble()
}
onStreamDelta(update.text, batch.aggregateText)
}
}
// Tool state can change without text growth.
if (!finalAnswerOnly) {
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
}
if (!runActive && batch.hasTurnAssistant) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
if (finalAnswerOnly) {
speakSettledFinalAnswer(batch.finalAnswerText)
} else if (!finishStandardSpeechStream()) {
flushRemainingBuffer()
}
streamObserverJob?.cancel()
scheduleAgentAudioCompletionCheck()
}
}
}
}
private fun onVisualStreamDelta(fullContent: String) {
_uiState.update {
it.copy(
state = VoiceState.Thinking,
outputAudioActive = false,
responseText = fullContent,
)
}
}
/**
* Final-only mode deliberately trades streaming latency for a clean spoken
* result. The last non-empty assistant bubble is the settled answer; earlier
* bubbles and tool states remain visible in Chat but never enter TTS.
*/
private fun speakSettledFinalAnswer(answer: String) {
val spoken = sanitizeForTts(answer)
if (spoken.isBlank()) return
_uiState.update {
it.copy(
state = VoiceState.Speaking,
outputAudioActive = false,
responseText = answer,
)
}
prepareStandardSpeechStream()
if (offerStandardSpeechText(spoken)) {
finishStandardSpeechStream()
} else {
sentenceBuffer = StringBuilder()
pendingRawDelta = StringBuilder()
appendSanitizedDelta(spoken)
flushRemainingBuffer()
}
}
private fun beginAssistantSpeechBubble() {
idleFlushJob?.cancel()
idleFlushJob = null
if (standardSpeechStreamOwnsReply()) {
offerStandardSpeechText("\n\n")
} else {
flushRemainingBuffer()
}
}
@@ -4312,7 +4464,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* device through the normal tool loop.
*/
private fun observeHermesToolLoopForSpeech(message: ChatMessage) {
if (!_uiState.value.voiceMode || message.toolCalls.isEmpty()) return
if (finalAnswerOnly || !_uiState.value.voiceMode || message.toolCalls.isEmpty()) return
var spokenForMessage = brokeredToolSpeechCounts[message.id] ?: 0
message.toolCalls.forEach { tool ->
@@ -4,12 +4,18 @@ import android.content.Context
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardBearerAuth
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
import com.hermesandroid.relay.network.upstream.clearNativeDashboardTokens
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -78,6 +84,10 @@ class UpstreamTransportController(
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
private val dashboardCookieStores =
ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
private val dashboardTokenStores =
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
private var dashboardHttpClientCache:
Triple<String, String, okhttp3.OkHttpClient>? = null
/**
* Cookie store for [connectionId] — ONE instance per connection,
@@ -107,6 +117,28 @@ class UpstreamTransportController(
return dashboardCookieStoreFor(connectionId)
}
private fun dashboardTokenStoreFor(connectionId: String): EncryptedNativeDashboardTokenStore {
val key = tokenStoreKeyProvider(connectionId)
?: com.hermesandroid.relay.data.Connection.buildTokenStoreKey(connectionId)
return dashboardTokenStores.getOrPut(connectionId) {
EncryptedNativeDashboardTokenStore(context, key)
}
}
private fun bearerAuthForTrustedDashboard(
connectionId: String,
dashboardUrl: String,
): DashboardBearerAuth? {
if (activeConnectionIdProvider() != connectionId) return null
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
return trustedDashboardBearerAuthOrNull(
candidate = dashboardUrl,
trusted = trustedDashboardUrl,
tokenStoreProvider = { dashboardTokenStoreFor(connectionId) },
)
}
// --- DashboardApiClient factory ----------------------------------------
/**
@@ -120,6 +152,7 @@ class UpstreamTransportController(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
),
)
@@ -133,9 +166,80 @@ class UpstreamTransportController(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
bearerAuth = activeConnectionIdProvider()?.let {
bearerAuthForTrustedDashboard(it, dashboardUrl)
},
),
)
/**
* Native PKCE client for the active connection's exact trusted dashboard
* base. Setup probes and stale routes never receive the encrypted bearer
* store.
*/
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? {
val connectionId = activeConnectionIdProvider() ?: return null
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
return null
}
if (!com.hermesandroid.relay.network.upstream.sameDashboardBase(
candidate = dashboardUrl,
trusted = trustedDashboardUrl,
)
) {
return null
}
return NativeDashboardAuthClient(
baseUrl = dashboardUrl,
tokenStore = dashboardTokenStoreFor(connectionId),
)
}
/** Exact-origin authenticated HTTP client for non-REST dashboard consumers such as voice. */
@Synchronized
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient {
val connectionId = activeConnectionIdProvider() ?: "unassociated"
dashboardHttpClientCache?.let { (cachedConnection, cachedUrl, client) ->
if (cachedConnection == connectionId && cachedUrl == dashboardUrl) return client
disposeDashboardHttpClient(client)
dashboardHttpClientCache = null
}
return DashboardApiClient.defaultClient(
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
},
).also { dashboardHttpClientCache = Triple(connectionId, dashboardUrl, it) }
}
@Synchronized
fun clearDashboardAuthentication(connectionId: String) {
dashboardCookieStoreFor(connectionId).clear()
clearNativeDashboardTokens(dashboardTokenStoreFor(connectionId))
dashboardHttpClientCache
?.takeIf { it.first == connectionId }
?.third
?.let(::disposeDashboardHttpClient)
if (dashboardHttpClientCache?.first == connectionId) {
dashboardHttpClientCache = null
}
gatewayClientCache
?.takeIf { it.first == connectionId }
?.third
?.shutdown()
if (gatewayClientCache?.first == connectionId) {
gatewayClientCache = null
}
}
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { client.cache?.close() }
client.dispatcher.executorService.shutdown()
}
// --- Gateway availability ----------------------------------------------
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
@@ -226,6 +330,8 @@ class UpstreamTransportController(
synchronized(this) {
gatewayClientCache?.third?.shutdown()
gatewayClientCache = null
dashboardHttpClientCache?.third?.let(::disposeDashboardHttpClient)
dashboardHttpClientCache = null
}
}
@@ -262,14 +262,15 @@ private fun VoiceFloatingOverlayPill(
session.voice,
session.outputEnabled,
)
val profileText = session.profileName?.takeIf { it.isNotBlank() } ?: "default profile"
val profileText = session.profileName?.takeIf { it.isNotBlank() }
?: stringResource(R.string.voice_overlay_default_profile)
val stateText = when (uiState.state) {
VoiceState.Idle -> "Ready"
VoiceState.Listening -> "Listening"
VoiceState.Transcribing -> "Transcribing"
VoiceState.Thinking -> "Thinking"
VoiceState.Speaking -> "Speaking"
VoiceState.Error -> "Error"
VoiceState.Idle -> stringResource(R.string.voice_settings_status_ready)
VoiceState.Listening -> stringResource(R.string.voice_overlay_listening)
VoiceState.Transcribing -> stringResource(R.string.voice_overlay_transcribing)
VoiceState.Thinking -> stringResource(R.string.voice_overlay_thinking)
VoiceState.Speaking -> stringResource(R.string.voice_overlay_speaking)
VoiceState.Error -> stringResource(R.string.voice_overlay_state_error)
}
if (minimized) {
+294 -4
View File
@@ -631,8 +631,8 @@
<string name="settings_profile_lock_desc">Fixe o app em um perfil de agente</string>
<string name="settings_quick_controls">Controles rápidos</string>
<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_persistent_connection_desc">Manter a conexão mesmo sem nenhum chat em andamento</string>
<string name="settings_connect_on_demand">Chats ativos permanecem conectados automaticamente · a conexão ociosa é fechada para economizar bateria</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>
@@ -1063,6 +1063,7 @@
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_language_russian">Русский</string>
<string name="appearance_appearance">Aparência</string>
<string name="appearance_light_dark">Claro/escuro</string>
<string name="appearance_fixed_light">%1$s é um tema claro fixo.</string>
@@ -1130,7 +1131,7 @@
<string name="dev_settings_relay_features">Recursos do Relay</string>
<string name="dev_settings_relay_features_desc">Mostrar configurações do Servidor Relay e de pareamento para desenvolvimento do Bridge/Terminal</string>
<string name="dev_settings_realtime_voice_lab">Laboratório de voz em tempo real</string>
<string name="dev_settings_realtime_voice_lab_desc">Abrir a bancada de testes do WebSocket do provedor em versões de desenvolvimento</string>
<string name="dev_settings_realtime_voice_lab_desc">Verifique a configuração de voz em tempo real e execute um teste de microfone</string>
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratório de voz em tempo real</string>
<string name="dev_settings_lock_dev_options">Bloquear opções do desenvolvedor</string>
<string name="dev_settings_lock_dev_options_desc">Ocultar esta seção e desativar recursos experimentais</string>
@@ -1161,6 +1162,7 @@
<string name="dev_settings_export_failed">Falha na exportação</string>
<string name="dev_settings_imported">Configurações importadas</string>
<string name="dev_settings_import_failed">Falha na importação — arquivo inválido</string>
<string name="dev_settings_reset_failed">Falha ao redefinir</string>
<string name="dev_settings_export_sensitive_backup_title">Exportar backup sensível?</string>
<string name="dev_settings_export_sensitive_backup_body">Este backup inclui conexões salvas, chaves da API, tokens de sessão do relay, IDs de dispositivos e cookies do painel. Qualquer pessoa com o arquivo poderá acessar seu servidor Hermes.</string>
<string name="dev_settings_export_action">Exportar</string>
@@ -1501,6 +1503,8 @@
<string name="voice_settings_save_realtime_agent">Salvar agente em tempo real</string>
<string name="voice_settings_global_controls_title">Controles globais de voz</string>
<string name="voice_settings_global_controls_desc">Estas configurações se aplicam aos dois mecanismos de voz em todos os perfis.</string>
<string name="voice_settings_final_answer_only">Somente a resposta final</string>
<string name="voice_settings_final_answer_only_desc">Fala apenas a resposta concluída. O progresso das ferramentas, as atualizações de serviço e os comentários intermediários permanecem visuais.</string>
<string name="voice_settings_interaction_mode">Modo de interação</string>
<string name="voice_settings_interaction_tap">Tocar para falar</string>
<string name="voice_settings_interaction_hold">Manter pressionado para falar</string>
@@ -1995,8 +1999,12 @@
<string name="voice_overlay_collapse_cd">Recolher controles de voz</string>
<string name="voice_overlay_expand_cd">Expandir controles de voz</string>
<string name="voice_overlay_exit_cd">Sair do modo de voz</string>
<string name="voice_overlay_compact">Compacto</string>
<string name="voice_overlay_focus">Foco</string>
<string name="voice_overlay_conversation">Conversa</string>
<string name="voice_overlay_image_ready">Imagem pronta</string>
<string name="voice_overlay_rich_result_ready">Resultado avançado pronto</string>
<string name="voice_overlay_rich_results_count">%1$d resultados prontos</string>
<string name="voice_overlay_view_conversation">Ver conversa</string>
<string name="voice_overlay_overlay">Sobreposição</string>
<string name="voice_overlay_exit">Sair</string>
<string name="voice_overlay_settings_cd">Configurações de voz</string>
@@ -2138,6 +2146,19 @@
<string name="conn_info_confirms_on_next_message">Confirma na próxima mensagem</string>
<string name="conn_info_connect">Conectar</string>
<string name="conn_info_connected">Conectado</string>
<string name="conn_info_active_configuration">Configuração ativa</string>
<string name="conn_info_inherited">Herdado</string>
<string name="conn_info_bypassed">Ignorado</string>
<string name="conn_info_customize_identity">Personalizar identidade</string>
<string name="conn_info_agent_passport">Passaporte do agente</string>
<string name="conn_info_chat_override">Substituição do chat</string>
<string name="conn_info_fast_tier">Nível rápido</string>
<string name="conn_info_fast">Rápido</string>
<string name="conn_info_context">Contexto</string>
<string name="conn_info_profile_already_bypasses">As aprovações já são ignoradas pelo perfil.</string>
<string name="conn_info_start_new_chat">Iniciar novo chat</string>
<string name="conn_info_approval_mode_short_desc">Escolha como as aprovações são aplicadas.</string>
<string name="conn_info_approval_policy">Política de aprovação</string>
<string name="conn_info_connecting">Conectando…</string>
<string name="conn_info_connection">Conexão</string>
<string name="conn_info_connection_state">Estado da conexão</string>
@@ -2465,6 +2486,23 @@
<string name="attachment_unmute">Ativar som</string>
<string name="attachment_mute">Silenciar</string>
<string name="attachment_title">Anexo</string>
<plurals name="attachment_group_count">
<item quantity="one">%1$d anexo</item>
<item quantity="other">%1$d anexos</item>
</plurals>
<string name="attachment_group_named">%1$s · %2$s</string>
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
<string name="attachment_group_typed_more">%1$s +%2$d</string>
<string name="attachment_group_collapse">Recolher anexos</string>
<string name="attachment_group_expand">Expandir anexos</string>
<string name="attachment_group_collapsed">Recolhido</string>
<string name="attachment_group_expanded">Expandido</string>
<string name="attachment_type_image">Imagem</string>
<string name="attachment_type_video">Vídeo</string>
<string name="attachment_type_audio">Áudio</string>
<string name="attachment_type_pdf">PDF</string>
<string name="attachment_type_text">Texto</string>
<string name="attachment_type_file">Arquivo</string>
<!-- CrashReportDialog -->
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
@@ -3177,4 +3215,256 @@
<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>
<string name="dashboard_component_connected">conectado</string>
<string name="dashboard_component_server_errors_5m">erros do servidor / 5 min</string>
<string name="appearance_image_generation_style">Geração de imagens</string>
<string name="appearance_image_generation_style_desc">Alterne entre as três animações de progresso ou mantenha um estilo em todas as gerações.</string>
<string name="appearance_image_generation_rotate">Alternar</string>
<string name="appearance_image_generation_grid">Grade</string>
<string name="appearance_image_generation_sphere">Esfera</string>
<string name="appearance_image_generation_nodes">Nós</string>
<string name="dev_settings_image_generation_lab">Laboratório de geração de imagens</string>
<string name="dev_settings_image_generation_lab_desc">Visualize os ciclos de geração e a revelação final da imagem</string>
<string name="dev_settings_open_image_generation_lab_cd">Abrir o laboratório de geração de imagens</string>
<string name="dashboard_native_signin_opening">Conclua o login no navegador e volte aqui.</string>
<string name="dashboard_native_signin_cancelled">Login pelo navegador cancelado.</string>
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
<string name="conn_info_approval_mode_desc">Política persistente para este perfil do Hermes. Aplica-se a todos os chats e dispositivos.</string>
<string name="conn_info_approval_mode_unsupported">Atualize o Hermes para escolher um modo de aprovação do perfil. O YOLO por chat continuará disponível.</string>
<string name="conn_info_approval_mode_profile_read_only">Somente leitura para este perfil multiplexado. O modo atual aparece após o início da sessão do perfil; alterá-lo exige RPCs de configuração por perfil do upstream.</string>
<string name="conn_info_approval_mode_manual">Manual</string>
<string name="conn_info_approval_mode_manual_desc">Perguntar antes de cada chamada de ferramenta protegida.</string>
<string name="conn_info_approval_mode_smart">Inteligente</string>
<string name="conn_info_approval_mode_smart_desc">Perguntar apenas quando o Hermes detectar risco elevado.</string>
<string name="conn_info_approval_mode_off">Desativado</string>
<string name="conn_info_approval_mode_off_desc">Ignorar permanentemente as aprovações deste perfil.</string>
<string name="agent_send_sms">Enviar SMS</string>
<string name="agent_search_contacts">Pesquisar contatos</string>
<string name="agent_screenshot">Captura de tela</string>
<string name="agent_return_hermes">Voltar ao Hermes</string>
<string name="agent_open_app">Abrir app</string>
<string name="agent_key_press">Pressionar tecla</string>
<string name="agent_call">Ligar</string>
<string name="agent_bridge_setup">Configuração do Bridge</string>
<plurals name="chat_scroll_bottom_unread">
<item quantity="one">Rolar para baixo, %1$d mensagem não lida</item>
<item quantity="other">Rolar para baixo, %1$d mensagens não lidas</item>
</plurals>
<string name="model_picker_refreshing">Atualizando</string>
<string name="model_picker_refresh">Atualizar</string>
<string name="model_picker_other">Outro</string>
<string name="model_picker_model">Modelo</string>
<string name="input_voice_session">sessão de voz</string>
<string name="input_needs_setup">precisa de configuração</string>
<string name="input_live_voice">Conversa de voz ao vivo</string>
<string name="image_on_server">esta imagem está no servidor</string>
<string name="image_inline">imagem embutida</string>
<string name="image_generated">Imagem gerada</string>
<string name="error_classify_realtime_auth_body">O provedor de voz em tempo real está ausente ou rejeitou a autenticação no servidor. Atualize a autenticação do provedor no Relay ou escolha outro provedor.</string>
<string name="diag_what_happened">O que aconteceu</string>
<string name="diag_severity_warning">Aviso</string>
<string name="diag_severity_info">Informações</string>
<string name="diag_severity_error">Erro</string>
<string name="diag_export">Exportar diagnóstico</string>
<string name="diag_copied">Diagnóstico copiado</string>
<string name="demo_wind">Vento</string>
<string name="demo_weather">Parcialmente nublado</string>
<string name="demo_user_question">Legal! Você também sabe escrever código?</string>
<string name="demo_sunset">Pôr do sol</string>
<string name="demo_city_name">Baía Aurora</string>
<string name="cmd_cat_software_development">Desenvolvimento de software</string>
<string name="cmd_cat_session">sessão</string>
<string name="cmd_cat_server">servidor</string>
<string name="cmd_cat_personality">personalidade</string>
<string name="cmd_cat_info">informações</string>
<string name="cmd_cat_configuration">configuração</string>
<string name="cmd_cat_built_in">integrado</string>
<string name="chat_voice_needs_route">A voz precisa de um dashboard do Hermes acessível ou de uma rota de voz do Relay</string>
<string name="chat_stream_sessions">fluxo de sessões</string>
<string name="chat_stream_portable">fluxo portátil</string>
<string name="chat_server_default">Padrão do servidor</string>
<string name="chat_sends_now">↳ envia agora — o Hermes se ajusta no meio da rodada</string>
<string name="chat_selecting_route">selecionando rota</string>
<string name="chat_scroll_bottom">Rolar até o final</string>
<string name="chat_failed_read_file">Falha ao ler o arquivo</string>
<string name="chat_dont_ask_again_verb">Não perguntar novamente para "%1$s"</string>
<string name="chat_dont_ask_again">Não perguntar novamente</string>
<string name="chat_delivered_after_turn">↳ entregue após o término desta rodada</string>
<string name="chat_contacting_server">contatando o servidor</string>
<string name="chat_config_loading">carregando config</string>
<string name="chat_config_active">config ativa carregada</string>
<string name="bubble_voice_action">Ação de voz</string>
<string name="bubble_voice">Voz</string>
<string name="bubble_streaming">transmitindo</string>
<string name="bubble_realtime_agent">Agente em tempo real</string>
<string name="bubble_phone_action">Ação no telefone</string>
<string name="bubble_moa_advisor">Resposta do conselheiro Mixture of Agents</string>
<string name="bubble_advisor_unavailable">Conselheiro indisponível.</string>
<string name="bubble_advisor_prefix">Conselheiro </string>
<string name="bg_processes_title">Processo em segundo plano</string>
<string name="bg_processes_running">Em execução</string>
<string name="bg_processes_recent">Recentes</string>
<string name="bg_processes_open">Abrir processos em segundo plano</string>
<string name="bg_processes_empty">Nenhum processo em segundo plano neste chat</string>
<string name="bg_expand_output">Expandir saída do processo</string>
<string name="bg_collapse_output">Recolher saída do processo</string>
<string name="badge_unknown_error">Erro desconhecido</string>
<string name="badge_tool_failed">Ferramenta falhou</string>
<string name="badge_stopped">Interrompido</string>
<string name="badge_skill">Habilidade</string>
<string name="badge_response_interrupted">Resposta interrompida</string>
<string name="badge_model_changed">Modelo alterado</string>
<string name="badge_memory">Memória</string>
<string name="badge_error">Erro</string>
<string name="badge_continued">Continuado após uma rodada interrompida</string>
<string name="badge_bg_work_completed">Trabalho em segundo plano concluído</string>
<string name="badge_artifact">Artefato</string>
<string name="attach_tap_reveal">toque para revelar</string>
<string name="attach_tap_download">Toque para baixar</string>
<string name="attach_open_external">Abrir externamente</string>
<string name="attach_cant_read_image">Não foi possível ler esta imagem</string>
<string name="attach_cant_open_image">Não foi possível abrir esta imagem</string>
<string name="attach_cant_action_image">Não foi possível concluir essa ação de imagem</string>
<string name="onboarding_server_url">Digite o URL do servidor Relay para começar.</string>
<string name="onboarding_lets_connect">Vamos conectar</string>
<string name="onboarding_custom_slot">Slot de conteúdo personalizado</string>
<string name="onboarding_talk_to_agent">Converse com seu agente</string>
<string name="onboarding_stream_desc">Transmita conversas com qualquer perfil do Hermes. Faça perguntas, execute tarefas e colabore em tempo real</string>
<string name="power_bridge_desc">Permita que o Hermes envie comandos de bridge aprovados para este telefone.</string>
<string name="power_bridge">Bridge</string>
<string name="path_transport_path">caminho de transporte</string>
<string name="path_show_routes">Mostrar rotas</string>
<string name="path_session_details">Detalhes da sessão</string>
<string name="path_live_thinking">Raciocínio ao vivo</string>
<string name="path_capability_live">esta capacidade está ativa</string>
<string name="slash_voice_mode">Alternar o modo de voz</string>
<string name="slash_tool_progress">Alternar a exibição do progresso das ferramentas</string>
<string name="slash_token_usage">Mostrar uso de tokens</string>
<string name="slash_switch_model">Trocar o modelo desta sessão</string>
<string name="slash_side_question">Pergunta paralela usando o contexto da sessão</string>
<string name="slash_set_title">Definir um título para esta sessão</string>
<string name="slash_session_info">Mostrar informações da sessão</string>
<string name="slash_retry_last">Repetir a última mensagem</string>
<string name="slash_resume">Retomar uma sessão anterior</string>
<string name="slash_remove_exchange">Remover a última troca</string>
<string name="slash_reload_mcp">Recarregar servidores MCP</string>
<string name="slash_reasoning">Definir o nível de esforço de raciocínio</string>
<string name="slash_queue_prompt">Colocar um prompt na fila para a próxima rodada</string>
<string name="slash_providers">Mostrar provedores disponíveis</string>
<string name="slash_personality">Definir uma personalidade predefinida</string>
<string name="slash_new_session">Iniciar uma nova sessão</string>
<string name="slash_kill_bg">Encerrar processos em segundo plano em execução</string>
<string name="slash_deny">Negar um comando pendente</string>
<string name="slash_compress">Comprimir o contexto da conversa</string>
<string name="slash_commands">Mostrar comandos disponíveis</string>
<string name="slash_clear_personality">Limpar a sobreposição de personalidade</string>
<string name="slash_checkpoints">Listar ou restaurar checkpoints</string>
<string name="slash_browse">Navegar por todos os comandos</string>
<string name="slash_background_prompt">Executar um prompt em segundo plano</string>
<string name="slash_auto_approve">Alternar modo de aprovação automática</string>
<string name="slash_approve">Aprovar um comando pendente</string>
<string name="slash_analytics">Análises de uso</string>
<string name="slash_active_profile">Mostrar perfil ativo</string>
<string name="power_terminal_desc">Abra um shell do servidor por meio da sua sessão Relay pareada.</string>
<string name="power_terminal">Terminal</string>
<plurals name="timeline_summary_pass">
<item quantity="one">%1$d aprovado</item>
<item quantity="other">%1$d aprovados</item>
</plurals>
<plurals name="timeline_summary_warn">
<item quantity="one">%1$d aviso</item>
<item quantity="other">%1$d avisos</item>
</plurals>
<plurals name="timeline_summary_fail">
<item quantity="one">%1$d falhou</item>
<item quantity="other">%1$d falharam</item>
</plurals>
<plurals name="timeline_events_count">
<item quantity="one">%1$d evento</item>
<item quantity="other">%1$d eventos</item>
</plurals>
<string name="voice_standard_host_config">Hermes padrão · Configuração do host</string>
<string name="voice_settings_realtime_behavior_title">Comportamento em tempo real</string>
<string name="voice_settings_mode_preset_title">Predefinição de modo</string>
<string name="voice_settings_mode_preset_desc">Ajuste interação, interrupção, rastreamento e entrega de tarefas longas em conjunto.</string>
<string name="voice_settings_enabled_status">Ativado</string>
<string name="voice_settings_disabled_status">Desativado</string>
<string name="voice_reading_host_config">Lendo a configuração de voz do host…</string>
<string name="voice_provider_standard">Hermes padrão</string>
<string name="voice_preset_quiet_visual">Silencioso / somente visual</string>
<string name="voice_preset_quiet">Silencioso</string>
<string name="voice_preset_manual_values">Seus valores manuais não correspondem exatamente a uma predefinição.</string>
<string name="voice_preset_low_latency">Baixa latência</string>
<string name="voice_preset_keep_unchanged">Mecanismo, rota, provedor, modelo, voz e credenciais permanecem inalterados.</string>
<string name="voice_preset_hands_free">Mãos livres</string>
<string name="voice_preset_fast">Rápido</string>
<string name="voice_preset_custom">Personalizado</string>
<string name="voice_preset_careful_tools">Ferramentas cautelosas</string>
<string name="voice_preset_careful">Cuidadoso</string>
<string name="voice_preset_applied">Predefinição %1$s aplicada</string>
<string name="voice_open_manage_provider">Abra o Manage para configurar o provedor de voz do host</string>
<string name="voice_connection_interrupted">A conexão de voz foi interrompida. Toque no microfone para tentar novamente.</string>
<string name="tool_preparing">Preparando ferramenta…</string>
<string name="tool_name_write_file">Escrever arquivo</string>
<string name="tool_name_web_search">Pesquisa na web</string>
<string name="tool_name_web_extract">Extrair página</string>
<string name="tool_name_vision">Análise de imagem</string>
<string name="tool_name_tts">Fala</string>
<string name="tool_name_todo">Tarefas</string>
<string name="tool_name_terminal">Terminal</string>
<string name="tool_name_skill">Habilidade</string>
<string name="tool_name_session">Pesquisa de sessões</string>
<string name="tool_name_read_file">Ler arquivo</string>
<string name="tool_name_process">Processos</string>
<string name="tool_name_memory">Memória</string>
<string name="tool_name_file">Arquivo</string>
<string name="tool_name_execute_code">Executar código</string>
<string name="tool_name_delegate">Delegar tarefa</string>
<string name="tool_name_cron">Agendador</string>
<string name="tool_name_computer">Uso do computador</string>
<string name="tool_name_android">Ação no telefone</string>
<string name="timeline_no_checks">sem verificações</string>
<string name="thinking_title">Processo de raciocínio</string>
<string name="thinking_thinking_short">Pensando</string>
<string name="thinking_thinking">Pensando...</string>
<string name="task_status_working">Trabalhando</string>
<string name="task_status_needs_input">Requer entrada</string>
<string name="task_status_failed">Falhou</string>
<string name="task_status_delivering">Entregando</string>
<string name="task_status_complete">Concluído</string>
<string name="task_status_cancelled">Cancelado</string>
<string name="task_expand_timeline">Expandir linha do tempo da tarefa</string>
<string name="task_collapse_timeline">Recolher linha do tempo da tarefa</string>
<string name="task_background_prefix">Tarefa em segundo plano, </string>
<string name="voice_overlay_compact">Compacto</string>
<string name="hermes_card_hold_hint">~1s</string>
<string name="voice_uses_host_config">Usa a configuração existente deste host Hermes</string>
<string name="voice_settings_host_behavior_title">Hermes host behavior</string>
<string name="voice_settings_host_behavior_body">These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.</string>
<string name="voice_settings_choose_provider_title">Choose provider</string>
<string name="voice_settings_change_provider">Change provider</string>
<string name="voice_settings_voice_output_label">Voice output</string>
<string name="voice_settings_model_and_voice_title">Model &amp; voice</string>
<string name="voice_settings_uses_host_config">Uses host configuration</string>
<string name="voice_settings_configured_in_standard">Configured in Standard Hermes</string>
<string name="voice_settings_answer_delivery_title">Answer delivery</string>
<string name="voice_settings_output_route_label">Output route</string>
<string name="voice_settings_manage_provider">Manage provider</string>
<string name="voice_settings_host_wide_standard_desc">Host-wide Standard Hermes settings</string>
<string name="voice_settings_refreshes_from_provider">Refreshes from the provider</string>
<string name="voice_settings_save_changes">Save changes</string>
<string name="voice_settings_high_quality">High quality</string>
<string name="voice_settings_provider_default">Provider default</string>
<string name="voice_settings_needs_setup">Needs setup</string>
<string name="voice_settings_key_required">Key required</string>
<string name="voice_settings_label_host">Host</string>
<string name="voice_settings_choose_model">Choose a model</string>
<string name="voice_settings_view_all_voices">View all %1$d voices</string>
<string name="voice_settings_show_fewer_voices">Show fewer voices</string>
<string name="wizard_pairing_code">Código de pareamento</string>
<string name="wizard_pair_command">comando hermes pair</string>
</resources>
+288 -4
View File
@@ -672,8 +672,8 @@
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
<string name="settings_quick_controls">快捷控制</string>
<string name="settings_persistent_connection">持久连接</string>
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
<string name="settings_persistent_connection_desc">即使没有聊天运行也保持连接</string>
<string name="settings_connect_on_demand">活跃聊天会自动保持连接 · 空闲时关闭连接以节省电量</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>
@@ -1117,6 +1117,7 @@
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_language_russian">Русский</string>
<string name="appearance_appearance">外观</string>
<string name="appearance_light_dark">浅色 / 深色</string>
<string name="appearance_fixed_light">%1$s 是固定的浅色主题。</string>
@@ -1185,7 +1186,7 @@
<string name="dev_settings_relay_features">Relay 功能</string>
<string name="dev_settings_relay_features_desc">显示 Relay 服务器和配对设置,用于 Bridge/Terminal 开发</string>
<string name="dev_settings_realtime_voice_lab">实时语音实验室</string>
<string name="dev_settings_realtime_voice_lab_desc">为开发构建打开 provider websocket 测试台</string>
<string name="dev_settings_realtime_voice_lab_desc">检查实时语音设置并运行专项麦克风测试</string>
<string name="dev_settings_open_realtime_voice_lab_cd">打开实时语音实验室</string>
<string name="dev_settings_lock_dev_options">锁定开发者选项</string>
<string name="dev_settings_lock_dev_options_desc">隐藏此分区并禁用实验性功能</string>
@@ -1216,6 +1217,7 @@
<string name="dev_settings_export_failed">导出失败</string>
<string name="dev_settings_imported">设置已导入</string>
<string name="dev_settings_import_failed">导入失败——文件无效</string>
<string name="dev_settings_reset_failed">重置失败</string>
<string name="dev_settings_export_sensitive_backup_title">导出敏感备份?</string>
<string name="dev_settings_export_sensitive_backup_body">此备份包含已保存的连接、API 密钥、Relay 会话令牌、设备 ID 和仪表盘 Cookie。任何持有该文件的人都可能访问您的 Hermes 服务器。</string>
<string name="dev_settings_export_action">导出</string>
@@ -1562,6 +1564,8 @@
<string name="voice_settings_save_realtime_agent">保存实时 Agent</string>
<string name="voice_settings_global_controls_title">全局语音控制</string>
<string name="voice_settings_global_controls_desc">这些设置适用于所有个人资料上的两个语音引擎。</string>
<string name="voice_settings_final_answer_only">仅朗读最终答案</string>
<string name="voice_settings_final_answer_only_desc">只朗读最终确定的答案。工具进度、服务更新和中间评论仍仅以视觉方式显示。</string>
<string name="voice_settings_interaction_mode">交互模式</string>
<string name="voice_settings_interaction_tap">点击说话</string>
<string name="voice_settings_interaction_hold">按住说话</string>
@@ -2087,8 +2091,12 @@
<string name="voice_overlay_collapse_cd">收起语音控制</string>
<string name="voice_overlay_expand_cd">展开语音控制</string>
<string name="voice_overlay_exit_cd">退出语音模式</string>
<string name="voice_overlay_compact">紧凑</string>
<string name="voice_overlay_focus">专注</string>
<string name="voice_overlay_conversation">对话</string>
<string name="voice_overlay_image_ready">图片已就绪</string>
<string name="voice_overlay_rich_result_ready">丰富结果已就绪</string>
<string name="voice_overlay_rich_results_count">%1$d 个结果已就绪</string>
<string name="voice_overlay_view_conversation">查看对话</string>
<string name="voice_overlay_overlay">浮窗</string>
<string name="voice_overlay_exit">退出</string>
<string name="voice_overlay_settings_cd">语音设置</string>
@@ -2232,6 +2240,19 @@
<string name="conn_info_confirms_on_next_message">下条消息确认</string>
<string name="conn_info_connect">连接</string>
<string name="conn_info_connected">已连接</string>
<string name="conn_info_active_configuration">当前配置</string>
<string name="conn_info_inherited">继承</string>
<string name="conn_info_bypassed">已绕过</string>
<string name="conn_info_customize_identity">自定义身份</string>
<string name="conn_info_agent_passport">智能体档案</string>
<string name="conn_info_chat_override">对话覆盖</string>
<string name="conn_info_fast_tier">快速层级</string>
<string name="conn_info_fast">快速</string>
<string name="conn_info_context">上下文</string>
<string name="conn_info_profile_already_bypasses">此配置已绕过审批。</string>
<string name="conn_info_start_new_chat">开始新对话</string>
<string name="conn_info_approval_mode_short_desc">选择如何执行审批。</string>
<string name="conn_info_approval_policy">审批策略</string>
<string name="conn_info_connecting">连接中…</string>
<string name="conn_info_connection">连接</string>
<string name="conn_info_connection_state">连接状态</string>
@@ -2583,6 +2604,22 @@
<string name="attachment_unmute">取消静音</string>
<string name="attachment_mute">静音</string>
<string name="attachment_title">附件</string>
<plurals name="attachment_group_count">
<item quantity="other">%1$d 个附件</item>
</plurals>
<string name="attachment_group_named">%1$s · %2$s</string>
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
<string name="attachment_group_typed_more">%1$s +%2$d</string>
<string name="attachment_group_collapse">收起附件</string>
<string name="attachment_group_expand">展开附件</string>
<string name="attachment_group_collapsed">已收起</string>
<string name="attachment_group_expanded">已展开</string>
<string name="attachment_type_image">图片</string>
<string name="attachment_type_video">视频</string>
<string name="attachment_type_audio">音频</string>
<string name="attachment_type_pdf">PDF</string>
<string name="attachment_type_text">文本</string>
<string name="attachment_type_file">文件</string>
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay 意外关闭</string>
@@ -3271,4 +3308,251 @@
<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>
<string name="dashboard_component_connected">已连接</string>
<string name="dashboard_component_server_errors_5m">服务器错误 / 5 分钟</string>
<string name="appearance_image_generation_style">图像生成</string>
<string name="appearance_image_generation_style_desc">轮换使用三种进度动画,或让每次生成都使用同一种样式。</string>
<string name="appearance_image_generation_rotate">轮换</string>
<string name="appearance_image_generation_grid">网格</string>
<string name="appearance_image_generation_sphere">球体</string>
<string name="appearance_image_generation_nodes">节点</string>
<string name="dev_settings_image_generation_lab">图像生成实验室</string>
<string name="dev_settings_image_generation_lab_desc">预览生成循环和最终图像显现效果</string>
<string name="dev_settings_open_image_generation_lab_cd">打开图像生成实验室</string>
<string name="dashboard_native_signin_opening">请在浏览器中完成登录,然后返回此处。</string>
<string name="dashboard_native_signin_cancelled">已取消浏览器登录。</string>
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
<string name="conn_info_approval_mode_desc">此 Hermes 配置文件的持久策略。适用于所有聊天和设备。</string>
<string name="conn_info_approval_mode_unsupported">请更新 Hermes 以选择配置文件批准模式。单聊天 YOLO 仍可使用。</string>
<string name="conn_info_approval_mode_profile_read_only">此多路复用配置文件为只读。配置文件会话启动后会显示当前模式;更改模式需要 upstream 提供按配置文件划分的配置 RPC。</string>
<string name="conn_info_approval_mode_manual">手动</string>
<string name="conn_info_approval_mode_manual_desc">每次调用受保护工具前都询问。</string>
<string name="conn_info_approval_mode_smart">智能</string>
<string name="conn_info_approval_mode_smart_desc">仅在 Hermes 检测到较高风险时询问。</string>
<string name="conn_info_approval_mode_off">关闭</string>
<string name="conn_info_approval_mode_off_desc">始终跳过此配置文件的批准。</string>
<string name="agent_send_sms">发送短信</string>
<string name="agent_search_contacts">搜索联系人</string>
<string name="agent_screenshot">截屏</string>
<string name="agent_return_hermes">返回 Hermes</string>
<string name="agent_open_app">打开应用</string>
<string name="agent_key_press">按键</string>
<string name="agent_call">拨打电话</string>
<string name="agent_bridge_setup">Bridge 设置</string>
<plurals name="chat_scroll_bottom_unread">
<item quantity="other">滚动到底部,%1$d 条未读消息</item>
</plurals>
<string name="model_picker_refreshing">正在刷新</string>
<string name="model_picker_refresh">刷新</string>
<string name="model_picker_other">其他</string>
<string name="model_picker_model">模型</string>
<string name="input_voice_session">语音会话</string>
<string name="input_needs_setup">需要设置</string>
<string name="input_live_voice">实时语音对话</string>
<string name="image_on_server">此图片位于服务器上</string>
<string name="image_inline">内嵌图片</string>
<string name="image_generated">已生成的图片</string>
<string name="error_classify_realtime_auth_body">实时语音提供商缺少或拒绝了服务器端身份验证。请在 Relay 上刷新提供商身份验证,或选择其他提供商。</string>
<string name="diag_what_happened">发生了什么</string>
<string name="diag_severity_warning">警告</string>
<string name="diag_severity_info">信息</string>
<string name="diag_severity_error">错误</string>
<string name="diag_export">导出诊断信息</string>
<string name="diag_copied">诊断信息已复制</string>
<string name="demo_wind">风</string>
<string name="demo_weather">多云</string>
<string name="demo_user_question">太棒了!你也会写代码吗?</string>
<string name="demo_sunset">日落</string>
<string name="demo_city_name">极光湾</string>
<string name="cmd_cat_software_development">软件开发</string>
<string name="cmd_cat_session">会话</string>
<string name="cmd_cat_server">服务器</string>
<string name="cmd_cat_personality">个性</string>
<string name="cmd_cat_info">信息</string>
<string name="cmd_cat_configuration">配置</string>
<string name="cmd_cat_built_in">内置</string>
<string name="chat_voice_needs_route">语音需要可访问的 Hermes 控制台或 Relay 语音路由</string>
<string name="chat_stream_sessions">会话流</string>
<string name="chat_stream_portable">便携流</string>
<string name="chat_server_default">服务器默认</string>
<string name="chat_sends_now">↳ 立即发送 — Hermes 会在本轮中调整</string>
<string name="chat_selecting_route">正在选择路由</string>
<string name="chat_scroll_bottom">滚动到底部</string>
<string name="chat_failed_read_file">读取文件失败</string>
<string name="chat_dont_ask_again_verb">不再询问“%1$s”</string>
<string name="chat_dont_ask_again">不再询问</string>
<string name="chat_delivered_after_turn">↳ 本轮结束后送达</string>
<string name="chat_contacting_server">正在连接服务器</string>
<string name="chat_config_loading">正在加载配置</string>
<string name="chat_config_active">已加载活动配置</string>
<string name="bubble_voice_action">语音操作</string>
<string name="bubble_voice">语音</string>
<string name="bubble_streaming">流式传输中</string>
<string name="bubble_realtime_agent">实时智能体</string>
<string name="bubble_phone_action">手机操作</string>
<string name="bubble_moa_advisor">Mixture of Agents 顾问响应</string>
<string name="bubble_advisor_unavailable">顾问不可用。</string>
<string name="bubble_advisor_prefix">顾问:</string>
<string name="bg_processes_title">后台进程</string>
<string name="bg_processes_running">运行中</string>
<string name="bg_processes_recent">最近</string>
<string name="bg_processes_open">打开后台进程</string>
<string name="bg_processes_empty">此聊天中没有后台进程</string>
<string name="bg_expand_output">展开进程输出</string>
<string name="bg_collapse_output">折叠进程输出</string>
<string name="badge_unknown_error">未知错误</string>
<string name="badge_tool_failed">工具执行失败</string>
<string name="badge_stopped">已停止</string>
<string name="badge_skill">技能</string>
<string name="badge_response_interrupted">响应已中断</string>
<string name="badge_model_changed">模型已更改</string>
<string name="badge_memory">记忆</string>
<string name="badge_error">错误</string>
<string name="badge_continued">在中断回合后继续</string>
<string name="badge_bg_work_completed">后台工作已完成</string>
<string name="badge_artifact">工件</string>
<string name="attach_tap_reveal">点击显示</string>
<string name="attach_tap_download">点击下载</string>
<string name="attach_open_external">在外部打开</string>
<string name="attach_cant_read_image">无法读取此图片</string>
<string name="attach_cant_open_image">无法打开此图片</string>
<string name="attach_cant_action_image">无法完成该图片操作</string>
<string name="onboarding_server_url">输入您的 Relay 服务器 URL 即可开始。</string>
<string name="onboarding_lets_connect">开始连接</string>
<string name="onboarding_custom_slot">自定义内容插槽</string>
<string name="onboarding_talk_to_agent">与您的智能体对话</string>
<string name="onboarding_stream_desc">与任何 Hermes 配置文件流式对话。提问、运行任务并实时协作</string>
<string name="power_bridge_desc">允许 Hermes 向此手机发送已批准的 bridge 命令。</string>
<string name="power_bridge">Bridge</string>
<string name="path_transport_path">传输路径</string>
<string name="path_show_routes">显示路由</string>
<string name="path_session_details">会话详情</string>
<string name="path_live_thinking">实时思考</string>
<string name="path_capability_live">此功能已生效</string>
<string name="slash_voice_mode">切换语音模式</string>
<string name="slash_tool_progress">循环切换工具进度显示</string>
<string name="slash_token_usage">显示 token 用量</string>
<string name="slash_switch_model">切换此会话的模型</string>
<string name="slash_side_question">使用会话上下文的附带问题</string>
<string name="slash_set_title">为此会话设置标题</string>
<string name="slash_session_info">显示会话信息</string>
<string name="slash_retry_last">重试最后一条消息</string>
<string name="slash_resume">恢复之前的会话</string>
<string name="slash_remove_exchange">删除最后一轮对话</string>
<string name="slash_reload_mcp">重新加载 MCP 服务器</string>
<string name="slash_reasoning">设置推理力度级别</string>
<string name="slash_queue_prompt">将提示词加入下一轮队列</string>
<string name="slash_providers">显示可用提供商</string>
<string name="slash_personality">设置预定义个性</string>
<string name="slash_new_session">开始新会话</string>
<string name="slash_kill_bg">终止运行中的后台进程</string>
<string name="slash_deny">拒绝待处理命令</string>
<string name="slash_compress">压缩对话上下文</string>
<string name="slash_commands">显示可用命令</string>
<string name="slash_clear_personality">清除个性叠加层</string>
<string name="slash_checkpoints">列出或恢复检查点</string>
<string name="slash_browse">浏览所有命令</string>
<string name="slash_background_prompt">在后台运行提示词</string>
<string name="slash_auto_approve">切换自动批准模式</string>
<string name="slash_approve">批准待处理命令</string>
<string name="slash_analytics">使用情况分析</string>
<string name="slash_active_profile">显示活动配置文件</string>
<string name="power_terminal_desc">通过配对的 Relay 会话打开服务器 shell。</string>
<string name="power_terminal">终端</string>
<plurals name="timeline_summary_pass">
<item quantity="other">%1$d 个通过</item>
</plurals>
<plurals name="timeline_summary_warn">
<item quantity="other">%1$d 个警告</item>
</plurals>
<plurals name="timeline_summary_fail">
<item quantity="other">%1$d 个失败</item>
</plurals>
<plurals name="timeline_events_count">
<item quantity="other">%1$d 个事件</item>
</plurals>
<string name="voice_standard_host_config">标准 Hermes · 主机配置</string>
<string name="voice_settings_realtime_behavior_title">实时行为</string>
<string name="voice_settings_mode_preset_title">模式预设</string>
<string name="voice_settings_mode_preset_desc">统一调整交互、中断、追踪和长任务交付方式。</string>
<string name="voice_settings_enabled_status">已启用</string>
<string name="voice_settings_disabled_status">已停用</string>
<string name="voice_reading_host_config">正在读取主机语音配置…</string>
<string name="voice_provider_standard">标准 Hermes</string>
<string name="voice_preset_quiet_visual">安静 / 仅视觉</string>
<string name="voice_preset_quiet">安静</string>
<string name="voice_preset_manual_values">您手动设置的值与预设不完全匹配。</string>
<string name="voice_preset_low_latency">低延迟</string>
<string name="voice_preset_keep_unchanged">引擎、路由、提供商、模型、语音和凭据均保持不变。</string>
<string name="voice_preset_hands_free">免提</string>
<string name="voice_preset_fast">快速</string>
<string name="voice_preset_custom">自定义</string>
<string name="voice_preset_careful_tools">谨慎工具</string>
<string name="voice_preset_careful">谨慎</string>
<string name="voice_preset_applied">已应用 %1$s 预设</string>
<string name="voice_open_manage_provider">打开 Manage 以配置主机语音提供商</string>
<string name="voice_connection_interrupted">语音连接已中断。点击麦克风重试。</string>
<string name="tool_preparing">正在准备工具…</string>
<string name="tool_name_write_file">写入文件</string>
<string name="tool_name_web_search">网页搜索</string>
<string name="tool_name_web_extract">提取页面</string>
<string name="tool_name_vision">图像分析</string>
<string name="tool_name_tts">语音合成</string>
<string name="tool_name_todo">任务</string>
<string name="tool_name_terminal">终端</string>
<string name="tool_name_skill">技能</string>
<string name="tool_name_session">会话搜索</string>
<string name="tool_name_read_file">读取文件</string>
<string name="tool_name_process">进程</string>
<string name="tool_name_memory">记忆</string>
<string name="tool_name_file">文件</string>
<string name="tool_name_execute_code">执行代码</string>
<string name="tool_name_delegate">委派任务</string>
<string name="tool_name_cron">调度器</string>
<string name="tool_name_computer">电脑操作</string>
<string name="tool_name_android">手机操作</string>
<string name="timeline_no_checks">无检查</string>
<string name="thinking_title">思考过程</string>
<string name="thinking_thinking_short">思考中</string>
<string name="thinking_thinking">思考中…</string>
<string name="task_status_working">处理中</string>
<string name="task_status_needs_input">需要输入</string>
<string name="task_status_failed">失败</string>
<string name="task_status_delivering">正在送达</string>
<string name="task_status_complete">已完成</string>
<string name="task_status_cancelled">已取消</string>
<string name="task_expand_timeline">展开任务时间线</string>
<string name="task_collapse_timeline">折叠任务时间线</string>
<string name="task_background_prefix">后台任务,</string>
<string name="voice_overlay_compact">紧凑</string>
<string name="hermes_card_hold_hint">~1秒</string>
<string name="voice_uses_host_config">使用此 Hermes 主机的现有配置</string>
<string name="voice_settings_host_behavior_title">Hermes host behavior</string>
<string name="voice_settings_host_behavior_body">These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.</string>
<string name="voice_settings_choose_provider_title">Choose provider</string>
<string name="voice_settings_change_provider">Change provider</string>
<string name="voice_settings_voice_output_label">Voice output</string>
<string name="voice_settings_model_and_voice_title">Model &amp; voice</string>
<string name="voice_settings_uses_host_config">Uses host configuration</string>
<string name="voice_settings_configured_in_standard">Configured in Standard Hermes</string>
<string name="voice_settings_answer_delivery_title">Answer delivery</string>
<string name="voice_settings_output_route_label">Output route</string>
<string name="voice_settings_manage_provider">Manage provider</string>
<string name="voice_settings_host_wide_standard_desc">Host-wide Standard Hermes settings</string>
<string name="voice_settings_refreshes_from_provider">Refreshes from the provider</string>
<string name="voice_settings_save_changes">Save changes</string>
<string name="voice_settings_high_quality">High quality</string>
<string name="voice_settings_provider_default">Provider default</string>
<string name="voice_settings_needs_setup">Needs setup</string>
<string name="voice_settings_key_required">Key required</string>
<string name="voice_settings_label_host">Host</string>
<string name="voice_settings_choose_model">Choose a model</string>
<string name="voice_settings_view_all_voices">View all %1$d voices</string>
<string name="voice_settings_show_fewer_voices">Show fewer voices</string>
<string name="wizard_pairing_code">配对码</string>
<string name="wizard_pair_command">hermes pair 命令</string>
</resources>
+294 -4
View File
@@ -672,8 +672,8 @@
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
<string name="settings_quick_controls">Schnellsteuerung</string>
<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_persistent_connection_desc">Auch ohne laufenden Chat verbunden bleiben</string>
<string name="settings_connect_on_demand">Aktive Chats bleiben automatisch verbunden · Leerlaufverbindung wird geschlossen, um Akku zu sparen</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>
@@ -1120,6 +1120,7 @@
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_language_russian">Русский</string>
<string name="appearance_appearance">Darstellung</string>
<string name="appearance_light_dark">Hell / Dunkel</string>
<string name="appearance_fixed_light">%1$s ist ein festes helles Design.</string>
@@ -1188,7 +1189,7 @@
<string name="dev_settings_relay_features">Relay-Funktionen</string>
<string name="dev_settings_relay_features_desc">Relay-Server- und Kopplungseinstellungen für Bridge-/Terminal-Entwicklung anzeigen</string>
<string name="dev_settings_realtime_voice_lab">Echtzeit-Sprachlabor</string>
<string name="dev_settings_realtime_voice_lab_desc">WebSocket-Testumgebung des Anbieters für Entwicklungs-Builds öffnen</string>
<string name="dev_settings_realtime_voice_lab_desc">Echtzeit-Sprachkonfiguration prüfen und einen gezielten Mikrofontest ausführen</string>
<string name="dev_settings_open_realtime_voice_lab_cd">Echtzeit-Sprachlabor öffnen</string>
<string name="dev_settings_lock_dev_options">Entwickleroptionen sperren</string>
<string name="dev_settings_lock_dev_options_desc">Diesen Abschnitt ausblenden und experimentelle Funktionen deaktivieren</string>
@@ -1219,6 +1220,7 @@
<string name="dev_settings_export_failed">Export fehlgeschlagen</string>
<string name="dev_settings_imported">Einstellungen importiert</string>
<string name="dev_settings_import_failed">Import fehlgeschlagen — ungültige Datei</string>
<string name="dev_settings_reset_failed">Zurücksetzen fehlgeschlagen</string>
<string name="dev_settings_export_sensitive_backup_title">Vertrauliche Sicherung exportieren?</string>
<string name="dev_settings_export_sensitive_backup_body">Diese Sicherung enthält gespeicherte Verbindungen, API-Schlüssel, Relay-Sitzungstoken, Geräte-IDs und Dashboard-Cookies. Jede Person mit dieser Datei kann möglicherweise auf deinen Hermes-Server zugreifen.</string>
<string name="dev_settings_export_action">Exportieren</string>
@@ -1565,6 +1567,8 @@
<string name="voice_settings_save_realtime_agent">Echtzeit-Agent speichern</string>
<string name="voice_settings_global_controls_title">Globale Sprachsteuerung</string>
<string name="voice_settings_global_controls_desc">Diese Einstellungen gelten für beide Sprach-Engines in jedem Profil.</string>
<string name="voice_settings_final_answer_only">Nur endgültige Antwort</string>
<string name="voice_settings_final_answer_only_desc">Spricht nur die abgeschlossene Antwort. Werkzeugfortschritt, Dienstmeldungen und Zwischenkommentare bleiben visuell.</string>
<string name="voice_settings_interaction_mode">Interaktionsmodus</string>
<string name="voice_settings_interaction_tap">Tippen zum Sprechen</string>
<string name="voice_settings_interaction_hold">Halten zum Sprechen</string>
@@ -2090,8 +2094,12 @@
<string name="voice_overlay_collapse_cd">Sprachsteuerung einklappen</string>
<string name="voice_overlay_expand_cd">Sprachsteuerung ausklappen</string>
<string name="voice_overlay_exit_cd">Sprachmodus beenden</string>
<string name="voice_overlay_compact">Kompakt</string>
<string name="voice_overlay_focus">Fokus</string>
<string name="voice_overlay_conversation">Gespräch</string>
<string name="voice_overlay_image_ready">Bild bereit</string>
<string name="voice_overlay_rich_result_ready">Rich-Ergebnis bereit</string>
<string name="voice_overlay_rich_results_count">%1$d Ergebnisse bereit</string>
<string name="voice_overlay_view_conversation">Gespräch anzeigen</string>
<string name="voice_overlay_overlay">Overlay</string>
<string name="voice_overlay_exit">Beenden</string>
<string name="voice_overlay_settings_cd">Spracheinstellungen</string>
@@ -2237,6 +2245,19 @@
<string name="conn_info_confirms_on_next_message">Bei nächster Nachricht bestätigen</string>
<string name="conn_info_connect">Verbinden</string>
<string name="conn_info_connected">Verbunden</string>
<string name="conn_info_active_configuration">Aktive Konfiguration</string>
<string name="conn_info_inherited">Geerbt</string>
<string name="conn_info_bypassed">Umgangen</string>
<string name="conn_info_customize_identity">Identität anpassen</string>
<string name="conn_info_agent_passport">Agentenpass</string>
<string name="conn_info_chat_override">Chat-Überschreibung</string>
<string name="conn_info_fast_tier">Schnellstufe</string>
<string name="conn_info_fast">Schnell</string>
<string name="conn_info_context">Kontext</string>
<string name="conn_info_profile_already_bypasses">Genehmigungen werden bereits vom Profil umgangen.</string>
<string name="conn_info_start_new_chat">Neuen Chat starten</string>
<string name="conn_info_approval_mode_short_desc">Wähle, wie Genehmigungen erzwungen werden.</string>
<string name="conn_info_approval_policy">Genehmigungsrichtlinie</string>
<string name="conn_info_connecting">Verbindung wird hergestellt…</string>
<string name="conn_info_connection">Verbindung</string>
<string name="conn_info_connection_state">Verbindungsstatus</string>
@@ -2586,6 +2607,23 @@
<string name="attachment_unmute">Ton an</string>
<string name="attachment_mute">Stummschalten</string>
<string name="attachment_title">Anhang</string>
<plurals name="attachment_group_count">
<item quantity="one">%1$d Anhang</item>
<item quantity="other">%1$d Anhänge</item>
</plurals>
<string name="attachment_group_named">%1$s · %2$s</string>
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
<string name="attachment_group_typed_more">%1$s +%2$d</string>
<string name="attachment_group_collapse">Anhänge einklappen</string>
<string name="attachment_group_expand">Anhänge ausklappen</string>
<string name="attachment_group_collapsed">Eingeklappt</string>
<string name="attachment_group_expanded">Ausgeklappt</string>
<string name="attachment_type_image">Bild</string>
<string name="attachment_type_video">Video</string>
<string name="attachment_type_audio">Audio</string>
<string name="attachment_type_pdf">PDF</string>
<string name="attachment_type_text">Text</string>
<string name="attachment_type_file">Datei</string>
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
@@ -3337,4 +3375,256 @@
<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>
<string name="dashboard_component_connected">verbunden</string>
<string name="dashboard_component_server_errors_5m">Serverfehler / 5 Min.</string>
<string name="appearance_image_generation_style">Bildgenerierung</string>
<string name="appearance_image_generation_style_desc">Wechsle zwischen allen drei Fortschrittsanimationen oder verwende bei jeder Generierung denselben Stil.</string>
<string name="appearance_image_generation_rotate">Wechseln</string>
<string name="appearance_image_generation_grid">Raster</string>
<string name="appearance_image_generation_sphere">Kugel</string>
<string name="appearance_image_generation_nodes">Knoten</string>
<string name="dev_settings_image_generation_lab">Bildgenerierungs-Labor</string>
<string name="dev_settings_image_generation_lab_desc">Generierungsschleifen und die abschließende Bildenthüllung als Vorschau anzeigen</string>
<string name="dev_settings_open_image_generation_lab_cd">Bildgenerierungs-Labor öffnen</string>
<string name="dashboard_native_signin_opening">Schließe die Anmeldung im Browser ab und kehre dann hierher zurück.</string>
<string name="dashboard_native_signin_cancelled">Browser-Anmeldung abgebrochen.</string>
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
<string name="conn_info_approval_mode_desc">Dauerhafte Richtlinie für dieses Hermes-Profil. Gilt für alle Chats und Geräte.</string>
<string name="conn_info_approval_mode_unsupported">Aktualisiere Hermes, um einen Profil-Bestätigungsmodus auszuwählen. Chatbezogenes YOLO bleibt verfügbar.</string>
<string name="conn_info_approval_mode_profile_read_only">Schreibgeschützt für dieses multiplexte Profil. Der aktuelle Modus erscheint nach dem Start der Profilsitzung; Änderungen erfordern profilbezogene Konfigurations-RPCs von Upstream.</string>
<string name="conn_info_approval_mode_manual">Manuell</string>
<string name="conn_info_approval_mode_manual_desc">Vor jedem geschützten Werkzeugaufruf nachfragen.</string>
<string name="conn_info_approval_mode_smart">Smart</string>
<string name="conn_info_approval_mode_smart_desc">Nur nachfragen, wenn Hermes ein erhöhtes Risiko erkennt.</string>
<string name="conn_info_approval_mode_off">Aus</string>
<string name="conn_info_approval_mode_off_desc">Bestätigungen für dieses Profil dauerhaft umgehen.</string>
<string name="agent_send_sms">SMS senden</string>
<string name="agent_search_contacts">Kontakte suchen</string>
<string name="agent_screenshot">Screenshot</string>
<string name="agent_return_hermes">Zurück zu Hermes</string>
<string name="agent_open_app">App öffnen</string>
<string name="agent_key_press">Tastendruck</string>
<string name="agent_call">Anrufen</string>
<string name="agent_bridge_setup">Bridge-Einrichtung</string>
<plurals name="chat_scroll_bottom_unread">
<item quantity="one">Nach unten scrollen, %1$d ungelesene Nachricht</item>
<item quantity="other">Nach unten scrollen, %1$d ungelesene Nachrichten</item>
</plurals>
<string name="model_picker_refreshing">Wird aktualisiert</string>
<string name="model_picker_refresh">Aktualisieren</string>
<string name="model_picker_other">Sonstige</string>
<string name="model_picker_model">Modell</string>
<string name="input_voice_session">Sprach-Session</string>
<string name="input_needs_setup">Einrichtung erforderlich</string>
<string name="input_live_voice">Live-Sprachgespräch</string>
<string name="image_on_server">dieses Bild befindet sich auf dem Server</string>
<string name="image_inline">Inline-Bild</string>
<string name="image_generated">Generiertes Bild</string>
<string name="error_classify_realtime_auth_body">Der Realtime-Sprachanbieter fehlt oder hat die serverseitige Authentifizierung abgelehnt. Aktualisiere die Anbieter-Authentifizierung auf dem Relay oder wähle einen anderen Anbieter.</string>
<string name="diag_what_happened">Was ist passiert</string>
<string name="diag_severity_warning">Warnung</string>
<string name="diag_severity_info">Info</string>
<string name="diag_severity_error">Fehler</string>
<string name="diag_export">Diagnose exportieren</string>
<string name="diag_copied">Diagnose kopiert</string>
<string name="demo_wind">Wind</string>
<string name="demo_weather">Teilweise bewölkt</string>
<string name="demo_user_question">Super! Kannst du auch Code schreiben?</string>
<string name="demo_sunset">Sonnenuntergang</string>
<string name="demo_city_name">Aurora Bay</string>
<string name="cmd_cat_software_development">Softwareentwicklung</string>
<string name="cmd_cat_session">Session</string>
<string name="cmd_cat_server">Server</string>
<string name="cmd_cat_personality">Persönlichkeit</string>
<string name="cmd_cat_info">Info</string>
<string name="cmd_cat_configuration">Konfiguration</string>
<string name="cmd_cat_built_in">integriert</string>
<string name="chat_voice_needs_route">Für Sprache wird ein erreichbares Hermes-Dashboard oder eine Relay-Sprachroute benötigt</string>
<string name="chat_stream_sessions">Sessions-Stream</string>
<string name="chat_stream_portable">portabler Stream</string>
<string name="chat_server_default">Server-Standard</string>
<string name="chat_sends_now">↳ wird sofort gesendet – Hermes passt sich während des Turns an</string>
<string name="chat_selecting_route">Route wird ausgewählt</string>
<string name="chat_scroll_bottom">Nach unten scrollen</string>
<string name="chat_failed_read_file">Datei konnte nicht gelesen werden</string>
<string name="chat_dont_ask_again_verb">Nicht erneut nach „%1$s“ fragen</string>
<string name="chat_dont_ask_again">Nicht erneut fragen</string>
<string name="chat_delivered_after_turn">↳ wird nach Abschluss dieses Turns zugestellt</string>
<string name="chat_contacting_server">Server wird kontaktiert</string>
<string name="chat_config_loading">Konfiguration wird geladen</string>
<string name="chat_config_active">aktive Konfiguration geladen</string>
<string name="bubble_voice_action">Sprachaktion</string>
<string name="bubble_voice">Sprache</string>
<string name="bubble_streaming">Streaming</string>
<string name="bubble_realtime_agent">Realtime-Agent</string>
<string name="bubble_phone_action">Telefonaktion</string>
<string name="bubble_moa_advisor">Antwort des Mixture-of-Agents-Beraters</string>
<string name="bubble_advisor_unavailable">Berater nicht verfügbar.</string>
<string name="bubble_advisor_prefix">Berater: </string>
<string name="bg_processes_title">Hintergrundprozess</string>
<string name="bg_processes_running">Läuft</string>
<string name="bg_processes_recent">Kürzlich</string>
<string name="bg_processes_open">Hintergrundprozesse öffnen</string>
<string name="bg_processes_empty">Keine Hintergrundprozesse in diesem Chat</string>
<string name="bg_expand_output">Prozessausgabe ausklappen</string>
<string name="bg_collapse_output">Prozessausgabe einklappen</string>
<string name="badge_unknown_error">Unbekannter Fehler</string>
<string name="badge_tool_failed">Tool-Fehler</string>
<string name="badge_stopped">Gestoppt</string>
<string name="badge_skill">Skill</string>
<string name="badge_response_interrupted">Antwort unterbrochen</string>
<string name="badge_model_changed">Modell geändert</string>
<string name="badge_memory">Memory</string>
<string name="badge_error">Fehler</string>
<string name="badge_continued">Nach einem unterbrochenen Turn fortgesetzt</string>
<string name="badge_bg_work_completed">Hintergrundarbeit abgeschlossen</string>
<string name="badge_artifact">Artefakt</string>
<string name="attach_tap_reveal">tippen zum Anzeigen</string>
<string name="attach_tap_download">Zum Herunterladen tippen</string>
<string name="attach_open_external">Extern öffnen</string>
<string name="attach_cant_read_image">Dieses Bild konnte nicht gelesen werden</string>
<string name="attach_cant_open_image">Dieses Bild konnte nicht geöffnet werden</string>
<string name="attach_cant_action_image">Diese Bildaktion konnte nicht ausgeführt werden</string>
<string name="onboarding_server_url">Gib die URL deines Relay-Servers ein, um zu starten.</string>
<string name="onboarding_lets_connect">Jetzt verbinden</string>
<string name="onboarding_custom_slot">Benutzerdefinierter Inhaltsslot</string>
<string name="onboarding_talk_to_agent">Sprich mit deinem Agenten</string>
<string name="onboarding_stream_desc">Streame Gespräche mit jedem Hermes-Profil. Stelle Fragen, führe Aufgaben aus und arbeite in Echtzeit zusammen</string>
<string name="power_bridge_desc">Erlaube Hermes, genehmigte Bridge-Befehle an dieses Telefon zu senden.</string>
<string name="power_bridge">Bridge</string>
<string name="path_transport_path">Transportpfad</string>
<string name="path_show_routes">Routen anzeigen</string>
<string name="path_session_details">Session-Details</string>
<string name="path_live_thinking">Live-Denken</string>
<string name="path_capability_live">diese Funktion ist aktiv</string>
<string name="slash_voice_mode">Sprachmodus umschalten</string>
<string name="slash_tool_progress">Anzeige des Tool-Fortschritts umschalten</string>
<string name="slash_token_usage">Token-Verbrauch anzeigen</string>
<string name="slash_switch_model">Modell für diese Session wechseln</string>
<string name="slash_side_question">Nebenfrage mit Session-Kontext</string>
<string name="slash_set_title">Titel für diese Session festlegen</string>
<string name="slash_session_info">Session-Info anzeigen</string>
<string name="slash_retry_last">Letzte Nachricht wiederholen</string>
<string name="slash_resume">Vorherige Session fortsetzen</string>
<string name="slash_remove_exchange">Letzten Austausch entfernen</string>
<string name="slash_reload_mcp">MCP-Server neu laden</string>
<string name="slash_reasoning">Reasoning-Aufwand festlegen</string>
<string name="slash_queue_prompt">Prompt für den nächsten Turn in die Warteschlange stellen</string>
<string name="slash_providers">Verfügbare Anbieter anzeigen</string>
<string name="slash_personality">Vordefinierte Persönlichkeit festlegen</string>
<string name="slash_new_session">Neue Session starten</string>
<string name="slash_kill_bg">Laufende Hintergrundprozesse beenden</string>
<string name="slash_deny">Ausstehenden Befehl ablehnen</string>
<string name="slash_compress">Gesprächskontext komprimieren</string>
<string name="slash_commands">Verfügbare Befehle anzeigen</string>
<string name="slash_clear_personality">Persönlichkeits-Overlay entfernen</string>
<string name="slash_checkpoints">Checkpoints anzeigen oder wiederherstellen</string>
<string name="slash_browse">Alle Befehle durchsuchen</string>
<string name="slash_background_prompt">Prompt im Hintergrund ausführen</string>
<string name="slash_auto_approve">Auto-Genehmigung umschalten</string>
<string name="slash_approve">Ausstehenden Befehl genehmigen</string>
<string name="slash_analytics">Nutzungsanalyse</string>
<string name="slash_active_profile">Aktives Profil anzeigen</string>
<string name="power_terminal_desc">Öffne eine Server-Shell über deine gekoppelte Relay-Session.</string>
<string name="power_terminal">Terminal</string>
<plurals name="timeline_summary_pass">
<item quantity="one">%1$d bestanden</item>
<item quantity="other">%1$d bestanden</item>
</plurals>
<plurals name="timeline_summary_warn">
<item quantity="one">%1$d Warnung</item>
<item quantity="other">%1$d Warnungen</item>
</plurals>
<plurals name="timeline_summary_fail">
<item quantity="one">%1$d fehlgeschlagen</item>
<item quantity="other">%1$d fehlgeschlagen</item>
</plurals>
<plurals name="timeline_events_count">
<item quantity="one">%1$d Ereignis</item>
<item quantity="other">%1$d Ereignisse</item>
</plurals>
<string name="voice_standard_host_config">Standard-Hermes · Host-Konfiguration</string>
<string name="voice_reading_host_config">Host-Sprachkonfiguration wird gelesen…</string>
<string name="voice_provider_standard">Standard-Hermes</string>
<string name="voice_preset_quiet_visual">Leise / nur visuell</string>
<string name="voice_preset_quiet">Leise</string>
<string name="voice_preset_manual_values">Deine manuellen Werte entsprechen keinem Preset genau.</string>
<string name="voice_preset_low_latency">Niedrige Latenz</string>
<string name="voice_preset_keep_unchanged">Engine, Route, Anbieter, Modell, Stimme und Anmeldedaten bleiben unverändert.</string>
<string name="voice_preset_hands_free">Freihändig</string>
<string name="voice_preset_fast">Schnell</string>
<string name="voice_preset_custom">Benutzerdefiniert</string>
<string name="voice_preset_careful_tools">Vorsichtige Tools</string>
<string name="voice_preset_careful">Vorsichtig</string>
<string name="voice_preset_applied">Preset „%1$s“ angewendet</string>
<string name="voice_open_manage_provider">Öffne Manage, um den Sprach-Anbieter des Hosts zu konfigurieren</string>
<string name="voice_connection_interrupted">Die Sprachverbindung wurde unterbrochen. Tippe auf das Mikrofon, um es erneut zu versuchen.</string>
<string name="tool_preparing">Tool wird vorbereitet…</string>
<string name="tool_name_write_file">Datei schreiben</string>
<string name="tool_name_web_search">Websuche</string>
<string name="tool_name_web_extract">Seite extrahieren</string>
<string name="tool_name_vision">Bildanalyse</string>
<string name="tool_name_tts">Sprachausgabe</string>
<string name="tool_name_todo">Aufgaben</string>
<string name="tool_name_terminal">Terminal</string>
<string name="tool_name_skill">Skill</string>
<string name="tool_name_session">Session-Suche</string>
<string name="tool_name_read_file">Datei lesen</string>
<string name="tool_name_process">Prozesse</string>
<string name="tool_name_memory">Memory</string>
<string name="tool_name_file">Datei</string>
<string name="tool_name_execute_code">Code ausführen</string>
<string name="tool_name_delegate">Aufgabe delegieren</string>
<string name="tool_name_cron">Scheduler</string>
<string name="tool_name_computer">Computer-Nutzung</string>
<string name="tool_name_android">Telefonaktion</string>
<string name="timeline_no_checks">keine Prüfungen</string>
<string name="thinking_title">Denkprozess</string>
<string name="thinking_thinking_short">Denkt nach</string>
<string name="thinking_thinking">Denkt nach…</string>
<string name="task_status_working">In Arbeit</string>
<string name="task_status_needs_input">Eingabe erforderlich</string>
<string name="task_status_failed">Fehlgeschlagen</string>
<string name="task_status_delivering">Wird zugestellt</string>
<string name="task_status_complete">Abgeschlossen</string>
<string name="task_status_cancelled">Abgebrochen</string>
<string name="task_expand_timeline">Aufgaben-Timeline ausklappen</string>
<string name="task_collapse_timeline">Aufgaben-Timeline einklappen</string>
<string name="task_background_prefix">Hintergrundaufgabe, </string>
<string name="voice_settings_host_behavior_title">Hermes host behavior</string>
<string name="voice_settings_host_behavior_body">These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.</string>
<string name="voice_settings_choose_provider_title">Choose provider</string>
<string name="voice_settings_change_provider">Change provider</string>
<string name="voice_settings_voice_output_label">Voice output</string>
<string name="voice_settings_enabled_status">Enabled</string>
<string name="voice_settings_disabled_status">Disabled</string>
<string name="voice_settings_model_and_voice_title">Model &amp; voice</string>
<string name="voice_settings_uses_host_config">Uses host configuration</string>
<string name="voice_settings_configured_in_standard">Configured in Standard Hermes</string>
<string name="voice_settings_answer_delivery_title">Answer delivery</string>
<string name="voice_settings_output_route_label">Output route</string>
<string name="voice_settings_manage_provider">Manage provider</string>
<string name="voice_settings_host_wide_standard_desc">Host-wide Standard Hermes settings</string>
<string name="voice_settings_refreshes_from_provider">Refreshes from the provider</string>
<string name="voice_settings_save_changes">Save changes</string>
<string name="voice_settings_high_quality">High quality</string>
<string name="voice_settings_provider_default">Provider default</string>
<string name="voice_settings_needs_setup">Needs setup</string>
<string name="voice_settings_key_required">Key required</string>
<string name="voice_settings_mode_preset_title">Mode preset</string>
<string name="voice_settings_mode_preset_desc">Tune interaction, interruption, trace, and long-task delivery together.</string>
<string name="voice_settings_realtime_behavior_title">Real-time behavior</string>
<string name="voice_settings_label_host">Host</string>
<string name="voice_settings_choose_model">Modell auswählen</string>
<string name="voice_settings_view_all_voices">Alle %1$d Stimmen anzeigen</string>
<string name="voice_settings_show_fewer_voices">Weniger Stimmen anzeigen</string>
<string name="voice_overlay_compact">Kompakt</string>
<string name="hermes_card_hold_hint">~1s</string>
<string name="voice_uses_host_config">Verwendet die bestehende Konfiguration dieses Hermes-Hosts</string>
<string name="wizard_pairing_code">Pairing-Code</string>
<string name="wizard_pair_command">Befehl „hermes pair“</string>
</resources>
+294 -4
View File
@@ -599,8 +599,8 @@
<string name="settings_profile_lock_desc">Anclar la aplicación a un perfil de agente</string>
<string name="settings_quick_controls">Controles rápidos</string>
<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_persistent_connection_desc">Mantener la conexión aunque no haya ningún chat en curso</string>
<string name="settings_connect_on_demand">Los chats activos siguen conectados automáticamente · la conexión inactiva se cierra para ahorrar batería</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>
@@ -1076,7 +1076,7 @@
<string name="dev_settings_relay_features">Características de Relay</string>
<string name="dev_settings_relay_features_desc">Mostrar el servidor Relay y la configuración de emparejamiento para el desarrollo de Bridge/Terminal</string>
<string name="dev_settings_realtime_voice_lab">Laboratorio de voz en tiempo real</string>
<string name="dev_settings_realtime_voice_lab_desc">Abra el banco de pruebas del proveedor websocket para compilaciones de desarrollo.</string>
<string name="dev_settings_realtime_voice_lab_desc">Revisa la configuración de voz en tiempo real y ejecuta una prueba de micrófono</string>
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratorio de voz en tiempo real</string>
<string name="dev_settings_lock_dev_options">Bloquear opciones de desarrollador</string>
<string name="dev_settings_lock_dev_options_desc">Ocultar esta sección y desactivar las funciones experimentales</string>
@@ -1107,6 +1107,7 @@
<string name="dev_settings_export_failed">Exportación fallida</string>
<string name="dev_settings_imported">Configuraciones importadas</string>
<string name="dev_settings_import_failed">Error al importar: archivo no válido</string>
<string name="dev_settings_reset_failed">Error al restablecer</string>
<string name="dev_settings_export_sensitive_backup_title">¿Exportar copia de seguridad confidencial?</string>
<string name="dev_settings_export_sensitive_backup_body">Esta copia de seguridad incluye conexiones guardadas, claves API, tokens de sesión relay, ID de dispositivos y cookies del panel. Cualquier persona que tenga el archivo podrá acceder a su servidor Hermes.</string>
<string name="dev_settings_export_action">Exportar</string>
@@ -1441,6 +1442,8 @@
<string name="voice_settings_save_realtime_agent">Guardar agente en tiempo real</string>
<string name="voice_settings_global_controls_title">Controles de voz globales</string>
<string name="voice_settings_global_controls_desc">Estas configuraciones se aplican a ambos motores de voz, en todos los perfiles.</string>
<string name="voice_settings_final_answer_only">Solo la respuesta final</string>
<string name="voice_settings_final_answer_only_desc">Reproduce únicamente la respuesta definitiva. El progreso de herramientas, las actualizaciones de servicio y los comentarios intermedios permanecen visuales.</string>
<string name="voice_settings_interaction_mode">Modo de interacción</string>
<string name="voice_settings_interaction_tap">Toca para hablar</string>
<string name="voice_settings_interaction_hold">Espera para hablar</string>
@@ -1902,8 +1905,12 @@
<string name="voice_overlay_collapse_cd">Contraer controles de voz</string>
<string name="voice_overlay_expand_cd">Ampliar los controles de voz</string>
<string name="voice_overlay_exit_cd">Salir del modo de voz</string>
<string name="voice_overlay_compact">Compacto</string>
<string name="voice_overlay_focus">Enfocar</string>
<string name="voice_overlay_conversation">Conversación</string>
<string name="voice_overlay_image_ready">Imagen lista</string>
<string name="voice_overlay_rich_result_ready">Resultado enriquecido listo</string>
<string name="voice_overlay_rich_results_count">%1$d resultados listos</string>
<string name="voice_overlay_view_conversation">Ver conversación</string>
<string name="voice_overlay_overlay">Cubrir</string>
<string name="voice_overlay_exit">Salida</string>
<string name="voice_overlay_settings_cd">Configuraciones de voz</string>
@@ -2043,6 +2050,19 @@
<string name="conn_info_confirms_on_next_message">Confirma en el siguiente mensaje</string>
<string name="conn_info_connect">Conectar</string>
<string name="conn_info_connected">Conectado</string>
<string name="conn_info_active_configuration">Configuración activa</string>
<string name="conn_info_inherited">Heredado</string>
<string name="conn_info_bypassed">Omitido</string>
<string name="conn_info_customize_identity">Personalizar identidad</string>
<string name="conn_info_agent_passport">Pasaporte del agente</string>
<string name="conn_info_chat_override">Anulación del chat</string>
<string name="conn_info_fast_tier">Nivel rápido</string>
<string name="conn_info_fast">Rápido</string>
<string name="conn_info_context">Contexto</string>
<string name="conn_info_profile_already_bypasses">El perfil ya omite las aprobaciones.</string>
<string name="conn_info_start_new_chat">Iniciar nuevo chat</string>
<string name="conn_info_approval_mode_short_desc">Elige cómo se aplican las aprobaciones.</string>
<string name="conn_info_approval_policy">Política de aprobación</string>
<string name="conn_info_connecting">Conectando…</string>
<string name="conn_info_connection">Conexión</string>
<string name="conn_info_connection_state">Estado de conexión</string>
@@ -2342,6 +2362,23 @@
<string name="attachment_unmute">Dejar de silenciar</string>
<string name="attachment_mute">Silenciar</string>
<string name="attachment_title">Adjunto</string>
<plurals name="attachment_group_count">
<item quantity="one">%1$d archivo adjunto</item>
<item quantity="other">%1$d archivos adjuntos</item>
</plurals>
<string name="attachment_group_named">%1$s · %2$s</string>
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
<string name="attachment_group_typed_more">%1$s +%2$d</string>
<string name="attachment_group_collapse">Contraer archivos adjuntos</string>
<string name="attachment_group_expand">Expandir archivos adjuntos</string>
<string name="attachment_group_collapsed">Contraído</string>
<string name="attachment_group_expanded">Expandido</string>
<string name="attachment_type_image">Imagen</string>
<string name="attachment_type_video">Vídeo</string>
<string name="attachment_type_audio">Audio</string>
<string name="attachment_type_pdf">PDF</string>
<string name="attachment_type_text">Texto</string>
<string name="attachment_type_file">Archivo</string>
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
<string name="crash_dismiss">Descartar</string>
@@ -2897,6 +2934,7 @@
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_language_russian">Русский</string>
<string name="chat_profile_history_unavailable">No se pudo acceder al historial de conversaciones del perfil activo. Vuelve a conectarte e inténtalo de nuevo.</string>
<string name="conn_info_profile_available">Disponible</string>
<string name="conn_info_profile_available_desc">Disponible cuando se necesite; las conversaciones pueden iniciarse o reanudarse</string>
@@ -3022,4 +3060,256 @@
<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>
<string name="dashboard_component_connected">conectado</string>
<string name="dashboard_component_server_errors_5m">errores del servidor / 5 min</string>
<string name="appearance_image_generation_style">Generación de imágenes</string>
<string name="appearance_image_generation_style_desc">Alterna entre las tres animaciones de progreso o conserva un estilo para cada generación.</string>
<string name="appearance_image_generation_rotate">Alternar</string>
<string name="appearance_image_generation_grid">Cuadrícula</string>
<string name="appearance_image_generation_sphere">Esfera</string>
<string name="appearance_image_generation_nodes">Nodos</string>
<string name="dev_settings_image_generation_lab">Laboratorio de generación de imágenes</string>
<string name="dev_settings_image_generation_lab_desc">Previsualiza los ciclos de generación y la revelación final de la imagen</string>
<string name="dev_settings_open_image_generation_lab_cd">Abrir el laboratorio de generación de imágenes</string>
<string name="dashboard_native_signin_opening">Completa el inicio de sesión en el navegador y vuelve aquí.</string>
<string name="dashboard_native_signin_cancelled">Inicio de sesión en el navegador cancelado.</string>
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
<string name="conn_info_approval_mode_desc">Política persistente para este perfil de Hermes. Se aplica a todos los chats y dispositivos.</string>
<string name="conn_info_approval_mode_unsupported">Actualiza Hermes para elegir un modo de aprobación del perfil. YOLO por chat seguirá disponible.</string>
<string name="conn_info_approval_mode_profile_read_only">Solo lectura para este perfil multiplexado. El modo actual aparece al iniciar la sesión del perfil; cambiarlo requiere RPC de configuración por perfil de upstream.</string>
<string name="conn_info_approval_mode_manual">Manual</string>
<string name="conn_info_approval_mode_manual_desc">Preguntar antes de cada llamada a una herramienta protegida.</string>
<string name="conn_info_approval_mode_smart">Inteligente</string>
<string name="conn_info_approval_mode_smart_desc">Preguntar solo cuando Hermes detecte un riesgo elevado.</string>
<string name="conn_info_approval_mode_off">Desactivado</string>
<string name="conn_info_approval_mode_off_desc">Omitir permanentemente las aprobaciones para este perfil.</string>
<string name="agent_send_sms">Enviar SMS</string>
<string name="agent_search_contacts">Buscar contactos</string>
<string name="agent_screenshot">Captura de pantalla</string>
<string name="agent_return_hermes">Volver a Hermes</string>
<string name="agent_open_app">Abrir app</string>
<string name="agent_key_press">Pulsación de tecla</string>
<string name="agent_call">Llamar</string>
<string name="agent_bridge_setup">Configuración de Bridge</string>
<plurals name="chat_scroll_bottom_unread">
<item quantity="one">Desplazarse abajo, %1$d mensaje sin leer</item>
<item quantity="other">Desplazarse abajo, %1$d mensajes sin leer</item>
</plurals>
<string name="model_picker_refreshing">Actualizando</string>
<string name="model_picker_refresh">Actualizar</string>
<string name="model_picker_other">Otro</string>
<string name="model_picker_model">Modelo</string>
<string name="input_voice_session">sesión de voz</string>
<string name="input_needs_setup">requiere configuración</string>
<string name="input_live_voice">Conversación de voz en directo</string>
<string name="image_on_server">esta imagen está en el servidor</string>
<string name="image_inline">imagen integrada</string>
<string name="image_generated">Imagen generada</string>
<string name="error_classify_realtime_auth_body">El proveedor de voz en tiempo real no existe o rechazó la autenticación del servidor. Actualiza la autenticación del proveedor en el relay o elige otro proveedor.</string>
<string name="diag_what_happened">Qué sucedió</string>
<string name="diag_severity_warning">Advertencia</string>
<string name="diag_severity_info">Información</string>
<string name="diag_severity_error">Error</string>
<string name="diag_export">Exportar diagnóstico</string>
<string name="diag_copied">Diagnóstico copiado</string>
<string name="demo_wind">Viento</string>
<string name="demo_weather">Parcialmente nublado</string>
<string name="demo_user_question">¡Genial! ¿También sabes escribir código?</string>
<string name="demo_sunset">Atardecer</string>
<string name="demo_city_name">Bahía Aurora</string>
<string name="cmd_cat_software_development">Desarrollo de software</string>
<string name="cmd_cat_session">sesión</string>
<string name="cmd_cat_server">servidor</string>
<string name="cmd_cat_personality">personalidad</string>
<string name="cmd_cat_info">información</string>
<string name="cmd_cat_configuration">configuración</string>
<string name="cmd_cat_built_in">integrada</string>
<string name="chat_voice_needs_route">La voz necesita un dashboard de Hermes accesible o una ruta de voz de Relay</string>
<string name="chat_stream_sessions">stream de sesiones</string>
<string name="chat_stream_portable">stream portátil</string>
<string name="chat_server_default">Predeterminado del servidor</string>
<string name="chat_sends_now">↳ se envía ahora — Hermes se ajusta durante el turno</string>
<string name="chat_selecting_route">seleccionando ruta</string>
<string name="chat_scroll_bottom">Desplazarse al final</string>
<string name="chat_failed_read_file">No se pudo leer el archivo</string>
<string name="chat_dont_ask_again_verb">No volver a preguntar por "%1$s"</string>
<string name="chat_dont_ask_again">No volver a preguntar</string>
<string name="chat_delivered_after_turn">↳ se entregará al terminar este turno</string>
<string name="chat_contacting_server">contactando con el servidor</string>
<string name="chat_config_loading">cargando configuración</string>
<string name="chat_config_active">configuración activa cargada</string>
<string name="bubble_voice_action">Acción de voz</string>
<string name="bubble_voice">Voz</string>
<string name="bubble_streaming">streaming</string>
<string name="bubble_realtime_agent">Agente en tiempo real</string>
<string name="bubble_phone_action">Acción en el teléfono</string>
<string name="bubble_moa_advisor">Respuesta del asesor Mixture of Agents</string>
<string name="bubble_advisor_unavailable">Asesor no disponible.</string>
<string name="bubble_advisor_prefix">Asesor: </string>
<string name="bg_processes_title">Proceso en segundo plano</string>
<string name="bg_processes_running">En ejecución</string>
<string name="bg_processes_recent">Recientes</string>
<string name="bg_processes_open">Abrir procesos en segundo plano</string>
<string name="bg_processes_empty">No hay procesos en segundo plano en este chat</string>
<string name="bg_expand_output">Expandir salida del proceso</string>
<string name="bg_collapse_output">Contraer salida del proceso</string>
<string name="badge_unknown_error">Error desconocido</string>
<string name="badge_tool_failed">Fallo de herramienta</string>
<string name="badge_stopped">Detenido</string>
<string name="badge_skill">Habilidad</string>
<string name="badge_response_interrupted">Respuesta interrumpida</string>
<string name="badge_model_changed">Modelo cambiado</string>
<string name="badge_memory">Memoria</string>
<string name="badge_error">Error</string>
<string name="badge_continued">Continuado tras un turno interrumpido</string>
<string name="badge_bg_work_completed">Trabajo en segundo plano completado</string>
<string name="badge_artifact">Artefacto</string>
<string name="attach_tap_reveal">toca para revelar</string>
<string name="attach_tap_download">Toca para descargar</string>
<string name="attach_open_external">Abrir externamente</string>
<string name="attach_cant_read_image">No se pudo leer esta imagen</string>
<string name="attach_cant_open_image">No se pudo abrir esta imagen</string>
<string name="attach_cant_action_image">No se pudo completar esa acción con la imagen</string>
<string name="onboarding_server_url">Introduce la URL de tu servidor relay para empezar.</string>
<string name="onboarding_lets_connect">Conectémonos</string>
<string name="onboarding_custom_slot">Ranura de contenido personalizado</string>
<string name="onboarding_talk_to_agent">Habla con tu agente</string>
<string name="onboarding_stream_desc">Transmite conversaciones con cualquier perfil de Hermes. Haz preguntas, ejecuta tareas y colabora en tiempo real</string>
<string name="power_bridge_desc">Permite que Hermes envíe comandos bridge aprobados a este teléfono.</string>
<string name="power_bridge">Bridge</string>
<string name="path_transport_path">ruta de transporte</string>
<string name="path_show_routes">Mostrar rutas</string>
<string name="path_session_details">Detalles de la sesión</string>
<string name="path_live_thinking">Pensamiento en directo</string>
<string name="path_capability_live">esta capacidad está activa</string>
<string name="slash_voice_mode">Activar/desactivar modo de voz</string>
<string name="slash_tool_progress">Alternar la visualización de progreso de herramientas</string>
<string name="slash_token_usage">Mostrar uso de tokens</string>
<string name="slash_switch_model">Cambiar el modelo de esta sesión</string>
<string name="slash_side_question">Pregunta paralela usando el contexto de la sesión</string>
<string name="slash_set_title">Definir un título para esta sesión</string>
<string name="slash_session_info">Mostrar información de la sesión</string>
<string name="slash_retry_last">Reintentar el último mensaje</string>
<string name="slash_resume">Reanudar una sesión anterior</string>
<string name="slash_remove_exchange">Eliminar el último intercambio</string>
<string name="slash_reload_mcp">Recargar servidores MCP</string>
<string name="slash_reasoning">Definir el nivel de esfuerzo de razonamiento</string>
<string name="slash_queue_prompt">Poner un prompt en cola para el siguiente turno</string>
<string name="slash_providers">Mostrar proveedores disponibles</string>
<string name="slash_personality">Definir una personalidad predefinida</string>
<string name="slash_new_session">Iniciar una nueva sesión</string>
<string name="slash_kill_bg">Detener procesos en segundo plano</string>
<string name="slash_deny">Rechazar un comando pendiente</string>
<string name="slash_compress">Comprimir el contexto de la conversación</string>
<string name="slash_commands">Mostrar comandos disponibles</string>
<string name="slash_clear_personality">Borrar la superposición de personalidad</string>
<string name="slash_checkpoints">Listar o restaurar checkpoints</string>
<string name="slash_browse">Explorar todos los comandos</string>
<string name="slash_background_prompt">Ejecutar un prompt en segundo plano</string>
<string name="slash_auto_approve">Activar/desactivar modo de aprobación automática</string>
<string name="slash_approve">Aprobar un comando pendiente</string>
<string name="slash_analytics">Analíticas de uso</string>
<string name="slash_active_profile">Mostrar perfil activo</string>
<string name="power_terminal_desc">Abre un shell del servidor mediante tu sesión relay vinculada.</string>
<string name="power_terminal">Terminal</string>
<plurals name="timeline_summary_pass">
<item quantity="one">%1$d exitoso</item>
<item quantity="other">%1$d exitosos</item>
</plurals>
<plurals name="timeline_summary_warn">
<item quantity="one">%1$d advertencia</item>
<item quantity="other">%1$d advertencias</item>
</plurals>
<plurals name="timeline_summary_fail">
<item quantity="one">%1$d con error</item>
<item quantity="other">%1$d con errores</item>
</plurals>
<plurals name="timeline_events_count">
<item quantity="one">%1$d evento</item>
<item quantity="other">%1$d eventos</item>
</plurals>
<string name="voice_standard_host_config">Hermes estándar · Configuración del host</string>
<string name="voice_reading_host_config">Leyendo la configuración de voz del host…</string>
<string name="voice_provider_standard">Hermes estándar</string>
<string name="voice_preset_quiet_visual">Silencioso / solo visual</string>
<string name="voice_preset_quiet">Silencioso</string>
<string name="voice_preset_manual_values">Tus valores manuales no coinciden exactamente con ningún preset.</string>
<string name="voice_preset_low_latency">Baja latencia</string>
<string name="voice_preset_keep_unchanged">El motor, la ruta, el proveedor, el modelo, la voz y las credenciales permanecen sin cambios.</string>
<string name="voice_preset_hands_free">Manos libres</string>
<string name="voice_preset_fast">Rápido</string>
<string name="voice_preset_custom">Personalizado</string>
<string name="voice_preset_careful_tools">Herramientas cuidadosas</string>
<string name="voice_preset_careful">Cuidadoso</string>
<string name="voice_preset_applied">Preset %1$s aplicado</string>
<string name="voice_open_manage_provider">Abre Manage para configurar el proveedor de voz del host</string>
<string name="voice_connection_interrupted">La conexión de voz se interrumpió. Toca el micrófono para intentarlo de nuevo.</string>
<string name="tool_preparing">Preparando herramienta…</string>
<string name="tool_name_write_file">Escribir archivo</string>
<string name="tool_name_web_search">Búsqueda web</string>
<string name="tool_name_web_extract">Extraer página</string>
<string name="tool_name_vision">Análisis de imagen</string>
<string name="tool_name_tts">Texto a voz</string>
<string name="tool_name_todo">Tareas</string>
<string name="tool_name_terminal">Terminal</string>
<string name="tool_name_skill">Habilidad</string>
<string name="tool_name_session">Búsqueda de sesiones</string>
<string name="tool_name_read_file">Leer archivo</string>
<string name="tool_name_process">Procesos</string>
<string name="tool_name_memory">Memoria</string>
<string name="tool_name_file">Archivo</string>
<string name="tool_name_execute_code">Ejecutar código</string>
<string name="tool_name_delegate">Delegar tarea</string>
<string name="tool_name_cron">Programador</string>
<string name="tool_name_computer">Uso del ordenador</string>
<string name="tool_name_android">Acción en el teléfono</string>
<string name="timeline_no_checks">sin comprobaciones</string>
<string name="thinking_title">Proceso de pensamiento</string>
<string name="thinking_thinking_short">Pensando</string>
<string name="thinking_thinking">Pensando…</string>
<string name="task_status_working">Trabajando</string>
<string name="task_status_needs_input">Requiere entrada</string>
<string name="task_status_failed">Fallido</string>
<string name="task_status_delivering">Entregando</string>
<string name="task_status_complete">Completado</string>
<string name="task_status_cancelled">Cancelado</string>
<string name="task_expand_timeline">Expandir línea temporal de la tarea</string>
<string name="task_collapse_timeline">Contraer línea temporal de la tarea</string>
<string name="task_background_prefix">Tarea en segundo plano, </string>
<string name="voice_settings_label_host">Anfitrión</string>
<string name="voice_settings_choose_model">Elegir un modelo</string>
<string name="voice_settings_view_all_voices">Ver las %1$d voces</string>
<string name="voice_settings_show_fewer_voices">Mostrar menos voces</string>
<string name="voice_overlay_compact">Compacto</string>
<string name="hermes_card_hold_hint">~1s</string>
<string name="voice_uses_host_config">Usa la configuración existente de este host de Hermes</string>
<string name="voice_settings_host_behavior_title">Hermes host behavior</string>
<string name="voice_settings_host_behavior_body">These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.</string>
<string name="voice_settings_choose_provider_title">Choose provider</string>
<string name="voice_settings_change_provider">Change provider</string>
<string name="voice_settings_voice_output_label">Voice output</string>
<string name="voice_settings_enabled_status">Enabled</string>
<string name="voice_settings_disabled_status">Disabled</string>
<string name="voice_settings_model_and_voice_title">Model &amp; voice</string>
<string name="voice_settings_uses_host_config">Uses host configuration</string>
<string name="voice_settings_configured_in_standard">Configured in Standard Hermes</string>
<string name="voice_settings_answer_delivery_title">Answer delivery</string>
<string name="voice_settings_output_route_label">Output route</string>
<string name="voice_settings_manage_provider">Manage provider</string>
<string name="voice_settings_host_wide_standard_desc">Host-wide Standard Hermes settings</string>
<string name="voice_settings_refreshes_from_provider">Refreshes from the provider</string>
<string name="voice_settings_save_changes">Save changes</string>
<string name="voice_settings_high_quality">High quality</string>
<string name="voice_settings_provider_default">Provider default</string>
<string name="voice_settings_needs_setup">Needs setup</string>
<string name="voice_settings_key_required">Key required</string>
<string name="voice_settings_mode_preset_title">Mode preset</string>
<string name="voice_settings_mode_preset_desc">Tune interaction, interruption, trace, and long-task delivery together.</string>
<string name="voice_settings_realtime_behavior_title">Real-time behavior</string>
<string name="wizard_pairing_code">Código de emparejamiento</string>
<string name="wizard_pair_command">comando hermes pair</string>
</resources>
+293 -4
View File
@@ -672,8 +672,8 @@
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
<string name="settings_quick_controls">クイックコントロール</string>
<string name="settings_persistent_connection">永続的な接続</string>
<string name="settings_persistent_connection_desc">Hermes への接続をバッ​​クグラウンドで開いたままにします</string>
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
<string name="settings_persistent_connection_desc">チャットが実行されていないときも接続を維持</string>
<string name="settings_connect_on_demand">アクティブなチャットは自動的に接続を維持 · アイドル時はバッテリー節約のため接続を閉じます</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>
@@ -1133,6 +1133,7 @@
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_language_russian">Русский</string>
<string name="appearance_appearance">外観</string>
<string name="appearance_light_dark">ライト/ダーク</string>
<string name="appearance_fixed_light">%1$s は固定のライトテーマです。</string>
@@ -1201,7 +1202,7 @@
<string name="dev_settings_relay_features">Relay の特徴</string>
<string name="dev_settings_relay_features_desc">Relay サーバーと Bridge/ターミナル開発用のペアリング設定を表示</string>
<string name="dev_settings_realtime_voice_lab">リアルタイム音声ラボ</string>
<string name="dev_settings_realtime_voice_lab_desc">開発ビルド用のプロバイダー WebSocket テストベンチを開きます</string>
<string name="dev_settings_realtime_voice_lab_desc">リアルタイム音声の設定を確認し、マイクの集中テストを実行します</string>
<string name="dev_settings_open_realtime_voice_lab_cd">リアルタイム音声ラボを開く</string>
<string name="dev_settings_lock_dev_options">開発者向けオプションをロックする</string>
<string name="dev_settings_lock_dev_options_desc">このセクションを非表示にし、実験的な機能を無効にします</string>
@@ -1232,6 +1233,7 @@
<string name="dev_settings_export_failed">エクスポートに失敗しました</string>
<string name="dev_settings_imported">インポートされた設定</string>
<string name="dev_settings_import_failed">インポートに失敗しました - 無効なファイル</string>
<string name="dev_settings_reset_failed">リセットに失敗しました</string>
<string name="dev_settings_export_sensitive_backup_title">機密性の高いバックアップをエクスポートしますか?</string>
<string name="dev_settings_export_sensitive_backup_body">このバックアップには、保存された接続、API キー、Relay セッション トークン、デバイス ID、およびダッシュボード Cookie が含まれます。ファイルを持っている人は誰でも、Hermes サーバーにアクセスできる可能性があります。</string>
<string name="dev_settings_export_action">輸出</string>
@@ -1578,6 +1580,8 @@
<string name="voice_settings_save_realtime_agent">リアルタイムエージェントを保存する</string>
<string name="voice_settings_global_controls_title">グローバル音声コントロール</string>
<string name="voice_settings_global_controls_desc">これらの設定は、すべてのプロファイルの両方の音声エンジンに適用されます。</string>
<string name="voice_settings_final_answer_only">最終回答のみ</string>
<string name="voice_settings_final_answer_only_desc">確定した回答だけを読み上げます。ツールの進行状況、サービス更新、中間コメントは画面表示のみになります。</string>
<string name="voice_settings_interaction_mode">インタラクションモード</string>
<string name="voice_settings_interaction_tap">タップして話す</string>
<string name="voice_settings_interaction_hold">押し続けて話す</string>
@@ -2103,8 +2107,12 @@
<string name="voice_overlay_collapse_cd">音声コントロールを折りたたむ</string>
<string name="voice_overlay_expand_cd">音声コントロールを拡張する</string>
<string name="voice_overlay_exit_cd">音声モードを終了する</string>
<string name="voice_overlay_compact">コンパクト</string>
<string name="voice_overlay_focus">集中</string>
<string name="voice_overlay_conversation">会話</string>
<string name="voice_overlay_image_ready">画像の準備ができました</string>
<string name="voice_overlay_rich_result_ready">リッチな結果の準備ができました</string>
<string name="voice_overlay_rich_results_count">結果が%1$d件準備できました</string>
<string name="voice_overlay_view_conversation">会話を表示</string>
<string name="voice_overlay_overlay">かぶせる</string>
<string name="voice_overlay_exit">出口</string>
<string name="voice_overlay_settings_cd">音声設定</string>
@@ -2248,6 +2256,19 @@
<string name="conn_info_confirms_on_next_message">次のメッセージで確認します</string>
<string name="conn_info_connect">接続する</string>
<string name="conn_info_connected">接続済み</string>
<string name="conn_info_active_configuration">有効な構成</string>
<string name="conn_info_inherited">継承</string>
<string name="conn_info_bypassed">バイパス</string>
<string name="conn_info_customize_identity">アイデンティティをカスタマイズ</string>
<string name="conn_info_agent_passport">エージェントパスポート</string>
<string name="conn_info_chat_override">チャットの上書き</string>
<string name="conn_info_fast_tier">高速ティア</string>
<string name="conn_info_fast">高速</string>
<string name="conn_info_context">コンテキスト</string>
<string name="conn_info_profile_already_bypasses">承認はプロファイルですでにバイパスされています。</string>
<string name="conn_info_start_new_chat">新しいチャットを開始</string>
<string name="conn_info_approval_mode_short_desc">承認を適用する方法を選択します。</string>
<string name="conn_info_approval_policy">承認ポリシー</string>
<string name="conn_info_connecting">接続中…</string>
<string name="conn_info_connection">繋がり</string>
<string name="conn_info_connection_state">接続状態</string>
@@ -2597,6 +2618,22 @@
<string name="attachment_unmute">ミュートを解除する</string>
<string name="attachment_mute">ミュート</string>
<string name="attachment_title">アタッチメント</string>
<plurals name="attachment_group_count">
<item quantity="other">添付ファイル %1$d 件</item>
</plurals>
<string name="attachment_group_named">%1$s · %2$s</string>
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
<string name="attachment_group_typed_more">%1$s +%2$d</string>
<string name="attachment_group_collapse">添付ファイルを折りたたむ</string>
<string name="attachment_group_expand">添付ファイルを展開する</string>
<string name="attachment_group_collapsed">折りたたみ済み</string>
<string name="attachment_group_expanded">展開済み</string>
<string name="attachment_type_image">画像</string>
<string name="attachment_type_video">動画</string>
<string name="attachment_type_audio">音声</string>
<string name="attachment_type_pdf">PDF</string>
<string name="attachment_type_text">テキスト</string>
<string name="attachment_type_file">ファイル</string>
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
@@ -3337,4 +3374,256 @@
<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>
<string name="dashboard_component_connected">接続済み</string>
<string name="dashboard_component_server_errors_5m">サーバーエラー / 5分</string>
<string name="appearance_image_generation_style">画像生成</string>
<string name="appearance_image_generation_style_desc">3種類の進行アニメーションを順番に使うか、毎回同じスタイルを使用します。</string>
<string name="appearance_image_generation_rotate">ローテーション</string>
<string name="appearance_image_generation_grid">グリッド</string>
<string name="appearance_image_generation_sphere">球体</string>
<string name="appearance_image_generation_nodes">ノード</string>
<string name="dev_settings_image_generation_lab">画像生成ラボ</string>
<string name="dev_settings_image_generation_lab_desc">生成ループと最終画像の表示をプレビューします</string>
<string name="dev_settings_open_image_generation_lab_cd">画像生成ラボを開く</string>
<string name="dashboard_native_signin_opening">ブラウザでサインインを完了してから、ここに戻ってください。</string>
<string name="dashboard_native_signin_cancelled">ブラウザでのサインインをキャンセルしました。</string>
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
<string name="conn_info_approval_mode_desc">このHermesプロフィールに対する永続的なポリシーです。すべてのチャットとデバイスに適用されます。</string>
<string name="conn_info_approval_mode_unsupported">プロフィール承認モードを選択するにはHermesを更新してください。チャット単位のYOLOは引き続き利用できます。</string>
<string name="conn_info_approval_mode_profile_read_only">この多重化プロフィールでは読み取り専用です。現在のモードはプロフィールセッション開始後に表示され、変更にはupstreamのプロフィール別設定RPCが必要です。</string>
<string name="conn_info_approval_mode_manual">手動</string>
<string name="conn_info_approval_mode_manual_desc">保護されたツール呼び出しのたびに確認します。</string>
<string name="conn_info_approval_mode_smart">スマート</string>
<string name="conn_info_approval_mode_smart_desc">Hermesが高いリスクを検出した場合にのみ確認します。</string>
<string name="conn_info_approval_mode_off">オフ</string>
<string name="conn_info_approval_mode_off_desc">このプロフィールの承認を常に省略します。</string>
<string name="agent_send_sms">SMS を送信</string>
<string name="agent_search_contacts">連絡先を検索</string>
<string name="agent_screenshot">スクリーンショット</string>
<string name="agent_return_hermes">Hermes に戻る</string>
<string name="agent_open_app">アプリを開く</string>
<string name="agent_key_press">キー押下</string>
<string name="agent_call">通話</string>
<string name="agent_bridge_setup">Bridge セットアップ</string>
<plurals name="chat_scroll_bottom_unread">
<item quantity="one">下にスクロール、未読 %1$d 件</item>
<item quantity="other">下にスクロール、未読 %1$d 件</item>
</plurals>
<string name="model_picker_refreshing">更新中…</string>
<string name="model_picker_refresh">更新</string>
<string name="model_picker_other">その他</string>
<string name="model_picker_model">モデル</string>
<string name="input_voice_session">音声セッション</string>
<string name="input_needs_setup">セットアップが必要</string>
<string name="input_live_voice">ライブ音声会話</string>
<string name="image_on_server">この画像はサーバー上にあります</string>
<string name="image_inline">インライン画像</string>
<string name="image_generated">生成された画像</string>
<string name="error_classify_realtime_auth_body">リアルタイム音声プロバイダーが存在しないか、サーバー側の認証が拒否されました。Relay でプロバイダー認証を更新するか、別のプロバイダーを選択してください。</string>
<string name="diag_what_happened">何が起きましたか</string>
<string name="diag_severity_warning">警告</string>
<string name="diag_severity_info">情報</string>
<string name="diag_severity_error">エラー</string>
<string name="diag_export">診断情報をエクスポート</string>
<string name="diag_copied">診断情報をコピーしました</string>
<string name="demo_wind">風</string>
<string name="demo_weather">晴れ時々曇り</string>
<string name="demo_user_question">いいね!コードも書けるの?</string>
<string name="demo_sunset">夕焼け</string>
<string name="demo_city_name">オーロラ・ベイ</string>
<string name="cmd_cat_software_development">ソフトウェア開発</string>
<string name="cmd_cat_session">セッション</string>
<string name="cmd_cat_server">サーバー</string>
<string name="cmd_cat_personality">パーソナリティ</string>
<string name="cmd_cat_info">情報</string>
<string name="cmd_cat_configuration">設定</string>
<string name="cmd_cat_built_in">組み込み</string>
<string name="chat_voice_needs_route">音声を使用するには、到達可能な Hermes ダッシュボードまたは Relay 音声ルートが必要です</string>
<string name="chat_stream_sessions">セッションストリーム</string>
<string name="chat_stream_portable">ポータブルストリーム</string>
<string name="chat_server_default">サーバーのデフォルト</string>
<string name="chat_sends_now">↳ 今すぐ送信 — Hermes がターン中に調整します</string>
<string name="chat_selecting_route">ルートを選択中…</string>
<string name="chat_scroll_bottom">一番下までスクロール</string>
<string name="chat_failed_read_file">ファイルを読み取れませんでした</string>
<string name="chat_dont_ask_again_verb">「%1$s」について今後は確認しない</string>
<string name="chat_dont_ask_again">今後は確認しない</string>
<string name="chat_delivered_after_turn">↳ このターンが終了した後に配信</string>
<string name="chat_contacting_server">サーバーに接続中…</string>
<string name="chat_config_loading">設定を読み込み中…</string>
<string name="chat_config_active">アクティブな設定を読み込みました</string>
<string name="bubble_voice_action">音声アクション</string>
<string name="bubble_voice">音声</string>
<string name="bubble_streaming">ストリーミング</string>
<string name="bubble_realtime_agent">リアルタイムエージェント</string>
<string name="bubble_phone_action">電話アクション</string>
<string name="bubble_moa_advisor">Mixture of Agents アドバイザーの応答</string>
<string name="bubble_advisor_unavailable">アドバイザーは利用できません。</string>
<string name="bubble_advisor_prefix">アドバイザー: </string>
<string name="bg_processes_title">バックグラウンドプロセス</string>
<string name="bg_processes_running">実行中</string>
<string name="bg_processes_recent">最近</string>
<string name="bg_processes_open">バックグラウンドプロセスを開く</string>
<string name="bg_processes_empty">このチャットにバックグラウンドプロセスはありません</string>
<string name="bg_expand_output">プロセス出力を展開</string>
<string name="bg_collapse_output">プロセス出力を折りたたむ</string>
<string name="badge_unknown_error">不明なエラー</string>
<string name="badge_tool_failed">ツールエラー</string>
<string name="badge_stopped">停止</string>
<string name="badge_skill">スキル</string>
<string name="badge_response_interrupted">応答が中断されました</string>
<string name="badge_model_changed">モデル変更</string>
<string name="badge_memory">メモリ</string>
<string name="badge_error">エラー</string>
<string name="badge_continued">中断されたターンの後に続行</string>
<string name="badge_bg_work_completed">バックグラウンド作業が完了</string>
<string name="badge_artifact">アーティファクト</string>
<string name="attach_tap_reveal">タップして表示</string>
<string name="attach_tap_download">タップしてダウンロード</string>
<string name="attach_open_external">外部で開く</string>
<string name="attach_cant_read_image">この画像を読み取れませんでした</string>
<string name="attach_cant_open_image">この画像を開けませんでした</string>
<string name="attach_cant_action_image">画像アクションを完了できませんでした</string>
<string name="onboarding_server_url">開始するにはリレーサーバーの URL を入力してください。</string>
<string name="onboarding_lets_connect">接続しましょう</string>
<string name="onboarding_custom_slot">カスタムコンテンツスロット</string>
<string name="onboarding_talk_to_agent">エージェントと話す</string>
<string name="onboarding_stream_desc">任意の Hermes プロファイルと会話をストリーミング。質問したり、タスクを実行したり、リアルタイムで共同作業できます</string>
<string name="power_bridge_desc">Hermes が承認済みの Bridge コマンドをこの電話に送信できるようにします。</string>
<string name="power_bridge">Bridge</string>
<string name="path_transport_path">転送パス</string>
<string name="path_show_routes">ルートを表示</string>
<string name="path_session_details">セッションの詳細</string>
<string name="path_live_thinking">リアルタイム思考</string>
<string name="path_capability_live">この機能は有効です</string>
<string name="slash_voice_mode">音声モードを切り替え</string>
<string name="slash_tool_progress">ツール進捗表示を切り替え</string>
<string name="slash_token_usage">トークン使用量を表示</string>
<string name="slash_switch_model">このセッションのモデルを切り替え</string>
<string name="slash_side_question">セッションコンテキストを使ったサイド質問</string>
<string name="slash_set_title">このセッションにタイトルを設定</string>
<string name="slash_session_info">セッション情報を表示</string>
<string name="slash_retry_last">最後のメッセージを再試行</string>
<string name="slash_resume">以前のセッションを再開</string>
<string name="slash_remove_exchange">最後のやり取りを削除</string>
<string name="slash_reload_mcp">MCP サーバーを再読み込み</string>
<string name="slash_reasoning">推論努力レベルを設定</string>
<string name="slash_queue_prompt">次のターンのプロンプトをキューに入れる</string>
<string name="slash_providers">利用可能なプロバイダーを表示</string>
<string name="slash_personality">プリセットのパーソナリティを設定</string>
<string name="slash_new_session">新しいセッションを開始</string>
<string name="slash_kill_bg">実行中のバックグラウンドプロセスを終了</string>
<string name="slash_deny">保留中のコマンドを拒否</string>
<string name="slash_compress">会話コンテキストを圧縮</string>
<string name="slash_commands">利用可能なコマンドを表示</string>
<string name="slash_clear_personality">パーソナリティオーバーレイをクリア</string>
<string name="slash_checkpoints">チェックポイントを一覧表示または復元</string>
<string name="slash_browse">すべてのコマンドを閲覧</string>
<string name="slash_background_prompt">バックグラウンドでプロンプトを実行</string>
<string name="slash_auto_approve">自動承認モードを切り替え</string>
<string name="slash_approve">保留中のコマンドを承認</string>
<string name="slash_analytics">使用状況の分析</string>
<string name="slash_active_profile">アクティブなプロファイルを表示</string>
<string name="power_terminal_desc">ペアリング済みの Relay セッション経由でサーバーシェルを開きます。</string>
<string name="power_terminal">ターミナル</string>
<plurals name="timeline_summary_pass">
<item quantity="one">%1$d 成功</item>
<item quantity="other">%1$d 成功</item>
</plurals>
<plurals name="timeline_summary_warn">
<item quantity="one">%1$d 警告</item>
<item quantity="other">%1$d 警告</item>
</plurals>
<plurals name="timeline_summary_fail">
<item quantity="one">%1$d 失敗</item>
<item quantity="other">%1$d 失敗</item>
</plurals>
<plurals name="timeline_events_count">
<item quantity="one">%1$d イベント</item>
<item quantity="other">%1$d イベント</item>
</plurals>
<string name="voice_standard_host_config">標準 Hermes · ホスト設定</string>
<string name="voice_settings_realtime_behavior_title">リアルタイム動作</string>
<string name="voice_settings_mode_preset_title">モードプリセット</string>
<string name="voice_settings_mode_preset_desc">操作、割り込み、トレース、長時間タスクの配信をまとめて調整します。</string>
<string name="voice_settings_enabled_status">有効</string>
<string name="voice_settings_disabled_status">無効</string>
<string name="voice_reading_host_config">ホストの音声設定を読み込み中…</string>
<string name="voice_provider_standard">標準 Hermes</string>
<string name="voice_preset_quiet_visual">静音 / 表示のみ</string>
<string name="voice_preset_quiet">静音</string>
<string name="voice_preset_manual_values">手動設定の値はプリセットと完全には一致していません。</string>
<string name="voice_preset_low_latency">低遅延</string>
<string name="voice_preset_keep_unchanged">エンジン、ルート、プロバイダー、モデル、音声、認証情報は変更されません。</string>
<string name="voice_preset_hands_free">ハンズフリー</string>
<string name="voice_preset_fast">高速</string>
<string name="voice_preset_custom">カスタム</string>
<string name="voice_preset_careful_tools">慎重なツール</string>
<string name="voice_preset_careful">慎重</string>
<string name="voice_preset_applied">%1$s プリセットを適用しました</string>
<string name="voice_open_manage_provider">Manage を開いてホストの音声プロバイダーを設定</string>
<string name="voice_connection_interrupted">音声接続が中断されました。マイクをタップしてもう一度お試しください。</string>
<string name="tool_preparing">ツールを準備中…</string>
<string name="tool_name_write_file">ファイル書き込み</string>
<string name="tool_name_web_search">ウェブ検索</string>
<string name="tool_name_web_extract">ページ抽出</string>
<string name="tool_name_vision">画像分析</string>
<string name="tool_name_tts">音声合成</string>
<string name="tool_name_todo">タスク</string>
<string name="tool_name_terminal">ターミナル</string>
<string name="tool_name_skill">スキル</string>
<string name="tool_name_session">セッション検索</string>
<string name="tool_name_read_file">ファイル読み取り</string>
<string name="tool_name_process">プロセス</string>
<string name="tool_name_memory">メモリ</string>
<string name="tool_name_file">ファイル</string>
<string name="tool_name_execute_code">コード実行</string>
<string name="tool_name_delegate">タスク委任</string>
<string name="tool_name_cron">スケジューラー</string>
<string name="tool_name_computer">コンピューター操作</string>
<string name="tool_name_android">電話アクション</string>
<string name="timeline_no_checks">チェックなし</string>
<string name="thinking_title">思考プロセス</string>
<string name="thinking_thinking_short">考え中</string>
<string name="thinking_thinking">考え中…</string>
<string name="task_status_working">作業中</string>
<string name="task_status_needs_input">入力が必要</string>
<string name="task_status_failed">失敗</string>
<string name="task_status_delivering">配信中</string>
<string name="task_status_complete">完了</string>
<string name="task_status_cancelled">キャンセル済み</string>
<string name="task_expand_timeline">タスクタイムラインを展開</string>
<string name="task_collapse_timeline">タスクタイムラインを折りたたむ</string>
<string name="task_background_prefix">バックグラウンドタスク、 </string>
<string name="voice_overlay_compact">コンパクト</string>
<string name="hermes_card_hold_hint">~1秒</string>
<string name="voice_uses_host_config">この Hermes ホストの既存設定を使用します</string>
<string name="voice_settings_host_behavior_title">Hermes host behavior</string>
<string name="voice_settings_host_behavior_body">These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.</string>
<string name="voice_settings_choose_provider_title">Choose provider</string>
<string name="voice_settings_change_provider">Change provider</string>
<string name="voice_settings_voice_output_label">Voice output</string>
<string name="voice_settings_model_and_voice_title">Model &amp; voice</string>
<string name="voice_settings_uses_host_config">Uses host configuration</string>
<string name="voice_settings_configured_in_standard">Configured in Standard Hermes</string>
<string name="voice_settings_answer_delivery_title">Answer delivery</string>
<string name="voice_settings_output_route_label">Output route</string>
<string name="voice_settings_manage_provider">Manage provider</string>
<string name="voice_settings_host_wide_standard_desc">Host-wide Standard Hermes settings</string>
<string name="voice_settings_refreshes_from_provider">Refreshes from the provider</string>
<string name="voice_settings_save_changes">Save changes</string>
<string name="voice_settings_high_quality">High quality</string>
<string name="voice_settings_provider_default">Provider default</string>
<string name="voice_settings_needs_setup">Needs setup</string>
<string name="voice_settings_key_required">Key required</string>
<string name="voice_settings_label_host">Host</string>
<string name="voice_settings_choose_model">Choose a model</string>
<string name="voice_settings_view_all_voices">View all %1$d voices</string>
<string name="voice_settings_show_fewer_voices">Show fewer voices</string>
<string name="wizard_pairing_code">ペアリングコード</string>
<string name="wizard_pair_command">hermes pair コマンド</string>
</resources>
File diff suppressed because it is too large Load Diff
+295 -5
View File
@@ -202,6 +202,8 @@
<string name="conn_label_session">Session</string>
<string name="conn_label_route">Route</string>
<string name="conn_label_status">Status</string>
<string name="dashboard_component_connected">connected</string>
<string name="dashboard_component_server_errors_5m">server errors / 5m</string>
<string name="conn_label_connect">Connect</string>
<string name="conn_label_connections">Connections</string>
<string name="conn_detail_add_connection">Add a Vanilla Hermes API/dashboard connection</string>
@@ -689,8 +691,8 @@
<string name="settings_profile_lock_desc">Pin the app to one agent profile</string>
<string name="settings_quick_controls">Quick Controls</string>
<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_persistent_connection_desc">Stay connected even when no chat is running</string>
<string name="settings_connect_on_demand">Active chats stay connected automatically · idle connection closes to save battery</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>
@@ -1214,6 +1216,7 @@
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_language_russian">Русский</string>
<string name="appearance_appearance">Appearance</string>
<string name="appearance_light_dark">Light / Dark</string>
<string name="appearance_fixed_light">%1$s is a fixed light theme.</string>
@@ -1223,6 +1226,12 @@
<string name="appearance_font">Font</string>
<string name="appearance_font_desc">Sets the typeface across the whole app. Code and timestamps stay monospaced.</string>
<string name="appearance_animation">Animation</string>
<string name="appearance_image_generation_style">Image generation</string>
<string name="appearance_image_generation_style_desc">Rotate through all three progress animations, or keep one style for every generation.</string>
<string name="appearance_image_generation_rotate">Rotate</string>
<string name="appearance_image_generation_grid">Grid</string>
<string name="appearance_image_generation_sphere">Sphere</string>
<string name="appearance_image_generation_nodes">Nodes</string>
<string name="appearance_ascii_sphere">ASCII sphere</string>
<string name="appearance_ascii_sphere_desc">Show animated sphere on empty chat screen and ambient mode</string>
<string name="appearance_behind_messages">Behind messages</string>
@@ -1282,8 +1291,11 @@
<string name="dev_settings_relay_features">Relay features</string>
<string name="dev_settings_relay_features_desc">Show Relay Server and Pairing settings for Bridge/Terminal development</string>
<string name="dev_settings_realtime_voice_lab">Realtime voice lab</string>
<string name="dev_settings_realtime_voice_lab_desc">Open the provider websocket testbench for dev builds</string>
<string name="dev_settings_realtime_voice_lab_desc">Inspect realtime voice setup and run a focused microphone test</string>
<string name="dev_settings_open_realtime_voice_lab_cd">Open realtime voice lab</string>
<string name="dev_settings_image_generation_lab">Image generation lab</string>
<string name="dev_settings_image_generation_lab_desc">Preview generation loops and the final image reveal</string>
<string name="dev_settings_open_image_generation_lab_cd">Open image generation lab</string>
<string name="dev_settings_lock_dev_options">Lock developer options</string>
<string name="dev_settings_lock_dev_options_desc">Hide this section and disable experimental features</string>
<string name="dev_settings_locked_toast">Developer options locked</string>
@@ -1313,6 +1325,7 @@
<string name="dev_settings_export_failed">Export failed</string>
<string name="dev_settings_imported">Settings imported</string>
<string name="dev_settings_import_failed">Import failed — invalid file</string>
<string name="dev_settings_reset_failed">Reset failed</string>
<string name="dev_settings_export_sensitive_backup_title">Export sensitive backup?</string>
<string name="dev_settings_export_sensitive_backup_body">This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server.</string>
<string name="dev_settings_export_action">Export</string>
@@ -1670,6 +1683,8 @@
<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>
<string name="voice_settings_final_answer_only">Final answer only</string>
<string name="voice_settings_final_answer_only_desc">Speak only the settled answer. Tool progress, service updates, and intermediate commentary stay visual.</string>
<string name="voice_settings_interaction_mode">Interaction mode</string>
<string name="voice_settings_interaction_tap">Tap to talk</string>
<string name="voice_settings_interaction_hold">Hold to talk</string>
@@ -1850,6 +1865,10 @@
<string name="dashboard_oauth_verifying">Verifying dashboard session…</string>
<string name="dashboard_oauth_not_accepted">Sign-in was not accepted yet. Finish the dashboard flow to continue.</string>
<string name="dashboard_oauth_verify_failed">Dashboard sign-in verification failed</string>
<string name="dashboard_native_signin_opening">Complete sign-in in your browser, then return here.</string>
<string name="dashboard_native_signin_cancelled">Browser sign-in cancelled.</string>
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
<string name="dashboard_close_signin">Close sign-in</string>
<!-- Error body -->
@@ -2195,8 +2214,12 @@
<string name="voice_overlay_collapse_cd">Collapse voice controls</string>
<string name="voice_overlay_expand_cd">Expand voice controls</string>
<string name="voice_overlay_exit_cd">Exit voice mode</string>
<string name="voice_overlay_compact">Compact</string>
<string name="voice_overlay_focus">Focus</string>
<string name="voice_overlay_conversation">Conversation</string>
<string name="voice_overlay_focus">Voice focus</string>
<string name="voice_overlay_image_ready">Image ready</string>
<string name="voice_overlay_rich_result_ready">Rich result ready</string>
<string name="voice_overlay_rich_results_count">%1$d results ready</string>
<string name="voice_overlay_view_conversation">View conversation</string>
<string name="voice_overlay_overlay">Overlay</string>
<string name="voice_overlay_exit">Exit</string>
<string name="voice_overlay_settings_cd">Voice settings</string>
@@ -2342,6 +2365,19 @@
<string name="conn_info_confirms_on_next_message">Confirms on next message</string>
<string name="conn_info_connect">Connect</string>
<string name="conn_info_connected">Connected</string>
<string name="conn_info_active_configuration">Active configuration</string>
<string name="conn_info_inherited">Inherited</string>
<string name="conn_info_bypassed">Bypassed</string>
<string name="conn_info_customize_identity">Customize identity</string>
<string name="conn_info_agent_passport">Agent Passport</string>
<string name="conn_info_chat_override">Chat override</string>
<string name="conn_info_fast_tier">Fast tier</string>
<string name="conn_info_fast">Fast</string>
<string name="conn_info_context">Context</string>
<string name="conn_info_profile_already_bypasses">Approvals already bypassed by profile.</string>
<string name="conn_info_start_new_chat">Start new chat</string>
<string name="conn_info_approval_mode_short_desc">Choose how approvals are enforced.</string>
<string name="conn_info_approval_policy">Approval policy</string>
<string name="conn_info_connecting">Connecting…</string>
<string name="conn_info_connection">Connection</string>
<string name="conn_info_connection_state">Connection state</string>
@@ -2462,7 +2498,19 @@
<string name="conn_info_yes">Yes</string>
<string name="conn_info_yes_hidden">Yes (hidden)</string>
<string name="conn_info_yolo_mode_desc">Bypass approval prompts for tool calls.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Bypass approval prompts for this chat only. Resets when the session changes.</string>
<string name="conn_info_yolo_mode_profile_off">Profile approvals are Off, so this chat already bypasses prompts. Choose Manual or Smart before using the per-chat override.</string>
<string name="conn_info_yolo_mode_title">YOLO mode</string>
<string name="conn_info_approval_mode_title">Profile approval mode</string>
<string name="conn_info_approval_mode_desc">Persistent policy for this Hermes profile. Applies across chats and devices.</string>
<string name="conn_info_approval_mode_unsupported">Update Hermes to choose a profile approval mode. Per-chat YOLO remains available.</string>
<string name="conn_info_approval_mode_profile_read_only">Read-only for this multiplexed profile. Its current mode appears after the profile session starts; changing it requires upstream profile-scoped config RPCs.</string>
<string name="conn_info_approval_mode_manual">Manual</string>
<string name="conn_info_approval_mode_manual_desc">Ask before every protected tool call.</string>
<string name="conn_info_approval_mode_smart">Smart</string>
<string name="conn_info_approval_mode_smart_desc">Ask only when Hermes detects elevated risk.</string>
<string name="conn_info_approval_mode_off">Off</string>
<string name="conn_info_approval_mode_off_desc">Persistently bypass approvals for this profile.</string>
<!-- EndpointsCard -->
@@ -2691,6 +2739,23 @@
<string name="attachment_unmute">Unmute</string>
<string name="attachment_mute">Mute</string>
<string name="attachment_title">Attachment</string>
<plurals name="attachment_group_count">
<item quantity="one">%1$d attachment</item>
<item quantity="other">%1$d attachments</item>
</plurals>
<string name="attachment_group_named">%1$s · %2$s</string>
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
<string name="attachment_group_typed_more">%1$s +%2$d</string>
<string name="attachment_group_collapse">Collapse attachments</string>
<string name="attachment_group_expand">Expand attachments</string>
<string name="attachment_group_collapsed">Collapsed</string>
<string name="attachment_group_expanded">Expanded</string>
<string name="attachment_type_image">Image</string>
<string name="attachment_type_video">Video</string>
<string name="attachment_type_audio">Audio</string>
<string name="attachment_type_pdf">PDF</string>
<string name="attachment_type_text">Text</string>
<string name="attachment_type_file">File</string>
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
@@ -3343,4 +3408,229 @@
<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>
<string name="onboarding_talk_to_agent">Talk to Your Agent</string>
<string name="onboarding_stream_desc">Stream conversations with any Hermes profile. Ask questions, run tasks, and collaborate in real time</string>
<string name="onboarding_lets_connect">Let\'s Connect</string>
<string name="onboarding_server_url">Enter your relay server URL to get started.</string>
<string name="onboarding_custom_slot">Custom content slot</string>
<string name="wizard_pairing_code">Pairing code</string>
<string name="wizard_pair_command">hermes pair command</string>
<string name="model_picker_other">Other</string>
<string name="model_picker_model">Model</string>
<string name="model_picker_refreshing">Refreshing</string>
<string name="model_picker_refresh">Refresh</string>
<string name="demo_city_name">Aurora Bay</string>
<string name="demo_weather">Partly cloudy</string>
<string name="demo_wind">Wind</string>
<string name="demo_sunset">Sunset</string>
<string name="demo_user_question">Nice! Can you write code too?</string>
<string name="input_live_voice">Live voice conversation</string>
<string name="error_classify_realtime_auth_body">The realtime voice provider is missing or rejected server-side auth. Refresh provider auth on the relay or choose another provider.</string>
<string name="voice_settings_host_behavior_title">Hermes host behavior</string>
<string name="voice_settings_host_behavior_body">These settings control voice mode on the Hermes host. They do not change the phone microphone controls on the Listening tab.</string>
<string name="voice_settings_choose_provider_title">Choose provider</string>
<string name="voice_settings_change_provider">Change provider</string>
<string name="voice_settings_voice_output_label">Voice output</string>
<string name="voice_settings_enabled_status">Enabled</string>
<string name="voice_settings_disabled_status">Disabled</string>
<string name="voice_settings_model_and_voice_title">Model &amp; voice</string>
<string name="voice_settings_uses_host_config">Uses host configuration</string>
<string name="voice_settings_configured_in_standard">Configured in Standard Hermes</string>
<string name="voice_settings_answer_delivery_title">Answer delivery</string>
<string name="voice_settings_output_route_label">Output route</string>
<string name="voice_settings_manage_provider">Manage provider</string>
<string name="voice_settings_host_wide_standard_desc">Host-wide Standard Hermes settings</string>
<string name="voice_settings_refreshes_from_provider">Refreshes from the provider</string>
<string name="voice_settings_save_changes">Save changes</string>
<string name="voice_settings_high_quality">High quality</string>
<string name="voice_settings_provider_default">Provider default</string>
<string name="voice_settings_needs_setup">Needs setup</string>
<string name="voice_settings_key_required">Key required</string>
<string name="voice_settings_mode_preset_title">Mode preset</string>
<string name="voice_settings_mode_preset_desc">Tune interaction, interruption, trace, and long-task delivery together.</string>
<string name="voice_preset_hands_free">Hands-free</string>
<string name="voice_preset_fast">Fast</string>
<string name="voice_preset_careful">Careful</string>
<string name="voice_preset_careful_tools">Careful tools</string>
<string name="voice_preset_quiet">Quiet</string>
<string name="voice_preset_quiet_visual">Quiet / visual-only</string>
<string name="voice_preset_custom">Custom</string>
<string name="voice_preset_manual_values">Your manual values do not exactly match a preset.</string>
<string name="voice_preset_keep_unchanged">Engine, route, provider, model, voice, and credentials stay unchanged.</string>
<string name="voice_preset_low_latency">Low latency</string>
<string name="voice_preset_applied">%1$s preset applied</string>
<string name="voice_settings_label_host">Host</string>
<string name="voice_settings_choose_model">Choose a model</string>
<string name="voice_settings_view_all_voices">View all %1$d voices</string>
<string name="voice_settings_show_fewer_voices">Show fewer voices</string>
<string name="voice_settings_realtime_behavior_title">Real-time behavior</string>
<string name="voice_overlay_compact">Compact</string>
<string name="voice_connection_interrupted">Voice connection was interrupted. Tap the mic to try again.</string>
<string name="diag_severity_info">Info</string>
<string name="diag_severity_warning">Warning</string>
<string name="diag_severity_error">Error</string>
<string name="diag_what_happened">What happened</string>
<string name="diag_copied">Diagnostic copied</string>
<string name="diag_export">Export diagnostic</string>
<string name="hermes_card_hold_hint">~1s</string>
<string name="attach_tap_download">Tap to download</string>
<string name="attach_tap_reveal">tap to reveal</string>
<string name="chat_server_default">Server default</string>
<string name="attach_open_external">Open externally</string>
<string name="timeline_no_checks">no checks</string>
<plurals name="timeline_events_count">
<item quantity="one">%1$d event</item>
<item quantity="other">%1$d events</item>
</plurals>
<plurals name="timeline_summary_fail">
<item quantity="one">%1$d failing</item>
<item quantity="other">%1$d failing</item>
</plurals>
<plurals name="timeline_summary_warn">
<item quantity="one">%1$d warning</item>
<item quantity="other">%1$d warnings</item>
</plurals>
<plurals name="timeline_summary_pass">
<item quantity="one">%1$d passing</item>
<item quantity="other">%1$d passing</item>
</plurals>
<string name="tool_preparing">Preparing tool…</string>
<string name="thinking_title">Thought process</string>
<string name="thinking_thinking">Thinking...</string>
<string name="thinking_thinking_short">Thinking</string>
<string name="task_background_prefix">Background task, </string>
<string name="task_collapse_timeline">Collapse task timeline</string>
<string name="task_expand_timeline">Expand task timeline</string>
<string name="task_status_working">Working</string>
<string name="task_status_needs_input">Needs input</string>
<string name="task_status_delivering">Delivering</string>
<string name="task_status_complete">Complete</string>
<string name="task_status_failed">Failed</string>
<string name="task_status_cancelled">Cancelled</string>
<string name="bubble_voice_action">Voice action</string>
<string name="bubble_phone_action">Phone action</string>
<string name="bubble_voice">Voice</string>
<string name="bubble_realtime_agent">Realtime Agent</string>
<string name="bubble_advisor_unavailable">Advisor unavailable.</string>
<string name="bubble_advisor_prefix">Advisor </string>
<string name="bubble_moa_advisor">Mixture of Agents advisor response</string>
<string name="bubble_streaming">streaming</string>
<string name="path_live_thinking">Live thinking</string>
<string name="path_show_routes">Show routes</string>
<string name="path_session_details">Session details</string>
<string name="path_capability_live">this capability is live</string>
<string name="path_transport_path">transport path</string>
<string name="bg_processes_open">Open background processes</string>
<string name="bg_processes_empty">No background processes in this chat</string>
<string name="bg_processes_running">Running</string>
<string name="bg_processes_recent">Recent</string>
<string name="bg_processes_title">Background process</string>
<string name="bg_collapse_output">Collapse process output</string>
<string name="bg_expand_output">Expand process output</string>
<string name="chat_sends_now">↳ sends now — Hermes adjusts mid-turn</string>
<string name="chat_delivered_after_turn">↳ delivered after this turn finishes</string>
<string name="chat_failed_read_file">Failed to read file</string>
<string name="chat_dont_ask_again">Don\'t ask again</string>
<string name="chat_dont_ask_again_verb">Don\'t ask again for \"%1$s\"</string>
<string name="chat_voice_needs_route">Voice needs a reachable Hermes dashboard or Relay voice route</string>
<string name="chat_scroll_bottom">Scroll to bottom</string>
<string name="chat_stream_sessions">sessions stream</string>
<string name="chat_stream_portable">portable stream</string>
<string name="chat_config_active">active config loaded</string>
<string name="chat_config_loading">loading config</string>
<string name="chat_selecting_route">selecting route</string>
<string name="chat_contacting_server">contacting server</string>
<plurals name="chat_scroll_bottom_unread">
<item quantity="one">Scroll to bottom, %1$d unread message</item>
<item quantity="other">Scroll to bottom, %1$d unread messages</item>
</plurals>
<string name="power_terminal">Terminal</string>
<string name="power_terminal_desc">Open a server shell through your paired relay session.</string>
<string name="power_bridge">Bridge</string>
<string name="power_bridge_desc">Let Hermes send approved bridge commands to this phone.</string>
<string name="voice_provider_standard">Standard Hermes</string>
<string name="voice_reading_host_config">Reading host voice configuration…</string>
<string name="voice_open_manage_provider">Open Manage to configure the host voice provider</string>
<string name="voice_uses_host_config">Uses this Hermes host’s existing configuration</string>
<string name="voice_standard_host_config">Standard Hermes · Host configuration</string>
<string name="input_voice_session">voice session</string>
<string name="input_needs_setup">needs setup</string>
<string name="attach_cant_read_image">Couldn\'t read this image</string>
<string name="attach_cant_action_image">Couldn\'t complete that image action</string>
<string name="attach_cant_open_image">Couldn\'t open this image</string>
<string name="image_on_server">this image is on the server</string>
<string name="image_inline">inline image</string>
<string name="image_generated">Generated image</string>
<string name="slash_new_session">Start a new session</string>
<string name="slash_retry_last">Retry the last message</string>
<string name="slash_remove_exchange">Remove the last exchange</string>
<string name="slash_set_title">Set a title for this session</string>
<string name="slash_compress">Compress conversation context</string>
<string name="slash_checkpoints">List or restore checkpoints</string>
<string name="slash_kill_bg">Kill running background processes</string>
<string name="slash_resume">Resume a previous session</string>
<string name="slash_background_prompt">Run a prompt in the background</string>
<string name="slash_side_question">Side question using session context</string>
<string name="slash_queue_prompt">Queue a prompt for the next turn</string>
<string name="slash_approve">Approve a pending command</string>
<string name="slash_deny">Deny a pending command</string>
<string name="slash_switch_model">Switch model for this session</string>
<string name="slash_providers">Show available providers</string>
<string name="slash_personality">Set a predefined personality</string>
<string name="slash_tool_progress">Cycle tool progress display</string>
<string name="slash_auto_approve">Toggle auto-approve mode</string>
<string name="slash_reasoning">Set reasoning effort level</string>
<string name="slash_voice_mode">Toggle voice mode</string>
<string name="slash_reload_mcp">Reload MCP servers</string>
<string name="slash_commands">Show available commands</string>
<string name="slash_session_info">Show session info</string>
<string name="slash_token_usage">Show token usage</string>
<string name="slash_analytics">Usage analytics</string>
<string name="slash_browse">Browse all commands</string>
<string name="slash_active_profile">Show active profile</string>
<string name="slash_clear_personality">Clear the personality overlay</string>
<string name="cmd_cat_software_development">Software Development</string>
<string name="cmd_cat_built_in">built-in</string>
<string name="cmd_cat_server">server</string>
<string name="cmd_cat_session">session</string>
<string name="cmd_cat_configuration">configuration</string>
<string name="cmd_cat_info">info</string>
<string name="cmd_cat_personality">personality</string>
<string name="tool_name_terminal">Terminal</string>
<string name="tool_name_execute_code">Execute code</string>
<string name="tool_name_read_file">Read file</string>
<string name="tool_name_write_file">Write file</string>
<string name="tool_name_web_search">Web search</string>
<string name="tool_name_web_extract">Extract page</string>
<string name="tool_name_memory">Memory</string>
<string name="tool_name_skill">Skill</string>
<string name="tool_name_delegate">Delegate task</string>
<string name="tool_name_cron">Scheduler</string>
<string name="tool_name_todo">Tasks</string>
<string name="tool_name_process">Processes</string>
<string name="tool_name_session">Session search</string>
<string name="tool_name_vision">Image analysis</string>
<string name="tool_name_computer">Computer use</string>
<string name="tool_name_android">Phone action</string>
<string name="tool_name_tts">Speech</string>
<string name="tool_name_file">File</string>
<string name="badge_tool_failed">Tool failed</string>
<string name="badge_memory">Memory</string>
<string name="badge_skill">Skill</string>
<string name="badge_artifact">Artifact</string>
<string name="badge_response_interrupted">Response interrupted</string>
<string name="badge_unknown_error">Unknown error</string>
<string name="badge_error">Error</string>
<string name="badge_stopped">Stopped</string>
<string name="badge_model_changed">Model changed</string>
<string name="badge_bg_work_completed">Background work completed</string>
<string name="badge_continued">Continued after an interrupted turn</string>
<string name="agent_send_sms">Send SMS</string>
<string name="agent_call">Call</string>
<string name="agent_search_contacts">Search Contacts</string>
<string name="agent_open_app">Open App</string>
<string name="agent_return_hermes">Return to Hermes</string>
<string name="agent_screenshot">Screenshot</string>
<string name="agent_key_press">Key Press</string>
<string name="agent_bridge_setup">Bridge Setup</string>
</resources>
+1
View File
@@ -5,5 +5,6 @@
<locale android:name="es" />
<locale android:name="ja" />
<locale android:name="pt-BR" />
<locale android:name="ru" />
<locale android:name="zh-Hans" />
</locale-config>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sideload flavor strings.
`a11y_description_sideload` is the full-surface description users see when
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
flavor we can be explicit about voice + vision + full device control here:
the sideload user is assumed to be a power user who installed an APK by
hand, not a Play Store customer.
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
flag any mention of "full read/write access" or "hands-free control" as
outside the declared use case.
-->
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">Компаньон уведомлений Hermes Dev</string>
<string name="a11y_description_sideload">Hermes Bridge предоставляет агенту полный доступ для чтения и записи на телефон для управления без помощи рук с помощью голоса и зрения. Все действия записываются в журнал активности, а разрушительные действия требуют вашего подтверждения.</string>
</resources>
@@ -33,6 +33,12 @@ class AppLanguageTest {
assertEquals(AppLanguage.SPANISH, AppLanguage.fromLanguageTags("es-MX"))
}
@Test
fun russianTagsResolveToRussian() {
assertEquals(AppLanguage.RUSSIAN, AppLanguage.fromLanguageTags("ru-RU"))
assertEquals(AppLanguage.RUSSIAN, AppLanguage.fromLanguageTags("ru"))
assertEquals(AppLanguage.RUSSIAN, AppLanguage.fromLanguageTags("ru-UA"))
}
@Test
fun languageOptionsProduceExpectedLocaleLists() {
assertTrue(AppLanguage.SYSTEM_DEFAULT.toLocaleList().isEmpty)
@@ -42,6 +48,7 @@ class AppLanguageTest {
assertEquals("ja", AppLanguage.JAPANESE.toLocaleList().toLanguageTags())
assertEquals("zh-Hans", AppLanguage.SIMPLIFIED_CHINESE.languageTag)
assertEquals("es", AppLanguage.SPANISH.toLocaleList().toLanguageTags())
assertEquals("ru", AppLanguage.RUSSIAN.toLocaleList().toLanguageTags())
assertEquals(
"zh",
AppLanguage.SIMPLIFIED_CHINESE.toLocaleList()[0]?.language,
@@ -126,6 +126,20 @@ class ChatTurnCheckpointStoreTest {
startedAt = 1_002L,
),
),
moaReferences = listOf(
ChatTurnMoaReferenceCheckpoint(
index = 1,
count = 2,
label = "advisor-a",
text = "Safe advice",
),
ChatTurnMoaReferenceCheckpoint(
index = 2,
count = 2,
label = "advisor-b",
available = false,
),
),
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
id = "run-1",
title = "Research",
@@ -111,6 +111,75 @@ class ConnectionDashboardFieldsTest {
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
}
@Test
fun buildRouteCandidates_preservesExplicitSameHostHttpsDashboard() {
val routes = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
dashboardUrl = "https://hermes.example.com:443",
)
assertEquals(1, routes.size)
assertEquals("https://hermes.example.com:443", routes.single().dashboard?.url)
assertEquals("https://hermes.example.com:8643", routes.single().api?.url)
}
@Test
fun reconcileDashboardRoutes_repairsStoredSameHostDerivedPort() {
val stored = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
)
val repaired = Connection.reconcileDashboardRoutes(
dashboardUrl = "https://hermes.example.com:443",
candidates = stored,
)
assertEquals("https://hermes.example.com:443", repaired.single().dashboard?.url)
}
@Test
fun reconcileDashboardRoutes_keepsDifferentHostRoamingDashboard() {
val stored = Connection.buildRouteCandidates(
apiServerUrl = "http://100.71.8.56:8642",
relayUrl = "ws://100.71.8.56:8767",
)
val repaired = Connection.reconcileDashboardRoutes(
dashboardUrl = "https://hermes.example.com:443",
candidates = stored,
)
assertEquals("http://100.71.8.56:9119", repaired.single().dashboard?.url)
}
@Test
fun persistedSecureDashboard_repairsDerivedGatewayRouteOnReload() {
val stored = Connection(
id = "conn-https",
label = "Secure Hermes",
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
tokenStoreKey = "hermes_auth_https",
dashboardUrl = "https://hermes.example.com:443",
routeCandidates = Connection.buildRouteCandidates(
apiServerUrl = "https://hermes.example.com:8643",
relayUrl = "wss://hermes.example.com:8767",
),
)
val reloaded = json.decodeFromString<Connection>(
json.encodeToString(Connection.serializer(), stored),
).withDashboardDefaults()
assertEquals("https://hermes.example.com:443", reloaded.dashboardUrl)
assertEquals(
"https://hermes.example.com:443",
reloaded.routeCandidates.single().dashboard?.url,
)
}
@Test
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
val route = Connection.endpointCandidateFromDashboardUrl(
@@ -0,0 +1,52 @@
package com.hermesandroid.relay.data
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import io.mockk.coJustRun
import io.mockk.coVerify
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Test
import java.io.File
class DataManagerIoTest {
@Test
fun writeBackupFailsWhenProviderReturnsNoOutputStream() = runTest {
val uri = mockk<Uri>()
val resolver = mockk<ContentResolver>()
val context = mockk<Context>()
every { context.contentResolver } returns resolver
every { resolver.openOutputStream(uri) } returns null
val success = DataManager(context).writeBackupToUri(uri, "{}")
assertFalse(success)
}
@Test
fun restoreTreatsEmptyConnectionsAsReplacementSnapshot() = runTest {
val context = mockk<Context>()
val store = mockk<ConnectionStore>()
every { context.filesDir } returns File("build/tmp/data-manager-test/files")
coJustRun {
store.replaceConnections(
connections = emptyList(),
activeConnectionId = null,
startupConnectionId = null,
)
}
DataManager(context, store).restoreConnectionBackup(DataManager.AppBackup())
coVerify(exactly = 1) {
store.replaceConnections(
connections = emptyList(),
activeConnectionId = null,
startupConnectionId = null,
)
}
}
}
@@ -0,0 +1,51 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class FeatureFlagsTest {
private lateinit var context: Context
@Before
fun setUp() = runTest {
context = ApplicationProvider.getApplicationContext()
context.relayDataStore.edit { it.clear() }
}
@After
fun tearDown() = runTest {
context.relayDataStore.edit { it.clear() }
}
@Test
fun missingPreferenceUsesBuildDefault() = runTest {
assertEquals(FeatureFlags.isDevBuild, FeatureFlags.devOptionsUnlocked(context).first())
}
@Test
fun explicitLockOverridesDebugBuildDefault() = runTest {
FeatureFlags.lockDevOptions(context)
assertFalse(FeatureFlags.devOptionsUnlocked(context).first())
}
@Test
fun unlockPersistsAfterExplicitLock() = runTest {
FeatureFlags.lockDevOptions(context)
FeatureFlags.unlockDevOptions(context)
assertTrue(FeatureFlags.devOptionsUnlocked(context).first())
}
}
@@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -65,4 +67,44 @@ class VoicePreferencesRepositoryTest {
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
assertEquals("leo", settings.realtimeVoice)
}
@Test
fun finalAnswerOnlyPersistsGloballyAcrossProfileScopes() = runTest {
assertFalse(repository.settings.first().finalAnswerOnly)
repository.setFinalAnswerOnly(true)
assertTrue(repository.settings.first().finalAnswerOnly)
repository.setActiveScope("connection-a", "coder")
assertTrue(repository.settings.first().finalAnswerOnly)
repository.setActiveScope("connection-b", "writer")
assertTrue(repository.settings.first().finalAnswerOnly)
}
@Test
fun presentationModePersistsGloballyAcrossProfileScopes() = runTest {
assertEquals(
VoicePresentationMode.Focus.storageValue,
repository.settings.first().presentationMode,
)
repository.setPresentationMode(VoicePresentationMode.Conversation)
assertEquals(
VoicePresentationMode.Conversation.storageValue,
repository.settings.first().presentationMode,
)
repository.setActiveScope("connection-a", "coder")
assertEquals(
VoicePresentationMode.Conversation.storageValue,
repository.settings.first().presentationMode,
)
}
@Test
fun unknownPresentationModeFallsBackToFocus() {
assertEquals(VoicePresentationMode.Focus, VoicePresentationMode.fromStorage("unknown"))
assertEquals(VoicePresentationMode.Focus, VoicePresentationMode.fromStorage(null))
}
}
@@ -147,6 +147,28 @@ class RelayVoiceClientRoutingTest {
assertEquals("leo", payload["voice"]?.jsonPrimitive?.content)
}
@Test
fun realtimeAgentSessionSendsFinalAnswerOnlyPolicy() = runTest {
val client = RelayVoiceClient(
context = context,
okHttpClient = httpClient,
relayUrlProvider = { relayUrl(lanServer) },
sessionTokenProvider = { "session-token" },
)
val result = client.runRealtimeAgent(
prompt = "Check Hermes quietly",
inputPcm = ByteArray(0),
finalAnswerOnly = true,
) { _, _ -> }
assertTrue(result.exceptionOrNull()?.message, result.isSuccess)
val request = lanServer.takeRequest(2, TimeUnit.SECONDS)
?: error("missing realtime session request")
val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
assertEquals("true", payload["final_answer_only"]?.jsonPrimitive?.content)
}
@Test
fun voiceOutputSessionResponseParsesResumeMetadata() {
val response = Json.decodeFromString(
@@ -2102,6 +2124,81 @@ class RelayVoiceClientRoutingTest {
)
}
@Test
fun persistentRealtimePromotionUsesSpokenHandoffAsForegroundBoundary() = runBlocking {
val opened = CountDownLatch(1)
val turns = Channel<RealtimeTurnInput>(Channel.UNLIMITED)
val turnCompletions = Channel<Unit>(Channel.UNLIMITED)
val followUpDelivered = CompletableDeferred<Result<Unit>>()
lateinit var socket: ScriptedWebSocket
lateinit var listener: WebSocketListener
lanServer.dispatcher = sessionOnlyDispatcher(
path = "/voice/realtime-agent/session",
body = """
{
"success": true,
"session_id": "realtime-agent-promotion-boundary-test",
"websocket_path": "/voice/realtime-agent/session-test",
"provider": "xai_realtime",
"model": "grok-voice-latest",
"voice": "leo",
"sample_rate": 24000
}
""".trimIndent(),
)
val client = RelayVoiceClient(
context = context,
okHttpClient = httpClient,
relayUrlProvider = { relayUrl(lanServer) },
sessionTokenProvider = { "session-token" },
webSocketFactory = { request, callback ->
listener = callback
socket = ScriptedWebSocket(request, callback) { true }
callback.onOpen(socket, mockk(relaxed = true))
opened.countDown()
socket
},
)
val sessionJob = async(Dispatchers.IO) {
client.runRealtimeAgent(
prompt = "Start a long task",
inputPcm = ByteArray(0),
turnInputs = turns,
onTurnComplete = { turnCompletions.trySend(Unit) },
) { _, _ -> }
}
try {
assertTrue(opened.await(2, TimeUnit.SECONDS))
listener.onMessage(
socket,
"""{"type":"hermes.run.promoted","run_id":"run-silent","spoken_handoff":false}""",
)
withTimeout(2_000) { turnCompletions.receive() }
turns.send(
RealtimeTurnInput(
inputPcm = ByteArray(6_400) { 4 },
deliveryResult = followUpDelivered,
)
)
assertTrue(withTimeout(2_000) { followUpDelivered.await() }.isSuccess)
listener.onMessage(
socket,
"""{"type":"hermes.run.promoted","run_id":"run-spoken","spoken_handoff":true}""",
)
delay(100)
assertTrue(turnCompletions.tryReceive().isFailure)
listener.onMessage(socket, """{"type":"voice.response.done"}""")
withTimeout(2_000) { turnCompletions.receive() }
} finally {
turns.close()
withTimeout(2_000) { sessionJob.await() }
}
}
private fun relayUrl(server: MockWebServer): String =
"ws://${server.hostName}:${server.port}"
@@ -0,0 +1,43 @@
package com.hermesandroid.relay.network.upstream
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ActiveTurnKeepAliveRegistryTest {
@After
fun tearDown() = ActiveTurnKeepAliveRegistry.resetForTest()
@Test
fun siblingSessionsHoldIndependentLeases() {
ActiveTurnKeepAliveRegistry.acquire("connection::victor::session-a")
ActiveTurnKeepAliveRegistry.acquire("connection::default::session-b")
ActiveTurnKeepAliveRegistry.setWaiting("connection::victor::session-a", true)
assertEquals(
ActiveTurnKeepAliveRegistry.Snapshot(activeTurnCount = 2, waitingSessionCount = 1),
ActiveTurnKeepAliveRegistry.snapshot.value,
)
ActiveTurnKeepAliveRegistry.release("connection::victor::session-a")
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
assertEquals(0, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
}
@Test
fun sessionRenameMovesRatherThanDuplicatesLease() {
ActiveTurnKeepAliveRegistry.acquire("temporary")
ActiveTurnKeepAliveRegistry.setWaiting("temporary", true)
ActiveTurnKeepAliveRegistry.rename("temporary", "durable")
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
ActiveTurnKeepAliveRegistry.release("durable")
assertFalse(ActiveTurnKeepAliveRegistry.snapshot.value.required)
}
}
@@ -1,10 +1,12 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentState
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnMoaReferenceCheckpoint
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.MessageRole
@@ -15,6 +17,8 @@ import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
@@ -800,6 +804,57 @@ class ChatHandlerTest {
assertEquals("2 background tasks completed", msg.content)
}
@Test
fun loadMessageHistory_rendersAutoContinueAsNeutralSystemTimelineRow() {
handler.loadMessageHistory(
listOf(
MessageItem(
id = "continue-1",
role = "user",
content = JsonPrimitive("private continuation prompt"),
displayKind = "auto_continue",
),
),
)
val msg = handler.messages.value.single()
assertEquals(MessageRole.SYSTEM, msg.role)
assertEquals("Continued after an interrupted turn", msg.content)
}
@Test
fun reconcileInterimMessage_collapsesProvisionalFinalBubble() {
handler.addPlaceholderMessage(
ChatMessage(
id = "interim",
role = MessageRole.ASSISTANT,
content = "candidate",
timestamp = 1L,
isStreaming = false,
),
)
handler.addPlaceholderMessage(
ChatMessage(
id = "provisional",
role = MessageRole.ASSISTANT,
content = "",
timestamp = 2L,
isStreaming = true,
),
)
handler.reconcileInterimMessage(
interimMessageId = "interim",
currentMessageId = "provisional",
content = "candidate answer",
)
val assistant = handler.messages.value.single()
assertEquals("interim", assistant.id)
assertEquals("candidate answer", assistant.content)
assertTrue(assistant.isStreaming)
}
@Test
fun loadMessageHistory_skipsToolMessages() {
val items = listOf(
@@ -849,6 +904,231 @@ class ChatHandlerTest {
assertEquals("", handler.messages.value[0].content)
}
// --- loadMessageHistory: persisted user image references (HRUI-073) ---
@Test
fun loadMessageHistory_liftsCaptionFirstPersistedImageRef() {
val requested = mutableListOf<Pair<String, String>>()
handler.onPersistedUserImageRequested = { messageId, path ->
requested += messageId to path
}
handler.loadMessageHistory(
listOf(
MessageItem(
id = "image-user-1",
role = "user",
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
)
)
)
val message = handler.messages.value.single()
assertEquals("What is this?", message.content)
assertEquals(listOf("image-user-1" to "/tmp/cat.png"), requested)
}
@Test
fun loadMessageHistory_keepsImageOnlyTurnAndParsesQuotedSpacedPath() {
val requested = mutableListOf<String>()
handler.onPersistedUserImageRequested = { _, path -> requested += path }
handler.loadMessageHistory(
listOf(
MessageItem(
id = "image-user-2",
role = "user",
content = JsonPrimitive(
"@image:`/tmp/Hermes composer images/holiday photo.webp`"
),
)
)
)
assertEquals("", handler.messages.value.single().content)
assertEquals(
listOf("/tmp/Hermes composer images/holiday photo.webp"),
requested,
)
}
@Test
fun loadMessageHistory_extractsMultipleRefsInOrder() {
val requested = mutableListOf<String>()
handler.onPersistedUserImageRequested = { _, path -> requested += path }
handler.loadMessageHistory(
listOf(
MessageItem(
id = "image-user-3",
role = "user",
content = JsonPrimitive(
"Compare these\n@image:/tmp/a.png\n@image:\"/tmp/two images/b.jpg\""
),
)
)
)
assertEquals("Compare these", handler.messages.value.single().content)
assertEquals(listOf("/tmp/a.png", "/tmp/two images/b.jpg"), requested)
}
@Test
fun loadMessageHistory_extractsRefFromNativeVisionContentArray() {
val requested = mutableListOf<String>()
handler.onPersistedUserImageRequested = { _, path -> requested += path }
val nativeVisionContent = buildJsonArray {
add(
buildJsonObject {
put("type", "text")
put("text", "Describe this\n@image:/tmp/native.png")
}
)
add(
buildJsonObject {
put("type", "image_url")
put(
"image_url",
buildJsonObject { put("url", "data:image/png;base64,AAAA") },
)
}
)
}
handler.loadMessageHistory(
listOf(MessageItem(id = "image-user-4", role = "user", content = nativeVisionContent))
)
assertEquals("Describe this", handler.messages.value.single().content)
assertEquals(listOf("/tmp/native.png"), requested)
}
@Test
fun loadMessageHistory_keepsMalformedUnknownAndInlineImageDirectivesAsText() {
val requested = mutableListOf<String>()
handler.onPersistedUserImageRequested = { _, path -> requested += path }
val content = listOf(
"@image:relative.png",
"@image:`/tmp/unclosed.png",
"@image:/etc/passwd",
"mention @image:/tmp/inline.png here",
).joinToString("\n")
handler.loadMessageHistory(
listOf(MessageItem(id = "image-user-5", role = "user", content = JsonPrimitive(content)))
)
assertEquals(content, handler.messages.value.single().content)
assertTrue(requested.isEmpty())
}
@Test
fun loadMessageHistory_canRenderPathFreeUnavailableImageState() {
handler.onPersistedUserImageRequested = { messageId, path ->
handler.mutateMessage(messageId) { message ->
message.copy(
attachments = message.attachments + Attachment(
contentType = "image/png",
content = "",
fileName = path.substringAfterLast('/'),
state = AttachmentState.FAILED,
errorMessage = "Image unavailable on this connection",
)
)
}
}
handler.loadMessageHistory(
listOf(
MessageItem(
id = "image-user-6",
role = "user",
content = JsonPrimitive("@image:/tmp/deleted.png"),
)
)
)
val message = handler.messages.value.single()
assertEquals("", message.content)
assertEquals(1, message.attachments.size)
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
assertEquals("deleted.png", message.attachments.single().fileName)
assertFalse(message.attachments.single().errorMessage.orEmpty().contains("/tmp/"))
}
@Test
fun loadMessageHistory_coldResumeDispatchesPersistedImageOnlyOnceAcrossReloads() {
var requestCount = 0
handler.onPersistedUserImageRequested = { messageId, path ->
requestCount++
handler.mutateMessage(messageId) { message ->
message.copy(
attachments = message.attachments + Attachment(
contentType = "image/png",
content = "",
fileName = "resume.png",
state = AttachmentState.FAILED,
errorMessage = "Image unavailable on this connection",
relayToken = path,
)
)
}
}
val history = listOf(
MessageItem(
id = "image-user-cold",
role = "user",
content = JsonPrimitive("@image:/tmp/resume.png"),
)
)
handler.loadMessageHistory(history)
handler.loadMessageHistory(history)
val message = handler.messages.value.single()
assertEquals(1, requestCount)
assertEquals(1, message.attachments.size)
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
}
@Test
fun loadMessageHistory_immediateReloadCarriesLocalImageWithoutDuplicateFetch() {
handler.addUserMessage(
ChatMessage(
id = "optimistic-image",
role = MessageRole.USER,
content = "What is this?",
timestamp = 1L,
attachments = listOf(
Attachment(
contentType = "image/png",
content = "base64-pixels",
fileName = "cat.png",
)
),
)
)
var requestCount = 0
handler.onPersistedUserImageRequested = { _, _ -> requestCount++ }
val history = listOf(
MessageItem(
id = "server-image",
role = "user",
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
)
)
handler.loadMessageHistory(history)
handler.loadMessageHistory(history)
val message = handler.messages.value.single()
assertEquals("server-image", message.id)
assertEquals("What is this?", message.content)
assertEquals(1, message.attachments.size)
assertEquals("base64-pixels", message.attachments.single().content)
assertEquals(0, requestCount)
}
// --- loadMessageHistory: outbound attachment preservation (GAP 1) ---
@Test
@@ -1237,6 +1517,49 @@ class ChatHandlerTest {
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_coalescesReplayedDomainIdWithoutLosingOrderOrContent() {
val replayedId = "2c93af28-0b0b-436b-a112-7f164cac931d"
handler.loadMessageHistory(
listOf(
MessageItem(
id = "user-1",
role = "user",
content = JsonPrimitive("question"),
timestamp = 1.0,
),
MessageItem(
id = replayedId,
role = "assistant",
content = JsonPrimitive("partial answer"),
timestamp = 2.0,
),
MessageItem(
id = "system-1",
role = "system",
content = JsonPrimitive("distinct visible content"),
timestamp = 3.0,
),
// Rejoin replay of the same persisted message. The latest
// snapshot is authoritative, but its first transcript position
// and Compose identity must remain stable.
MessageItem(
id = replayedId,
role = "assistant",
content = JsonPrimitive("final answer"),
timestamp = 4.0,
),
)
)
val messages = handler.messages.value
assertEquals(listOf("user-1", replayedId, "system-1"), messages.map { it.id })
assertEquals("final answer", messages[1].content)
assertEquals("distinct visible content", messages[2].content)
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by
@@ -1737,6 +2060,20 @@ class ChatHandlerTest {
completedAt = 7L,
),
),
moaReferences = listOf(
ChatTurnMoaReferenceCheckpoint(
index = 1,
count = 2,
label = "advisor-a",
text = "Recovered advice",
),
ChatTurnMoaReferenceCheckpoint(
index = 2,
count = 2,
label = "advisor-b",
available = false,
),
),
),
turnStatus = "Running terminal",
priorUserMessageCount = 1,
@@ -1755,10 +2092,107 @@ class ChatHandlerTest {
assertFalse(restored.toolCalls[0].isComplete)
assertTrue(restored.toolCalls[1].isComplete)
assertEquals(true, restored.toolCalls[1].success)
assertEquals(listOf(1, 2), restored.moaReferences.map { it.index })
assertEquals("Recovered advice", restored.moaReferences.first().text)
assertFalse(restored.moaReferences.last().available)
assertTrue(handler.isStreaming.value)
assertEquals("Running terminal", handler.turnStatus.value)
}
@Test
fun onMoaReference_upsertsByCanonicalIndexAndResetsOnNewSequence() {
handler.addPlaceholderMessage(
ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
content = "",
timestamp = 1L,
isStreaming = true,
),
)
handler.onMoaReference(
"assistant-live",
GatewayMoaReference(2, 2, "advisor-b", "Second"),
)
handler.onMoaReference(
"assistant-live",
GatewayMoaReference(1, 2, "advisor-a", "First"),
)
assertEquals(listOf(1), handler.messages.value.single().moaReferences.map { it.index })
handler.onMoaReference(
"assistant-live",
GatewayMoaReference(2, 2, "advisor-b", "Second"),
)
handler.onMoaReference(
"assistant-live",
GatewayMoaReference(1, 2, "advisor-a", "First"),
)
assertEquals(listOf(1, 2), handler.messages.value.single().moaReferences.map { it.index })
handler.onMoaReference(
"assistant-live",
GatewayMoaReference(2, 2, "advisor-b", "Updated second"),
)
assertEquals(
"Updated second",
handler.messages.value.single().moaReferences.single { it.index == 2 }.text,
)
handler.onMoaReference(
"assistant-live",
GatewayMoaReference(1, 2, "advisor-a", "New first"),
)
val reset = handler.messages.value.single().moaReferences
assertEquals(listOf(1), reset.map { it.index })
assertEquals("New first", reset.single().text)
}
@Test
fun loadMessageHistory_doesNotPersistMoaReferenceBlocks() {
handler.addPlaceholderMessage(
ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
content = "Answer",
timestamp = 1L,
moaReferences = listOf(
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
),
),
)
handler.loadMessageHistory(
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Answer"))),
)
assertTrue(handler.messages.value.single().moaReferences.isEmpty())
}
@Test
fun loadMessageHistory_preservesMoaReferencesWhileMatchingTurnIsStillLive() {
handler.addPlaceholderMessage(
ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
content = "Partial",
timestamp = 1L,
isStreaming = true,
moaReferences = listOf(
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
),
),
)
handler.loadMessageHistory(
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Partial"))),
)
assertEquals("Transient", handler.messages.value.single().moaReferences.single().text)
}
// --- Helper ---
private fun createUserMessage(id: String, content: String) = ChatMessage(
@@ -686,6 +686,7 @@ class DashboardApiClientTest {
name = "Local",
baseUrl = "https://llm.example/v1",
model = "qwen",
models = listOf("qwen", "qwen-vl", " qwen ", ""),
apiKey = "never-persist-this",
)
@@ -701,7 +702,9 @@ class DashboardApiClientTest {
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
val save = server.takeRequest()
assertEquals("/api/providers/custom-endpoints", save.path)
assertTrue(save.body.readUtf8().contains("never-persist-this"))
val saveBody = save.body.readUtf8()
assertTrue(saveBody.contains("never-persist-this"))
assertTrue(saveBody.contains(""""models":["qwen","qwen-vl"]"""))
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)

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