Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f19ca028 | ||
|
|
9ca42e3e6c | ||
|
|
2f949c7d15 | ||
|
|
c77fd057bb | ||
|
|
ed1c47f47d | ||
|
|
f5c2a2b888 | ||
|
|
9187d8e77c | ||
|
|
4efc52dd5b | ||
|
|
f2b92b2755 | ||
|
|
8651656899 | ||
|
|
b458d83fcc | ||
|
|
ca0c5b2a54 | ||
|
|
77e34c2c02 | ||
|
|
f4ee409106 | ||
|
|
aa26f7c9b6 | ||
|
|
bfb608bea6 | ||
|
|
95a95fe7d2 | ||
|
|
1bdf2ae71b | ||
|
|
f9e7a2f320 | ||
|
|
988fac8522 | ||
|
|
d4832a6a38 | ||
|
|
f9c8736e5b | ||
|
|
a815dd33fa | ||
|
|
cc9c75a636 | ||
|
|
43179e03c0 | ||
|
|
325b8e5670 | ||
|
|
693ac4ed64 | ||
|
|
f94d663ac4 | ||
|
|
d1a21bd42e | ||
|
|
7ee2d73010 | ||
|
|
682bde84fe | ||
|
|
16bdbe5f44 | ||
|
|
f43fba9fed | ||
|
|
d1745413fd | ||
|
|
1b7a8025c3 | ||
|
|
d92a87483e | ||
|
|
e9da59cf40 | ||
|
|
0bea626ed8 | ||
|
|
f453dd27f3 | ||
|
|
c9a03c9ed7 | ||
|
|
36546c2712 | ||
|
|
68f8b58a0b |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,10 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
- **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
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# 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
|
||||
|
||||
+7
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
-21
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
# Hermes-Relay-Android v1.5.3
|
||||
|
||||
**Release Date:** July 25, 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,28 +12,13 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This release makes the Hermes Dashboard and Gateway the clear standard connection, keeps active work reachable across the app and Android backgrounding, and gives profiles, voice, attachments, image generation, and approvals a more coherent native interface.
|
||||
|
||||
## Added
|
||||
|
||||
- The Agent Passport drawer combines live route and session context with explicit profile switching, personality, model, reasoning, approval-policy, chat-override, and processing-tier controls.
|
||||
- Secure browser-based Dashboard sign-in is scoped to the selected host. Chat, sessions, Manage, and Standard Voice share the same authenticated Gateway route while the API server remains an automatic fallback.
|
||||
- Standard and Realtime voice settings use focused provider, model, and voice cards with upstream-aware discovery, descriptions, inline previews, waveforms, and a browsable catalog. Standard replies can begin speaking completed segments before generation finishes.
|
||||
- User-started turns stay protected until every concurrent session settles. Privacy-safe notifications reopen the correct chat for approvals, questions, elevated permissions, or secure responses.
|
||||
- Onboarding finishes with a layered permission review: notifications are recommended deliberately, optional capabilities remain separate, and users can continue without granting phone access.
|
||||
- Chat surfaces one-turn model choices, approval modes, advisor progress, queued recovery, project labels, collapsible attachments, persisted images, interim Gateway events, and image-generation activity.
|
||||
This patch prevents Voice Focus from closing when live chat rows receive their persisted server identities during history reconciliation.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Gateway reconnects reactivate the original live session without resubmitting acknowledged prompts or duplicating session rows.
|
||||
- Tailscale, QR, and other remote routes move Dashboard, Gateway, sessions, Manage, Standard Voice, API fallback, and optional Relay together.
|
||||
- Dashboard authentication, model routing, recovery, and profile state stay scoped to the selected connection and session, including during cold start and rapid switching.
|
||||
- Promoted voice and background tasks keep their owning Chat row until the work settles.
|
||||
- User-installed certificate authorities work for self-hosted HTTPS/WSS while normal chain, hostname, and Relay-pin verification remain enforced.
|
||||
- Malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- Developer Options no longer exposes the obsolete Relay feature flag; version-tap unlock, relock, backup, import, and reset actions now persist and report accurately.
|
||||
- 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.
|
||||
|
||||
@@ -717,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
|
||||
@@ -731,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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
Connect through secure Dashboard sign-in, switch profiles from the new Agent Passport, and keep multiple background chats active with actionable approval and question alerts. Image generation, attachments, model routing, voice, and Gateway recovery are clearer and more reliable. Setup now guides optional notification, camera, microphone, and companion permissions without blocking chat.
|
||||
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 @@
|
||||
通过安全的 Dashboard 登录连接,并在新的智能体护照中切换配置文件。多个后台对话可保持运行,审批或提问通知可直接返回正确会话。图像生成、附件、模型路由、语音和 Gateway 恢复更加清晰可靠。设置流程会说明可选的通知、相机、麦克风和通知伴侣权限,且不会阻止聊天。
|
||||
语音专注模式现在会在实时消息与已保存的聊天记录同步时保持稳定的列表标识,避免重复键导致应用关闭。
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"title": "Voice stays open",
|
||||
"date": "2026-07-31",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stable voice transcripts",
|
||||
"bullets": [
|
||||
"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": "Stable conversation updates",
|
||||
"bullets": [
|
||||
"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": "Reliable narration and background work",
|
||||
"bullets": [
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
v1.5.0 - Hermes, always in reach
|
||||
v1.5.3 - Voice stays open
|
||||
|
||||
* Connect through secure Dashboard sign-in and switch profiles from the new Agent Passport.
|
||||
* Keep multiple background chats active and reopen the right session from approval or question alerts.
|
||||
* Follow richer attachments, image generation, model routing, Gateway recovery, and streaming voice.
|
||||
* Finish setup with clear, optional permission guidance that never blocks standard chat.
|
||||
* 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() ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1223,6 +1223,14 @@ 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
|
||||
@@ -1240,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
|
||||
@@ -1278,7 +1286,7 @@ 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 {
|
||||
@@ -1540,6 +1548,34 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
private class ReconcileSlot(
|
||||
val serverId: String,
|
||||
|
||||
+88
-3
@@ -108,13 +108,18 @@ class NativeDashboardAuthClient(
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
val verifier = randomBytes(32).base64Url()
|
||||
// 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 root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
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)
|
||||
@@ -130,6 +135,41 @@ class NativeDashboardAuthClient(
|
||||
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,
|
||||
@@ -280,7 +320,52 @@ internal class NativeDashboardCallbackException(
|
||||
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")
|
||||
(
|
||||
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('/')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
@@ -33,6 +33,24 @@ internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedire
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
@@ -182,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,
|
||||
@@ -1919,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(
|
||||
@@ -1936,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+16
-13
@@ -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 {
|
||||
|
||||
@@ -174,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
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message.agentName,
|
||||
text = localizeAgentName(message.agentName),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
@@ -288,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
|
||||
@@ -321,17 +321,17 @@ fun MessageBubble(
|
||||
thinkingContent = if (reference.available) {
|
||||
reference.text
|
||||
} else {
|
||||
"Advisor unavailable."
|
||||
stringResource(R.string.bubble_advisor_unavailable)
|
||||
},
|
||||
isStreaming = false,
|
||||
headerText = buildString {
|
||||
append("Advisor ")
|
||||
append(stringResource(R.string.bubble_advisor_prefix))
|
||||
append(reference.index)
|
||||
reference.count?.let { append("/").append(it) }
|
||||
append(" · ")
|
||||
append(reference.label)
|
||||
},
|
||||
accessibilityLabel = "Mixture of Agents advisor response",
|
||||
accessibilityLabel = stringResource(R.string.bubble_moa_advisor),
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp),
|
||||
@@ -469,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +42,7 @@ fun ThinkingBlock(
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
headerText: String? = null,
|
||||
accessibilityLabel: String = "Thinking",
|
||||
accessibilityLabel: String = stringResource(R.string.thinking_thinking_short),
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
@@ -73,7 +75,7 @@ fun ThinkingBlock(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = headerText ?: 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
|
||||
)
|
||||
@@ -88,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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,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.
|
||||
@@ -885,7 +929,6 @@ fun ChatScreen(
|
||||
if (!voiceUiState.voiceMode) {
|
||||
voiceOverlayHost.hide()
|
||||
pendingVoiceOverlayPermission = false
|
||||
voiceCompactMode = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,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) {
|
||||
@@ -1147,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
|
||||
@@ -1184,7 +1261,7 @@ fun ChatScreen(
|
||||
val personalities = listOf(
|
||||
SlashCommand(
|
||||
command = "/personality none",
|
||||
description = "Clear the personality overlay",
|
||||
description = slashDescClearPersonality,
|
||||
category = "personality"
|
||||
)
|
||||
) + personalityNames.map { name ->
|
||||
@@ -1331,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
|
||||
}
|
||||
|
||||
@@ -2308,24 +2450,8 @@ fun ChatScreen(
|
||||
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 &&
|
||||
@@ -2763,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
|
||||
}
|
||||
@@ -2801,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)
|
||||
@@ -2979,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()
|
||||
@@ -3184,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
|
||||
@@ -3215,6 +3340,8 @@ fun ChatScreen(
|
||||
onHermesConfirmationAnswer = { answer ->
|
||||
voiceViewModel.answerHermesConfirmation(answer)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
// === END v0.4.1 ===
|
||||
)
|
||||
}
|
||||
@@ -3326,6 +3453,7 @@ private fun ChatColdStartLoadingState(
|
||||
onNavigateToConnections: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val commands = remember(
|
||||
connectionLabel,
|
||||
chatMode,
|
||||
@@ -3335,6 +3463,7 @@ private fun ChatColdStartLoadingState(
|
||||
isLoadingSessions,
|
||||
) {
|
||||
buildChatLoadingCommands(
|
||||
context = context,
|
||||
connectionLabel = connectionLabel,
|
||||
chatMode = chatMode,
|
||||
apiReachable = apiReachable,
|
||||
@@ -3434,6 +3563,7 @@ private fun ChatColdStartLoadingState(
|
||||
}
|
||||
|
||||
private fun buildChatLoadingCommands(
|
||||
context: android.content.Context,
|
||||
connectionLabel: String?,
|
||||
chatMode: ChatMode,
|
||||
apiReachable: Boolean,
|
||||
@@ -3443,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 {
|
||||
@@ -3459,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 {
|
||||
@@ -3468,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 {
|
||||
@@ -3713,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +22,7 @@ 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
|
||||
@@ -38,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
|
||||
@@ -50,10 +52,10 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
|
||||
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
|
||||
@@ -62,6 +64,7 @@ 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
|
||||
@@ -76,6 +79,7 @@ fun DashboardSignInScreen(
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
@@ -90,8 +94,8 @@ fun DashboardSignInScreen(
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var actionIsError by remember { mutableStateOf(false) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var redirectAuthMode by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf(DashboardRedirectAuthMode.WebView)
|
||||
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) }
|
||||
@@ -135,20 +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 = client.getAuthProviders().getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
|
||||
authFlows = status.authFlows
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
@@ -179,11 +183,11 @@ 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
|
||||
@@ -194,25 +198,28 @@ fun DashboardSignInScreen(
|
||||
|
||||
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
|
||||
if (
|
||||
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
|
||||
DashboardRedirectAuthMode.WebView
|
||||
) {
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
|
||||
actionInFlight = true
|
||||
actionIsError = false
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_opening)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
|
||||
nativeSignInJob = scope.launch {
|
||||
try {
|
||||
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||
@@ -228,19 +235,19 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
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)
|
||||
actionIsError = false
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = context.getString(R.string.dashboard_signin_no_session)
|
||||
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
actionMessage = error.message
|
||||
?: context.getString(R.string.dashboard_signin_failed)
|
||||
?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
@@ -253,10 +260,8 @@ fun DashboardSignInScreen(
|
||||
onDispose { nativeSignInJob?.cancel() }
|
||||
}
|
||||
|
||||
oauthProvider
|
||||
?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
|
||||
?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
oauthProvider?.let { provider ->
|
||||
DashboardOAuthScreen(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
@@ -272,8 +277,8 @@ 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()
|
||||
}
|
||||
},
|
||||
@@ -282,6 +287,7 @@ fun DashboardSignInScreen(
|
||||
actionIsError = true
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -289,10 +295,7 @@ fun DashboardSignInScreen(
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.dashboard_sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
nativeSignInJob?.cancel()
|
||||
onBack()
|
||||
}) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
@@ -321,13 +324,11 @@ fun DashboardSignInScreen(
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
actionIsError = actionIsError,
|
||||
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
|
||||
nativeSignInInFlight = nativeSignInJob != null,
|
||||
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionIsError = false
|
||||
nativeSignInJob?.cancel()
|
||||
},
|
||||
@@ -396,9 +397,7 @@ private fun DashboardSignInForm(
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
actionIsError: Boolean,
|
||||
nativePkce: Boolean,
|
||||
nativeSignInInFlight: Boolean,
|
||||
nativeTransportEligible: Boolean,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
onCancelNativeSignIn: () -> Unit,
|
||||
@@ -427,18 +426,19 @@ private fun DashboardSignInForm(
|
||||
redirectProviders.forEach { provider ->
|
||||
Button(
|
||||
onClick = { onOAuthSignIn(provider) },
|
||||
enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
|
||||
enabled = !actionInFlight,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_native_signin_requires_https),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
@@ -476,18 +476,11 @@ private fun DashboardSignInForm(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DashboardOAuthDialog(
|
||||
private fun DashboardOAuthScreen(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
@@ -504,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,
|
||||
@@ -512,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(
|
||||
@@ -549,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,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
|
||||
}
|
||||
@@ -672,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?,
|
||||
@@ -679,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,
|
||||
)
|
||||
@@ -704,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,
|
||||
@@ -717,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,
|
||||
)
|
||||
@@ -741,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,
|
||||
)
|
||||
@@ -1613,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1667,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,
|
||||
@@ -1729,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))
|
||||
}
|
||||
}
|
||||
@@ -1741,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()),
|
||||
@@ -1752,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)
|
||||
}
|
||||
@@ -1782,7 +1804,7 @@ internal fun VoiceProviderGroupCard(
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { pickerOpen = false }) { Text("Done") } },
|
||||
confirmButton = { TextButton(onClick = { pickerOpen = false }) { Text(stringResource(R.string.settings_done)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1816,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,
|
||||
@@ -1836,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)
|
||||
@@ -1895,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1942,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,
|
||||
@@ -1954,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 = {
|
||||
@@ -2055,7 +2077,7 @@ private fun ChoicePickerDialog(
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.pair_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2180,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),
|
||||
@@ -2205,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)
|
||||
@@ -2231,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),
|
||||
@@ -2240,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)
|
||||
@@ -2259,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)
|
||||
}
|
||||
@@ -2288,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2417,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",
|
||||
@@ -3163,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(
|
||||
@@ -3186,7 +3208,7 @@ private fun DeliveryModeInfoDialog(onDismiss: () -> Unit) {
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Got it")
|
||||
Text(stringResource(R.string.paired_devices_got_it))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -3224,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,
|
||||
@@ -3703,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,
|
||||
)
|
||||
@@ -4403,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
|
||||
@@ -4461,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),
|
||||
)
|
||||
@@ -4482,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(
|
||||
@@ -4510,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
|
||||
@@ -4534,7 +4567,7 @@ private fun VoiceModePickerDialog(
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_done)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4545,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) {
|
||||
@@ -4577,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",
|
||||
|
||||
@@ -5922,6 +5922,13 @@ class ChatViewModel : ViewModel() {
|
||||
queuedCount = event.queuedCount ?: 0,
|
||||
),
|
||||
)
|
||||
if (event.spokenHandoff == false) {
|
||||
// Silent promotion is the foreground turn boundary. The
|
||||
// background run keeps its owner/card and continues to
|
||||
// receive progress, cancellation, and delivery events.
|
||||
handler.onStreamComplete(assistantMessageId)
|
||||
activeStream = null
|
||||
}
|
||||
}
|
||||
"hermes.run.queued" -> {
|
||||
handler.updateBackgroundTask(assistantMessageId) { task ->
|
||||
|
||||
@@ -212,9 +212,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -788,6 +791,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
extraApiUrls = extraApiUrls,
|
||||
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
|
||||
),
|
||||
existing = activeConnection.value?.routeCandidates.orEmpty(),
|
||||
)
|
||||
@@ -4686,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 {
|
||||
@@ -4927,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()
|
||||
@@ -6116,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
|
||||
@@ -6139,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 &&
|
||||
|
||||
@@ -111,7 +111,16 @@ internal data class AssistantSpeechBatch(
|
||||
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
|
||||
@@ -323,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 ||
|
||||
@@ -648,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 = ""
|
||||
@@ -1368,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
|
||||
) {
|
||||
@@ -1379,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" }}",
|
||||
@@ -1390,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
|
||||
@@ -3162,17 +3176,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
clearSpokenChunksState()
|
||||
|
||||
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.
|
||||
// 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 {
|
||||
@@ -3299,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
|
||||
@@ -3371,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 ->
|
||||
@@ -3415,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
|
||||
}
|
||||
@@ -3638,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,
|
||||
@@ -3845,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
|
||||
@@ -3929,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")
|
||||
@@ -4059,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()
|
||||
@@ -4328,26 +4345,42 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) { messages, runActive, sessionId -> Triple(messages, runActive, sessionId) }
|
||||
.collect { (messages, runActive, sessionId) ->
|
||||
if (!sessionFence.accepts(sessionId, messages)) {
|
||||
cancelStandardSpeechStream("chat session changed")
|
||||
streamObserverJob?.cancel()
|
||||
// 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)
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
if (finalAnswerOnly) {
|
||||
if (batch.deltas.isNotEmpty()) {
|
||||
onVisualStreamDelta(batch.aggregateText)
|
||||
}
|
||||
} else {
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
if (!finalAnswerOnly) {
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
}
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
if (finalAnswerOnly) {
|
||||
speakSettledFinalAnswer(batch.finalAnswerText)
|
||||
} else if (!finishStandardSpeechStream()) {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
@@ -4355,6 +4388,44 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -4393,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 ->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -1502,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>
|
||||
@@ -1996,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>
|
||||
@@ -3235,4 +3242,229 @@
|
||||
<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 & 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1563,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>
|
||||
@@ -2088,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>
|
||||
@@ -3328,4 +3335,224 @@
|
||||
<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 & 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1566,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>
|
||||
@@ -2091,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>
|
||||
@@ -3395,4 +3402,229 @@
|
||||
<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 & 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>
|
||||
|
||||
@@ -1442,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>
|
||||
@@ -1903,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>
|
||||
@@ -2928,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>
|
||||
@@ -3080,4 +3087,229 @@
|
||||
<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 & 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1579,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>
|
||||
@@ -2104,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>
|
||||
@@ -3394,4 +3401,229 @@
|
||||
<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 & 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
@@ -1216,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>
|
||||
@@ -1682,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>
|
||||
@@ -2211,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>
|
||||
@@ -3401,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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -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}"
|
||||
|
||||
|
||||
@@ -1517,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
|
||||
|
||||
+28
-1
@@ -68,10 +68,36 @@ class NativeDashboardAuthTest {
|
||||
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
|
||||
assertEquals("nous", query["provider"])
|
||||
assertTrue(query.getValue("state").length >= 32)
|
||||
assertTrue(query.getValue("code_challenge").length >= 43)
|
||||
assertEquals(43, query.getValue("code_challenge").length)
|
||||
assertFalse(query.getValue("code_challenge").contains('='))
|
||||
assertNotEquals(query["state"], query["code_challenge"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
|
||||
val location = "https://portal.nousresearch.com/oauth/authorize" +
|
||||
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fgateway%2Fauth%2Fcallback"
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.test/gateway",
|
||||
canonicalDashboardBaseFromNousRedirect(location),
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
canonicalDashboardBaseFromNousRedirect(
|
||||
"https://portal.nousresearch.com/oauth/authorize" +
|
||||
"?redirect_uri=http%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
canonicalDashboardBaseFromNousRedirect(
|
||||
"https://attacker.example/oauth/authorize" +
|
||||
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun beginAuthorization_rejectsHostnameLoopback() {
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
@@ -103,6 +129,7 @@ class NativeDashboardAuthTest {
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
|
||||
.split("&")
|
||||
.first { it.startsWith("code_challenge=") }
|
||||
|
||||
+21
@@ -115,7 +115,28 @@ class NativeDashboardSignInCoordinatorTest {
|
||||
)
|
||||
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://172.16.24.250:9119"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://100.71.8.56:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://203.0.113.10:9119"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun androidRedirectMode_usesBrowserForNous_andCookieFlowForSelfHostedOidc() {
|
||||
val flows = listOf("cookie", "native_pkce")
|
||||
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.NativePkce,
|
||||
androidDashboardRedirectAuthMode("nous", flows),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
androidDashboardRedirectAuthMode("oidc", flows),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
androidDashboardRedirectAuthMode("nous", listOf("cookie")),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun completeSignIn(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.R
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LocalizedToolLabelsTest {
|
||||
|
||||
@Test
|
||||
fun knownToolNamesMapToStringResources() {
|
||||
assertEquals(R.string.tool_name_terminal, localizeToolNameKey("terminal_execute"))
|
||||
assertEquals(R.string.tool_name_web_search, localizeToolNameKey("web_search"))
|
||||
assertEquals(R.string.tool_name_execute_code, localizeToolNameKey("execute_code"))
|
||||
assertEquals(R.string.tool_name_read_file, localizeToolNameKey("read_file"))
|
||||
assertEquals(R.string.tool_name_write_file, localizeToolNameKey("write_file"))
|
||||
assertEquals(R.string.tool_name_write_file, localizeToolNameKey("apply_patch"))
|
||||
assertEquals(R.string.tool_name_session, localizeToolNameKey("session_search"))
|
||||
assertEquals(R.string.tool_name_memory, localizeToolNameKey("mnemosyne_store"))
|
||||
assertEquals(R.string.tool_name_android, localizeToolNameKey("android_phone_action"))
|
||||
assertEquals(R.string.tool_name_android, localizeToolNameKey("android_search_contacts"))
|
||||
assertEquals(R.string.tool_name_computer, localizeToolNameKey("computer_use"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownToolNamesReturnNull() {
|
||||
assertNull(localizeToolNameKey("totally_unknown_tool"))
|
||||
assertNull(localizeToolNameKey(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun knownBadgesMapToStringResources() {
|
||||
assertEquals(R.string.badge_tool_failed, localizeBadgeKey("Tool failed"))
|
||||
assertEquals(R.string.badge_memory, localizeBadgeKey("Memory"))
|
||||
assertEquals(R.string.badge_skill, localizeBadgeKey("Skill"))
|
||||
assertEquals(R.string.badge_artifact, localizeBadgeKey("Artifact"))
|
||||
assertEquals(R.string.badge_response_interrupted, localizeBadgeKey("Response interrupted"))
|
||||
assertEquals(R.string.badge_unknown_error, localizeBadgeKey("Unknown error"))
|
||||
assertEquals(R.string.badge_stopped, localizeBadgeKey("Stopped"))
|
||||
assertEquals(R.string.bubble_realtime_agent, localizeBadgeKey("Realtime Agent"))
|
||||
assertEquals(R.string.bubble_voice, localizeBadgeKey("Voice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownBadgesReturnNull() {
|
||||
assertNull(localizeBadgeKey("Some other badge"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun knownAgentNamesMapToStringResources() {
|
||||
assertEquals(R.string.agent_send_sms, localizeAgentNameKey("Send SMS"))
|
||||
assertEquals(R.string.agent_call, localizeAgentNameKey("Call"))
|
||||
assertEquals(R.string.agent_search_contacts, localizeAgentNameKey("Search Contacts"))
|
||||
assertEquals(R.string.agent_open_app, localizeAgentNameKey("Open App"))
|
||||
assertEquals(R.string.agent_bridge_setup, localizeAgentNameKey("Bridge Setup"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownAgentNamesReturnNull() {
|
||||
assertNull(localizeAgentNameKey("Custom agent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timelineTitlesSplitToolNameFromStatus() {
|
||||
val parts = localizeTimelineTitleName("web_search · running")
|
||||
assertEquals("web_search", parts?.first)
|
||||
assertEquals(R.string.tool_name_web_search, parts?.second)
|
||||
|
||||
val unknown = localizeTimelineTitleName("unknown_tool · running")
|
||||
assertEquals("unknown_tool", unknown?.first)
|
||||
assertNull(unknown?.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timelineTitlesWithoutSeparatorReturnNull() {
|
||||
assertNull(localizeTimelineTitleName("no separator here"))
|
||||
}
|
||||
}
|
||||
+31
-7
@@ -12,7 +12,7 @@ import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import com.hermesandroid.relay.viewmodel.backgroundRunAfterCancelRequest
|
||||
import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterResponseDone
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterPromotion
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -20,6 +20,32 @@ import org.junit.Test
|
||||
|
||||
class VoiceModeOverlayStateTest {
|
||||
|
||||
@Test
|
||||
fun transcriptKeys_remainDistinctWhenRowsShareReconciledServerId() {
|
||||
val serverId = "7c4af8b7-1bb2-4830-a4e5-0332d5ddcd1f"
|
||||
val messages = listOf(
|
||||
ChatMessage(
|
||||
id = serverId,
|
||||
uiKey = "persisted-assistant-row",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Earlier snapshot",
|
||||
timestamp = 1L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = serverId,
|
||||
uiKey = "live-assistant-row",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Reconciled live snapshot",
|
||||
timestamp = 2L,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("persisted-assistant-row", "live-assistant-row"),
|
||||
messages.map(::voiceTranscriptItemKey),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerTranscript_isTranscribingAfterMicrophoneCaptureStops() {
|
||||
assertEquals(VoiceState.Transcribing, realtimeTranscriptState(micCaptureActive = false))
|
||||
@@ -27,12 +53,10 @@ class VoiceModeOverlayStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun responseDone_keepsLogicalTurnActiveOnlyWhileBackgroundRunIsLive() {
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RUNNING))
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RECONNECTING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DELIVERING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DONE))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(null))
|
||||
fun promotion_keepsForegroundBusyOnlyForSpokenHandoff() {
|
||||
assertEquals(false, realtimeTurnActiveAfterPromotion(spokenHandoff = false))
|
||||
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = true))
|
||||
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatScrollSnapshotTest {
|
||||
@Test
|
||||
fun `completion releases only the retained live tail`() {
|
||||
assertNull(releaseRetainedLiveTail("assistant-live", "assistant-live"))
|
||||
assertEquals(
|
||||
"assistant-live",
|
||||
releaseRetainedLiveTail("assistant-live", "different-message"),
|
||||
)
|
||||
assertNull(releaseRetainedLiveTail(null, "assistant-live"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tall markdown tail is positioned by its trailing edge`() {
|
||||
assertEquals(
|
||||
1_208,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 2_400,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 600,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardWebViewAuthPolicyTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfHostedOidc_usesDashboardLoginWithoutNativeOrLoopbackParameters() {
|
||||
val url = DashboardApiClient.authLoginUrl(
|
||||
baseUrl = "https://hermes.example.test",
|
||||
provider = "self-hosted",
|
||||
next = "/",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.test/auth/login?provider=self-hosted&next=%2F",
|
||||
url,
|
||||
)
|
||||
assertFalse(url.contains("/auth/native/authorize"))
|
||||
assertFalse(url.contains("redirect_uri"))
|
||||
assertFalse(url.contains("127.0.0.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicDashboardCallback_importsCookieAndVerifiesAuthenticatedSession() = runTest {
|
||||
assertEquals(
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify,
|
||||
dashboardWebViewAuthNavigation(
|
||||
"https://hermes.example.test",
|
||||
"https://hermes.example.test/auth/callback?code=public-code&state=public-state",
|
||||
),
|
||||
)
|
||||
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"authenticated":true,"username":"operator","provider":"self-hosted"}""",
|
||||
),
|
||||
)
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
val callbackUrl = server.url("/auth/callback?code=public-code").toString()
|
||||
assertEquals(
|
||||
1,
|
||||
importDashboardCookieHeader(
|
||||
store = store,
|
||||
url = callbackUrl,
|
||||
cookieHeader = "hermes_session=authenticated",
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(
|
||||
baseUrl = server.url("/").toString(),
|
||||
okHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(store))
|
||||
.build(),
|
||||
)
|
||||
|
||||
val session = client.currentSession().getOrThrow()
|
||||
|
||||
assertTrue(session.authenticated)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/auth/me", request.path)
|
||||
assertEquals("hermes_session=authenticated", request.getHeader("Cookie"))
|
||||
client.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignLoopbackCallbacksAreRejectedWhileProviderPagesContinue() {
|
||||
val dashboard = "https://hermes.example.test"
|
||||
listOf(
|
||||
"http://127.0.0.1:40179/callback?code=code",
|
||||
"http://localhost:40179/callback?code=code",
|
||||
"http://[::1]:40179/callback?code=code",
|
||||
).forEach { callback ->
|
||||
assertEquals(
|
||||
callback,
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback,
|
||||
dashboardWebViewAuthNavigation(dashboard, callback),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
DashboardWebViewAuthNavigation.Continue,
|
||||
dashboardWebViewAuthNavigation(
|
||||
dashboard,
|
||||
"https://auth.example.test/application/o/authorize/",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -60,6 +61,15 @@ class RelayErrorClassifierTest {
|
||||
|
||||
assertEquals("Session expired", err.title)
|
||||
assertTrue(err.body.contains("re-pair", ignoreCase = true))
|
||||
assertEquals(HumanErrorAction.Repair, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun certificateMismatchExposesRepairAction() {
|
||||
val err = classifyError(SSLException("certificate changed"))
|
||||
|
||||
assertEquals("Certificate mismatch", err.title)
|
||||
assertEquals(HumanErrorAction.Repair, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -33,6 +33,19 @@ class AssistantSpeechCursorTest {
|
||||
assertEquals(listOf("The check is complete."), second.deltas.map { it.text })
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("I'll check that.\n\nThe check is complete.", second.aggregateText)
|
||||
assertEquals("The check is complete.", second.finalAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final answer skips blank tool bubbles and intermediate commentary`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
val interim = message("interim", MessageRole.ASSISTANT, "I'll check that.")
|
||||
val toolOnly = message("tool", MessageRole.ASSISTANT, " ")
|
||||
val final = message("final", MessageRole.ASSISTANT, " The settled answer. ")
|
||||
|
||||
val batch = cursor.poll(listOf(interim, toolOnly, final))
|
||||
|
||||
assertEquals("The settled answer.", batch.finalAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+68
@@ -240,6 +240,74 @@ class ChatViewModelRealtimeTurnTest {
|
||||
assertEquals(2, handler.messages.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentBackgroundPromotionReleasesForegroundStreamButKeepsTaskOwnership() {
|
||||
val assistantId = viewModel.startRealtimeAgentTurn(
|
||||
userText = "Check release readiness",
|
||||
chatSessionId = "session-1",
|
||||
)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantId,
|
||||
event = RealtimeVoiceEvent(
|
||||
type = "hermes.run.promoted",
|
||||
runId = "run-silent",
|
||||
tier = "durable",
|
||||
spokenHandoff = false,
|
||||
raw = "{}",
|
||||
),
|
||||
)
|
||||
|
||||
val promoted = handler.messages.value.single { it.id == assistantId }
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertFalse(promoted.isStreaming)
|
||||
assertEquals(BackgroundTaskPhase.RUNNING, promoted.backgroundTask?.phase)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = "newer-turn",
|
||||
event = RealtimeVoiceEvent(
|
||||
type = "hermes.run.progress",
|
||||
runId = "run-silent",
|
||||
message = "Checking Android",
|
||||
raw = "{}",
|
||||
),
|
||||
)
|
||||
|
||||
val updated = handler.messages.value.single { it.id == assistantId }
|
||||
assertEquals("Checking Android", updated.backgroundTask?.statusLine)
|
||||
assertEquals(BackgroundTaskPhase.RUNNING, updated.backgroundTask?.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spokenBackgroundPromotionWaitsForProviderResponseBoundary() {
|
||||
val assistantId = viewModel.startRealtimeAgentTurn(
|
||||
userText = "Check release readiness",
|
||||
chatSessionId = "session-1",
|
||||
)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantId,
|
||||
event = RealtimeVoiceEvent(
|
||||
type = "hermes.run.promoted",
|
||||
runId = "run-spoken",
|
||||
spokenHandoff = true,
|
||||
raw = "{}",
|
||||
),
|
||||
)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
|
||||
viewModel.applyRealtimeAgentEvent(
|
||||
assistantMessageId = assistantId,
|
||||
event = RealtimeVoiceEvent(type = "voice.response.done", raw = "{}"),
|
||||
)
|
||||
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertEquals(
|
||||
BackgroundTaskPhase.RUNNING,
|
||||
handler.messages.value.single { it.id == assistantId }.backgroundTask?.phase,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backgroundRunKeepsItsOwnerAfterANewerLocalVoiceCommand() {
|
||||
val backgroundAssistantId = viewModel.startRealtimeAgentTurn(
|
||||
|
||||
+19
-1
@@ -54,7 +54,25 @@ class EffectiveDashboardRouteTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API-only route derives dashboard even when primary dashboard is explicit`() {
|
||||
fun `selected API-only route keeps explicit same-host secure dashboard`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
)
|
||||
val fallback = EndpointCandidate(
|
||||
role = "public",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("hermes.example.com", 8643, tls = true),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.com:443",
|
||||
resolveEffectiveDashboardUrl(connection, fallback),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API-only route derives dashboard for a different route host`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
|
||||
@@ -42,6 +42,15 @@ class VoiceTurnSessionFenceTest {
|
||||
assertFalse(fence.accepts(sessionId = "other", messages = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient mismatch does not lose existing session binding`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = "active")
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
|
||||
assertFalse(fence.accepts(sessionId = "other", messages = emptyList()))
|
||||
assertTrue(fence.accepts(sessionId = "active", messages = emptyList()))
|
||||
}
|
||||
|
||||
private fun user(uiKey: String) = ChatMessage(
|
||||
id = "id-$uiKey",
|
||||
role = MessageRole.USER,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.0" apply false
|
||||
id("com.android.library") version "9.3.0" apply false
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("com.android.library") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
|
||||
}
|
||||
|
||||
+57
-2
@@ -1716,7 +1716,10 @@ override:
|
||||
**Protocol additions (relay <-> Android, additive).**
|
||||
|
||||
- `hermes.run.promoted` - run moved to background; carries `run_id`,
|
||||
`promote_after_ms`, `spoken_handoff`.
|
||||
`promote_after_ms`, `spoken_handoff`. A false `spoken_handoff` is also the
|
||||
foreground turn boundary; when true, the following `voice.response.done` is
|
||||
the boundary. In both cases the background socket, task card, cancellation,
|
||||
progress, and result delivery remain active.
|
||||
- `hermes.run.background_completed` - background run finished; precedes the
|
||||
provider/relay summary.
|
||||
- Extend `hermes.run.progress` with `tier` and `floor` so the client can render
|
||||
@@ -2166,7 +2169,7 @@ An API endpoint or Relay can be added later without recreating the connection.
|
||||
|
||||
## ADR 39 — Android dashboard redirect auth uses native PKCE
|
||||
|
||||
**Status:** Accepted (2026-07-25).
|
||||
**Status:** Superseded by ADR 40 (2026-07-27).
|
||||
|
||||
**Context.** Android originally completed redirect-provider dashboard sign-in
|
||||
inside a WebView and imported cookies. Current upstream Gateway can advertise a
|
||||
@@ -2199,3 +2202,55 @@ socket.
|
||||
is offered.
|
||||
- Older upstream versions remain usable through the explicitly identified
|
||||
WebView compatibility path.
|
||||
|
||||
---
|
||||
|
||||
## ADR 40 — Android dashboard redirect auth is provider-compatible
|
||||
|
||||
**Status:** Amended (2026-07-28).
|
||||
|
||||
**Context.** Upstream advertises `native_pkce` in `/api/status.auth_flows` for
|
||||
its desktop client. The corresponding `/auth/native/*` broker is explicitly a
|
||||
desktop system-browser flow: it redirects to a loopback listener owned by the
|
||||
desktop process and returns bearer tokens rather than dashboard cookies.
|
||||
Android incorrectly treated that server-wide capability as a platform-neutral
|
||||
mode selector, so redirect providers such as self-hosted OIDC were sent through
|
||||
the desktop loopback contract.
|
||||
|
||||
**Decision.** Android redirect-provider sign-in uses the upstream dashboard
|
||||
cookie flow by default:
|
||||
|
||||
- open `/auth/login?provider=...&next=...` in a full-screen embedded sign-in
|
||||
destination with a normal app bar rather than a modal WebView;
|
||||
- allow the provider to return through the dashboard's public
|
||||
`/auth/callback`;
|
||||
- import only cookies observed on the configured dashboard origin;
|
||||
- verify the imported session through `/api/auth/me`;
|
||||
- reject a foreign `http://127.0.0.1`, `localhost`, or `[::1]` `/callback`
|
||||
navigation instead of following or importing it.
|
||||
|
||||
Android does not select `/auth/native/authorize` merely because it appears in
|
||||
`auth_flows`. Self-hosted OIDC remains on the cookie contract above. Nous Portal
|
||||
is the narrow exception: its Cloudflare Turnstile challenge rejects embedded
|
||||
Android WebViews, so Android uses the gateway-brokered native PKCE route for
|
||||
that provider when advertised and opens it in a system Custom Tab. The
|
||||
ephemeral loopback listener, S256 verifier, state validation, encrypted bearer
|
||||
store, and exact-origin attachment remain app-owned. Public cleartext
|
||||
dashboards are rejected; explicitly configured RFC 1918 and Tailscale-IP
|
||||
dashboard routes retain the same HTTP allowance as their existing cookie
|
||||
sessions. If the provider redirect from a private route declares a canonical
|
||||
HTTPS dashboard callback, Android begins browser authorization on that
|
||||
canonical origin so the temporary PKCE cookie and callback remain same-origin;
|
||||
the one-time code exchange and resulting exact-origin bearer stay bound to the
|
||||
active private route.
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- Self-hosted OIDC uses the same public callback registered for the dashboard.
|
||||
- Android Manage, Chat, Voice, and onboarding continue to share one verified
|
||||
dashboard cookie session.
|
||||
- A server-wide desktop capability can no longer switch Android into a
|
||||
loopback callback flow.
|
||||
- Android retains a full-screen embedded WebView for compatible dashboard
|
||||
cookie providers, while providers that prohibit embedding use the explicit
|
||||
brokered native route.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"main": "e13a7220bde2fe67cda9d41f9979d9142c6906c36c726d075ee8bb4e085caae9",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"main": "e13a7220bde2fe67cda9d41f9979d9142c6906c36c726d075ee8bb4e085caae9",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"main": "e13a7220bde2fe67cda9d41f9979d9142c6906c36c726d075ee8bb4e085caae9",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"main": "e13a7220bde2fe67cda9d41f9979d9142c6906c36c726d075ee8bb4e085caae9",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -115,12 +115,27 @@
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"ru": {
|
||||
"native_name": "Русский",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "e13a7220bde2fe67cda9d41f9979d9142c6906c36c726d075ee8bb4e085caae9",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "english-fallback",
|
||||
"website": "english-fallback"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"native_name": "简体中文",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"main": "e13a7220bde2fe67cda9d41f9979d9142c6906c36c726d075ee8bb4e085caae9",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Localization
|
||||
|
||||
English is the canonical product language. Android also ships Brazilian
|
||||
Portuguese, German, Japanese, Simplified Chinese, and Spanish catalogs;
|
||||
Portuguese, German, Japanese, Russian, Simplified Chinese, and Spanish catalogs;
|
||||
additional languages can be added without changing the runtime architecture.
|
||||
|
||||
Translation coverage and linguistic verification are separate. Shipped locale
|
||||
@@ -12,7 +12,7 @@ See `docs/translation-playbook.md` for the required translation and critique
|
||||
workflow.
|
||||
|
||||
Users can switch between System default, English, Brazilian Portuguese, German,
|
||||
Japanese, Spanish, and Simplified Chinese from Settings → Appearance → Language.
|
||||
Japanese, Russian, Spanish, and Simplified Chinese from Settings → Appearance → Language.
|
||||
The picker stays synchronized with Android's per-app language setting; Android
|
||||
12 and lower use AppCompat's automatic locale storage.
|
||||
|
||||
@@ -93,7 +93,8 @@ status. README and user-documentation translations may follow app translation;
|
||||
maintainer `docs/` and ADRs remain canonical English.
|
||||
|
||||
The public documentation currently localizes a deliberately bounded first-run
|
||||
set for every Android locale:
|
||||
set for German, Spanish, Japanese, Brazilian Portuguese, and Simplified Chinese.
|
||||
Russian currently falls back to the canonical English documentation:
|
||||
|
||||
- documentation home;
|
||||
- Quick Start;
|
||||
@@ -126,9 +127,11 @@ VitePress runs this gate automatically before development and production builds.
|
||||
|
||||
## Marketing website
|
||||
|
||||
The product site ships the same locale set under `/de/`, `/es/`, `/ja/`,
|
||||
`/pt-BR/`, and `/zh-CN/`. Marketing copy, navigation, accessibility labels, and
|
||||
page metadata are localized. Product screenshots, command examples, and live UI
|
||||
The product site ships German, Spanish, Japanese, Brazilian Portuguese, and
|
||||
Simplified Chinese under `/de/`, `/es/`, `/ja/`, `/pt-BR/`, and `/zh-CN/`.
|
||||
Russian currently falls back to the canonical English site. Marketing copy,
|
||||
navigation, accessibility labels, and page metadata are localized. Product
|
||||
screenshots, command examples, and live UI
|
||||
recreations remain unchanged so they continue to represent the shipped product.
|
||||
|
||||
Validate the typed copy dictionaries and their English-source freshness with:
|
||||
|
||||
@@ -85,12 +85,10 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.5.0 - Hermes, always in reach
|
||||
v1.5.3 - Voice stays open
|
||||
|
||||
* Secure Dashboard sign-in and a clearer Agent Passport.
|
||||
* Active background chats with actionable approval and question alerts.
|
||||
* Better attachments, image generation, voice, routing, and recovery.
|
||||
* Optional permission guidance that never blocks chat.
|
||||
* Prevent Voice Focus from closing during chat-history reconciliation.
|
||||
* Keep live transcript rows stable when persisted identities arrive.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
+18
-13
@@ -170,16 +170,21 @@ Phone control — mirrors upstream relay protocol.
|
||||
|
||||
### 3.3 Auth Flow
|
||||
|
||||
Dashboard/Gateway redirect providers use the upstream native PKCE contract when
|
||||
`GET /api/status` advertises `native_pkce`. Android opens the selected provider
|
||||
in a Custom Tab and owns a single ephemeral callback on
|
||||
`http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
|
||||
exist only for that sign-in coroutine. Access and refresh tokens are encrypted
|
||||
per connection and are attached only to the exact trusted dashboard base for
|
||||
Manage, Gateway tickets, and standard voice. Native exchange is allowed only
|
||||
for HTTPS dashboard addresses (plus literal loopback for development). A
|
||||
gateway without the capability uses the legacy cookie/WebView flow; a failed
|
||||
native attempt never silently downgrades.
|
||||
Dashboard/Gateway redirect authentication is provider-compatible. Nous Portal,
|
||||
which relies on a challenge that rejects embedded Android WebViews, uses the
|
||||
upstream brokered `native_pkce` flow in a system Custom Tab when the dashboard
|
||||
advertises it. The app owns an ephemeral loopback callback and stores the
|
||||
resulting bearer session only for that connection and exact dashboard origin.
|
||||
Self-hosted OIDC remains on the dashboard cookie flow: Android opens
|
||||
`/auth/login` in a full-screen embedded browser destination, lets the provider
|
||||
return through the public `/auth/callback`, imports only same-origin cookies,
|
||||
and verifies them through `/api/auth/me`. HTTPS is required on public routes;
|
||||
explicit private-LAN and Tailscale-IP dashboards may use their existing HTTP
|
||||
transport. When such a private route advertises a canonical HTTPS Nous callback,
|
||||
Android starts the browser on that canonical origin so Hermes' temporary PKCE
|
||||
cookie and the provider callback remain same-origin, then exchanges the
|
||||
one-time code through the active private route. The verified session is shared
|
||||
by Manage, Gateway tickets, and standard voice.
|
||||
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
|
||||
@@ -419,7 +424,7 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
|
||||
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, and voice activity buffer. See `docs/decisions.md` §19.
|
||||
- **Connection (single-server settings)** — summary-first detail for one Hermes installation. Dashboard/Gateway health drives standard Chat, Manage, Sessions, and Voice readiness. API fallback and Relay extensions appear as independently optional capabilities. Advanced configuration exposes manual Dashboard, API, and Relay endpoints plus their native credentials; missing API or Relay settings never make a healthy Dashboard/Gateway connection look broken. Pairing-code and QR fallbacks remain available for Relay and compatibility setups. Transport security posture and paired-device grants remain visible without leading the normal setup flow with ports or bearer keys.
|
||||
- **Chat** — Show reasoning toggle, smooth auto-scroll toggle (live-follow streaming, default on), show token usage toggle, app context prompt toggle, tool call display (Off/Compact/Detailed), streaming endpoint selector (`auto` / `sessions` / `runs`), Stats for Nerds (analytics charts)
|
||||
- **Voice** — route-aware voice engine selector (`Vanilla Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Vanilla Hermes voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
- **Voice** — route-aware voice engine selector (`Vanilla Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, a final-answer-only speech policy, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Final-answer-only keeps tool/service progress and intermediate commentary visual while both voice engines wait to speak the settled answer; approvals, confirmation questions, and blocking failures remain actionable. Vanilla Hermes voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
- **Notification companion** — opt-in status, "Open Android Settings" action, test notification dump
|
||||
- **Permissions** — central permission/capability review screen linked from Settings and onboarding. It makes the Vanilla Hermes path explicit ("Chat and Manage" need no Android runtime grant), lists optional camera/microphone/notification access with current status and Android Settings links, and shows sideload-only Device Control requirements only in the sideload flavor.
|
||||
- **Appearance** — theme (auto/light/dark), dynamic colors toggle
|
||||
@@ -847,7 +852,7 @@ utilities.
|
||||
- `GET /voice/config` — provider availability + current settings from `tts:` / `stt:` in `~/.hermes/config.yaml`. When the basic TTS provider is Gemini or xAI, the response includes a `tts.enhanced` capability block (voices/models/audio-tag support + `supports_persona`/`supports_language` flags) so the app renders a per-request enhanced-voice picker. The Vanilla Hermes dashboard `/api/audio/speak` has no per-request surface — enhanced voice there stays config-only via Manage `PUT /api/config`.
|
||||
- `GET/PATCH /voice/output/config`, `POST /voice/output/session`, and `GET /voice/output/{session_id}` — relay-mediated streaming TTS renderer sessions. Android sends final Hermes text or brokered tool-status text and receives mono PCM deltas for direct `AudioTrack` playback. Session creation accepts optional provider/model/voice/sample-rate/language overrides for ephemeral draft previews; omitted values continue to resolve from the saved profile/relay defaults. Session responses include resumable-session metadata and PCM events carry `event_id`/`audio_event_id`, so short route changes during stable speech playback can resume and replay missed audio without re-rendering. Config responses include provider option metadata (`providers[].models`, `providers[].voices`, `providers[].languages`, `providers[].sample_rates`) for first-class dropdowns.
|
||||
- `GET/PATCH /voice/realtime/config`, `POST /voice/realtime/session`, and `GET /voice/realtime/{session_id}` — relay-mediated realtime provider-agent sessions for lab/dev experiments. Android can send PCM input events and receives mono PCM provider deltas for direct `AudioTrack` playback. Realtime config responses expose the same provider option shape where known.
|
||||
- `GET/PATCH /voice/realtime-agent/config`, `POST /voice/realtime-agent/session`, and `GET /voice/realtime-agent/{session_id}` — experimental Hermes-brokered Realtime Agent engine. The broker binds active profile/chat session/auth, streams Android mic PCM to a native realtime provider such as `xai_realtime` or `openai_realtime`, normalizes provider transcript/audio/function-call events, mirrors Hermes session/tool/confirmation events into Android, and returns compact Hermes tool results to the provider for concise spoken follow-up. Session responses include resumable-session metadata (`resume_token`, `resume_supported`, `resume_ttl_ms`); server events carry `event_id`, audio deltas carry `audio_event_id`, and Android can resume a detached session through the current `effectiveRelayUrl` after short Wi-Fi/cellular/LAN/Tailscale changes without starting a second Hermes run. A replacement route is usable only after relay `voice.session.resumed` confirmation; socket generation + resume-episode claims reject stale failure/close/fatal callbacks, unacknowledged input is replayed atomically, and each route-loss episode owns a bounded retry budget that starts at loss rather than session prewarm. Terminal exhaustion detaches session-owned reconnect UI so a stopped retry loop cannot leave an active task pill behind. The only provider-facing tool surface is `hermes_run_task`, `hermes_get_status`, `hermes_cancel`, and `hermes_confirm`.
|
||||
- `GET/PATCH /voice/realtime-agent/config`, `POST /voice/realtime-agent/session`, and `GET /voice/realtime-agent/{session_id}` — experimental Hermes-brokered Realtime Agent engine. The broker binds active profile/chat session/auth, streams Android mic PCM to a native realtime provider such as `xai_realtime` or `openai_realtime`, normalizes provider transcript/audio/function-call events, mirrors Hermes session/tool/confirmation events into Android, and returns compact Hermes tool results to the provider for concise spoken follow-up. Session creation accepts an ephemeral `final_answer_only` boolean; when enabled, the broker disables routine spoken handoffs and progress while preserving approval, confirmation, and blocking-failure prompts. Session responses include resumable-session metadata (`resume_token`, `resume_supported`, `resume_ttl_ms`); server events carry `event_id`, audio deltas carry `audio_event_id`, and Android can resume a detached session through the current `effectiveRelayUrl` after short Wi-Fi/cellular/LAN/Tailscale changes without starting a second Hermes run. A replacement route is usable only after relay `voice.session.resumed` confirmation; socket generation + resume-episode claims reject stale failure/close/fatal callbacks, unacknowledged input is replayed atomically, and each route-loss episode owns a bounded retry budget that starts at loss rather than session prewarm. Terminal exhaustion detaches session-owned reconnect UI so a stopped retry loop cannot leave an active task pill behind. The only provider-facing tool surface is `hermes_run_task`, `hermes_get_status`, `hermes_cancel`, and `hermes_confirm`.
|
||||
- `GET /voice/output/providers/{provider_id}/options`, `GET /voice/realtime/providers/{provider_id}/options`, and `GET /voice/realtime-agent/providers/{provider_id}/options` — provider-specific option refresh before saving. Android calls these when a provider is selected so dynamic account-backed choices can be fetched by the relay without exposing provider secrets. xAI refreshes built-in/paginated custom voices when API/OAuth auth is available; ElevenLabs refreshes voices/models/languages with its API key; OpenAI uses static documented voice choices. Realtime Agent provider payloads include `supports_realtime_agent_native` so render/lab-only realtime support is not confused with native speech-to-speech Hermes tooling. Responses include `schema_version`, grouped voice metadata, recommended/custom flags, and model/voice compatibility hints when known. Unknown or unauthenticated discovery falls back to static provider metadata plus manual entry.
|
||||
- `POST /voice/output/providers/{provider_id}/validate`, `POST /voice/realtime/providers/{provider_id}/validate`, and `POST /voice/realtime-agent/providers/{provider_id}/validate` — pre-save validation for provider/model/voice/sample-rate selections. Unknown manual IDs return warnings; explicit incompatibilities return blocking errors.
|
||||
- Voice-output provider defaults are relay-owned under `voice_output:` in `~/.hermes-relay/config.yaml` (or `RELAY_VOICE_OUTPUT_CONFIG`), then overridden by `RELAY_VOICE_OUTPUT_*` env vars for temporary tests. Authenticated operator clients may patch safe defaults (`enabled`, `provider`, `model`, `voice`, `sample_rate`, `language`, `codec`, `optimize_streaming_latency`, `text_normalization`, `auto_speech_tags`, `fallback_enabled`) through the relay. With `?profile=<name>`, the patch writes that profile's `voice_output:` section. Provider secrets and local auth paths stay server-side. `auto_speech_tags` is an xAI enhanced-voice control: when the renderer is `xai_tts` the relay applies `upstream_voice.apply_xai_speech_tags()` (upstream's inline/wrapping tone markers) to each chunk before rendering, so the streaming path matches the basic `/voice/synthesize` tone behavior. The `voice_lab` renderer set is xai/openai/elevenlabs — there is no Gemini streaming provider, so Gemini enhanced voice is `/voice/synthesize`-only.
|
||||
@@ -864,7 +869,7 @@ utilities.
|
||||
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
|
||||
- `VoiceModeOverlay` — full-screen UI with the MorphingSphere at 60% height in `voiceMode=true`, transcribed + response text, mic button supporting Tap / Hold / Continuous interaction modes.
|
||||
- `MorphingSphere` gains `SphereState.Listening` (soft blue/purple, subtle wobble with user amplitude) and `SphereState.Speaking` (vivid green/teal, dramatic core-warmth pulse with agent amplitude). Additive changes — existing call sites unchanged via defaulted `voiceAmplitude` / `voiceMode` params.
|
||||
- Voice Settings screen off the main Settings — Output / Listening / Advanced tabs split engine/provider selection from turn-taking controls and diagnostics. Output groups the provider summary and model/voice catalog, exposes inline no-save play/stop previews for the draft model and individual voices, shows the speaking waveform on the active row, and keeps Discard separate from Save. Dropdowns come from relay-advertised provider metadata, refresh through provider-specific options routes when the selected provider changes, become searchable/grouped for large voice catalogs, and validate compatibility before saving, with advanced manual entry for raw provider/model/voice IDs. Voice routes receive the selected Hermes profile; the relay reports whether values came from profile config, relay config, or global fallback. Test Current Engine remains under Advanced and uses `/voice/output/*` playback for stable mode and `/voice/realtime-agent/*` provider-native session playback for realtime mode; normal assistant speech uses the same streaming renderer PCM path when available.
|
||||
- Voice Settings screen off the main Settings — Output / Listening / Advanced tabs split engine/provider selection from turn-taking controls and diagnostics. Global controls include a final-answer-only policy shared by Standard and Realtime voice. Output groups the provider summary and model/voice catalog, exposes inline no-save play/stop previews for the draft model and individual voices, shows the speaking waveform on the active row, and keeps Discard separate from Save. Dropdowns come from relay-advertised provider metadata, refresh through provider-specific options routes when the selected provider changes, become searchable/grouped for large voice catalogs, and validate compatibility before saving, with advanced manual entry for raw provider/model/voice IDs. Voice routes receive the selected Hermes profile; the relay reports whether values came from profile config, relay config, or global fallback. Test Current Engine remains under Advanced and uses `/voice/output/*` playback for stable mode and `/voice/realtime-agent/*` provider-native session playback for realtime mode; normal assistant speech uses the same streaming renderer PCM path when available.
|
||||
|
||||
See `docs/decisions.md` → **Voice Mode — Architecture** for the historical baseline decisions. Current voice mode records PCM/WAV for STT, routes stable assistant speech through `/voice/output/*`, keeps `/voice/realtime/*` as a provider-agent lab path, and exposes `/voice/realtime-agent/*` as an experimental Hermes-brokered engine.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
appVersionName = "1.5.0"
|
||||
appVersionCode = "33"
|
||||
agp = "9.3.0"
|
||||
appVersionName = "1.5.3"
|
||||
appVersionCode = "36"
|
||||
agp = "9.3.1"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
navigation-compose = "2.9.8"
|
||||
@@ -15,7 +15,7 @@ security-crypto = "1.1.0"
|
||||
tink-android = "1.23.0"
|
||||
lifecycle = "2.11.0"
|
||||
activity-compose = "1.13.0"
|
||||
browser = "1.9.0"
|
||||
browser = "1.10.0"
|
||||
appcompat = "1.7.1"
|
||||
core-ktx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
|
||||
@@ -16,6 +16,10 @@ from .tools.desktop_tool import (
|
||||
_HANDLERS as _DESKTOP_HANDLERS,
|
||||
_check_tool as _desktop_check_tool,
|
||||
)
|
||||
from .tools.relay_plugin_tool import (
|
||||
_SCHEMAS as _RELAY_PLUGIN_SCHEMAS,
|
||||
_HANDLERS as _RELAY_PLUGIN_HANDLERS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +53,18 @@ def register(ctx):
|
||||
check_fn=_make_desktop_check(tool_name),
|
||||
)
|
||||
|
||||
# Declarative Android pages live on the Hermes host and need no paired
|
||||
# device or relay process. They remain available for authoring/inspection
|
||||
# whenever this plugin is loaded.
|
||||
for tool_name, schema in _RELAY_PLUGIN_SCHEMAS.items():
|
||||
ctx.register_tool(
|
||||
name=tool_name,
|
||||
toolset="relay",
|
||||
schema=schema,
|
||||
handler=_RELAY_PLUGIN_HANDLERS[tool_name],
|
||||
check_fn=lambda: True,
|
||||
)
|
||||
|
||||
# Register the in-session `/relay` slash command (status/devices/pair) and
|
||||
# the `on_session_start` lifecycle hook. Both are self-guarded internally,
|
||||
# and wrapped here too so a missing register_command/register_hook on an
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Relay",
|
||||
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.0",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Authenticated dashboard routes for declarative Android plugin pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Path
|
||||
|
||||
from ..mobile_plugin_store import (
|
||||
MobilePluginConflictError,
|
||||
MobilePluginNotFoundError,
|
||||
MobilePluginStore,
|
||||
MobilePluginStoreError,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/mobile")
|
||||
|
||||
|
||||
def _store() -> MobilePluginStore:
|
||||
return MobilePluginStore()
|
||||
|
||||
|
||||
def _bad_request(exc: MobilePluginStoreError) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/manifest")
|
||||
async def get_mobile_manifest() -> dict[str, Any]:
|
||||
return _store().manifest()
|
||||
|
||||
|
||||
@router.get("/pages/{plugin_id}")
|
||||
async def get_mobile_page(plugin_id: str = Path(...)) -> dict[str, Any]:
|
||||
try:
|
||||
entry = _store().get(plugin_id)
|
||||
document = dict(entry["document"])
|
||||
document["host_revision"] = entry.get("revision", 1)
|
||||
return document
|
||||
except MobilePluginStoreError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
except MobilePluginNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="mobile plugin not found") from exc
|
||||
|
||||
|
||||
@router.get("/plugins")
|
||||
async def list_mobile_plugins() -> dict[str, Any]:
|
||||
return {"plugins": _store().list()}
|
||||
|
||||
|
||||
@router.get("/plugins/{plugin_id}")
|
||||
async def get_mobile_plugin(plugin_id: str = Path(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return _store().get(plugin_id)
|
||||
except MobilePluginStoreError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
except MobilePluginNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="mobile plugin not found") from exc
|
||||
|
||||
|
||||
@router.put("/plugins/{plugin_id}/draft")
|
||||
async def put_mobile_plugin_draft(
|
||||
plugin_id: str = Path(...),
|
||||
body: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return _store().draft(
|
||||
plugin_id,
|
||||
title=body.get("title", ""),
|
||||
description=body.get("description", ""),
|
||||
document=body.get("document"),
|
||||
lifecycle=body.get("lifecycle", "session"),
|
||||
)
|
||||
except (AttributeError, MobilePluginStoreError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/plugins/{plugin_id}/promote")
|
||||
async def promote_mobile_plugin(
|
||||
plugin_id: str = Path(...),
|
||||
body: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
expected_digest = body.get("expected_digest")
|
||||
if not isinstance(expected_digest, str) or not expected_digest:
|
||||
raise MobilePluginStoreError("expected_digest is required")
|
||||
return _store().publish(plugin_id, expected_digest=expected_digest)
|
||||
except MobilePluginConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except MobilePluginStoreError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
except MobilePluginNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="mobile plugin not found") from exc
|
||||
|
||||
|
||||
@router.post("/plugins/{plugin_id}/remove")
|
||||
async def delete_mobile_plugin(
|
||||
plugin_id: str = Path(...),
|
||||
body: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
expected_digest = body.get("expected_digest")
|
||||
if not isinstance(expected_digest, str) or not expected_digest:
|
||||
raise MobilePluginStoreError("expected_digest is required")
|
||||
return _store().remove(plugin_id, expected_digest=expected_digest)
|
||||
except MobilePluginConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except MobilePluginStoreError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
except MobilePluginNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="mobile plugin not found") from exc
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.4.3",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
@@ -139,6 +139,7 @@ def _validate_public_url(url: str) -> str:
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_plugin_module("dashboard.mobile_plugin_api").router)
|
||||
|
||||
|
||||
def _relay_unreachable(err: Exception) -> HTTPException:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from plugin.dashboard import plugin_api
|
||||
|
||||
|
||||
def _document() -> dict:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"pages": [
|
||||
{
|
||||
"id": "home",
|
||||
"title": {"type": "literal", "value": "Status"},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"id": "status",
|
||||
"text": {"type": "literal", "value": "Ready"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MobilePluginApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.env = patch.dict(os.environ, {"HERMES_HOME": self.temp.name})
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(plugin_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_draft_manifest_page_promote_list_and_remove(self) -> None:
|
||||
response = self.client.put(
|
||||
"/mobile/plugins/system-status/draft",
|
||||
json={"title": "System Status", "description": "Live health", "document": _document()},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("draft", response.json()["status"])
|
||||
draft_digest = response.json()["digest"]
|
||||
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
self.assertEqual("draft", manifest["contributions"][0]["status"])
|
||||
loaded_document = self.client.get("/mobile/pages/system-status").json()
|
||||
self.assertEqual(1, loaded_document.pop("host_revision"))
|
||||
self.assertEqual(_document(), loaded_document)
|
||||
|
||||
promoted = self.client.post(
|
||||
"/mobile/plugins/system-status/promote",
|
||||
json={"expected_digest": draft_digest},
|
||||
)
|
||||
self.assertEqual("published", promoted.json()["status"])
|
||||
published_digest = promoted.json()["digest"]
|
||||
listing = self.client.get("/mobile/plugins").json()["plugins"]
|
||||
self.assertEqual("published", listing[0]["status"])
|
||||
self.assertNotIn("document", listing[0])
|
||||
|
||||
removed = self.client.post(
|
||||
"/mobile/plugins/system-status/remove",
|
||||
json={"expected_digest": published_digest},
|
||||
)
|
||||
self.assertEqual({"ok": True, "id": "system-status"}, removed.json())
|
||||
self.assertEqual([], self.client.get("/mobile/manifest").json()["contributions"])
|
||||
|
||||
def test_traversal_and_bad_document_are_rejected(self) -> None:
|
||||
traversal = self.client.put(
|
||||
"/mobile/plugins/..%5Coutside/draft",
|
||||
json={"title": "Bad", "document": _document()},
|
||||
)
|
||||
self.assertEqual(400, traversal.status_code)
|
||||
|
||||
bad_document = self.client.put(
|
||||
"/mobile/plugins/bad/draft",
|
||||
json={"title": "Bad", "document": {"schemaVersion": 1, "pages": []}},
|
||||
)
|
||||
self.assertEqual(400, bad_document.status_code)
|
||||
|
||||
def test_promote_rejects_a_stale_review_digest(self) -> None:
|
||||
first = self.client.put(
|
||||
"/mobile/plugins/changing/draft",
|
||||
json={"title": "First", "document": _document()},
|
||||
).json()
|
||||
self.client.put(
|
||||
"/mobile/plugins/changing/draft",
|
||||
json={"title": "Changed", "document": _document()},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/mobile/plugins/changing/promote",
|
||||
json={"expected_digest": first["digest"]},
|
||||
)
|
||||
|
||||
self.assertEqual(409, response.status_code)
|
||||
listing = self.client.get("/mobile/plugins").json()["plugins"]
|
||||
self.assertEqual("draft", listing[0]["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Durable store for bounded, declarative Android plugin pages.
|
||||
|
||||
Documents are JSON data only. They cannot contain executable code, URLs, Android
|
||||
intents, or an alternate backend namespace; Android remains the renderer and
|
||||
authority boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
MAX_DOCUMENT_BYTES = 512 * 1024
|
||||
ALLOWED_LIFECYCLES = frozenset({"session", "persistent"})
|
||||
ALLOWED_ELEMENT_TYPES = frozenset(
|
||||
{
|
||||
"group",
|
||||
"card",
|
||||
"text",
|
||||
"badge",
|
||||
"button",
|
||||
"text_input",
|
||||
"toggle",
|
||||
"progress",
|
||||
"image",
|
||||
"divider",
|
||||
"spacer",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MobilePluginStoreError(ValueError):
|
||||
"""A caller supplied an invalid id, lifecycle, or declarative document."""
|
||||
|
||||
|
||||
class MobilePluginNotFoundError(FileNotFoundError):
|
||||
"""The requested generated plugin does not exist."""
|
||||
|
||||
|
||||
class MobilePluginConflictError(MobilePluginStoreError):
|
||||
"""The reviewed draft changed before the user-approved mutation."""
|
||||
|
||||
|
||||
def hermes_home() -> Path:
|
||||
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
class MobilePluginStore:
|
||||
def __init__(self, root: Optional[Path] = None) -> None:
|
||||
self.root = root or hermes_home() / "mobile-plugins"
|
||||
|
||||
def draft(
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
document: dict[str, Any],
|
||||
lifecycle: str = "session",
|
||||
) -> dict[str, Any]:
|
||||
plugin_id = self.validate_id(plugin_id)
|
||||
title = title.strip()
|
||||
if not title or len(title) > 120:
|
||||
raise MobilePluginStoreError("title must contain 1 to 120 characters")
|
||||
description = description.strip()
|
||||
if len(description) > 1_000:
|
||||
raise MobilePluginStoreError("description must not exceed 1000 characters")
|
||||
if lifecycle not in ALLOWED_LIFECYCLES:
|
||||
raise MobilePluginStoreError("lifecycle must be session or persistent")
|
||||
self.validate_document(document)
|
||||
|
||||
now = int(time.time())
|
||||
prior = self._read(plugin_id, required=False)
|
||||
if prior.get("status") == "published":
|
||||
raise MobilePluginConflictError(
|
||||
"published plugins cannot be replaced by an agent draft; remove it in Android first"
|
||||
)
|
||||
entry = {
|
||||
"id": plugin_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "draft",
|
||||
"lifecycle": lifecycle,
|
||||
"document": document,
|
||||
"created_at": prior.get("created_at", now) if prior else now,
|
||||
"updated_at": now,
|
||||
"published_at": prior.get("published_at") if prior else None,
|
||||
"revision": int(prior.get("revision", 0)) + 1 if prior else 1,
|
||||
}
|
||||
entry["digest"] = self._digest(entry)
|
||||
self._write(plugin_id, entry)
|
||||
return entry
|
||||
|
||||
def publish(self, plugin_id: str, *, expected_digest: Optional[str] = None) -> dict[str, Any]:
|
||||
entry = self.get(plugin_id)
|
||||
self._require_digest(entry, expected_digest)
|
||||
now = int(time.time())
|
||||
entry["status"] = "published"
|
||||
entry["lifecycle"] = "persistent"
|
||||
entry["updated_at"] = now
|
||||
entry["published_at"] = now
|
||||
entry["revision"] = int(entry.get("revision", 0)) + 1
|
||||
entry["digest"] = self._digest(entry)
|
||||
self._write(entry["id"], entry)
|
||||
return entry
|
||||
|
||||
def remove(self, plugin_id: str, *, expected_digest: Optional[str] = None) -> dict[str, Any]:
|
||||
plugin_id = self.validate_id(plugin_id)
|
||||
if expected_digest is not None:
|
||||
self._require_digest(self.get(plugin_id), expected_digest)
|
||||
path = self._path(plugin_id)
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError as exc:
|
||||
raise MobilePluginNotFoundError(plugin_id) from exc
|
||||
return {"ok": True, "id": plugin_id}
|
||||
|
||||
def get(self, plugin_id: str) -> dict[str, Any]:
|
||||
return self._read(self.validate_id(plugin_id), required=True)
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
if not self.root.is_dir():
|
||||
return []
|
||||
entries: list[dict[str, Any]] = []
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
if not PLUGIN_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
entry = self._read(path.stem, required=False)
|
||||
if entry:
|
||||
entries.append({k: v for k, v in entry.items() if k != "document"})
|
||||
return entries
|
||||
|
||||
def manifest(self) -> dict[str, Any]:
|
||||
contributions = []
|
||||
for summary in self.list():
|
||||
is_draft = summary["status"] == "draft"
|
||||
contributions.append(
|
||||
{
|
||||
"id": summary["id"],
|
||||
"surface": "page",
|
||||
"title": f"Draft: {summary['title']}" if is_draft else summary["title"],
|
||||
"description": summary.get("description", ""),
|
||||
"lifecycle": summary.get("lifecycle", "session"),
|
||||
"status": summary["status"],
|
||||
"revision": summary.get("revision", 1),
|
||||
"digest": summary.get("digest", ""),
|
||||
"document": {
|
||||
"method": "GET",
|
||||
"path": f"mobile/pages/{summary['id']}",
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"id": "hermes-relay",
|
||||
"display_name": "Relay Plugins",
|
||||
"description": "Declarative pages created for Hermes Android",
|
||||
"min_host_api": 1,
|
||||
"default_enabled": False,
|
||||
"contributions": contributions,
|
||||
"requested_capabilities": [
|
||||
{
|
||||
"id": "plugin.api.write",
|
||||
"reason": "Keep or remove generated plugin pages after your approval",
|
||||
"required": False,
|
||||
}
|
||||
],
|
||||
"updates": {"poll_seconds": 5},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_id(plugin_id: str) -> str:
|
||||
normalized = str(plugin_id).strip().lower()
|
||||
if not PLUGIN_ID_RE.fullmatch(normalized):
|
||||
raise MobilePluginStoreError("invalid plugin id")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def validate_document(document: dict[str, Any]) -> None:
|
||||
if not isinstance(document, dict):
|
||||
raise MobilePluginStoreError("document must be a JSON object")
|
||||
version = document.get("schemaVersion", document.get("schema_version"))
|
||||
if version != 1:
|
||||
raise MobilePluginStoreError("document schemaVersion must be 1")
|
||||
pages = document.get("pages")
|
||||
if not isinstance(pages, list) or not 1 <= len(pages) <= 32:
|
||||
raise MobilePluginStoreError("document pages must contain 1 to 32 entries")
|
||||
seen_ids: set[str] = set()
|
||||
element_count = [0]
|
||||
for page in pages:
|
||||
if not isinstance(page, dict) or not PLUGIN_ID_RE.fullmatch(str(page.get("id", ""))):
|
||||
raise MobilePluginStoreError("every page requires a safe id")
|
||||
if not isinstance(page.get("title"), dict):
|
||||
raise MobilePluginStoreError("every page requires a declarative title")
|
||||
MobilePluginStore._validate_element(
|
||||
page.get("content"),
|
||||
depth=1,
|
||||
seen_ids=seen_ids,
|
||||
count=element_count,
|
||||
)
|
||||
if MobilePluginStore._contains_action_request(document):
|
||||
raise MobilePluginStoreError(
|
||||
"generated documents cannot contain action.request; backend actions require "
|
||||
"a separately installed plugin"
|
||||
)
|
||||
encoded = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
if len(encoded) > MAX_DOCUMENT_BYTES:
|
||||
raise MobilePluginStoreError(
|
||||
f"document exceeds the {MAX_DOCUMENT_BYTES}-byte limit"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _contains_action_request(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
action = value.get("action")
|
||||
if isinstance(action, dict) and action.get("request") is not None:
|
||||
return True
|
||||
return any(MobilePluginStore._contains_action_request(child) for child in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(MobilePluginStore._contains_action_request(child) for child in value)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _validate_element(
|
||||
element: Any,
|
||||
*,
|
||||
depth: int,
|
||||
seen_ids: set[str],
|
||||
count: list[int],
|
||||
) -> None:
|
||||
if not isinstance(element, dict):
|
||||
raise MobilePluginStoreError("every page requires a declarative content element")
|
||||
if depth > 16:
|
||||
raise MobilePluginStoreError("document element depth exceeds 16")
|
||||
element_id = str(element.get("id", ""))
|
||||
if not PLUGIN_ID_RE.fullmatch(element_id):
|
||||
raise MobilePluginStoreError("every element requires a safe id")
|
||||
if element_id in seen_ids:
|
||||
raise MobilePluginStoreError(f"duplicate element id: {element_id}")
|
||||
seen_ids.add(element_id)
|
||||
element_type = element.get("type")
|
||||
if element_type not in ALLOWED_ELEMENT_TYPES:
|
||||
raise MobilePluginStoreError(f"unsupported element type: {element_type}")
|
||||
count[0] += 1
|
||||
if count[0] > 500:
|
||||
raise MobilePluginStoreError("document exceeds 500 elements")
|
||||
if element_type == "group":
|
||||
children = element.get("children")
|
||||
if not isinstance(children, list) or len(children) > 100:
|
||||
raise MobilePluginStoreError("group children must be an array of at most 100 elements")
|
||||
for child in children:
|
||||
MobilePluginStore._validate_element(
|
||||
child,
|
||||
depth=depth + 1,
|
||||
seen_ids=seen_ids,
|
||||
count=count,
|
||||
)
|
||||
elif element_type == "card":
|
||||
MobilePluginStore._validate_element(
|
||||
element.get("child"),
|
||||
depth=depth + 1,
|
||||
seen_ids=seen_ids,
|
||||
count=count,
|
||||
)
|
||||
|
||||
def _path(self, plugin_id: str) -> Path:
|
||||
safe_id = self.validate_id(plugin_id)
|
||||
root = os.path.realpath(os.fspath(self.root))
|
||||
root_prefix = root.rstrip(os.sep) + os.sep
|
||||
candidate = os.path.normpath(os.path.join(root, f"{safe_id}.json"))
|
||||
if not os.path.normcase(candidate).startswith(os.path.normcase(root_prefix)):
|
||||
raise MobilePluginStoreError("plugin entry escapes the mobile-plugin directory")
|
||||
path = Path(candidate)
|
||||
if path.is_symlink():
|
||||
raise MobilePluginStoreError("symbolic-link plugin entries are not allowed")
|
||||
resolved = os.path.realpath(candidate)
|
||||
if not os.path.normcase(resolved).startswith(os.path.normcase(root_prefix)):
|
||||
raise MobilePluginStoreError("plugin entry escapes the mobile-plugin directory")
|
||||
return Path(resolved)
|
||||
|
||||
@staticmethod
|
||||
def _digest(entry: dict[str, Any]) -> str:
|
||||
covered = {
|
||||
key: entry.get(key)
|
||||
for key in ("id", "title", "description", "status", "lifecycle", "document", "revision")
|
||||
}
|
||||
payload = json.dumps(
|
||||
covered,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
return "sha256:" + hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _require_digest(entry: dict[str, Any], expected_digest: Optional[str]) -> None:
|
||||
if expected_digest is not None and entry.get("digest") != expected_digest:
|
||||
raise MobilePluginConflictError("plugin changed after it was reviewed")
|
||||
|
||||
def _read(self, plugin_id: str, *, required: bool) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(self._path(plugin_id).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
return {}
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
return {}
|
||||
if not isinstance(data, dict) or data.get("id") != plugin_id:
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
return {}
|
||||
return data
|
||||
|
||||
def _write(self, plugin_id: str, entry: dict[str, Any]) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
path = self._path(plugin_id)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
if tmp.is_symlink():
|
||||
raise MobilePluginStoreError("symbolic-link temporary entries are not allowed")
|
||||
payload = json.dumps(entry, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
||||
try:
|
||||
with tmp.open("w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if os.name != "nt":
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_DOCUMENT_BYTES",
|
||||
"MobilePluginNotFoundError",
|
||||
"MobilePluginConflictError",
|
||||
"MobilePluginStore",
|
||||
"MobilePluginStoreError",
|
||||
]
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
name: hermes-relay
|
||||
manifest_version: 1
|
||||
version: 1.4.3
|
||||
version: 1.5.0
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
# All three are OPTIONAL — only needed if you use the relay's extra /
|
||||
@@ -82,6 +82,10 @@ provides_tools:
|
||||
- desktop_computer_action
|
||||
- desktop_computer_grant_request
|
||||
- desktop_computer_cancel
|
||||
- relay_plugin_draft
|
||||
- relay_plugin_publish
|
||||
- relay_plugin_remove
|
||||
- relay_plugin_list
|
||||
# Gateway platforms this plugin registers programmatically via
|
||||
# ctx.register_platform() in register(). Documentation parity with
|
||||
# provides_tools — the plugin stays kind=standalone (multi-capability),
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.4.3"
|
||||
__version__ = "1.5.0"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
@@ -259,6 +259,7 @@ class RealtimeAgentSession:
|
||||
# ADR 33 promotion state (populated from realtime_voice settings at create).
|
||||
promotion_enabled: bool = False
|
||||
promote_after_ms: int = 6000
|
||||
final_answer_only: bool = False
|
||||
spoken_handoff: bool = True
|
||||
result_delivery: str = "speak_verbatim"
|
||||
promoted_transcript: str | None = None
|
||||
@@ -402,6 +403,7 @@ class RealtimeAgentHandler:
|
||||
config=self.config,
|
||||
)
|
||||
context_messages = _parse_context_messages(payload.get("context_messages"))
|
||||
final_answer_only = _bool_value(payload.get("final_answer_only")) is True
|
||||
fetch_context_messages = getattr(self.hermes, "fetch_context_messages", None)
|
||||
if not context_messages and chat_session_id and callable(fetch_context_messages):
|
||||
context_messages = await self.hermes.fetch_context_messages(
|
||||
@@ -439,10 +441,19 @@ class RealtimeAgentHandler:
|
||||
),
|
||||
promotion_enabled=bool(settings.get("promotion_enabled", False)),
|
||||
promote_after_ms=int(settings.get("promote_after_ms", 6000)),
|
||||
spoken_handoff=bool(settings.get("spoken_handoff", True)),
|
||||
result_delivery=str(settings.get("result_delivery", "speak_verbatim")),
|
||||
final_answer_only=final_answer_only,
|
||||
spoken_handoff=(
|
||||
False if final_answer_only else bool(settings.get("spoken_handoff", True))
|
||||
),
|
||||
result_delivery=(
|
||||
"speak_verbatim"
|
||||
if final_answer_only
|
||||
else str(settings.get("result_delivery", "speak_verbatim"))
|
||||
),
|
||||
progress_spoken_after_seconds=(
|
||||
max(0, int(settings.get("progress_spoken_after_ms", 0))) / 1000.0
|
||||
0.0
|
||||
if final_answer_only
|
||||
else max(0, int(settings.get("progress_spoken_after_ms", 0))) / 1000.0
|
||||
),
|
||||
progress_repeat_seconds=(
|
||||
max(0, int(settings.get("progress_repeat_ms", 30000))) / 1000.0
|
||||
@@ -4805,6 +4816,19 @@ def _native_instructions(session: RealtimeAgentSession) -> str:
|
||||
current_timezone = interface_context["current_timezone"]
|
||||
context_block = _provider_context_block(session.context_messages)
|
||||
profile_block = _profile_prompt_block(session.profile_prompt_context)
|
||||
speech_policy = (
|
||||
"Final-answer-only speech is enabled. Do not speak acknowledgements, "
|
||||
"tool progress, service or status updates, or intermediate commentary. "
|
||||
"Call Hermes silently and wait to speak until its settled final answer is "
|
||||
"available. Approval or confirmation questions and blocking failures may "
|
||||
"still be spoken because the user must act on them. "
|
||||
if session.final_answer_only
|
||||
else (
|
||||
"You may speak one brief acknowledgement such as 'I'll check Hermes' "
|
||||
"or 'I'll check that' before the tool call, then call hermes_run_task "
|
||||
"immediately. The relay will provide restrained status while Hermes runs. "
|
||||
)
|
||||
)
|
||||
return (
|
||||
"You are the provider-native speech loop for Hermes Relay. Keep replies "
|
||||
"brief and conversational. Active interface: "
|
||||
@@ -4842,11 +4866,9 @@ def _native_instructions(session: RealtimeAgentSession) -> str:
|
||||
"information beyond what was already delivered. If the needed context is "
|
||||
"missing, stale, or requires fresh data or verification you do not "
|
||||
"already have, call hermes_run_task before answering. Do not say you lack "
|
||||
"context before a Hermes call. You "
|
||||
"may speak one brief acknowledgement such as 'I'll check Hermes' or "
|
||||
"'I'll check that' before the tool call, then call hermes_run_task "
|
||||
"immediately. Do not give a substantive answer until Hermes returns. "
|
||||
"The relay will provide restrained status while Hermes runs. "
|
||||
"context before a Hermes call. "
|
||||
f"{speech_policy}"
|
||||
"Do not give a substantive answer until Hermes returns. "
|
||||
"Route through Hermes for latest/recent/versioned data, device/desktop/app "
|
||||
"state, personal/session/project context, side effects, high-stakes or "
|
||||
"precision-sensitive answers, explicit check/verify/look-up requests, and "
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from plugin.mobile_plugin_store import (
|
||||
MobilePluginNotFoundError,
|
||||
MobilePluginStore,
|
||||
MobilePluginStoreError,
|
||||
)
|
||||
|
||||
|
||||
def _document(label: str = "Hello") -> dict:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"pages": [
|
||||
{
|
||||
"id": "home",
|
||||
"title": {"type": "literal", "value": label},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"id": "welcome",
|
||||
"text": {"type": "literal", "value": label},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MobilePluginStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.store = MobilePluginStore(Path(self.temp.name) / "mobile-plugins")
|
||||
|
||||
def test_draft_manifest_page_publish_remove_lifecycle(self) -> None:
|
||||
draft = self.store.draft(
|
||||
"daily-brief",
|
||||
title="Daily Brief",
|
||||
description="A reactive morning page",
|
||||
document=_document(),
|
||||
)
|
||||
self.assertEqual("draft", draft["status"])
|
||||
self.assertEqual("session", draft["lifecycle"])
|
||||
|
||||
manifest = self.store.manifest()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
contribution = manifest["contributions"][0]
|
||||
self.assertEqual("Draft: Daily Brief", contribution["title"])
|
||||
self.assertEqual("mobile/pages/daily-brief", contribution["document"]["path"])
|
||||
self.assertEqual(_document(), self.store.get("daily-brief")["document"])
|
||||
|
||||
published = self.store.publish("daily-brief")
|
||||
self.assertEqual("published", published["status"])
|
||||
self.assertEqual("Daily Brief", self.store.manifest()["contributions"][0]["title"])
|
||||
|
||||
self.assertEqual({"ok": True, "id": "daily-brief"}, self.store.remove("daily-brief"))
|
||||
self.assertEqual([], self.store.list())
|
||||
with self.assertRaises(MobilePluginNotFoundError):
|
||||
self.store.get("daily-brief")
|
||||
|
||||
def test_rejects_traversal_invalid_schema_and_empty_pages(self) -> None:
|
||||
for plugin_id in ("../outside", "nested/name", "x\\y", ".hidden"):
|
||||
with self.subTest(plugin_id=plugin_id):
|
||||
with self.assertRaises(MobilePluginStoreError):
|
||||
self.store.draft(plugin_id, title="Bad", description="", document=_document())
|
||||
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "schemaVersion"):
|
||||
self.store.draft(
|
||||
"bad-schema",
|
||||
title="Bad",
|
||||
description="",
|
||||
document={"schemaVersion": 2, "pages": [{}]},
|
||||
)
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "1 to 32"):
|
||||
self.store.draft(
|
||||
"no-pages",
|
||||
title="Bad",
|
||||
description="",
|
||||
document={"schemaVersion": 1, "pages": []},
|
||||
)
|
||||
|
||||
def test_listing_omits_document_payload(self) -> None:
|
||||
self.store.draft("compact", title="Compact", description="", document=_document())
|
||||
self.assertNotIn("document", self.store.list()[0])
|
||||
|
||||
def test_agent_draft_cannot_replace_a_published_plugin(self) -> None:
|
||||
draft = self.store.draft(
|
||||
"protected",
|
||||
title="Protected",
|
||||
description="",
|
||||
document=_document("First"),
|
||||
)
|
||||
self.store.publish("protected", expected_digest=draft["digest"])
|
||||
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "cannot be replaced"):
|
||||
self.store.draft(
|
||||
"protected",
|
||||
title="Changed",
|
||||
description="",
|
||||
document=_document("Changed"),
|
||||
)
|
||||
|
||||
self.assertEqual("published", self.store.get("protected")["status"])
|
||||
self.assertEqual(_document("First"), self.store.get("protected")["document"])
|
||||
|
||||
def test_rejects_embedded_backend_action_requests(self) -> None:
|
||||
document = _document()
|
||||
document["pages"][0]["content"] = {
|
||||
"type": "button",
|
||||
"id": "privileged-action",
|
||||
"label": {"type": "literal", "value": "Enable"},
|
||||
"action": {
|
||||
"id": "enable",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"path": "remote-access/tailscale/enable",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "action.request"):
|
||||
self.store.draft(
|
||||
"unsafe-action",
|
||||
title="Unsafe",
|
||||
description="",
|
||||
document=document,
|
||||
)
|
||||
|
||||
def test_rejects_symbolic_link_entries(self) -> None:
|
||||
self.store.root.mkdir(parents=True)
|
||||
outside = Path(self.temp.name) / "outside.json"
|
||||
outside.write_text("{}", encoding="utf-8")
|
||||
link = self.store.root / "linked.json"
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symbolic links unavailable: {exc}")
|
||||
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "symbolic-link"):
|
||||
self.store.get("linked")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -630,6 +630,26 @@ class RealtimeAgentRoutesTests(AioHTTPTestCase):
|
||||
self.assertGreaterEqual(body["resume_ttl_ms"], 1000)
|
||||
self.assertTrue(body["experimental"])
|
||||
|
||||
async def test_final_answer_only_overrides_spoken_progress_for_one_session(self) -> None:
|
||||
token = await self._make_session()
|
||||
|
||||
resp = await self.client.post(
|
||||
"/voice/realtime-agent/session",
|
||||
json={"final_answer_only": True},
|
||||
headers=self._bearer(token),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
body = await resp.json()
|
||||
session = self._server().realtime_agent.sessions[body["session_id"]]
|
||||
self.assertTrue(session.final_answer_only)
|
||||
self.assertFalse(session.spoken_handoff)
|
||||
self.assertEqual(session.progress_spoken_after_seconds, 0.0)
|
||||
self.assertEqual(session.result_delivery, "speak_verbatim")
|
||||
instructions = broker_module._native_instructions(session)
|
||||
self.assertIn("Do not speak acknowledgements", instructions)
|
||||
self.assertIn("wait to speak until its settled final answer", instructions)
|
||||
|
||||
async def test_provider_native_instructions_include_recent_context(self) -> None:
|
||||
token = await self._make_session()
|
||||
fake_provider = FakeNativeProvider()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin.tools.relay_plugin_tool import (
|
||||
relay_plugin_draft,
|
||||
relay_plugin_list,
|
||||
relay_plugin_publish,
|
||||
relay_plugin_remove,
|
||||
)
|
||||
|
||||
|
||||
class RelayPluginToolTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.env = patch.dict(os.environ, {"HERMES_HOME": self.temp.name})
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
self.document = {
|
||||
"schemaVersion": 1,
|
||||
"pages": [
|
||||
{
|
||||
"id": "home",
|
||||
"title": {"type": "literal", "value": "Tool Page"},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"id": "message",
|
||||
"text": {"type": "literal", "value": "Ready"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_agent_tool_lifecycle(self) -> None:
|
||||
drafted = relay_plugin_draft("tool-page", "Tool Page", self.document)
|
||||
self.assertEqual("draft", drafted["status"])
|
||||
self.assertEqual("tool-page", relay_plugin_list()["plugins"][0]["id"])
|
||||
publish = relay_plugin_publish("tool-page")
|
||||
self.assertTrue(publish["approval_required"])
|
||||
self.assertEqual("draft", relay_plugin_list()["plugins"][0]["status"])
|
||||
self.assertTrue(relay_plugin_remove("tool-page")["ok"])
|
||||
|
||||
def test_invalid_document_returns_structured_error(self) -> None:
|
||||
result = relay_plugin_draft("bad", "Bad", {"schemaVersion": 1, "pages": []})
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_persistent_draft_removal_requires_user_approval(self) -> None:
|
||||
relay_plugin_draft(
|
||||
"persistent-page",
|
||||
"Persistent",
|
||||
self.document,
|
||||
lifecycle="persistent",
|
||||
)
|
||||
|
||||
result = relay_plugin_remove("persistent-page")
|
||||
|
||||
self.assertTrue(result["approval_required"])
|
||||
self.assertEqual("persistent-page", relay_plugin_list()["plugins"][0]["id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Agent-visible tools for creating bounded declarative Android plugin pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..mobile_plugin_store import MobilePluginStore, MobilePluginStoreError
|
||||
|
||||
|
||||
def relay_plugin_draft(
|
||||
plugin_id: str,
|
||||
title: str,
|
||||
document: dict[str, Any],
|
||||
description: str = "",
|
||||
lifecycle: str = "session",
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return MobilePluginStore().draft(
|
||||
plugin_id,
|
||||
title=title,
|
||||
description=description,
|
||||
document=document,
|
||||
lifecycle=lifecycle,
|
||||
)
|
||||
except (AttributeError, MobilePluginStoreError) as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def relay_plugin_publish(plugin_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
entry = MobilePluginStore().get(plugin_id)
|
||||
except (OSError, MobilePluginStoreError) as exc:
|
||||
return {"error": str(exc)}
|
||||
return {
|
||||
"approval_required": True,
|
||||
"id": entry["id"],
|
||||
"status": entry["status"],
|
||||
"revision": entry.get("revision"),
|
||||
"digest": entry.get("digest"),
|
||||
"message": "Publishing requires an explicit authenticated Android user action.",
|
||||
"approval_endpoint": f"mobile/plugins/{entry['id']}/promote",
|
||||
}
|
||||
|
||||
|
||||
def relay_plugin_remove(plugin_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
store = MobilePluginStore()
|
||||
entry = store.get(plugin_id)
|
||||
except (OSError, MobilePluginStoreError) as exc:
|
||||
return {"error": str(exc)}
|
||||
if entry["status"] == "draft" and entry.get("lifecycle") == "session":
|
||||
return store.remove(plugin_id)
|
||||
return {
|
||||
"approval_required": True,
|
||||
"id": entry["id"],
|
||||
"status": entry["status"],
|
||||
"message": "Removing a published or persistent plugin requires an explicit Android user action.",
|
||||
"revision": entry.get("revision"),
|
||||
"digest": entry.get("digest"),
|
||||
"approval_endpoint": f"mobile/plugins/{entry['id']}/remove",
|
||||
}
|
||||
|
||||
|
||||
def relay_plugin_list() -> dict[str, Any]:
|
||||
return {"plugins": MobilePluginStore().list()}
|
||||
|
||||
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||
"relay_plugin_draft": {
|
||||
"name": "relay_plugin_draft",
|
||||
"description": (
|
||||
"Create or replace a draft Android plugin page using the bounded "
|
||||
"declarative schema. This stores JSON data only; executable code is not allowed."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plugin_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"description": {"type": "string", "maxLength": 1000},
|
||||
"lifecycle": {
|
||||
"type": "string",
|
||||
"enum": ["session", "persistent"],
|
||||
"default": "session",
|
||||
},
|
||||
"document": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Schema-version-1 PluginDocument JSON. Use schemaVersion=1 and pages. "
|
||||
"Each page needs id, title ({type: literal, value: ...} or a binding), "
|
||||
"and content. Supported content types are group, card, text, badge, "
|
||||
"button, text_input, toggle, progress, image, divider, and spacer. "
|
||||
"Groups use children; cards use child. Elements need stable unique ids. "
|
||||
"initialState may hold string/boolean/number/null typed PluginValue objects. "
|
||||
"Generated documents must not include action.request."
|
||||
),
|
||||
"properties": {
|
||||
"schemaVersion": {"type": "integer", "const": 1},
|
||||
"initialState": {"type": "object"},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "object"},
|
||||
"content": {"type": "object"},
|
||||
},
|
||||
"required": ["id", "title", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["schemaVersion", "pages"],
|
||||
},
|
||||
},
|
||||
"required": ["plugin_id", "title", "document"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"relay_plugin_publish": {
|
||||
"name": "relay_plugin_publish",
|
||||
"description": (
|
||||
"Request publication of a draft Android plugin page. This does not publish "
|
||||
"directly; it returns the authenticated Android approval action required."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"plugin_id": {"type": "string"}},
|
||||
"required": ["plugin_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"relay_plugin_remove": {
|
||||
"name": "relay_plugin_remove",
|
||||
"description": (
|
||||
"Remove a session-scoped draft. Published or persistent entries are not "
|
||||
"changed and return the authenticated Android approval action required."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"plugin_id": {"type": "string"}},
|
||||
"required": ["plugin_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"relay_plugin_list": {
|
||||
"name": "relay_plugin_list",
|
||||
"description": "List draft and published generated Android plugin pages.",
|
||||
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
"relay_plugin_draft": lambda args, **kw: relay_plugin_draft(**args),
|
||||
"relay_plugin_publish": lambda args, **kw: relay_plugin_publish(**args),
|
||||
"relay_plugin_remove": lambda args, **kw: relay_plugin_remove(**args),
|
||||
"relay_plugin_list": lambda args, **kw: relay_plugin_list(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["_HANDLERS", "_SCHEMAS"]
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -5,8 +5,8 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.0"
|
||||
id("com.android.library") version "9.3.0"
|
||||
id("com.android.application") version "9.3.1"
|
||||
id("com.android.library") version "9.3.1"
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
|
||||
}
|
||||
|
||||
@@ -153,7 +153,12 @@ def qualifier_to_tag(qualifier: str) -> str:
|
||||
if value.startswith("b+"):
|
||||
return value.removeprefix("b+").replace("+", "-")
|
||||
parts = value.split("-")
|
||||
return "-".join(part.removeprefix("r") for part in parts)
|
||||
# Only strip the region "r" prefix (e.g. values-en-rUS -> en-US); a plain
|
||||
# two-letter language code (e.g. values-ru) must be kept as-is.
|
||||
return "-".join(
|
||||
part[1:] if len(part) > 2 and part.startswith("r") else part
|
||||
for part in parts
|
||||
)
|
||||
|
||||
|
||||
def validate_locale_config(errors: list[str]) -> None:
|
||||
|
||||
Reference in New Issue
Block a user