Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c14b5db1 | ||
|
|
e3d6dd9509 | ||
|
|
91d05c982e | ||
|
|
85bbbd004d | ||
|
|
a8f61f2aeb | ||
|
|
d554c1819a | ||
|
|
920e7d58f0 | ||
|
|
0ce7c6c6b3 | ||
|
|
46e8f90c39 | ||
|
|
2d28f171e0 | ||
|
|
fdd8301796 | ||
|
|
c9ddc2ef8d | ||
|
|
b0d662b802 | ||
|
|
63ca0a1428 | ||
|
|
8196856d76 | ||
|
|
605ff00cc0 | ||
|
|
c9b1e1b04e | ||
|
|
a4466e4ca0 | ||
|
|
e13e381e3a | ||
|
|
19d33910d0 | ||
|
|
41480a3254 | ||
|
|
50f151edba | ||
|
|
0f108fe971 | ||
|
|
c8d6119e1a | ||
|
|
b2b7a2572b | ||
|
|
1ccf87401a | ||
|
|
50b1a17895 | ||
|
|
d536923c55 | ||
|
|
14a2e01ac5 | ||
|
|
a0d03f6947 | ||
|
|
478eeac3f7 | ||
|
|
799159457a | ||
|
|
f90650bdc9 | ||
|
|
6a41ec154c | ||
|
|
53f4c8187b | ||
|
|
675252090c | ||
|
|
e94db467e8 | ||
|
|
65dae79c8c | ||
|
|
e5d0334c8b | ||
|
|
9cc7b25fd1 | ||
|
|
2ce352a320 | ||
|
|
a6957268b9 | ||
|
|
8f42b96be1 | ||
|
|
8a9c058ddb | ||
|
|
5ef2c40f81 | ||
|
|
6b32fe7ddd | ||
|
|
02f38322fa | ||
|
|
f40b7abaf0 | ||
|
|
5fcbe5ff63 | ||
|
|
6ce504b1c9 | ||
|
|
31ebef825f | ||
|
|
68abbf156d | ||
|
|
73a8af7c8f | ||
|
|
2db00d4c59 | ||
|
|
847444d115 | ||
|
|
798e8eef55 | ||
|
|
35f791be50 | ||
|
|
e727979897 | ||
|
|
91025b733f | ||
|
|
34da86a96a | ||
|
|
30f9f51e2a | ||
|
|
713529f79f | ||
|
|
16d1728306 | ||
|
|
7a813995ba | ||
|
|
c86b2224ce | ||
|
|
11a782bc44 | ||
|
|
884a17ded7 | ||
|
|
745904d468 | ||
|
|
4258322acc | ||
|
|
2c544b8ab0 | ||
|
|
5122ee69f6 | ||
|
|
512199448e | ||
|
|
7a7b10f08a | ||
|
|
f7845291e7 | ||
|
|
689219405b | ||
|
|
f4e9de425a | ||
|
|
d4cdb96bab | ||
|
|
481ad48c57 | ||
|
|
c5f35145b4 | ||
|
|
3283c9b601 | ||
|
|
74be630e05 | ||
|
|
44f030f93b | ||
|
|
abce00f49e | ||
|
|
a61d27a92d | ||
|
|
123f1d1263 | ||
|
|
61c4337807 | ||
|
|
6e1338004c | ||
|
|
348152a7cb | ||
|
|
3ec34502ec | ||
|
|
d30de8656d | ||
|
|
9b9b8c7c06 | ||
|
|
93b82aa538 | ||
|
|
7f2049fa0a | ||
|
|
92444a7039 | ||
|
|
a02d7e10df | ||
|
|
8a3935ffa1 | ||
|
|
e48935929a | ||
|
|
1e82347e7d | ||
|
|
75ed3c4226 | ||
|
|
bade61ac34 | ||
|
|
f88559f856 | ||
|
|
22e4d24817 | ||
|
|
7edaa2df14 | ||
|
|
165feaa0d6 | ||
|
|
8c516c3c8d | ||
|
|
40bb0a4ef8 | ||
|
|
d96898a6aa |
@@ -0,0 +1,79 @@
|
||||
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
|
||||
#
|
||||
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
|
||||
# intentionally small: check out a clean upstream hermes-agent beside Relay and
|
||||
# run the desktop typed-stream/renderer tests that protect the gateway event
|
||||
# contract. A later expansion can boot the upstream gateway with a mock provider
|
||||
# once that harness is stable enough for CI.
|
||||
|
||||
name: CI - Desktop Upstream Baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check"
|
||||
required: false
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "30 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-upstream-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop-baseline:
|
||||
name: Desktop typed gateway baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}"
|
||||
else
|
||||
REF="main"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout vanilla upstream
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Assert upstream checkout is vanilla
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
|
||||
fi
|
||||
git -C _upstream status --short --untracked-files=no
|
||||
|
||||
- name: Run desktop gateway baseline contract
|
||||
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Run desktop gateway baseline tests
|
||||
working-directory: desktop
|
||||
env:
|
||||
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
|
||||
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
|
||||
@@ -50,6 +50,7 @@ jobs:
|
||||
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/config.py
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
@@ -103,4 +104,6 @@ jobs:
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
|
||||
@@ -4,11 +4,13 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -34,10 +36,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_file="legacy-pages-redirect/redirect.html"
|
||||
privacy_file="website/public/privacy.html"
|
||||
output_dir="legacy-pages-redirect/_site"
|
||||
rm -rf "$output_dir"
|
||||
mkdir -p \
|
||||
"$output_dir/guide/getting-started" \
|
||||
"$output_dir/privacy" \
|
||||
"$output_dir/reference/relay-server" \
|
||||
"$output_dir/architecture"
|
||||
for target in \
|
||||
@@ -50,8 +54,12 @@ jobs:
|
||||
architecture/connection-security.html; do
|
||||
cp "$source_file" "$output_dir/$target"
|
||||
done
|
||||
cp "$privacy_file" "$output_dir/privacy.html"
|
||||
cp "$privacy_file" "$output_dir/privacy/index.html"
|
||||
touch "$output_dir/.nojekyll"
|
||||
test "$(find "$output_dir" -type f | wc -l)" -eq 8
|
||||
test "$(find "$output_dir" -type f | wc -l)" -eq 10
|
||||
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
|
||||
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
|
||||
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
|
||||
echo "Full documentation content must not be deployed by this workflow." >&2
|
||||
exit 1
|
||||
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
- name: Validate release metadata and source compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py --live
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
||||
|
||||
@@ -74,6 +74,9 @@ jobs:
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
- name: Verify public privacy policy URLs
|
||||
run: python3 scripts/check-privacy-policy.py --live
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -123,6 +126,7 @@ jobs:
|
||||
- name: Validate release metadata and Android API compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
|
||||
|
||||
@@ -36,10 +36,12 @@ a staging branch.
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
|
||||
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work
|
||||
against unmodified upstream hermes-agent. Server-side needs go through upstream
|
||||
PRs or the optional relay plugin, never fork patches.
|
||||
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
|
||||
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
|
||||
and Vanilla Hermes voice. The API server is an optional automatic fallback and
|
||||
advanced headless-compatibility surface; Relay adds optional extensions. This
|
||||
path must work against unmodified upstream hermes-agent. Server-side needs go
|
||||
through upstream PRs or the optional relay plugin, never fork patches.
|
||||
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
|
||||
|
||||
@@ -6,9 +6,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
|
||||
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
|
||||
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
|
||||
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
|
||||
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
|
||||
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
|
||||
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
|
||||
|
||||
## [1.4.8] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
|
||||
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
|
||||
|
||||
## [1.4.7] - 2026-07-18
|
||||
|
||||
|
||||
@@ -1,5 +1,150 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-24 — Post-connect permission setup
|
||||
|
||||
Android onboarding now finishes with a layered permission step after a
|
||||
successful Hermes connection. Standard Chat and Manage are explicitly ready
|
||||
without a phone grant; Android notifications are recommended through one
|
||||
user-triggered runtime prompt; camera, microphone, notification companion, and
|
||||
flavor-supported device tools remain individually optional on the centralized
|
||||
Permissions screen. Existing just-in-time permission prompts remain available
|
||||
when users skip setup.
|
||||
|
||||
Coverage separates the Android-version notification policy from the Compose
|
||||
presentation and checks the recommended, granted, optional-review, and skip
|
||||
states. English and all shipped Android locale catalogs were updated together.
|
||||
|
||||
## 2026-07-24 — Realtime background-task delivery continuity
|
||||
|
||||
Chat stream completion now preserves an otherwise empty assistant row when it
|
||||
owns a promoted background task. This keeps the task identity available after
|
||||
the provider's initial spoken handoff, so later progress, completion, and
|
||||
forced-summary events update and settle the initiating row even when a newer
|
||||
persistent Voice command has started. Existing empty-response cleanup remains
|
||||
unchanged for assistant rows without background work.
|
||||
|
||||
## 2026-07-23 — Background interaction notifications
|
||||
|
||||
Android Gateway chat now treats approval, clarification, elevated-permission,
|
||||
and secret requests as actionable background events. Privacy-safe notifications
|
||||
use stable per-session identities, reopen the exact conversation, replace
|
||||
replayed requests, and clear on answer, expiry, or resumed turn activity.
|
||||
Detached active turns retain and replay their pending interaction when the
|
||||
conversation is reopened.
|
||||
|
||||
The audit classified `terminal.read.request` as renderer plumbing rather than a
|
||||
user decision. Android now answers it with the upstream no-terminal empty
|
||||
response instead of showing an interaction or waiting for the server timeout.
|
||||
The shared main manifest continues to provide notification and persistent
|
||||
connection support to both Google Play and sideload builds.
|
||||
|
||||
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
|
||||
|
||||
The shared Android network security configuration now accepts CA certificates
|
||||
that the device owner deliberately installed in Android's user credential store,
|
||||
in addition to system roots. Because the policy is attached to the common
|
||||
application manifest, it covers both product flavors and every platform-backed
|
||||
Hermes transport: endpoint probes, Dashboard requests and authentication
|
||||
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
|
||||
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
|
||||
checks remain active, and Relay's independent certificate pinner is not
|
||||
overridden.
|
||||
|
||||
An app-wide policy is required for arbitrary operator-supplied server names and
|
||||
for consistent WebView behavior. A runtime opt-in would require parallel custom
|
||||
trust implementations for each client and could not safely reconfigure WebView;
|
||||
per-connection CA import would add private trust-material lifecycle without
|
||||
covering all transports. The tradeoff is that Android disables public
|
||||
Certificate Transparency verification when user trust anchors are enabled.
|
||||
User documentation records the deliberate-installation boundary and a device or
|
||||
emulator validation procedure with positive chain and negative hostname checks.
|
||||
JVM coverage locks the accepted anchor sources and rejects pin overrides or
|
||||
debug-only trust additions.
|
||||
|
||||
## 2026-07-23 — Bounded Android code-highlighting ranges
|
||||
|
||||
Android Markdown code rendering now validates every syntax-highlighting span
|
||||
before applying it to Compose text. The bundled highlighting dependency can
|
||||
emit a reversed multiline-comment range when malformed or incomplete code
|
||||
contains a closing delimiter before its opening delimiter; the Markdown
|
||||
renderer previously passed that range directly to `AnnotatedString` and
|
||||
crashed. Both fenced and indented code use the guarded renderer, valid spans
|
||||
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
|
||||
coverage reproduces the dependency output and verifies the safe conversion.
|
||||
|
||||
## 2026-07-20 — Image generation placeholder during turns
|
||||
|
||||
Android chat now specializes the generic tool lifecycle for active
|
||||
`image_generate` calls. While the tool is pending, the message shows a
|
||||
theme-aware procedural diffusion canvas with a polite live-region announcement
|
||||
instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
connection and onboarding work merged to `dev`. The public changelog, GitHub
|
||||
release notes, in-app What's New surfaces, English and Simplified Chinese Play
|
||||
notes, and store-listing copy now describe the Android-only patch. Unreleased
|
||||
Relay security hardening and the Windows gateway PID fix remain assigned to the
|
||||
independent server release track.
|
||||
|
||||
## 2026-07-19 — Connection management detail pass
|
||||
|
||||
The Connections list now uses compact capability chips and exposes a cold-start
|
||||
preference when more than one server is saved. Last used remains the default;
|
||||
pinning a startup connection does not change the active connection during the
|
||||
current run. Dashboard-only connections now render their configured Dashboard
|
||||
and authentication status in Routes instead of an empty endpoint list, while
|
||||
optional API fallback routes remain explicitly separate.
|
||||
|
||||
Advanced settings render directly in their dedicated tab without a redundant
|
||||
expander. Security adds Dashboard authentication, credential-storage, Relay
|
||||
session, sign-out, and transport posture facts sourced from current runtime
|
||||
state. Android catalogs and localization source hashes were refreshed alongside
|
||||
focused startup-persistence coverage.
|
||||
|
||||
## 2026-07-18 — Dashboard-primary connection contract
|
||||
|
||||
The canonical Android connection model now treats one Hermes installation as a
|
||||
stable identity with independently optional endpoints. Dashboard/Gateway owns
|
||||
the standard upstream chat, authentication, sessions, Manage, and voice path;
|
||||
the API server is an automatic fallback and advanced headless compatibility
|
||||
surface; Relay remains an additive extension for terminal, bridge, media, and
|
||||
enhanced voice capabilities. Existing API-first records and pairing payloads
|
||||
remain compatible without defining normal onboarding.
|
||||
|
||||
The audit reconciled `AGENTS.md`, `docs/spec.md`, `docs/decisions.md`,
|
||||
`docs/upstream-surface-matrix.md`, `README.md`,
|
||||
`user-docs/features/connections.md`, `user-docs/features/dashboard.md`,
|
||||
`CHANGELOG.md`, and `DEVLOG.md`. `RELEASE.md` was reviewed and required no
|
||||
change because it contains no connection-routing contract.
|
||||
|
||||
## 2026-07-18 — Non-signalling Windows gateway PID probes
|
||||
|
||||
Relay profile discovery now prefers Hermes' supported psutil liveness check and
|
||||
falls back to native Windows process handles when psutil is unavailable. The
|
||||
POSIX signal-zero probe is restricted to POSIX hosts, preventing relay startup
|
||||
and periodic profile rescans from signalling or terminating the gateway named
|
||||
by `gateway.pid`. Regression coverage uses disposable child processes instead
|
||||
of pointing PID fixtures at the test runner.
|
||||
|
||||
## 2026-07-18 — Android privacy-policy URL hotfix
|
||||
|
||||
The canonical Android privacy policy moved to a stable public page on
|
||||
hermes-relay.dev. The historical GitHub Pages path now serves the complete
|
||||
policy for compatibility with existing store metadata instead of depending on
|
||||
a client-side redirect. Android's About screen, public privacy references, and
|
||||
Play submission documentation use the canonical site URL.
|
||||
|
||||
The legacy Pages workflow publishes both file and directory policy paths from
|
||||
the same canonical HTML source. Repository validation checks the policy's
|
||||
required disclosures and URL wiring, while Play preflight and stable tag release
|
||||
jobs additionally require both deployed policy URLs to return complete policy
|
||||
content before an artifact can be uploaded or promoted. Android advanced to
|
||||
1.4.8 with versionCode 31 for the replacement Play submission.
|
||||
|
||||
## 2026-07-18 — Android 1.4.7 release preparation
|
||||
|
||||
Android advanced to 1.4.7 with versionCode 30 after the localization, streaming,
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** July 15, 2026
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
|
||||
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
|
||||
|
||||
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
|
||||
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
|
||||
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
|
||||
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
|
||||
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
@@ -54,52 +54,48 @@ Install → connect → talk, in about two minutes.
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2 · Have Hermes running
|
||||
### 2 · Have the Hermes Dashboard running
|
||||
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
|
||||
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
|
||||
provider is vanilla Hermes setup:
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
hermes dashboard # start the standard Dashboard/Gateway surface
|
||||
```
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://hermes-relay.dev/docs/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://hermes-relay.dev/docs/guide/getting-started).
|
||||
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
|
||||
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
|
||||
covers Windows, remote access, and dashboard authentication. You do not need to
|
||||
enable the separate API server or invent an API key for the standard path.
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
|
||||
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
|
||||
configured provider when prompted. The app probes the available upstream
|
||||
capabilities and finishes with a connection summary.
|
||||
|
||||
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
|
||||
The separate API server can be discovered automatically or added later under
|
||||
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
|
||||
key is requested only when that optional endpoint is configured. Existing
|
||||
API-first setup QRs remain importable.
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|------|---------------|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Chat** | Dashboard/Gateway ready — you can talk |
|
||||
| **Manage** | Models, keys, skills, and profiles are available from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
| **API fallback** | Optional API route available/unavailable |
|
||||
| **Relay** | Optional extensions — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
|
||||
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
|
||||
the whole Vanilla Hermes setup.
|
||||
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
@@ -133,7 +129,7 @@ the QR from the phone's Connections screen — or use
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
+14
-9
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.4.7
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
|
||||
**Release Date:** July 18, 2026
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.4.7-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,19 +12,24 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch adds German, Brazilian Portuguese, and Japanese and makes long streamed replies grow smoothly while staying anchored at the latest text.
|
||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
||||
|
||||
## Added
|
||||
|
||||
- Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.
|
||||
- Language-picker and catalog freshness checks keep every shipped locale aligned with the canonical English resources.
|
||||
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
|
||||
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
|
||||
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Long streamed replies insert text at a display-paced cadence instead of visibly rebuilding the conversation.
|
||||
- The active response remains anchored at the latest text through growth and completion while readers who scroll into history remain undisturbed.
|
||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
||||
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
|
||||
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
|
||||
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
|
||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.4.7** (versionCode **30**).
|
||||
- App version: **1.5.0** (versionCode **33**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -36,6 +36,55 @@ removal.
|
||||
|
||||
---
|
||||
|
||||
## Upstream impact certification follow-ups (2026-07-19)
|
||||
|
||||
The client/plugin implementation batch for queued recovery,
|
||||
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
|
||||
trust, and retained bootstrap async safety is implemented. The following gates
|
||||
intentionally remain outside that code batch:
|
||||
|
||||
- **Image-generation lifecycle while tool progress is hidden.** The upstream
|
||||
TUI gateway suppresses every `tool.start` / `tool.complete` event when
|
||||
`display.tool_progress` is off, so a client cannot distinguish an active
|
||||
`image_generate` turn from generic model work. Propose a narrow upstream
|
||||
exception that always emits the lifecycle for `image_generate` while leaving
|
||||
unrelated tool diagnostics hidden. Android already treats that lifecycle as
|
||||
presentation state rather than a generic tool card and keeps the diffusion
|
||||
canvas visible when its local tool display is off.
|
||||
- Run `docs/upstream-compatibility-certification.md` against an approved test
|
||||
gateway with real provider calls and an Android device. Include concurrent
|
||||
model/image routing, turn isolation off/on, queued reconnect, same-profile
|
||||
background-completion ownership, compression lineage, and the explicitly
|
||||
approved restart case. Static upstream fixtures are necessary but do not
|
||||
prove device or restart behavior.
|
||||
- Upstream the atomic one-turn model arm/submit contract proposed in
|
||||
`docs/upstream-contributions.md`. Until then, document the narrow race where a
|
||||
disconnect or Stop after `/model --once` succeeds but before prompt submission
|
||||
can leave the override armed for a later prompt.
|
||||
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
|
||||
exposes a reset on the active gateway session or an authoritative reset event.
|
||||
`slash.exec` runs the command in a separate worker today, and the mirrored
|
||||
slash side effects do not reset the active TUI session's agent. Relay must not
|
||||
clear local model, reasoning, or Fast pins from a successful command response
|
||||
that did not mutate the agent those controls describe.
|
||||
- Keep profile-scoped cron execution attempts blocked on the public upstream API
|
||||
proposed in `docs/upstream-contributions.md`. The first-class interim
|
||||
assistant event is no longer blocked: Relay Android and desktop consume
|
||||
upstream `message.interim` / `response_previewed`.
|
||||
- Keep Standard voice labeled host-global until upstream exposes a stable
|
||||
profile/per-request audio contract; do not emulate it through Relay on the
|
||||
vanilla path.
|
||||
- Keep provider exclusion/disable filtering out of Android Manage until the
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
checkout and the desktop typed gateway renderer/tests.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||
|
||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||
@@ -1070,7 +1119,7 @@ Things to look into:
|
||||
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
|
||||
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
@@ -326,8 +326,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Local device-review helper. Never runs in or ships with the application APK. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConnectionReviewSeedTest {
|
||||
|
||||
@Test
|
||||
fun seedOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.none { it.id == REVIEW_ID }) {
|
||||
store.addConnection(
|
||||
Connection(
|
||||
id = REVIEW_ID,
|
||||
label = "Lab NAS",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
|
||||
dashboardUrl = "http://192.0.2.10:9119",
|
||||
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.any { it.id == REVIEW_ID }) {
|
||||
store.removeConnection(REVIEW_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -121,42 +121,39 @@ class OnboardingFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
fun connectPage_showsNearbyFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Vanilla Hermes")
|
||||
.onNodeWithText("Enter address instead")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_showsApiFields() {
|
||||
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.onNodeWithText("Hermes address")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
fun manualSetup_findButton_isShown() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
.onNodeWithText("Find Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Other connection methods").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
|
||||
|
||||
/** Debug-build-only deterministic host for design QA screenshots. */
|
||||
class VoiceSettingsDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSettingsDesignQaScene() {
|
||||
val provider = remember {
|
||||
RealtimeProviderInfo(
|
||||
id = "xai_tts",
|
||||
name = "xAI Grok TTS",
|
||||
status = "ready",
|
||||
models = listOf("grok-tts", "grok-tts-fast"),
|
||||
voices = listOf("eve", "ara", "sal", "rex", "leo"),
|
||||
model_labels = mapOf("grok-tts" to "Grok TTS"),
|
||||
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
|
||||
recommended_voices = listOf("eve", "ara"),
|
||||
supports_tts = true,
|
||||
)
|
||||
}
|
||||
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
|
||||
var selectedVoice by remember { mutableStateOf("eve") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val allVoices = remember {
|
||||
listOf(
|
||||
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
|
||||
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
|
||||
VoiceChoice("sal", "Sal", "Calm · grounded"),
|
||||
VoiceChoice("rex", "Rex", "Direct · confident"),
|
||||
VoiceChoice("leo", "Leo", "Bright · conversational"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
VoiceSettingsTabs(selectedSection) { selectedSection = it }
|
||||
VoiceProviderGroupCard(
|
||||
provider = provider,
|
||||
providerValue = provider.id,
|
||||
enabled = true,
|
||||
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
|
||||
onEnabledChange = {},
|
||||
onProviderChange = {},
|
||||
controlsEnabled = true,
|
||||
)
|
||||
ModelAndVoiceGroupCard(
|
||||
modelValue = "grok-tts",
|
||||
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
|
||||
voices = previewVoiceChoices(allVoices, selectedVoice),
|
||||
allVoices = allVoices,
|
||||
selectedVoice = selectedVoice,
|
||||
previewState = VoicePreviewUiState(
|
||||
selectionKey = "voice:eve",
|
||||
isPlaying = true,
|
||||
amplitude = 0.42f,
|
||||
),
|
||||
onModelChange = {},
|
||||
onVoiceChange = { selectedVoice = it },
|
||||
onPreviewVoice = {},
|
||||
enabled = true,
|
||||
)
|
||||
LanguageQualityCard(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
language = "English",
|
||||
languages = listOf(VoiceChoice("en", "English")),
|
||||
onLanguageChange = {},
|
||||
sampleRate = "24000",
|
||||
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
|
||||
onSampleRateChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
Long streamed replies now grow smoothly and stay anchored at the newest text through completion, while scrolling into history preserves your reading position. German, Brazilian Portuguese, and Japanese are now available throughout the app.
|
||||
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
|
||||
@@ -1 +1 @@
|
||||
长回复现在会平滑流式显示,并在完成时保持定位到最新文本;向上滚动查看历史记录时仍会保留阅读位置。应用现已支持德语、巴西葡萄牙语和日语。
|
||||
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Voice that keeps pace",
|
||||
"date": "2026-07-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A clearer voice studio",
|
||||
"bullets": [
|
||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Natural streaming speech",
|
||||
"bullets": [
|
||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More upstream-aware controls",
|
||||
"bullets": [
|
||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.9",
|
||||
"title": "Clearer Hermes connections",
|
||||
"date": "2026-07-19",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Dashboard-first setup",
|
||||
"bullets": [
|
||||
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
|
||||
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Consistent identity",
|
||||
"bullets": [
|
||||
"Server default now displays Hermes' pinned active profile consistently across the app.",
|
||||
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.8",
|
||||
"title": "Privacy policy restored",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Google Play compliance",
|
||||
"bullets": [
|
||||
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
|
||||
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.7",
|
||||
"title": "Smoother replies, more languages",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
v1.4.7 - Smoother replies, more languages
|
||||
v1.5.0 - Voice that keeps pace
|
||||
|
||||
Smooth streaming
|
||||
* Long replies grow steadily and stay anchored at the newest text through completion.
|
||||
* Scrolling into history still leaves your reading position alone.
|
||||
|
||||
More languages
|
||||
* Use German, Brazilian Portuguese, or Japanese throughout the app.
|
||||
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
|
||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// Action-required notifications are durable across process death.
|
||||
// Once the authenticated chat surface is visible it owns presentation;
|
||||
// unresolved asks are re-posted if the app returns to the background.
|
||||
InteractionRequestNotifier.cancelAll(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
@@ -31,7 +31,16 @@ object AgentDisplay {
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
serverDefaultProfileName: String? = null,
|
||||
): Profile? {
|
||||
selectedProfile?.let { return it }
|
||||
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
|
||||
return resolvedServerDefault
|
||||
?.let { activeName ->
|
||||
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
|
||||
}
|
||||
?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
}
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Pure, persisted-state-derived availability for a Hermes connection.
|
||||
*
|
||||
* This deliberately describes configured surfaces, not live reachability or
|
||||
* authentication. Runtime layers can combine it with their probe/auth state
|
||||
* without treating a missing optional API server or Relay as a broken Hermes
|
||||
* connection.
|
||||
*/
|
||||
data class ConnectionCapabilities(
|
||||
val dashboardGatewayConfigured: Boolean,
|
||||
val apiServerConfigured: Boolean,
|
||||
val relayConfigured: Boolean,
|
||||
) {
|
||||
val gatewayChatAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val manageAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val standardVoiceAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val apiChatFallbackAvailable: Boolean get() = apiServerConfigured
|
||||
val relayFeaturesAvailable: Boolean get() = relayConfigured
|
||||
val chatConfigured: Boolean get() = gatewayChatAvailable || apiChatFallbackAvailable
|
||||
val anySurfaceConfigured: Boolean
|
||||
get() = dashboardGatewayConfigured || apiServerConfigured || relayConfigured
|
||||
}
|
||||
|
||||
val Connection.capabilities: ConnectionCapabilities
|
||||
get() = ConnectionCapabilities(
|
||||
dashboardGatewayConfigured = resolvedDashboardUrl.isNotBlank(),
|
||||
apiServerConfigured = apiServerUrl.isNotBlank(),
|
||||
relayConfigured = relayUrl.isNotBlank(),
|
||||
)
|
||||
@@ -13,13 +13,16 @@ data class DashboardConnectionStatus(
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val gatewayMode: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
* Each connection has its own:
|
||||
* - API server URL + relay URL
|
||||
* - One or more independently-configured Hermes surfaces. Dashboard/Gateway
|
||||
* is the standard primary path; API server and Relay are optional.
|
||||
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||
* session token, device ID, API key, and paired-session metadata.
|
||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||
@@ -73,17 +76,34 @@ data class Connection(
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
/** Last time the user explicitly selected this connection. */
|
||||
val lastUsedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
val transportHint: String? = null,
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
/**
|
||||
* Effective Dashboard/Gateway endpoint. Legacy records did not persist a
|
||||
* dashboard URL, so they retain the conventional same-host `:9119`
|
||||
* derivation from the API server. Dashboard-only records persist an
|
||||
* explicit URL and may leave [apiServerUrl] and [relayUrl] blank.
|
||||
*/
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
/** Stable display/host identity that does not depend on the API surface. */
|
||||
val primaryEndpointUrl: String
|
||||
get() = resolvedDashboardUrl.takeIf { it.isNotBlank() }
|
||||
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: relayUrl.trim()
|
||||
|
||||
val primaryHost: String
|
||||
get() = extractHost(primaryEndpointUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -94,6 +114,8 @@ data class Connection(
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
const val DEFAULT_API_PORT: Int = 8642
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
@@ -111,12 +133,40 @@ data class Connection(
|
||||
* user typed a malformed value — better to show something recognizable
|
||||
* than to crash).
|
||||
*/
|
||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
||||
return try {
|
||||
URI(apiServerUrl).host ?: apiServerUrl
|
||||
} catch (_: Exception) {
|
||||
apiServerUrl
|
||||
}
|
||||
fun extractDefaultLabel(apiServerUrl: String): String =
|
||||
extractHost(apiServerUrl) ?: apiServerUrl
|
||||
|
||||
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
|
||||
fun chooseDiscoveredLabel(
|
||||
currentLabel: String,
|
||||
primaryHost: String,
|
||||
discoveredHostname: String?,
|
||||
): String {
|
||||
val current = currentLabel.trim()
|
||||
val discovered = discoveredHostname?.trim()?.takeIf { it.isNotBlank() }
|
||||
val isAutomatic = current.isBlank() || current.equals(primaryHost.trim(), ignoreCase = true)
|
||||
return if (isAutomatic && discovered != null) discovered else currentLabel
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-first label for a connection whose surfaces are optional.
|
||||
* The one-argument overload above remains for source compatibility.
|
||||
*/
|
||||
fun extractDefaultLabel(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): String {
|
||||
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: relayUrl.trim()
|
||||
return extractHost(primary) ?: primary
|
||||
}
|
||||
|
||||
private fun extractHost(url: String): String? = try {
|
||||
URI(url).host
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
@@ -141,6 +191,29 @@ data class Connection(
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
|
||||
fun deriveDefaultApiUrl(
|
||||
dashboardUrl: String,
|
||||
apiPort: Int = DEFAULT_API_PORT,
|
||||
): String? {
|
||||
val trimmed = dashboardUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$apiPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
@@ -150,7 +223,7 @@ data class Connection(
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
relayPort: Int = DEFAULT_RELAY_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
@@ -199,7 +272,7 @@ data class Connection(
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
"${it.role.lowercase()}|${it.routeAuthority()}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
@@ -222,13 +295,13 @@ data class Connection(
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.mapNotNull { it.mergeAuthority() }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
@@ -296,6 +369,85 @@ data class Connection(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
* its dashboard-enriched replacement still collide. Dashboard-only
|
||||
* candidates fall back to their primary route authority.
|
||||
*/
|
||||
private fun EndpointCandidate.mergeAuthority(): String? =
|
||||
api?.let { endpoint -> "api|${endpoint.host.lowercase()}:${endpoint.port}" }
|
||||
?: routeAuthority()?.let { authority -> "route|$authority" }
|
||||
|
||||
/**
|
||||
* Build a Dashboard/Gateway-primary route from a remote host or URL.
|
||||
* API and Relay are retained only when explicitly configured; callers
|
||||
* no longer need to invent an API key or legacy surface URL.
|
||||
*/
|
||||
fun endpointCandidateFromDashboardUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
dashboardUrl: String,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val normalizedDashboard = normalizeDashboardUrlInput(dashboardUrl)
|
||||
val dashboardUri = runCatching { URI(normalizedDashboard) }.getOrNull() ?: return null
|
||||
if (dashboardUri.scheme?.lowercase() !in setOf("http", "https") ||
|
||||
dashboardUri.host.isNullOrBlank()
|
||||
) return null
|
||||
|
||||
val api = apiServerUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { apiUrl ->
|
||||
val apiUri = runCatching { URI(apiUrl.trimEnd('/')) }.getOrNull()
|
||||
?: return@let null
|
||||
val tls = when (apiUri.scheme?.lowercase()) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return@let null
|
||||
}
|
||||
val host = apiUri.host?.takeIf { it.isNotBlank() } ?: return@let null
|
||||
ApiEndpoint(host, if (apiUri.port > 0) apiUri.port else 8642, tls)
|
||||
}
|
||||
val relay = relayUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { url ->
|
||||
val hint = when {
|
||||
url.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
url.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
RelayEndpoint(url, hint)
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(normalizedDashboard) },
|
||||
priority = priority,
|
||||
dashboard = DashboardEndpoint(normalizedDashboard),
|
||||
api = api,
|
||||
relay = relay,
|
||||
)
|
||||
}
|
||||
|
||||
fun normalizeDashboardUrlInput(
|
||||
raw: String,
|
||||
defaultPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
|
||||
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
|
||||
class ConnectionStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val context: Context?,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
|
||||
constructor(context: Context) : this(
|
||||
dataStore = context.relayDataStore,
|
||||
context = context.applicationContext,
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
|
||||
internal constructor(dataStore: DataStore<Preferences>) : this(
|
||||
dataStore = dataStore,
|
||||
context = null,
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
internal constructor(
|
||||
dataStore: DataStore<Preferences>,
|
||||
scope: CoroutineScope,
|
||||
) : this(dataStore = dataStore, context = null, scope = scope)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Optional cold-start pin. `null` means restore the last connection the
|
||||
* user actively selected, which remains the recommended default.
|
||||
*/
|
||||
private val _startupConnectionId = MutableStateFlow<String?>(null)
|
||||
val startupConnectionId: StateFlow<String?> = _startupConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
|
||||
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
||||
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
||||
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
|
||||
val startupId = prefs[KEY_STARTUP_CONNECTION_ID]
|
||||
|
||||
// Prefer the new key. If absent and the old key has data,
|
||||
// migrate it once: write to the new key and clear the old ones
|
||||
@@ -143,15 +157,21 @@ class ConnectionStore private constructor(
|
||||
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
}
|
||||
}
|
||||
_connections.value = decodeConnections(oldJson)
|
||||
_activeConnectionId.value = activeOld
|
||||
val restored = decodeConnections(oldJson)
|
||||
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||
_connections.value = restored
|
||||
_startupConnectionId.value = validStartupId
|
||||
_activeConnectionId.value = validStartupId ?: activeOld
|
||||
Log.i(
|
||||
TAG,
|
||||
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
||||
)
|
||||
} else {
|
||||
_connections.value = decodeConnections(newJson)
|
||||
_activeConnectionId.value = activeNew
|
||||
val restored = decodeConnections(newJson)
|
||||
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||
_connections.value = restored
|
||||
_startupConnectionId.value = validStartupId
|
||||
_activeConnectionId.value = validStartupId ?: activeNew
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
_startupConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
_startupConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
startupConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
val normalizedStartupId = startupConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
if (normalizedStartupId == null) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_STARTUP_CONNECTION_ID] = normalizedStartupId
|
||||
}
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
|
||||
_startupConnectionId.value = normalizedStartupId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
|
||||
suspend fun setActiveConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (current.any { it.id == id }) {
|
||||
val next = current.map { connection ->
|
||||
if (connection.id == id) {
|
||||
connection.copy(lastUsedAt = System.currentTimeMillis())
|
||||
} else {
|
||||
connection
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_activeConnectionId.value = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a specific cold-start connection, or `null` to restore last used. */
|
||||
suspend fun setStartupConnection(id: String?) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val validId = id?.takeIf { candidate ->
|
||||
decodeConnections(prefs[KEY_CONNECTIONS]).any { it.id == candidate }
|
||||
}
|
||||
if (validId == null) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
_activeConnectionId.value?.let { activeId ->
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = activeId
|
||||
}
|
||||
} else {
|
||||
prefs[KEY_STARTUP_CONNECTION_ID] = validId
|
||||
}
|
||||
_startupConnectionId.value = validId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update just the `lastActiveSessionId` on the identified connection.
|
||||
* Called whenever the user picks a chat session so connection-switch can
|
||||
@@ -504,6 +571,7 @@ class ConnectionStore private constructor(
|
||||
|
||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
||||
private val KEY_STARTUP_CONNECTION_ID = stringPreferencesKey("startup_connection_id")
|
||||
|
||||
// Pre-rename DataStore keys — read once in init on first launch after
|
||||
// the rename, then wiped. See the init block above.
|
||||
|
||||
@@ -52,6 +52,45 @@ object ConnectionValidation {
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
|
||||
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "Dashboard URL",
|
||||
)
|
||||
|
||||
/** A blank API server means the optional SSE fallback is not configured. */
|
||||
fun validateOptionalApiServerUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "API server URL",
|
||||
)
|
||||
|
||||
/** A blank Relay URL means Relay-only power features are not configured. */
|
||||
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("ws", "wss"),
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate the independently optional connection surfaces. A connection
|
||||
* needs at least one endpoint, but Dashboard-only, API-only, and
|
||||
* Relay-only records are all structurally valid.
|
||||
*/
|
||||
fun validateConnectionEndpoints(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): String? {
|
||||
if (dashboardUrl.isNullOrBlank() && apiServerUrl.isBlank() && relayUrl.isBlank()) {
|
||||
return "Configure at least one Hermes endpoint"
|
||||
}
|
||||
return validateDashboardUrl(dashboardUrl.orEmpty())
|
||||
?: validateOptionalApiServerUrl(apiServerUrl)
|
||||
?: validateOptionalRelayUrl(relayUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches the "added the same server twice" mistake. Matches when the
|
||||
* candidate's api + relay URLs exactly match an existing connection
|
||||
@@ -67,12 +106,33 @@ object ConnectionValidation {
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
excludeId: String? = null,
|
||||
dashboardUrl: String? = null,
|
||||
): Connection? = connections.firstOrNull { c ->
|
||||
c.id != excludeId &&
|
||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
||||
if (c.id == excludeId) {
|
||||
false
|
||||
} else {
|
||||
val legacyExactMatch =
|
||||
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
|
||||
urlsEqual(c.apiServerUrl, apiServerUrl) &&
|
||||
urlsEqual(c.relayUrl, relayUrl)
|
||||
val candidateDashboard = dashboardUrl
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val dashboardMatch = !candidateDashboard.isNullOrBlank() &&
|
||||
urlsEqual(c.resolvedDashboardUrl, candidateDashboard)
|
||||
legacyExactMatch || dashboardMatch
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateOptionalUrl(
|
||||
raw: String,
|
||||
allowedSchemes: Set<String>,
|
||||
kind: String,
|
||||
): String? = if (raw.isBlank()) null else validateUrl(raw, allowedSchemes, kind)
|
||||
|
||||
private fun urlsEqual(first: String, second: String): Boolean =
|
||||
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
|
||||
|
||||
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
||||
|
||||
@@ -72,10 +72,11 @@ class DataManager(
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
* - v6 (2026-07-19): preserves the optional pinned startup connection.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 5,
|
||||
val version: Int = 6,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
@@ -83,6 +84,7 @@ class DataManager(
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val activeConnectionId: String? = null,
|
||||
val startupConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
@@ -160,6 +162,7 @@ class DataManager(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
startupConnectionId = connectionStore?.startupConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
@@ -173,6 +176,7 @@ class DataManager(
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
startupConnectionId = backup.startupConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||
@@ -38,8 +39,10 @@ import kotlinx.serialization.Serializable
|
||||
data class EndpointCandidate(
|
||||
val role: String,
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
|
||||
val api: ApiEndpoint? = null,
|
||||
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
|
||||
val relay: RelayEndpoint? = null,
|
||||
val dashboard: DashboardEndpoint? = null,
|
||||
val proxy: ProxyEndpoint? = null,
|
||||
val security: String? = null,
|
||||
@@ -137,13 +140,42 @@ fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> if (api.tls) "HTTPS" else "Public"
|
||||
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
|
||||
"HTTPS"
|
||||
} else {
|
||||
"Public"
|
||||
}
|
||||
"https" -> "HTTPS"
|
||||
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
|
||||
fun EndpointCandidate.primaryRouteUrl(): String? =
|
||||
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: api?.url
|
||||
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Stable host/port identity without assuming that an API surface exists. */
|
||||
fun EndpointCandidate.routeAuthority(): String? {
|
||||
val rawUrl = primaryRouteUrl() ?: return null
|
||||
val httpUrl = when {
|
||||
rawUrl.startsWith("ws://", ignoreCase = true) -> "http://${rawUrl.substringAfter("://")}"
|
||||
rawUrl.startsWith("wss://", ignoreCase = true) -> "https://${rawUrl.substringAfter("://")}"
|
||||
else -> rawUrl
|
||||
}
|
||||
val uri = runCatching { URI(httpUrl) }.getOrNull() ?: return null
|
||||
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = when {
|
||||
uri.port > 0 -> uri.port
|
||||
uri.scheme.equals("https", ignoreCase = true) -> 443
|
||||
else -> 80
|
||||
}
|
||||
return "$host:$port"
|
||||
}
|
||||
|
||||
fun EndpointCandidate.hasSecureProxy(): Boolean =
|
||||
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
|
||||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
@@ -325,17 +326,18 @@ class ConnectionManager(
|
||||
// the synthesized list just collapses to the same URL anyway.
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: url
|
||||
val resolvedRelayUrl = resolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
||||
"relay=${resolved.relay.url} (fallback would have been $url)")
|
||||
"route=${resolved.primaryRouteUrl()} (relay fallback would have been $url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.relay.url,
|
||||
url = resolved.primaryRouteUrl(),
|
||||
)
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
@@ -348,7 +350,11 @@ class ConnectionManager(
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
if (targetUrl != null) {
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else {
|
||||
Log.d(TAG, "connect: selected route has no Relay surface; route published for HTTP/Gateway clients")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,10 +662,11 @@ class ConnectionManager(
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
|
||||
val relayUrl = resolved.relay?.url?.takeIf { it.isNotBlank() } ?: return@launch
|
||||
val normalizedNew = normalizeRelayUrl(relayUrl)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
connectToUrlOnMainPath(relayUrl, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
|
||||
@@ -135,6 +135,91 @@ class RelayHttpClient(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivitySnapshot(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val profile: String,
|
||||
val activities: List<ImageActivity> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivity(
|
||||
@SerialName("call_id") val callId: String,
|
||||
@SerialName("tool_name") val toolName: String,
|
||||
val state: String,
|
||||
@SerialName("started_at") val startedAt: Double,
|
||||
@SerialName("completed_at") val completedAt: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Poll the optional Relay image lifecycle bridge. A null success means the
|
||||
* connected Relay predates the endpoint; callers should stop polling and
|
||||
* continue using native Gateway events without surfacing an error.
|
||||
*/
|
||||
suspend fun fetchImageActivity(
|
||||
profile: String,
|
||||
sessionId: String,
|
||||
sinceEpochSeconds: Double,
|
||||
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = try {
|
||||
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("profile", profile)
|
||||
.addQueryParameter("session_id", sessionId)
|
||||
.addQueryParameter("since", sinceEpochSeconds.toString())
|
||||
.build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val activityClient = okHttpClient.newBuilder()
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
try {
|
||||
activityClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Image activity request failed (HTTP ${response.code})")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Empty response body"))
|
||||
}
|
||||
Result.success(
|
||||
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
|
||||
* [Result] — success carries a [FetchedMedia], failure wraps the
|
||||
@@ -615,6 +700,14 @@ class RelayHttpClient(
|
||||
val capabilities: List<String> = emptyList(),
|
||||
val profiles: List<RelayProfileInfo> = emptyList(),
|
||||
val health: String = "unknown",
|
||||
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayHeartbeat(
|
||||
val status: String = "missing",
|
||||
val supported: Boolean = false,
|
||||
@SerialName("age_seconds") val ageSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||
|
||||
@@ -834,6 +834,11 @@ class RelayVoiceClient(
|
||||
suspend fun runVoiceOutput(
|
||||
text: String,
|
||||
renderMode: String? = "verbatim",
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
onEvent: (RealtimeVoiceEvent) -> Unit,
|
||||
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
|
||||
@@ -844,7 +849,15 @@ class RelayVoiceClient(
|
||||
return@withContext Result.failure(missingAuthError())
|
||||
}
|
||||
|
||||
val sessionResult = createVoiceOutputSession(httpBase, token)
|
||||
val sessionResult = createVoiceOutputSession(
|
||||
httpBase = httpBase,
|
||||
token = token,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
|
||||
}
|
||||
@@ -1261,8 +1274,10 @@ class RelayVoiceClient(
|
||||
inputSampleRate: Int = 16_000,
|
||||
chatSessionId: String? = null,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1290,8 +1305,10 @@ class RelayVoiceClient(
|
||||
token = token,
|
||||
chatSessionId = chatSessionId,
|
||||
conversationContext = conversationContext,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -2658,17 +2675,25 @@ class RelayVoiceClient(
|
||||
token: String,
|
||||
chatSessionId: String?,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
): Result<RealtimeSessionResponse> {
|
||||
val body = buildJsonObject {
|
||||
putProfile()
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("provider", JsonPrimitive(it))
|
||||
}
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("model", JsonPrimitive(it))
|
||||
}
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("voice", JsonPrimitive(it))
|
||||
}
|
||||
sampleRate?.takeIf { it > 0 }?.let {
|
||||
put("sample_rate", JsonPrimitive(it))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2726,8 +2751,23 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildJsonObject { putProfile() }.toString()
|
||||
private fun createVoiceOutputSession(
|
||||
httpBase: String,
|
||||
token: String,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildVoiceOutputSessionPayload(
|
||||
profile = currentProfileName(),
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("$httpBase/voice/output/session")
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
@@ -2970,6 +3010,22 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildVoiceOutputSessionPayload(
|
||||
profile: String?,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): String = buildJsonObject {
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
|
||||
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
|
||||
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Wire shape of `GET /voice/config`. Providers are returned as nested
|
||||
* objects describing the currently-active STT and TTS backend. Extra
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
@@ -56,7 +58,10 @@ data class RouteProbeOutcome(
|
||||
* candidate is reachable we use it; reachability never promotes a lower
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
|
||||
* * **Reachability probe.** Dashboard-first routes use `GET
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `GET
|
||||
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
|
||||
* Each request has a 4-second
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
* transient handoff misses do not pin a good fallback offline.
|
||||
@@ -101,6 +106,12 @@ class EndpointResolver(
|
||||
*/
|
||||
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
|
||||
|
||||
private data class ProbeTarget(
|
||||
val baseUrl: String,
|
||||
val requestUrl: String,
|
||||
val path: String,
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||
@@ -156,13 +167,13 @@ class EndpointResolver(
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Stable cache key for a candidate: `"<role>|<primary host>:<port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
* but hostnames are lowercased — two roles pointing at the same
|
||||
* host:port share reachability state.
|
||||
*/
|
||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
||||
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
|
||||
"${candidate.role}|${candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()}"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,14 +205,14 @@ class EndpointResolver(
|
||||
val winner = raceGroup(group)
|
||||
if (winner != null) {
|
||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||
"api=${winner.api.host}:${winner.api.port} priority=$priority")
|
||||
"route=${winner.primaryRouteUrl()} priority=$priority")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.relay.url,
|
||||
url = winner.primaryRouteUrl(),
|
||||
)
|
||||
return winner
|
||||
}
|
||||
@@ -281,7 +292,7 @@ class EndpointResolver(
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot HEAD /health probe against a candidate. 2-second timeout,
|
||||
* One-shot probe against a candidate's primary configured surface.
|
||||
* no retries — callers that need retry semantics can re-invoke after
|
||||
* the cache expires.
|
||||
*
|
||||
@@ -290,18 +301,19 @@ class EndpointResolver(
|
||||
*/
|
||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
||||
val target = probeTarget(candidate)
|
||||
val url = target?.requestUrl?.toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
|
||||
detail = "Invalid API URL",
|
||||
detail = "No valid Dashboard, API, or Relay URL",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = candidate.primaryRouteUrl(),
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -310,11 +322,14 @@ class EndpointResolver(
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.head()
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
// Hermes API's aiohttp health route accepts GET but returns 405 to
|
||||
// HEAD. That response proves connectivity while the old probe marked
|
||||
// the route unreachable. Health payloads are tiny, so follow the
|
||||
// endpoint's actual public contract on every surface.
|
||||
val request = requestBuilder.get().build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
@@ -331,13 +346,13 @@ class EndpointResolver(
|
||||
title = probeTitle,
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
||||
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
|
||||
)
|
||||
ok
|
||||
}
|
||||
@@ -346,9 +361,9 @@ class EndpointResolver(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
@@ -359,23 +374,23 @@ class EndpointResolver(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
||||
"route=${target.baseUrl}: ${e.javaClass.simpleName}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
@@ -384,6 +399,50 @@ class EndpointResolver(
|
||||
}
|
||||
}
|
||||
|
||||
/** Choose the standard Dashboard/Gateway surface first when advertised. */
|
||||
private fun probeTarget(candidate: EndpointCandidate): ProbeTarget? {
|
||||
candidate.dashboard?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { base ->
|
||||
return ProbeTarget(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/api/status",
|
||||
path = "/api/status",
|
||||
)
|
||||
}
|
||||
|
||||
candidate.api?.url?.let { base ->
|
||||
return ProbeTarget(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/health",
|
||||
path = "/health",
|
||||
)
|
||||
}
|
||||
|
||||
candidate.relay?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { relayUrl ->
|
||||
val httpBase = when {
|
||||
relayUrl.startsWith("ws://", ignoreCase = true) ->
|
||||
"http://${relayUrl.substringAfter("://")}"
|
||||
relayUrl.startsWith("wss://", ignoreCase = true) ->
|
||||
"https://${relayUrl.substringAfter("://")}"
|
||||
else -> return null
|
||||
}
|
||||
return ProbeTarget(
|
||||
baseUrl = relayUrl,
|
||||
requestUrl = "$httpBase/health",
|
||||
path = "/health",
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a probe exception to a short, actionable string for the Routes
|
||||
* card. The TLS case is the headline: a route saved with `https://`
|
||||
|
||||
@@ -8,22 +8,29 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesLanDiscoveryResult(
|
||||
val host: String,
|
||||
val hostname: String? = null,
|
||||
val apiUrl: String,
|
||||
val dashboardUrl: String?,
|
||||
val apiReachable: Boolean,
|
||||
val dashboardReachable: Boolean,
|
||||
)
|
||||
) {
|
||||
val displayHost: String
|
||||
get() = hostname ?: host
|
||||
}
|
||||
|
||||
/**
|
||||
* User-triggered local-network discovery for standard Hermes setup.
|
||||
@@ -59,7 +66,9 @@ object HermesLanDiscovery {
|
||||
hosts.map { host ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
probeHost(client, host, apiPort, dashboardPort)
|
||||
probeHost(client, host, apiPort, dashboardPort)?.let { result ->
|
||||
result.copy(hostname = resolveHostname(host))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
@@ -130,6 +139,24 @@ object HermesLanDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveHostname(address: String): String? =
|
||||
withTimeoutOrNull(350L) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
normalizeResolvedHostname(
|
||||
address = address,
|
||||
resolved = InetAddress.getByName(address).canonicalHostName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun normalizeResolvedHostname(address: String, resolved: String?): String? {
|
||||
val normalized = resolved?.trim()?.trimEnd('.')?.takeIf { it.isNotBlank() } ?: return null
|
||||
if (normalized.equals(address.trim(), ignoreCase = true)) return null
|
||||
if (normalized.equals("localhost", ignoreCase = true)) return null
|
||||
if (normalized.matches(Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$"))) return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return contentType.contains("json", ignoreCase = true) && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import java.net.URI
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical API base for a selected Hermes profile.
|
||||
*
|
||||
* A dedicated profile API URL remains authoritative when advertised. When
|
||||
* the dashboard has positively identified a shared multiplex gateway and
|
||||
* the selected non-default profile is in its served-profile list, route
|
||||
* through the upstream `/p/<profile>` mirror on the connection's root API
|
||||
* origin. Older/single-profile servers and incomplete topology snapshots
|
||||
* deliberately keep the root URL.
|
||||
*/
|
||||
fun resolveChatBase(
|
||||
profileApiUrl: String?,
|
||||
baseApiUrl: String?,
|
||||
selectedProfileName: String?,
|
||||
gatewayMode: String?,
|
||||
servedProfiles: Collection<String>,
|
||||
): String? {
|
||||
val base = normalize(baseApiUrl)
|
||||
val dedicated = resolveForConnection(profileApiUrl, base)
|
||||
if (dedicated != null) return dedicated
|
||||
|
||||
val profile = selectedProfileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
|
||||
?: return base
|
||||
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
|
||||
servedProfiles.any { it.equals(profile, ignoreCase = false) }
|
||||
if (!isKnownMultiplexProfile) return base
|
||||
|
||||
val root = base?.toHttpUrlOrNull() ?: return base
|
||||
return root.newBuilder()
|
||||
.addPathSegment("p")
|
||||
.addPathSegment(profile)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
||||
|
||||
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
|
||||
enum class VoiceSpeechStreamStatus {
|
||||
Completed,
|
||||
Fallback,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
data class VoiceSpeechStreamOutcome(
|
||||
val status: VoiceSpeechStreamStatus,
|
||||
val audioStarted: Boolean,
|
||||
val error: Throwable? = null,
|
||||
)
|
||||
|
||||
data class VoiceSpeechStreamCallbacks(
|
||||
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
|
||||
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
|
||||
)
|
||||
|
||||
interface VoiceSpeechStream {
|
||||
fun append(text: String)
|
||||
fun finish()
|
||||
fun stop()
|
||||
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral STT/TTS contract. The routing seam between the Standard
|
||||
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
|
||||
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
/**
|
||||
* Open one provider-backed PCM stream for an assistant reply. A null
|
||||
* success means this route has no streaming surface and the caller should
|
||||
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
|
||||
* calls made before the socket opens and report whether any PCM was emitted
|
||||
* so callers never replay already-heard audio during compatibility fallback.
|
||||
*/
|
||||
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
|
||||
Result.success(null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
|
||||
client.openSpeechStream(callbacks)
|
||||
}
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
|
||||
@@ -1208,10 +1208,14 @@ class ChatHandler {
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
// Upstream injects role:system STEERING markers into the
|
||||
// session history on model/personality change — e.g.
|
||||
// "[System: The active model for this chat has changed to …]"
|
||||
@@ -1227,9 +1231,11 @@ class ChatHandler {
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val displayContent = displayEventContent(displayKind, item.displayMetadata)
|
||||
val rawServerContent = displayContent ?: item.contentText ?: ""
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.timestamp ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
@@ -1241,8 +1247,8 @@ class ChatHandler {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = item.contentText ?: ""
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
@@ -1496,19 +1502,53 @@ class ChatHandler {
|
||||
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
|
||||
* actually render.
|
||||
*/
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
|
||||
when (displayKind) {
|
||||
"model_switch" -> {
|
||||
val model = metadata.stringField("model")
|
||||
?: metadata.stringField("to_model")
|
||||
?: metadata.stringField("target_model")
|
||||
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
|
||||
}
|
||||
"async_delegation_complete" -> {
|
||||
val count = metadata.intField("task_count")
|
||||
?: metadata.intField("tasks")
|
||||
?: metadata.intField("count")
|
||||
when (count) {
|
||||
null -> "Background work completed"
|
||||
1 -> "1 background task completed"
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.intField(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
|
||||
primitive.contentOrNull?.toIntOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
|
||||
@@ -1682,7 +1722,10 @@ class ChatHandler {
|
||||
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
|
||||
// local title whenever the server hasn't supplied a non-blank one.
|
||||
val existingById = _sessions.value.associateBy { it.sessionId }
|
||||
val mapped = items.map { item ->
|
||||
// The drawer is always composed, even while closed, and keys rows by
|
||||
// session id. A refresh race or duplicated upstream row must not put
|
||||
// the same key into Compose's LazyColumn.
|
||||
val mapped = items.distinctBy { it.id }.map { item ->
|
||||
val startedAtMs = timestampToMillis(item.startedAt)
|
||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||
@@ -1760,7 +1803,15 @@ class ChatHandler {
|
||||
* Add a newly created session to the list.
|
||||
*/
|
||||
fun addSession(session: ChatSession) {
|
||||
_sessions.update { listOf(session) + it }
|
||||
// Gateway onSessionId and an overlapping REST refresh can both publish
|
||||
// the same freshly-created session. Treat this as an idempotent upsert,
|
||||
// preferring an existing server-enriched row while collapsing any
|
||||
// duplicates that were already present.
|
||||
_sessions.update { current ->
|
||||
val existing = current.firstOrNull { it.sessionId == session.sessionId }
|
||||
listOf(existing ?: session) +
|
||||
current.filterNot { it.sessionId == session.sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE streaming event entry points ---
|
||||
@@ -2867,13 +2918,22 @@ class ChatHandler {
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
msg.thinkingContent.isBlank() &&
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-stream reconciliation: re-scan final content for any annotation
|
||||
@@ -3073,3 +3133,15 @@ internal fun formatPhoneActionResult(
|
||||
append("Status ${result.status}.")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isIntentionalSilenceMarker(content: String): Boolean =
|
||||
when (content.trim().trim('"', '\'', '`').uppercase()) {
|
||||
"NO_REPLY",
|
||||
"[NO_REPLY]",
|
||||
"SILENT",
|
||||
"[SILENT]",
|
||||
"<SILENT>",
|
||||
"(SILENT)",
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
+333
-7
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -51,6 +52,37 @@ data class DashboardStatus(
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
@SerialName("gateway_mode") val gatewayMode: String? = null,
|
||||
val gateways: List<DashboardGatewayTopology> = emptyList(),
|
||||
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardGatewayTopology(
|
||||
val profile: String,
|
||||
val ports: Map<String, Int> = emptyMap(),
|
||||
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealthRollup(
|
||||
val supported: Boolean = false,
|
||||
val overall: String? = null,
|
||||
val components: List<DashboardComponentHealth> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealth(
|
||||
val name: String,
|
||||
val status: String,
|
||||
val message: String? = null,
|
||||
val configured: Int? = null,
|
||||
val connected: Int? = null,
|
||||
val healthy: Boolean? = null,
|
||||
val ok: Boolean? = null,
|
||||
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -92,6 +124,53 @@ data class DashboardChatDisplaySettings(
|
||||
val toolDisplay: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardMcpOAuthFlow(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val status: String,
|
||||
val authorizationUrl: String? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isTerminal: Boolean get() = status == "approved" || status == "error"
|
||||
}
|
||||
|
||||
data class DashboardCustomEndpoint(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val hasApiKey: Boolean = false,
|
||||
val apiKeyPreview: String? = null,
|
||||
val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpoints(
|
||||
val endpoints: List<DashboardCustomEndpoint>,
|
||||
val currentProvider: String? = null,
|
||||
val currentModel: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointDraft(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val makeDefault: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointValidation(
|
||||
val ok: Boolean,
|
||||
val reachable: Boolean,
|
||||
val message: String,
|
||||
val models: List<String>,
|
||||
)
|
||||
|
||||
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
|
||||
data class ElevenLabsVoice(
|
||||
val voiceId: String,
|
||||
@@ -255,6 +334,14 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
|
||||
|
||||
/**
|
||||
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
|
||||
* This adds plugin provider names and readiness metadata. Command-provider
|
||||
* IDs remain sourced from the dynamic config-schema enum.
|
||||
*/
|
||||
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
|
||||
getJsonObject("/api/tools/toolsets/tts/config")
|
||||
|
||||
/**
|
||||
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
|
||||
* writes the WHOLE document, so [config] MUST be the full values tree
|
||||
@@ -466,25 +553,100 @@ class DashboardApiClient(
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
suspend fun setMcpServerEnabled(
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun startMcpOAuth(
|
||||
name: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
|
||||
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
/**
|
||||
* Read-only hosted-OAuth capability probe. New dashboards recognize the
|
||||
* flow-status route and return its canonical expired-flow 404; older
|
||||
* FastAPI routers return the generic route-level 404. No OAuth worker is
|
||||
* started and no provider/browser interaction occurs.
|
||||
*/
|
||||
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
|
||||
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
|
||||
return result.fold(
|
||||
onSuccess = { Result.success(true) },
|
||||
onFailure = { error ->
|
||||
val message = error.message.orEmpty()
|
||||
when {
|
||||
message.contains("OAuth flow not found or expired") -> Result.success(true)
|
||||
message.contains("HTTP 404") -> Result.success(false)
|
||||
else -> Result.failure(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
|
||||
getJsonObject("/api/providers/custom-endpoints")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun saveCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun validateCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpointValidation> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints/validate",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching { root ->
|
||||
DashboardCustomEndpointValidation(
|
||||
ok = root.booleanField("ok") == true,
|
||||
reachable = root.booleanField("reachable") == true,
|
||||
message = root.stringField("message").orEmpty(),
|
||||
models = root.stringList("models"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun activateCustomEndpoint(
|
||||
id: String,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
|
||||
|
||||
suspend fun deleteCustomEndpoint(
|
||||
id: String,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
path = "/api/mcp/catalog/install${profileQuery(profile)}",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
@@ -929,6 +1091,57 @@ class DashboardApiClient(
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
|
||||
val flowId = root.stringField("flow_id")
|
||||
?: throw IOException("MCP OAuth response did not include a flow id")
|
||||
val status = root.stringField("status")
|
||||
?: throw IOException("MCP OAuth response did not include a status")
|
||||
return DashboardMcpOAuthFlow(
|
||||
flowId = flowId,
|
||||
serverName = root.stringField("server_name").orEmpty(),
|
||||
status = status,
|
||||
authorizationUrl = root.stringField("authorization_url"),
|
||||
error = root.stringField("error"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
|
||||
val current = root["current"] as? JsonObject
|
||||
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("id") ?: return@mapNotNull null
|
||||
DashboardCustomEndpoint(
|
||||
id = id,
|
||||
name = obj.stringField("name") ?: id,
|
||||
baseUrl = obj.stringField("base_url").orEmpty(),
|
||||
model = obj.stringField("model").orEmpty(),
|
||||
models = obj.stringList("models"),
|
||||
contextLength = obj.intField("context_length"),
|
||||
discoverModels = obj.booleanField("discover_models") != false,
|
||||
hasApiKey = obj.booleanField("has_api_key") == true,
|
||||
apiKeyPreview = obj.stringField("api_key_preview"),
|
||||
isCurrent = obj.booleanField("is_current") == true,
|
||||
)
|
||||
}
|
||||
return DashboardCustomEndpoints(
|
||||
endpoints = endpoints,
|
||||
currentProvider = current?.stringField("provider"),
|
||||
currentModel = current?.stringField("model"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
|
||||
buildJsonObject {
|
||||
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
@@ -947,6 +1160,20 @@ class DashboardApiClient(
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
|
||||
}
|
||||
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val profile = obj.stringField("profile") ?: return@mapNotNull null
|
||||
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
|
||||
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
|
||||
}.toMap()
|
||||
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
|
||||
}
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
@@ -955,6 +1182,45 @@ class DashboardApiClient(
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
profiles = profiles,
|
||||
gatewayMode = root.stringField("gateway_mode"),
|
||||
gateways = gateways,
|
||||
componentHealth = parseComponentHealth(root),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
|
||||
val rawComponents = root["components"] as? JsonObject
|
||||
?: return DashboardComponentHealthRollup()
|
||||
val components = rawComponents.mapNotNull { (name, element) ->
|
||||
val component = element as? JsonObject ?: return@mapNotNull null
|
||||
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
DashboardComponentHealth(
|
||||
name = safeName,
|
||||
status = component.stringField("status")
|
||||
?: component.stringField("state")
|
||||
?: component.stringField("health")
|
||||
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
|
||||
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
|
||||
?: "unknown",
|
||||
message = component.stringField("message")
|
||||
?: component.stringField("summary")
|
||||
?: component.stringField("error")
|
||||
?: component.stringField("reason"),
|
||||
configured = component.intField("configured"),
|
||||
connected = component.intField("connected"),
|
||||
healthy = component.booleanField("healthy"),
|
||||
ok = component.booleanField("ok"),
|
||||
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
|
||||
)
|
||||
}.sortedBy { it.name }
|
||||
return DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = root.stringField("overall")
|
||||
?: root.stringField("status")
|
||||
?: root.stringField("health"),
|
||||
components = components,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1203,6 +1469,61 @@ class DashboardCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy only Hermes' authenticated dashboard session cookies to another host
|
||||
* that belongs to the same saved Connection. Dashboard cookies are host-only
|
||||
* by design, while a Connection may reach one server through LAN and
|
||||
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
|
||||
* explicit sign-out clears every mirrored host together.
|
||||
*
|
||||
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
|
||||
* copied. Secure cookies also remain Secure; this helper never downgrades them
|
||||
* for an HTTP route.
|
||||
*/
|
||||
fun mirrorDashboardSessionCookies(
|
||||
store: DashboardCookieStore,
|
||||
targetUrl: String,
|
||||
trustedHosts: Set<String>,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
|
||||
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
if (targetHost !in allowedHosts) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
val all = store.load()
|
||||
val live = all.filterNot { it.isExpired(now) }
|
||||
val existingTargetKeys = live.asSequence()
|
||||
.filter { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
|
||||
.toSet()
|
||||
val mirrored = live.asSequence()
|
||||
.filter { it.isDashboardSessionCookie() }
|
||||
.filter { it.domain.lowercase() in allowedHosts }
|
||||
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.groupBy { "${it.name.lowercase()}|${it.path}" }
|
||||
.values
|
||||
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
|
||||
.map { it.copy(domain = targetHost, hostOnly = true) }
|
||||
.filterNot { it.key in existingTargetKeys }
|
||||
.toList()
|
||||
|
||||
if (mirrored.isNotEmpty() || live.size != all.size) {
|
||||
store.save(live + mirrored)
|
||||
}
|
||||
return mirrored.size
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
|
||||
val bareName = name
|
||||
.removePrefix("__Host-")
|
||||
.removePrefix("__Secure-")
|
||||
return bareName == "hermes_session" ||
|
||||
bareName == "hermes_session_at" ||
|
||||
bareName == "hermes_session_rt" ||
|
||||
bareName == "hermes_session_provider"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
@@ -1353,3 +1674,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun JsonObject?.stringList(name: String): List<String> =
|
||||
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
|
||||
* `category` field so it is robust to upstream's category-merging.
|
||||
*/
|
||||
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
|
||||
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
|
||||
fields.filter {
|
||||
it.key.startsWith("tts.") ||
|
||||
it.key.startsWith("stt.") ||
|
||||
it.key.startsWith("voice.")
|
||||
}
|
||||
|
||||
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
|
||||
data class TtsToolsetProvider(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String? = null,
|
||||
val isActive: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
|
||||
*
|
||||
* Unlike the config-schema enum, this payload adds readiness metadata and
|
||||
* reliably discovered plugin providers. Command providers remain schema-owned.
|
||||
* Older upstream builds may omit `tts_provider`;
|
||||
* those rows are ignored because their picker label is not a stable config ID.
|
||||
*/
|
||||
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
|
||||
val providers = root["providers"] as? JsonArray ?: return emptyList()
|
||||
return providers.mapNotNull { element ->
|
||||
val provider = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = provider.configString("tts_provider")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return@mapNotNull null
|
||||
TtsToolsetProvider(
|
||||
id = id,
|
||||
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
|
||||
status = provider.configString("status"),
|
||||
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
|
||||
/** Read the value at a dot-path from the nested config values tree, or null. */
|
||||
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
||||
|
||||
+450
-37
@@ -1,6 +1,8 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -24,6 +27,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -82,6 +86,8 @@ class GatewayChatClient(
|
||||
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
|
||||
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
|
||||
) {
|
||||
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
|
||||
private val sessionSource = "webui"
|
||||
companion object {
|
||||
private const val TAG = "GatewayChatClient"
|
||||
|
||||
@@ -128,6 +134,7 @@ class GatewayChatClient(
|
||||
* not enough.
|
||||
*/
|
||||
private const val ATTACH_RPC_TIMEOUT_MS = 60_000L
|
||||
private const val COMPRESS_RPC_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** Upstream image byte-upload RPC (underscore — `image.attach_bytes`, content_base64). */
|
||||
private const val ATTACH_METHOD_UPSTREAM = "image.attach_bytes"
|
||||
@@ -289,6 +296,10 @@ class GatewayChatClient(
|
||||
private val _serverContext = MutableStateFlow<Pair<Int, Int>?>(null)
|
||||
val serverContext: StateFlow<Pair<Int, Int>?> = _serverContext.asStateFlow()
|
||||
|
||||
/** Optional upstream project identity for the active session. */
|
||||
private val _serverProject = MutableStateFlow<GatewaySessionProject?>(null)
|
||||
val serverProject: StateFlow<GatewaySessionProject?> = _serverProject.asStateFlow()
|
||||
|
||||
/** Serializes connect / session-establish so concurrent sends share one socket. */
|
||||
private val connectMutex = Mutex()
|
||||
|
||||
@@ -361,6 +372,7 @@ class GatewayChatClient(
|
||||
private data class BackgroundTurn(
|
||||
val storedSessionId: String,
|
||||
val profile: String?,
|
||||
@Volatile var pendingAsk: GatewayAsk? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -399,6 +411,11 @@ class GatewayChatClient(
|
||||
private var unmatchedTurnCompleteListener:
|
||||
((GatewayBackgroundTurnCompletion) -> Unit)? = null
|
||||
|
||||
/** Input requested or resolved on a deliberately detached turn. */
|
||||
@Volatile
|
||||
private var backgroundInteractionListener:
|
||||
((GatewayBackgroundInteractionEvent) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Connection-level process listener. Unlike [GatewayTurnCallbacks], this is
|
||||
* consulted even when there is no locally initiated [activeTurn].
|
||||
@@ -528,10 +545,10 @@ class GatewayChatClient(
|
||||
// into the SSE fallback, which would resubmit the same
|
||||
// prompt as a duplicate turn. Recovery belongs to the
|
||||
// stream: the watchdog and mid-turn rejoin own it.
|
||||
if (turn.started || turn.ended) {
|
||||
if (turn.started || turn.ended || turn.transportRecoveryStarted) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"prompt.submit ack failed after turn start " +
|
||||
"prompt.submit ack failed after turn start/rejoin " +
|
||||
"(${submitted.exceptionOrNull()?.message}) — no SSE fallback",
|
||||
)
|
||||
return@launch
|
||||
@@ -580,10 +597,23 @@ class GatewayChatClient(
|
||||
val turn = activeTurn?.takeIf { !it.ended } ?: return false
|
||||
val liveId = liveSessionId ?: return false
|
||||
val storedId = storedSessionId ?: return false
|
||||
backgroundTurns[liveId] = BackgroundTurn(
|
||||
val backgroundTurn = BackgroundTurn(
|
||||
storedSessionId = storedId,
|
||||
profile = liveSessionProfile,
|
||||
pendingAsk = turn.pendingInteraction,
|
||||
)
|
||||
backgroundTurns[liveId] = backgroundTurn
|
||||
backgroundTurn.pendingAsk?.let { ask ->
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Requested(
|
||||
storedSessionId = storedId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = ask,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
turn.detach()
|
||||
return true
|
||||
}
|
||||
@@ -653,6 +683,12 @@ class GatewayChatClient(
|
||||
unmatchedTurnCompleteListener = listener
|
||||
}
|
||||
|
||||
fun setBackgroundInteractionListener(
|
||||
listener: ((GatewayBackgroundInteractionEvent) -> Unit)?,
|
||||
) {
|
||||
backgroundInteractionListener = listener
|
||||
}
|
||||
|
||||
fun setProcessEventListener(listener: ((GatewayProcessEvent) -> Unit)?) {
|
||||
processEventListener = listener
|
||||
}
|
||||
@@ -720,14 +756,15 @@ class GatewayChatClient(
|
||||
* New Hermes gateways expose `session.activate`, which attaches the new
|
||||
* WebSocket transport to the exact live id saved in the client checkpoint.
|
||||
* If that id has already been reaped (or the method is unavailable), fall
|
||||
* back to `session.resume` by durable session id. Its `running` + `inflight`
|
||||
* fields decide whether a live mapper is installed or history should settle
|
||||
* the turn instead.
|
||||
* back to `session.resume` by durable session id. Its `running`, `inflight`,
|
||||
* and optional `queued` fields decide whether a live mapper is installed or
|
||||
* history should settle the turn instead.
|
||||
*/
|
||||
suspend fun recoverTurn(
|
||||
storedId: String,
|
||||
preferredLiveId: String?,
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
queuedTurnProvider: ((GatewayQueuedTurn) -> GatewayInboundTurnRegistration?)? = null,
|
||||
): Result<GatewaySessionRecovery> = runCatching {
|
||||
require(storedId.isNotBlank()) { "stored session id required" }
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -757,6 +794,7 @@ class GatewayChatClient(
|
||||
boundTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
deferEvents = true,
|
||||
).also { turn ->
|
||||
turn.markRecoveredStarted()
|
||||
activeTurn = turn
|
||||
@@ -770,6 +808,7 @@ class GatewayChatClient(
|
||||
response = activated.getOrNull()
|
||||
if (response == null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
boundTurn.detach()
|
||||
boundTurn = null
|
||||
Log.d(
|
||||
@@ -786,6 +825,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
@@ -807,7 +847,7 @@ class GatewayChatClient(
|
||||
storedSessionId = storedId
|
||||
liveSessionProfile = requestedProfile
|
||||
updateCancelledDrainLiveSession(storedId, recoveredLiveId)
|
||||
(response["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(response)
|
||||
|
||||
val inflight = (response["inflight"] as? JsonObject)?.let { value ->
|
||||
GatewayInflightTurn(
|
||||
@@ -816,6 +856,11 @@ class GatewayChatClient(
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
)
|
||||
}
|
||||
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||
value.stringField("user")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::GatewayQueuedTurn)
|
||||
}
|
||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
||||
|
||||
if (running) {
|
||||
@@ -828,10 +873,50 @@ class GatewayChatClient(
|
||||
activeTurn = turn
|
||||
}
|
||||
}
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
}
|
||||
}
|
||||
boundTurn.releaseDeferredEvents()
|
||||
claimedBackground?.pendingAsk?.let { ask ->
|
||||
boundTurn.restoreInteraction(ask)
|
||||
}
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||
// its events flow through the completed checkpoint's mapper.
|
||||
val priorBoundTurn = boundTurn
|
||||
boundTurn = null
|
||||
val registration = queuedTurnProvider?.invoke(queued)
|
||||
if (registration != null) {
|
||||
val queuedTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(registration.callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
)
|
||||
// recoverTurn is resumed on its caller's coroutine context;
|
||||
// ChatViewModel calls it from Main, so this admission runs
|
||||
// atomically with the checkpoint handoff. Posting back
|
||||
// through bindInboundTurn would deadlock waiting on the
|
||||
// same paused Main dispatcher during cold-start recovery.
|
||||
if (registration.onHandle(queuedTurn)) {
|
||||
priorBoundTurn?.redirectDeferredEventsTo(queuedTurn)
|
||||
boundTurn = queuedTurn
|
||||
activeTurn = queuedTurn
|
||||
queuedTurn.armWatchdog()
|
||||
priorBoundTurn?.detach()
|
||||
} else {
|
||||
priorBoundTurn?.discardDeferredEvents()
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
priorBoundTurn?.discardDeferredEvents()
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
if (boundTurn != null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
boundTurn.detach()
|
||||
}
|
||||
boundTurn = null
|
||||
@@ -843,36 +928,109 @@ class GatewayChatClient(
|
||||
running = running,
|
||||
status = response.stringField("status"),
|
||||
inflight = inflight,
|
||||
handle = boundTurn?.takeUnless { it.ended },
|
||||
queued = queued,
|
||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||
?.takeUnless { it.ended },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject [text] into the in-flight turn (`session.steer`). The server
|
||||
* only accepts a steer while a tool batch is running — [SteerResult.Rejected]
|
||||
* means "no batch in flight, queue it instead". [SteerResult.Failed]
|
||||
* covers transport/RPC failure (no live session, socket down, 4010 …) —
|
||||
* callers should fall back to the local queue for both non-queued cases.
|
||||
* Inject [text] into the in-flight turn. Current upstream exposes this as
|
||||
* `session.redirect`, which can redirect an active model turn while keeping
|
||||
* valid work/context. Older gateways only expose `session.steer`; fall back
|
||||
* to that legacy RPC only when the redirect method is absent/unsupported so
|
||||
* old installs still queue the correction instead of dropping it.
|
||||
*/
|
||||
suspend fun steer(text: String): SteerResult {
|
||||
val sid = liveSessionId ?: return SteerResult.Failed
|
||||
val result = rpc(
|
||||
"session.steer",
|
||||
buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("text", text)
|
||||
},
|
||||
)
|
||||
val params = buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("text", text)
|
||||
}
|
||||
val redirect = rpc("session.redirect", params)
|
||||
val result = if (redirect.isLegacyRedirectUnsupported()) {
|
||||
Log.i(TAG, "session.redirect unsupported — falling back to session.steer (session=$storedSessionId)")
|
||||
rpc("session.steer", params)
|
||||
} else {
|
||||
redirect
|
||||
}
|
||||
val outcome = when (result.getOrNull()?.stringField("status")) {
|
||||
"queued" -> SteerResult.Queued
|
||||
"redirected", "queued" -> SteerResult.Queued
|
||||
"rejected" -> SteerResult.Rejected
|
||||
else -> SteerResult.Failed
|
||||
}
|
||||
Log.i(TAG, "Steer → $outcome (session=$storedSessionId)")
|
||||
Log.i(TAG, "Active-turn correction → $outcome (session=$storedSessionId)")
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress the live gateway session through the dedicated upstream RPC.
|
||||
* `/compress` is intentionally not sent through generic slash execution on
|
||||
* modern gateways because `session.compress` returns authoritative transcript,
|
||||
* usage, title/model/session-info, and compute-host isolation metadata.
|
||||
*/
|
||||
suspend fun compressSession(focusTopic: String? = null): Result<GatewayCompressResult> {
|
||||
val sid = liveSessionId
|
||||
?: return Result.failure(GatewayRpcException("no live session"))
|
||||
val params = buildJsonObject {
|
||||
put("session_id", sid)
|
||||
focusTopic?.trim()?.takeIf { it.isNotBlank() }?.let { put("focus_topic", it) }
|
||||
}
|
||||
val direct = rpc("session.compress", params, timeoutMs = COMPRESS_RPC_TIMEOUT_MS)
|
||||
val result = if (direct.exceptionOrNull().isMethodNotFound()) {
|
||||
val legacyCommand = "/compress" + focusTopic
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { " $it" }
|
||||
.orEmpty()
|
||||
slashExec(legacyCommand).map { slashResult ->
|
||||
buildJsonObject {
|
||||
put("status", "legacy")
|
||||
slashResult.stringField("output")?.let { put("output", it) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
direct
|
||||
}
|
||||
return result.map { payload ->
|
||||
applySessionResultInfo(payload)
|
||||
payload.toGatewayCompressResult()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Result<JsonObject>.isLegacyRedirectUnsupported(): Boolean {
|
||||
val error = exceptionOrNull() ?: return false
|
||||
val message = error.message.orEmpty()
|
||||
return error.isMethodNotFound() ||
|
||||
(error as? GatewayRpcException)?.code == 4010 ||
|
||||
message.contains("does not support active-turn redirect", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun JsonObject.toGatewayCompressResult(): GatewayCompressResult =
|
||||
GatewayCompressResult(
|
||||
status = stringField("status") ?: "completed",
|
||||
output = stringField("output"),
|
||||
removed = (this["removed"] as? JsonPrimitive)?.intOrNull,
|
||||
beforeMessages = (this["before_messages"] as? JsonPrimitive)?.intOrNull,
|
||||
afterMessages = (this["after_messages"] as? JsonPrimitive)?.intOrNull,
|
||||
beforeTokens = (this["before_tokens"] as? JsonPrimitive)?.intOrNull,
|
||||
afterTokens = (this["after_tokens"] as? JsonPrimitive)?.intOrNull,
|
||||
usage = GatewayEventMapper.parseGatewayUsage(this["usage"] as? JsonObject),
|
||||
info = this["info"] as? JsonObject,
|
||||
messages = (this["messages"] as? JsonArray)?.let { messages ->
|
||||
runCatching {
|
||||
json.decodeFromJsonElement(
|
||||
ListSerializer(MessageItem.serializer()),
|
||||
messages,
|
||||
)
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "session.compress returned unreadable messages", it)
|
||||
emptyList()
|
||||
}
|
||||
}.orEmpty(),
|
||||
)
|
||||
|
||||
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
|
||||
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> =
|
||||
rpc(
|
||||
@@ -1251,6 +1409,7 @@ class GatewayChatClient(
|
||||
unsolicitedTurnProvider = null
|
||||
coldPrewarmSessionReadyListener = null
|
||||
unmatchedTurnCompleteListener = null
|
||||
backgroundInteractionListener = null
|
||||
processEventListener = null
|
||||
closeSocket("client shutdown")
|
||||
backgroundCloseJob?.cancel()
|
||||
@@ -1350,6 +1509,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
@@ -1368,13 +1528,14 @@ class GatewayChatClient(
|
||||
// resume result's embedded `info` (same shape session.info carries),
|
||||
// so a reopened session shows its ACTUAL model immediately instead of
|
||||
// a misleading default until the first turn's async session.info.
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply connection-level session info (model / provider / reasoning effort /
|
||||
* personality / yolo / fast / context usage) into the `_server*` state flows.
|
||||
* personality / yolo / fast / context usage / project) into the `_server*`
|
||||
* state flows.
|
||||
* Shared by the `session.info` event handler and the `session.resume` RPC
|
||||
* result — the resume response embeds the same `info` object, so reopening a
|
||||
* session can paint its real model up front rather than waiting for a turn.
|
||||
@@ -1396,6 +1557,18 @@ class GatewayChatClient(
|
||||
info.stringField("credential_warning")?.takeIf { it.isNotBlank() }
|
||||
(info["yolo"] as? JsonPrimitive)?.booleanOrNull?.let { _serverYolo.value = it }
|
||||
(info["fast"] as? JsonPrimitive)?.booleanOrNull?.let { _serverFast.value = it }
|
||||
_serverProject.value = (info["project"] as? JsonObject)?.let { project ->
|
||||
project.stringField("name")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { name ->
|
||||
GatewaySessionProject(
|
||||
id = project.stringField("id")?.takeIf { it.isNotBlank() },
|
||||
slug = project.stringField("slug")?.takeIf { it.isNotBlank() },
|
||||
name = name,
|
||||
primaryPath = project.stringField("primary_path")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Context usage: require used > 0 — a COLD resume resets counters and
|
||||
// reports 0 until the first turn rebuilds the prompt; painting 0 would
|
||||
// mislead on a session that actually has history.
|
||||
@@ -1408,6 +1581,12 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a session create/resume result without leaking metadata from the prior session. */
|
||||
private fun applySessionResultInfo(result: JsonObject) {
|
||||
_serverProject.value = null
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
}
|
||||
|
||||
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
|
||||
private suspend fun ensureLiveProcessSession(): Result<String> {
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -1495,6 +1674,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", requestedStoredId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
@@ -1505,7 +1685,7 @@ class GatewayChatClient(
|
||||
storedSessionId = requestedStoredId
|
||||
liveSessionProfile = requestedProfile
|
||||
updateCancelledDrainLiveSession(requestedStoredId, live)
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(result)
|
||||
return
|
||||
}
|
||||
Log.w(
|
||||
@@ -1519,6 +1699,7 @@ class GatewayChatClient(
|
||||
"session.create",
|
||||
buildJsonObject {
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
// Bind the in-chat overrides to the new session as its
|
||||
@@ -1640,6 +1821,30 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// read_terminal is a renderer query, not a user decision. Android has
|
||||
// no xterm pane on the Gateway chat surface, so mirror upstream
|
||||
// desktop's no-live-pane behavior and answer with empty text instead
|
||||
// of blocking the agent for the server's 30-second timeout.
|
||||
if (type == "terminal.read.request") {
|
||||
val requestId = payload?.stringField("request_id")
|
||||
val ownedSession = !eventSessionId.isNullOrBlank() &&
|
||||
(eventSessionId == liveSessionId || backgroundTurns.containsKey(eventSessionId))
|
||||
if (!requestId.isNullOrBlank() && ownedSession) {
|
||||
scope.launch {
|
||||
rpc(
|
||||
"terminal.read.respond",
|
||||
buildJsonObject {
|
||||
put("request_id", requestId)
|
||||
put("text", "")
|
||||
},
|
||||
).onFailure {
|
||||
Log.w(TAG, "terminal.read.respond failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A profile/session switch may leave an upstream turn running while a
|
||||
// different profile becomes visible. Its events must never paint the
|
||||
// new transcript, but the terminal event still needs to reconcile the
|
||||
@@ -1647,6 +1852,47 @@ class GatewayChatClient(
|
||||
// switches back.
|
||||
val backgroundTurn = eventSessionId?.let(backgroundTurns::get)
|
||||
if (backgroundTurn != null) {
|
||||
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
|
||||
if (interactionRequest != null) {
|
||||
val previous = backgroundTurn.pendingAsk
|
||||
backgroundTurn.pendingAsk = interactionRequest
|
||||
if (previous?.kind != interactionRequest.kind ||
|
||||
previous.requestId != interactionRequest.requestId
|
||||
) {
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Requested(
|
||||
storedSessionId = backgroundTurn.storedSessionId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = interactionRequest,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val expiry = GatewayEventMapper.interactionExpiry(type, payload)
|
||||
val pendingAsk = backgroundTurn.pendingAsk
|
||||
val explicitlyExpired = expiry != null && pendingAsk != null &&
|
||||
pendingAsk.kind == expiry.kind &&
|
||||
(pendingAsk.kind == GatewayAsk.Kind.APPROVAL ||
|
||||
pendingAsk.requestId == expiry.requestId)
|
||||
val turnResumed = pendingAsk != null &&
|
||||
GatewayEventMapper.isInteractionResumeEvent(type)
|
||||
if (explicitlyExpired || turnResumed) {
|
||||
backgroundTurn.pendingAsk = null
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Resolved(
|
||||
storedSessionId = backgroundTurn.storedSessionId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = pendingAsk,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type == "message.complete" || type == "error") {
|
||||
backgroundTurns.remove(eventSessionId, backgroundTurn)
|
||||
val expectedText = if (type == "message.complete") payload?.stringField("text") else null
|
||||
@@ -1877,12 +2123,14 @@ class GatewayChatClient(
|
||||
|
||||
/**
|
||||
* Recover an in-flight turn after a mid-turn socket loss by reconnecting
|
||||
* the SOCKET ONLY and keeping [preservedLiveId] as the live session id.
|
||||
* the socket and rebinding [preservedLiveId] to the new transport.
|
||||
*
|
||||
* A bare reconnect lets the tail — including the final
|
||||
* `message.complete` — keep matching without another RPC. Current upstream
|
||||
* can rebind live sessions too, but older builds cannot, so this remains the
|
||||
* lowest-common-denominator same-client recovery path.
|
||||
* Current upstream detaches a live session from a closed WebSocket; a new
|
||||
* socket receives no tail until `session.activate` attaches that exact live
|
||||
* id. Older gateways do not expose the method, so method-not-found alone
|
||||
* retains the legacy bare-socket behavior. We never call `session.resume`
|
||||
* here: resuming a durable id can create a different live runtime and
|
||||
* orphan the turn already executing server-side.
|
||||
*
|
||||
* Retries with backoff for up to [midTurnRejoinWindowMs] so a multi-second
|
||||
* radio blip doesn't abandon the turn. Events emitted while the socket was
|
||||
@@ -1900,7 +2148,39 @@ class GatewayChatClient(
|
||||
connectCooldownUntil = 0L
|
||||
ensureConnected()
|
||||
}
|
||||
true
|
||||
if (preservedLiveId == null) {
|
||||
true
|
||||
} else {
|
||||
// Bind before activation so a tail event racing the RPC ack
|
||||
// still matches the original active turn.
|
||||
liveSessionId = preservedLiveId
|
||||
val activated = rpc(
|
||||
"session.activate",
|
||||
buildJsonObject { put("session_id", preservedLiveId) },
|
||||
)
|
||||
when {
|
||||
activated.isSuccess -> {
|
||||
activated.getOrNull()?.let(::applySessionResultInfo)
|
||||
true
|
||||
}
|
||||
activated.exceptionOrNull().isMethodNotFound() -> {
|
||||
Log.i(
|
||||
TAG,
|
||||
"session.activate unsupported during mid-turn rejoin — " +
|
||||
"using legacy socket recovery",
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Mid-turn session.activate retry failed: " +
|
||||
activated.exceptionOrNull()?.message,
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Mid-turn reconnect retry failed: ${e.message}")
|
||||
false
|
||||
@@ -1912,7 +2192,8 @@ class GatewayChatClient(
|
||||
if (preservedLiveId != null) liveSessionId = preservedLiveId
|
||||
Log.i(
|
||||
TAG,
|
||||
"Gateway socket rejoined mid-turn (session=$storedSessionId) — kept live session, awaiting tail",
|
||||
"Gateway socket rejoined mid-turn (session=$storedSessionId) — " +
|
||||
"rebound live session, awaiting tail",
|
||||
)
|
||||
// A reconnect that followed a route RETARGET gets a short settle
|
||||
// (the fresh socket won't replay the in-flight turn); a normal
|
||||
@@ -2084,15 +2365,26 @@ class GatewayChatClient(
|
||||
private fun watchdogTimeoutFor(eventType: String): Long = when (eventType) {
|
||||
"clarify.request", "secret.request" -> ASK_CLARIFY_SECRET_TIMEOUT_MS
|
||||
"sudo.request" -> ASK_SUDO_TIMEOUT_MS
|
||||
"approval.request", "terminal.read.request" -> ASK_UNBOUNDED_TIMEOUT_MS
|
||||
"approval.request" -> ASK_UNBOUNDED_TIMEOUT_MS
|
||||
else -> turnIdleTimeoutMs
|
||||
}
|
||||
|
||||
private inner class GatewayTurn(
|
||||
val callbacks: GatewayTurnCallbacks,
|
||||
dedupeAdjacentMessageStarts: Boolean = false,
|
||||
deferEvents: Boolean = false,
|
||||
) : ActiveTurnHandle {
|
||||
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
|
||||
val pendingInteraction: GatewayAsk?
|
||||
get() = mapper.currentInteraction
|
||||
fun restoreInteraction(ask: GatewayAsk) {
|
||||
mapper.restoreInteraction(ask)
|
||||
}
|
||||
private val deferredEventLock = Any()
|
||||
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
|
||||
private var eventsDeferred = deferEvents
|
||||
private var redirectedTo: GatewayTurn? = null
|
||||
private var queuedSuccessor: Pair<GatewayInboundTurnRegistration, GatewayTurn>? = null
|
||||
|
||||
/** t0 = construction ≈ sendTurn entry (the moment the user sent). */
|
||||
val tracer = TurnLatencyTracer("gateway")
|
||||
@@ -2113,10 +2405,22 @@ class GatewayChatClient(
|
||||
*/
|
||||
fun beginRejoin(): Boolean {
|
||||
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
if (shouldRejoin) reconcileRequired = true
|
||||
if (shouldRejoin) {
|
||||
reconcileRequired = true
|
||||
transportRecoveryStarted = true
|
||||
}
|
||||
return shouldRejoin
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket loss after `prompt.submit` makes server acceptance ambiguous
|
||||
* even when no turn event or RPC ack reached Android. Once recovery has
|
||||
* started, the caller must not resubmit through SSE.
|
||||
*/
|
||||
@Volatile
|
||||
var transportRecoveryStarted = false
|
||||
private set
|
||||
|
||||
private var watchdog: Job? = null
|
||||
|
||||
val ended: Boolean get() = mapper.turnEnded || cancelled
|
||||
@@ -2133,6 +2437,21 @@ class GatewayChatClient(
|
||||
private set
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
val redirect = synchronized(deferredEventLock) {
|
||||
if (eventsDeferred) {
|
||||
deferredEvents += type to payload
|
||||
return
|
||||
}
|
||||
redirectedTo
|
||||
}
|
||||
if (redirect != null) {
|
||||
redirect.onEvent(type, payload)
|
||||
return
|
||||
}
|
||||
processEvent(type, payload)
|
||||
}
|
||||
|
||||
private fun processEvent(type: String, payload: JsonObject?) {
|
||||
if (type != "session.info") started = true
|
||||
tracer.mark("ttfe")
|
||||
if (type == "message.delta" || type == "reasoning.delta" || type == "thinking.delta") {
|
||||
@@ -2152,6 +2471,74 @@ class GatewayChatClient(
|
||||
if (mapper.turnEnded) {
|
||||
disarmWatchdog()
|
||||
tracer.done()
|
||||
handoffQueuedSuccessor()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a queued prompt reported beside an in-flight recovery as a
|
||||
* distinct next turn. Its mapper starts deferred so events that race
|
||||
* the resume acknowledgement cannot paint the completing prior turn.
|
||||
*/
|
||||
fun installQueuedSuccessor(registration: GatewayInboundTurnRegistration) {
|
||||
synchronized(deferredEventLock) {
|
||||
if (queuedSuccessor == null) {
|
||||
queuedSuccessor = registration to GatewayTurn(
|
||||
callbacks = dispatchOn(registration.callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
deferEvents = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handoffQueuedSuccessor() {
|
||||
val successor = synchronized(deferredEventLock) {
|
||||
queuedSuccessor?.also {
|
||||
queuedSuccessor = null
|
||||
redirectedTo = it.second
|
||||
}
|
||||
} ?: return
|
||||
val (registration, turn) = successor
|
||||
|
||||
// Claim socket ownership immediately so the next message.start is
|
||||
// buffered by this exact successor instead of being admitted as a
|
||||
// generic unsolicited turn. UI admission is ordered after the
|
||||
// prior turn's terminal callbacks on the shared dispatcher.
|
||||
activeTurn = turn
|
||||
callbackDispatcher {
|
||||
if (registration.onHandle(turn)) {
|
||||
turn.releaseDeferredEvents()
|
||||
turn.armWatchdog()
|
||||
} else {
|
||||
turn.discardDeferredEvents()
|
||||
turn.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseDeferredEvents() {
|
||||
val pending = synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
deferredEvents.toList().also { deferredEvents.clear() }
|
||||
}
|
||||
pending.forEach { (type, payload) -> onEvent(type, payload) }
|
||||
}
|
||||
|
||||
fun redirectDeferredEventsTo(target: GatewayTurn) {
|
||||
val pending = synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
redirectedTo = target
|
||||
deferredEvents.toList().also { deferredEvents.clear() }
|
||||
}
|
||||
pending.forEach { (type, payload) -> target.onEvent(type, payload) }
|
||||
}
|
||||
|
||||
fun discardDeferredEvents() {
|
||||
synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
redirectedTo = null
|
||||
deferredEvents.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2315,6 +2702,9 @@ class GatewayChatClient(
|
||||
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
|
||||
onStart = { callbackDispatcher { callbacks.onStart() } },
|
||||
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
@@ -2329,6 +2719,7 @@ class GatewayChatClient(
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
||||
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
|
||||
// onStatusUpdate a default no-op, so omitting it here silently swallows
|
||||
// EVERY gateway status line — the ❌ terminal-error lifecycle update
|
||||
@@ -2340,12 +2731,12 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
/** Outcome of [GatewayChatClient.steer] — Rejected and Failed both mean "queue locally instead". */
|
||||
/** Outcome of an active-turn correction — Rejected and Failed both mean "queue locally instead". */
|
||||
enum class SteerResult {
|
||||
/** Server accepted — text lands in the next tool batch's last result. */
|
||||
/** Server accepted the active-turn correction. */
|
||||
Queued,
|
||||
|
||||
/** Server reachable but no tool batch in flight to steer. */
|
||||
/** Server reachable but no active turn is available to correct. */
|
||||
Rejected,
|
||||
|
||||
/** Transport/RPC failure (no live session, socket down, unsupported …). */
|
||||
@@ -2377,6 +2768,28 @@ internal class GatewayRpcException(message: String, val code: Int? = null) : Exc
|
||||
|
||||
private const val JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
|
||||
data class GatewayCompressResult(
|
||||
val status: String,
|
||||
val output: String? = null,
|
||||
val removed: Int? = null,
|
||||
val beforeMessages: Int? = null,
|
||||
val afterMessages: Int? = null,
|
||||
val beforeTokens: Int? = null,
|
||||
val afterTokens: Int? = null,
|
||||
val usage: UsageInfo? = null,
|
||||
val info: JsonObject? = null,
|
||||
val messages: List<MessageItem> = emptyList(),
|
||||
) {
|
||||
val effectiveUsage: UsageInfo?
|
||||
get() = usage ?: GatewayEventMapper.parseGatewayUsage(info?.get("usage") as? JsonObject)
|
||||
|
||||
val title: String?
|
||||
get() = info?.stringField("title")?.takeIf { it.isNotBlank() }
|
||||
|
||||
val isAuthoritative: Boolean
|
||||
get() = messages.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
|
||||
|
||||
+163
-86
@@ -31,13 +31,24 @@ class GatewayEventMapper(
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
internal val currentInteraction: GatewayAsk?
|
||||
get() = pendingInteraction
|
||||
|
||||
internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
|
||||
pendingInteraction = ask
|
||||
if (!duplicate) callbacks.onInteractionRequest(ask)
|
||||
}
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var previousEventType: String? = null
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var previewedText: String? = null
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
@@ -56,6 +67,30 @@ class GatewayEventMapper(
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
|
||||
interactionRequest(type, payload)?.let { ask ->
|
||||
restoreInteraction(ask)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
interactionExpiry(type, payload)?.let { expiry ->
|
||||
val pending = pendingInteraction
|
||||
if (pending != null && pending.matches(expiry)) {
|
||||
pendingInteraction = null
|
||||
}
|
||||
callbacks.onInteractionExpired(expiry)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
if (type in INTERACTION_RESUME_EVENTS) {
|
||||
pendingInteraction?.let { ask ->
|
||||
pendingInteraction = null
|
||||
callbacks.onInteractionResolved(
|
||||
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"reasoning.delta" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -95,13 +130,30 @@ class GatewayEventMapper(
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
|
||||
clearActivityStatuses()
|
||||
sawTextDelta = true
|
||||
previewedText = null
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.interim" -> {
|
||||
val text = payload.string("text") ?: payload.string("message")
|
||||
?: payload.string("preview") ?: payload.string("rendered")
|
||||
val alreadyStreamed = payload.boolean("already_streamed") == true
|
||||
if (!text.isNullOrBlank() || alreadyStreamed) {
|
||||
clearActivityStatuses()
|
||||
if (!sawMessageStart) {
|
||||
sawMessageStart = true
|
||||
callbacks.onStart()
|
||||
}
|
||||
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
|
||||
previewedText = text
|
||||
sawTextDelta = alreadyStreamed
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// The upstream background-completion poller currently emits
|
||||
// message.start immediately before _run_prompt_submit(), which
|
||||
@@ -114,6 +166,7 @@ class GatewayEventMapper(
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
previewedText = null
|
||||
callbacks.onStart()
|
||||
}
|
||||
|
||||
@@ -164,7 +217,15 @@ class GatewayEventMapper(
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
||||
val duplicatesPreview = responsePreviewed &&
|
||||
!text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
||||
if (!text.isNullOrEmpty() &&
|
||||
!duplicatesPreview &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
@@ -209,36 +270,6 @@ class GatewayEventMapper(
|
||||
)
|
||||
}
|
||||
|
||||
"clarify.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"approval.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
// Current Hermes omits timeout metadata. Keep the legacy
|
||||
// no-countdown behavior unless a future contract exposes
|
||||
// the effective per-request timeout explicitly.
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
),
|
||||
)
|
||||
|
||||
"tool.output_risk" -> {
|
||||
val toolId = payload.string("tool_id")
|
||||
val risk = payload.string("risk")?.lowercase() ?: return
|
||||
@@ -263,49 +294,6 @@ class GatewayEventMapper(
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"sudo.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
// Payload carries request_id ONLY — no command to show.
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"secret.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"sudo.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
"secret.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for the proposed upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
),
|
||||
)
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrBlank()) {
|
||||
@@ -342,21 +330,93 @@ class GatewayEventMapper(
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in APPROVAL_CHOICES }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
"thinking.delta",
|
||||
"reasoning.available",
|
||||
"message.delta",
|
||||
"message.interim",
|
||||
"message.start",
|
||||
"tool.generating",
|
||||
"tool.start",
|
||||
"tool.complete",
|
||||
"message.complete",
|
||||
"error",
|
||||
)
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"approval.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
)
|
||||
|
||||
"sudo.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"secret.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
|
||||
"clarify.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"sudo.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"secret.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for a future upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
|
||||
|
||||
/**
|
||||
* Hermes 2026-07-15 emits these operational wait lines through the
|
||||
@@ -422,3 +482,20 @@ private fun JsonObject?.int(key: String): Int? =
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in setOf("once", "session", "always", "deny") }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
|
||||
kind == other.kind && requestId == other.requestId
|
||||
|
||||
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
|
||||
kind == expiry.kind &&
|
||||
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
|
||||
|
||||
@@ -97,6 +97,19 @@ data class GatewayInflightTurn(
|
||||
val streaming: Boolean,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
val slug: String?,
|
||||
val name: String,
|
||||
val primaryPath: String?,
|
||||
)
|
||||
|
||||
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||
data class GatewaySessionRecovery(
|
||||
val storedSessionId: String,
|
||||
@@ -104,9 +117,14 @@ data class GatewaySessionRecovery(
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
val inflight: GatewayInflightTurn?,
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
)
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
data class GatewayBackgroundTurnCompletion(
|
||||
@@ -115,6 +133,25 @@ data class GatewayBackgroundTurnCompletion(
|
||||
val expectedAssistantText: String?,
|
||||
)
|
||||
|
||||
/** Input lifecycle from a deliberately detached Gateway turn. */
|
||||
sealed interface GatewayBackgroundInteractionEvent {
|
||||
val storedSessionId: String
|
||||
val profile: String?
|
||||
val ask: GatewayAsk
|
||||
|
||||
data class Requested(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
|
||||
data class Resolved(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
@@ -296,7 +333,9 @@ data class GatewayModelOptions(
|
||||
*
|
||||
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
|
||||
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
|
||||
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
|
||||
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
|
||||
* pins priority, `false` explicitly pins normal, and `null` omits the field so
|
||||
* the profile's service tier is inherited.
|
||||
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
|
||||
* accept it as a per-session override, so it is applied post-create instead.
|
||||
*/
|
||||
@@ -328,6 +367,13 @@ class GatewayTurnCallbacks(
|
||||
/** A gateway `message.start` opened an assistant response for this turn. */
|
||||
val onStart: () -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
/**
|
||||
* Gateway `message.interim` sealed an attempted assistant message before
|
||||
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
|
||||
* has not arrived through `message.delta` and should be rendered before
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -360,6 +406,8 @@ class GatewayTurnCallbacks(
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
/** Server declared a pending interaction expired; clear only the matching card. */
|
||||
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
|
||||
/** The turn resumed after a pending interaction was resolved elsewhere. */
|
||||
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
|
||||
/**
|
||||
* Gateway `status.update` lifecycle line — model fallback, retries, and
|
||||
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -159,6 +160,146 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ToolsetInfo(
|
||||
val name: String,
|
||||
val label: String = "",
|
||||
val description: String = "",
|
||||
val enabled: Boolean = false,
|
||||
val configured: Boolean = false,
|
||||
val tools: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
|
||||
|
||||
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
|
||||
json.decodeFromString<ToolsetListResponse>(body).data
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
|
||||
data class ApiModelOption(
|
||||
val id: String,
|
||||
val root: String? = null,
|
||||
val parent: String? = null,
|
||||
) {
|
||||
/** Secondary picker copy for a configured route alias. */
|
||||
val routeDetail: String?
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
return data.mapNotNull { row ->
|
||||
val obj = row as? JsonObject ?: return@mapNotNull null
|
||||
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
ApiModelOption(
|
||||
id = id,
|
||||
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
|
||||
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
|
||||
|
||||
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
|
||||
internal fun streamHttpFailureMessage(
|
||||
code: Int,
|
||||
reason: String,
|
||||
retryAfter: String?,
|
||||
body: String?,
|
||||
json: Json,
|
||||
): String {
|
||||
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
|
||||
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
|
||||
}
|
||||
val errorObj = error as? JsonObject
|
||||
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
|
||||
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
|
||||
?: (error as? JsonPrimitive)?.contentOrNull
|
||||
return buildString {
|
||||
append("API error ").append(code).append(": ")
|
||||
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
|
||||
append(detail?.takeIf { it.isNotBlank() } ?: reason)
|
||||
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
|
||||
append(" (Retry-After: ").append(it).append("s)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayDrainRetryDelayMillis(
|
||||
httpCode: Int?,
|
||||
retryAfter: String?,
|
||||
errorMessage: String,
|
||||
receivedEvent: Boolean,
|
||||
retryAlreadyScheduled: Boolean,
|
||||
): Long? {
|
||||
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
|
||||
!errorMessage.startsWith("API error 503: gateway_draining:")
|
||||
) return null
|
||||
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
|
||||
return seconds * 1_000L
|
||||
}
|
||||
|
||||
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
|
||||
private class RetryingEventSource(
|
||||
private val originalRequest: Request,
|
||||
private val handler: Handler,
|
||||
) : EventSource {
|
||||
private val lock = Any()
|
||||
private var active: EventSource? = null
|
||||
private var retryRunnable: Runnable? = null
|
||||
private var cancelled = false
|
||||
|
||||
override fun request(): Request = originalRequest
|
||||
|
||||
fun attach(source: EventSource) {
|
||||
synchronized(lock) {
|
||||
if (cancelled) source.cancel() else active = source
|
||||
}
|
||||
}
|
||||
|
||||
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
|
||||
val task = Runnable {
|
||||
synchronized(lock) {
|
||||
retryRunnable = null
|
||||
if (cancelled) return@Runnable
|
||||
// Keep creation under the same lock as cancel(): once Stop or
|
||||
// a session switch wins, no delayed POST can start afterward.
|
||||
active = create()
|
||||
}
|
||||
}
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
retryRunnable = task
|
||||
handler.postDelayed(task, delayMillis)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
val source: EventSource?
|
||||
val task: Runnable?
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
source = active
|
||||
active = null
|
||||
task = retryRunnable
|
||||
retryRunnable = null
|
||||
}
|
||||
task?.let(handler::removeCallbacks)
|
||||
source?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -199,8 +340,13 @@ class HermesApiClient(
|
||||
|
||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
response != null && !response.isSuccessful -> streamHttpFailureMessage(
|
||||
code = response.code,
|
||||
reason = response.message,
|
||||
retryAfter = response.header("Retry-After"),
|
||||
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
@@ -445,6 +591,24 @@ class HermesApiClient(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
|
||||
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/toolsets").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(IOException("HTTP ${response.code}"))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseToolsetListBody(json, body)
|
||||
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
@@ -453,17 +617,13 @@ class HermesApiClient(
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
parseModelOptionsBody(json, body).orEmpty()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
@@ -471,6 +631,9 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -623,6 +786,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
@@ -636,6 +802,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -799,9 +966,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -814,7 +992,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
@@ -876,6 +1055,9 @@ class HermesApiClient(
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
@@ -886,6 +1068,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -941,9 +1124,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -956,7 +1150,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
@@ -1070,6 +1265,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
@@ -1080,6 +1278,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -1246,9 +1445,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -1261,7 +1471,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- Capability detection ---
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
|
||||
* remain in memory only; OAuth codes, callback state, and tokens never enter
|
||||
* the Android client. The dashboard owns the complete PKCE/callback exchange.
|
||||
*/
|
||||
class McpOAuthFlowCoordinator(
|
||||
private val client: DashboardApiClient,
|
||||
private val pollDelayMillis: Long = 1_000,
|
||||
private val maxPolls: Int = 900,
|
||||
private val maxConsecutiveFailures: Int = 3,
|
||||
private val sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
) {
|
||||
suspend fun start(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
|
||||
if (started.status == "error") {
|
||||
throw IOException(started.error ?: "MCP OAuth failed to start")
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
var failures = 0
|
||||
repeat(maxPolls.coerceAtLeast(1)) {
|
||||
val current = client.getMcpOAuthFlow(flowId)
|
||||
if (current.isFailure) {
|
||||
failures += 1
|
||||
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
|
||||
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
|
||||
}
|
||||
} else {
|
||||
failures = 0
|
||||
val flow = current.getOrThrow()
|
||||
when (flow.status) {
|
||||
"approved" -> return@runCatching flow
|
||||
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
|
||||
}
|
||||
}
|
||||
sleep(pollDelayMillis.coerceAtLeast(0))
|
||||
}
|
||||
throw IOException("MCP OAuth authorization timed out")
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
suspend fun complete(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
openAuthorization: (String) -> Boolean,
|
||||
): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
val started = start(serverName, profile).getOrThrow()
|
||||
if (started.status == "approved") return@runCatching started
|
||||
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
|
||||
if (!openAuthorization(authorizationUrl)) {
|
||||
throw IOException("No browser is available to complete MCP OAuth")
|
||||
}
|
||||
resume(started.flowId).getOrThrow()
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
|
||||
val url = flow.authorizationUrl
|
||||
?: throw IOException("MCP OAuth server did not provide an authorization URL")
|
||||
if (url.toHttpUrlOrNull()?.scheme != "https") {
|
||||
throw IOException("MCP OAuth authorization URL must use HTTPS")
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
-3
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
|
||||
// upstream ever adds profile-aware TTS. Until then, standard voice remains
|
||||
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
|
||||
private val profileProvider: () -> String? = { null },
|
||||
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -57,9 +67,7 @@ class StandardHermesVoiceClient(
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
standardHermesDashboardAudioClient(okHttpClient)
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
@@ -148,6 +156,42 @@ class StandardHermesVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
baseUrl = baseUrl,
|
||||
ticket = ticket,
|
||||
path = "/api/audio/speak-stream",
|
||||
) ?: throw IOException("Could not build Hermes speech stream URL")
|
||||
val request = Request.Builder().url(websocketUrl).build()
|
||||
StandardHermesSpeechStream(
|
||||
request = request,
|
||||
callbacks = callbacks,
|
||||
json = json,
|
||||
socketFactory = webSocketFactory ?: callClient::newWebSocket,
|
||||
).also { it.connect() }
|
||||
.let { Result.success<VoiceSpeechStream?>(it) }
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -242,3 +286,209 @@ class StandardHermesVoiceClient(
|
||||
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
private class StandardHermesSpeechStream(
|
||||
private val request: Request,
|
||||
private val callbacks: VoiceSpeechStreamCallbacks,
|
||||
private val json: Json,
|
||||
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
) : VoiceSpeechStream {
|
||||
private val lock = Any()
|
||||
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
|
||||
private val pendingFrames = ArrayDeque<String>()
|
||||
private var socket: WebSocket? = null
|
||||
private var opened = false
|
||||
private var stopped = false
|
||||
private var finished = false
|
||||
private var audioStarted = false
|
||||
private var sampleRate = DEFAULT_SAMPLE_RATE
|
||||
private var oddByteCarry: Byte? = null
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
val frames = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) {
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
socket = webSocket
|
||||
opened = true
|
||||
pendingFrames.toList().also { pendingFrames.clear() }
|
||||
}
|
||||
frames.forEach(webSocket::send)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
|
||||
when (root.stringField("type")) {
|
||||
"start" -> {
|
||||
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: DEFAULT_SAMPLE_RATE
|
||||
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: 1
|
||||
if (channels != 1) {
|
||||
settle(
|
||||
status = VoiceSpeechStreamStatus.Fallback,
|
||||
error = IOException("Hermes speech stream returned $channels channels; mono required"),
|
||||
)
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
synchronized(lock) { sampleRate = nextRate }
|
||||
callbacks.onStart(nextRate, channels)
|
||||
}
|
||||
"fallback" -> {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) {
|
||||
VoiceSpeechStreamStatus.Completed
|
||||
} else {
|
||||
VoiceSpeechStreamStatus.Fallback
|
||||
},
|
||||
)
|
||||
webSocket.close(1000, "fallback")
|
||||
}
|
||||
"end" -> {
|
||||
settle(VoiceSpeechStreamStatus.Completed)
|
||||
webSocket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
val delivery = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
var incoming = bytes.toByteArray()
|
||||
oddByteCarry?.let { carry ->
|
||||
incoming = byteArrayOf(carry) + incoming
|
||||
oddByteCarry = null
|
||||
}
|
||||
val usable = incoming.size - (incoming.size % 2)
|
||||
if (usable < incoming.size) oddByteCarry = incoming.last()
|
||||
if (usable == 0) return
|
||||
audioStarted = true
|
||||
incoming.copyOf(usable) to sampleRate
|
||||
}
|
||||
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
|
||||
.onFailure { error ->
|
||||
settle(VoiceSpeechStreamStatus.Failed, error)
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
settleForDisconnect(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
val created = socketFactory(request, listener)
|
||||
synchronized(lock) {
|
||||
if (socket == null) socket = created
|
||||
if (stopped) created.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun append(text: String) {
|
||||
if (text.isEmpty()) return
|
||||
sendFrame(buildJsonObject { put("text", text) })
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
synchronized(lock) {
|
||||
if (finished || stopped || outcome.isCompleted) return
|
||||
finished = true
|
||||
}
|
||||
sendFrame(buildJsonObject { put("done", true) })
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val current = synchronized(lock) {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
socket
|
||||
}
|
||||
if (current != null) {
|
||||
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
|
||||
current.cancel()
|
||||
}
|
||||
settle(VoiceSpeechStreamStatus.Stopped)
|
||||
}
|
||||
|
||||
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
|
||||
|
||||
private fun sendFrame(frame: JsonObject) {
|
||||
val encoded = json.encodeToString(JsonObject.serializer(), frame)
|
||||
val current = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
if (!opened) {
|
||||
pendingFrames.addLast(encoded)
|
||||
return
|
||||
}
|
||||
socket
|
||||
}
|
||||
if (current?.send(encoded) != true) {
|
||||
settleForDisconnect(IOException("Hermes speech stream send failed"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleForDisconnect(error: Throwable) {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
|
||||
val result = synchronized(lock) {
|
||||
if (outcome.isCompleted) return
|
||||
VoiceSpeechStreamOutcome(
|
||||
status = status,
|
||||
audioStarted = audioStarted,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
outcome.complete(result)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_SAMPLE_RATE = 24_000
|
||||
}
|
||||
}
|
||||
|
||||
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
|
||||
|
||||
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
|
||||
requestedSeconds.coerceIn(
|
||||
minimumValue = 180L,
|
||||
maximumValue = 600L,
|
||||
)
|
||||
|
||||
internal fun standardHermesDashboardAudioClient(
|
||||
baseClient: OkHttpClient,
|
||||
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
|
||||
): OkHttpClient {
|
||||
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
|
||||
return baseClient.newBuilder()
|
||||
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ data class MessageItem(
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null,
|
||||
@SerialName("display_kind") val displayKind: String? = null,
|
||||
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
|
||||
// Reasoning persisted with the assistant message (upstream serializes
|
||||
// both names; reasoning is the canonical one). Restored into
|
||||
// ChatMessage.thinkingContent so the Thought-process block survives a
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
|
||||
/**
|
||||
* Action-required notifications for Gateway turns blocked on user input.
|
||||
*
|
||||
* Notification copy is deliberately generic. Approval commands, clarification
|
||||
* questions, secret prompts, environment-variable names, and password requests
|
||||
* stay inside the authenticated chat surface and never appear on the lock
|
||||
* screen. A stable tag derived from the durable session and request identity
|
||||
* makes replay/reconnect delivery replace the existing notification.
|
||||
*/
|
||||
object InteractionRequestNotifier {
|
||||
|
||||
private const val TAG = "InteractionNotifier"
|
||||
internal const val CHANNEL_ID = "chat_interactions"
|
||||
private const val CHANNEL_NAME = "Hermes needs input"
|
||||
internal const val NOTIFICATION_ID = 3823
|
||||
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
||||
|
||||
internal fun shouldPost(
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
hasPermission: Boolean,
|
||||
): Boolean = alertsEnabled && !appForeground && hasPermission
|
||||
|
||||
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
|
||||
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
|
||||
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
|
||||
}
|
||||
|
||||
internal fun notificationTag(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
|
||||
|
||||
internal fun chatRoute(sessionId: String, profile: String? = null): String =
|
||||
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
|
||||
|
||||
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Hermes needs sensitive input"
|
||||
}
|
||||
|
||||
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Open Hermes to respond securely."
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
): Boolean {
|
||||
ensureChannel(context)
|
||||
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
|
||||
return false
|
||||
}
|
||||
|
||||
val tag = notificationTag(sessionId, ask, profile)
|
||||
val requestKey = requestKey(sessionId, ask, profile)
|
||||
val requestCode = requestKey.hashCode()
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.Builder()
|
||||
.scheme("hermes-relay")
|
||||
.authority("interaction")
|
||||
.appendPath(requestKey)
|
||||
.build()
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
|
||||
}
|
||||
val tapPending = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val title = safeTitle(ask)
|
||||
val body = safeBody(ask)
|
||||
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes needs your input")
|
||||
.setContentText("Open Hermes to continue.")
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
|
||||
true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "notify failed", it)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancel failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only this feature's notifications. Android keeps notifications
|
||||
* across process death, so the active-notification scan is also used when
|
||||
* MainActivity returns without an in-memory request registry.
|
||||
*/
|
||||
fun cancelAll(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
runCatching {
|
||||
manager.activeNotifications
|
||||
.filter { it.notification.channelId == CHANNEL_ID }
|
||||
.forEach { manager.cancel(it.tag, it.id) }
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancelAll failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Alerts when a Hermes turn is waiting for your response."
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setShowBadge(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,13 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -128,6 +131,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
|
||||
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
|
||||
@@ -151,12 +155,17 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.audio.VoicePlayer
|
||||
import com.hermesandroid.relay.audio.VoiceRecorder
|
||||
import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
@@ -182,6 +191,68 @@ suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Startup chrome should wait for either standard chat surface, not Relay. */
|
||||
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
|
||||
connection?.capabilities?.chatConfigured == true
|
||||
|
||||
/**
|
||||
* App-root chat health derived only from the two transports that can carry a
|
||||
* conversation. Optional Relay state is deliberately absent.
|
||||
*/
|
||||
internal fun resolveAppChatRuntimeStatus(
|
||||
connection: Connection?,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
apiHealth: ConnectionViewModel.HealthStatus,
|
||||
): ChatRuntimeStatus {
|
||||
val capabilities = connection?.capabilities
|
||||
val gateway = when {
|
||||
capabilities?.dashboardGatewayConfigured != true -> ChatTransportReadiness.NotConfigured
|
||||
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
}
|
||||
val api = when {
|
||||
capabilities?.apiServerConfigured != true -> ChatTransportReadiness.NotConfigured
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unknown ||
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
}
|
||||
return resolveChatRuntimeStatus(gateway = gateway, apiSse = api)
|
||||
}
|
||||
|
||||
/** Route represented by the app footer's currently usable chat transport. */
|
||||
internal fun resolveFooterRouteCandidate(
|
||||
runtimeStatus: ChatRuntimeStatus,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
connection: Connection?,
|
||||
effectiveDashboardUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val connected = runtimeStatus as? ChatRuntimeStatus.Connected ?: return null
|
||||
return when (connected.transport) {
|
||||
ChatTransportPath.Gateway -> {
|
||||
val dashboardUrl = effectiveDashboardUrl.trim().trimEnd('/')
|
||||
.ifBlank { connection?.resolvedDashboardUrl.orEmpty() }
|
||||
if (dashboardUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val activeDashboardUrl = activeEndpoint?.dashboard?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
activeEndpoint?.takeIf { activeDashboardUrl == dashboardUrl }
|
||||
?: Connection.endpointCandidateFromDashboardUrl(
|
||||
role = Connection.inferRouteRole(dashboardUrl),
|
||||
priority = activeEndpoint?.priority ?: 0,
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatTransportPath.ApiSse -> activeEndpoint?.takeIf { it.api != null }
|
||||
?: connection?.routeCandidates?.firstOrNull { it.api != null }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
val label: String,
|
||||
@@ -203,17 +274,44 @@ sealed class Screen(
|
||||
// NavHost, and the NavigationBarItem click must navigate via [route]()
|
||||
// so no unresolved `{openAgentSheet}` leaks into the destination.
|
||||
data object Chat : Screen(
|
||||
"chat?openAgentSheet={openAgentSheet}",
|
||||
"chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
|
||||
"Chat",
|
||||
Icons.AutoMirrored.Filled.Chat,
|
||||
) {
|
||||
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
|
||||
fun route(openAgentSheet: Boolean = false): String =
|
||||
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
|
||||
const val ARG_SESSION_ID: String = "sessionId"
|
||||
const val ARG_PROFILE: String = "profile"
|
||||
fun route(
|
||||
openAgentSheet: Boolean = false,
|
||||
sessionId: String? = null,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val params = buildList {
|
||||
if (openAgentSheet) add("$ARG_OPEN_AGENT_SHEET=true")
|
||||
sessionId?.takeIf { it.isNotBlank() }?.let {
|
||||
add("$ARG_SESSION_ID=${android.net.Uri.encode(it)}")
|
||||
}
|
||||
profile?.takeIf { it.isNotBlank() }?.let {
|
||||
add("$ARG_PROFILE=${android.net.Uri.encode(it)}")
|
||||
}
|
||||
}
|
||||
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
|
||||
data object DashboardSignIn : Screen(
|
||||
"dashboard_sign_in?source={source}",
|
||||
"Dashboard sign in",
|
||||
Icons.Filled.Settings,
|
||||
) {
|
||||
const val ARG_SOURCE: String = "source"
|
||||
const val SOURCE_GENERAL: String = "general"
|
||||
const val SOURCE_PAIR: String = "pair"
|
||||
const val SOURCE_ONBOARDING: String = "onboarding"
|
||||
fun route(source: String = SOURCE_GENERAL): String = "dashboard_sign_in?source=$source"
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
|
||||
// Non-bottom-nav destinations — reached by explicit navigation, not the
|
||||
@@ -359,6 +457,12 @@ fun RelayApp() {
|
||||
// ConnectionStore's mutations are all suspend fns and we don't want to
|
||||
// block the main dispatcher from inside the composable body.
|
||||
val connectionSwitchScope = rememberCoroutineScope()
|
||||
// Add-connection preparation may outlive the initiating list frame now
|
||||
// that navigation happens immediately. Keep the job by placeholder id so
|
||||
// an instant Back can wait for creation and then discard it safely.
|
||||
val pendingAddConnectionJobs = remember {
|
||||
mutableMapOf<String, kotlinx.coroutines.Job>()
|
||||
}
|
||||
|
||||
// One-time init: the terminal channel ViewModel registers with the shared
|
||||
// multiplexer and observes the relay connection state so it can attach/
|
||||
@@ -434,11 +538,13 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ChatViewModel reactively when the chat-routed API client becomes available
|
||||
// Bind chat state independently of the optional API fallback client.
|
||||
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
|
||||
val chatTransportReady by connectionViewModel.chatReady.collectAsState()
|
||||
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val effectiveDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
@@ -633,9 +739,10 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(chatApiClient) {
|
||||
val client = chatApiClient ?: return@LaunchedEffect
|
||||
var boundCatalogConnectionId by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(chatApiClient, activeConnectionId) {
|
||||
val handler = connectionViewModel.chatHandler
|
||||
val connectionChanged = boundCatalogConnectionId != activeConnectionId
|
||||
// A route handoff / reconnect rebuilds the API client (new instance)
|
||||
// while the chat is unchanged — the bound handler is the same. Take the
|
||||
// cheap path: swap the client reference only, no re-init. This is what
|
||||
@@ -643,11 +750,24 @@ fun RelayApp() {
|
||||
// switch or a reconnect. A genuine re-bind (different handler) falls
|
||||
// through to the full one-time wiring below.
|
||||
if (chatViewModel.boundHandler === handler) {
|
||||
chatViewModel.updateApiClient(client)
|
||||
if (connectionChanged) {
|
||||
chatViewModel.resetConnectionCatalogs()
|
||||
// The active pointer changes before an API target is rebuilt.
|
||||
// Never let the outgoing API client refill the new connection's
|
||||
// catalogs during that window. Dashboard-only (null -> null)
|
||||
// refreshes immediately through the already-installed loader.
|
||||
chatViewModel.updateApiClient(null)
|
||||
} else {
|
||||
chatViewModel.updateApiClient(chatApiClient)
|
||||
}
|
||||
boundCatalogConnectionId = activeConnectionId
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
chatViewModel.initialize(client, handler)
|
||||
// The Dashboard/Gateway transport is independently sufficient for
|
||||
// chat, so handler and dashboard callbacks must bind even when no API
|
||||
// fallback client is configured.
|
||||
chatViewModel.initialize(chatApiClient, handler)
|
||||
|
||||
// Wire inbound-media dependencies. Idempotent rewire of the
|
||||
// ChatHandler callbacks.
|
||||
@@ -665,6 +785,9 @@ fun RelayApp() {
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
chatViewModel.setIsolatedProfileApiProvider {
|
||||
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
|
||||
}
|
||||
chatViewModel.setSessionProfileNameProvider {
|
||||
connectionViewModel.effectiveSessionProfileName.value
|
||||
}
|
||||
@@ -675,10 +798,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
chatViewModel.setDisplayProfileProvider {
|
||||
AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = connectionViewModel.selectedProfile.value,
|
||||
profiles = connectionViewModel.agentProfiles.value,
|
||||
)
|
||||
connectionViewModel.effectiveDisplayProfile.value
|
||||
}
|
||||
chatViewModel.setDisplayAliasProvider {
|
||||
connectionViewModel.profileDisplayAlias.value
|
||||
@@ -694,6 +814,12 @@ fun RelayApp() {
|
||||
chatViewModel.setProfileMessageLoader { sessionId ->
|
||||
connectionViewModel.loadProfileScopedMessages(sessionId)
|
||||
}
|
||||
// Personality choices live in Dashboard `/api/config` on a
|
||||
// dashboard-only connection. The setter immediately refreshes after
|
||||
// this callback is installed, covering the null-API initialization.
|
||||
chatViewModel.setDashboardConfigLoader {
|
||||
connectionViewModel.loadActiveDashboardConfig()
|
||||
}
|
||||
// …and delete from that same profile's DB so a non-default profile's
|
||||
// session can't be resurrected by the next profile-scoped list.
|
||||
chatViewModel.profileSessionDeleter = { sessionId ->
|
||||
@@ -710,6 +836,7 @@ fun RelayApp() {
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
connectionViewModel.saveLastSessionId(sessionId)
|
||||
}
|
||||
boundCatalogConnectionId = activeConnectionId
|
||||
}
|
||||
|
||||
// Reload sessions / switch profile context only on a SEMANTIC change
|
||||
@@ -718,16 +845,15 @@ fun RelayApp() {
|
||||
// means a route handoff (which churns the client) no longer triggers a
|
||||
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
|
||||
// already no-ops when the context key + session are unchanged.
|
||||
val chatClientReady = chatApiClient != null
|
||||
LaunchedEffect(
|
||||
chatClientReady,
|
||||
chatTransportReady,
|
||||
activeConnectionId,
|
||||
selectedProfile?.name,
|
||||
effectiveSessionProfileName,
|
||||
lastSessionId,
|
||||
profileSelectionSettled,
|
||||
) {
|
||||
if (!chatClientReady) return@LaunchedEffect
|
||||
if (!chatTransportReady) return@LaunchedEffect
|
||||
// Cold-start profile-isolation guard: hold the first profile-scoped load
|
||||
// until the persisted profile selection has SETTLED, so the session
|
||||
// drawer (and the restored session context) don't briefly load the
|
||||
@@ -770,7 +896,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) {
|
||||
LaunchedEffect(selectedProfile?.name, effectiveDisplayProfile?.name, agentProfiles, profileDisplayAlias) {
|
||||
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
|
||||
}
|
||||
|
||||
@@ -847,10 +973,10 @@ fun RelayApp() {
|
||||
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
|
||||
// client to follow the new dashboard route instead of stranding the turn on
|
||||
// the dead one.
|
||||
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
// Debounce a route FLIP before re-acquiring the gateway chat client. The
|
||||
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
|
||||
// stable on a transient endpoint-resolution miss, so effectiveApiUrl should
|
||||
// stable on a transient endpoint-resolution miss, so the Dashboard URL should
|
||||
// not flap — this is belt-and-suspenders against any residual sub-second
|
||||
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
|
||||
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
|
||||
@@ -859,9 +985,17 @@ fun RelayApp() {
|
||||
// unaffected; only a genuine url change waits for a settle window, and if
|
||||
// the url flips back within it the LaunchedEffect cancels + restarts so no
|
||||
// rebuild happens.
|
||||
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
|
||||
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) {
|
||||
var lastAcquiredDashboardUrl by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(
|
||||
streamingEndpoint,
|
||||
serverCapabilities,
|
||||
gatewayAvailability,
|
||||
effectiveDashboardUrl,
|
||||
) {
|
||||
if (
|
||||
lastAcquiredDashboardUrl != null &&
|
||||
lastAcquiredDashboardUrl != effectiveDashboardUrl
|
||||
) {
|
||||
delay(750L)
|
||||
}
|
||||
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
@@ -870,7 +1004,7 @@ fun RelayApp() {
|
||||
chatViewModel.updateGatewayClient(
|
||||
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
|
||||
)
|
||||
lastAcquiredApiUrl = effectiveApiUrl
|
||||
lastAcquiredDashboardUrl = effectiveDashboardUrl
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
@@ -961,7 +1095,6 @@ fun RelayApp() {
|
||||
CrashReportGate()
|
||||
|
||||
val navController = rememberNavController()
|
||||
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
@@ -1026,17 +1159,6 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
|
||||
val route = postOnboardingRoute
|
||||
if (onboardingCompleted && route != null) {
|
||||
postOnboardingRoute = null
|
||||
navController.navigate(route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startDestination uses the route TEMPLATE so it matches the
|
||||
// composable registered below; optional args default to null/false.
|
||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||
@@ -1127,11 +1249,8 @@ fun RelayApp() {
|
||||
// bottom navigation bar so the voice overlay can own the entire screen
|
||||
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
|
||||
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
@@ -1166,17 +1285,16 @@ fun RelayApp() {
|
||||
startupGateTimedOut = true
|
||||
}
|
||||
|
||||
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
|
||||
// A published activeEndpoint counts as "hermes online": the route
|
||||
// resolver only publishes a winner after a successful HEAD /health
|
||||
// probe against that route's API URL. At cold start this evidence
|
||||
// lands within ~1s — long before the client-based health probe,
|
||||
// which can't run until the API client exists (the client build
|
||||
// used to queue behind the Keystore decrypt; see
|
||||
// apiKeyForClientBuild in ConnectionViewModel).
|
||||
val startupApiUp = apiReachable ||
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
|
||||
activeEndpoint != null
|
||||
val hasStartupConnection = hasConfiguredStartupChat(activeConnection)
|
||||
val appChatRuntimeStatus = resolveAppChatRuntimeStatus(
|
||||
connection = activeConnection,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
apiHealth = apiHealth,
|
||||
)
|
||||
// A Dashboard/Gateway-only connection is a complete standard Hermes
|
||||
// connection. Startup readiness follows the same transport-neutral
|
||||
// priority as the footer instead of waiting for an optional API probe.
|
||||
val startupChatUp = appChatRuntimeStatus is ChatRuntimeStatus.Connected
|
||||
|
||||
// An Unreachable verdict only counts after it SURVIVES a settle
|
||||
// window: the first health probe often runs against the persisted
|
||||
@@ -1185,9 +1303,9 @@ fun RelayApp() {
|
||||
// first verdict was what flashed the disconnected chat UI at users
|
||||
// who were connected-just-waiting. The keyed effect restarts on
|
||||
// every health flip, cancelling a pending settle.
|
||||
LaunchedEffect(apiHealth, startupGateReleased) {
|
||||
LaunchedEffect(appChatRuntimeStatus, startupGateReleased) {
|
||||
if (startupGateReleased) return@LaunchedEffect
|
||||
if (apiHealth == ConnectionViewModel.HealthStatus.Unreachable) {
|
||||
if (hasStartupConnection && appChatRuntimeStatus is ChatRuntimeStatus.Unavailable) {
|
||||
delay(3_000L)
|
||||
startupUnreachableSettled = true
|
||||
} else {
|
||||
@@ -1211,16 +1329,16 @@ fun RelayApp() {
|
||||
StartupCheckState.Done,
|
||||
"route · ${startupEndpoint.displayLabel()}",
|
||||
)
|
||||
startupApiUp ->
|
||||
startupChatUp ->
|
||||
StartupCheck(StartupCheckState.Done, "route · direct")
|
||||
appReady ->
|
||||
StartupCheck(StartupCheckState.Active, "resolving route")
|
||||
else -> StartupCheck(StartupCheckState.Pending, "route")
|
||||
},
|
||||
when {
|
||||
startupApiUp ->
|
||||
startupChatUp ->
|
||||
StartupCheck(StartupCheckState.Done, "hermes online")
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
appChatRuntimeStatus is ChatRuntimeStatus.Unavailable ->
|
||||
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
|
||||
appReady ->
|
||||
StartupCheck(StartupCheckState.Active, "contacting hermes")
|
||||
@@ -1232,7 +1350,7 @@ fun RelayApp() {
|
||||
when {
|
||||
chatReady && initialChatSettled ->
|
||||
StartupCheck(StartupCheckState.Done, "conversation ready")
|
||||
startupApiUp ->
|
||||
startupChatUp ->
|
||||
StartupCheck(StartupCheckState.Active, "loading conversation")
|
||||
else -> StartupCheck(StartupCheckState.Pending, "conversation")
|
||||
},
|
||||
@@ -1346,7 +1464,8 @@ fun RelayApp() {
|
||||
// the previous run). The pre-warm fills cold keys and refreshes
|
||||
// stale (disk-hydrated) ones, then mirrors results back to disk.
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
|
||||
val effectiveManageProfile by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl, effectiveManageProfile) {
|
||||
val connection = activeConnection ?: return@LaunchedEffect
|
||||
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
|
||||
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
|
||||
@@ -1363,6 +1482,7 @@ fun RelayApp() {
|
||||
cookieStore = cookieStore,
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
cacheDir = hydrateContext.cacheDir,
|
||||
context = hydrateContext,
|
||||
)
|
||||
@@ -1460,7 +1580,7 @@ fun RelayApp() {
|
||||
// stays fully silent (the health "Connecting" cue used to flash here for a
|
||||
// few seconds and then clear with no "Connected" toast).
|
||||
val connectionReconnecting =
|
||||
globalConnectionStatus?.active == true && !postResumeQuiet &&
|
||||
appChatRuntimeStatus is ChatRuntimeStatus.Connecting && !postResumeQuiet &&
|
||||
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
|
||||
// === END v0.4.1 polish ===
|
||||
|
||||
@@ -1600,7 +1720,13 @@ fun RelayApp() {
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
|
||||
val routeLabel = activeEndpoint?.displayLabel()
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
runtimeStatus = appChatRuntimeStatus,
|
||||
activeEndpoint = activeEndpoint,
|
||||
connection = activeConnection,
|
||||
effectiveDashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
val routeLabel = footerRoute?.displayLabel()
|
||||
?: activeConnection?.label
|
||||
?: stringResource(R.string.status_no_route)
|
||||
val transportStatus = resolveChatTransportStatus(
|
||||
@@ -1613,12 +1739,9 @@ fun RelayApp() {
|
||||
} else {
|
||||
routeLabel
|
||||
}
|
||||
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
|
||||
val profileLabel = AgentDisplay.profileDisplayName(effectiveDisplayProfile)
|
||||
?: stringResource(R.string.status_profile_default)
|
||||
val displayProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
val displayProfile = effectiveDisplayProfile
|
||||
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||
?: AgentDisplay.displayModelName(displayProfile?.model)
|
||||
?: AgentDisplay.displayModelName(serverModelName)
|
||||
@@ -1701,8 +1824,9 @@ fun RelayApp() {
|
||||
}
|
||||
},
|
||||
onManageSignIn = {
|
||||
postOnboardingRoute = Screen.Manage.route
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(
|
||||
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_ONBOARDING),
|
||||
)
|
||||
},
|
||||
onOpenPermissions = {
|
||||
navController.navigate(Screen.PermissionsSettings.route)
|
||||
@@ -1717,6 +1841,16 @@ fun RelayApp() {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument(Screen.Chat.ARG_SESSION_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument(Screen.Chat.ARG_PROFILE) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
// Responsive bubble width based on screen width. The "Blend"
|
||||
@@ -1741,6 +1875,48 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
LaunchedEffect(
|
||||
requestedSessionId,
|
||||
requestedProfileRoute,
|
||||
profileSelectionSettled,
|
||||
effectiveSessionProfileName,
|
||||
agentProfiles,
|
||||
) {
|
||||
val sessionId = requestedSessionId ?: return@LaunchedEffect
|
||||
if (requestedProfileRoute != null) {
|
||||
val targetProfile = requestedProfileRoute.takeUnless {
|
||||
it == com.hermesandroid.relay.notifications
|
||||
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
|
||||
}
|
||||
if (!profileSelectionSettled) return@LaunchedEffect
|
||||
if (effectiveSessionProfileName != targetProfile) {
|
||||
val selection = targetProfile?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name == name }
|
||||
}
|
||||
if (targetProfile == null || selection != null) {
|
||||
connectionViewModel.selectProfile(selection)
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
chatViewModel.switchProfileContext(
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnectionId,
|
||||
profileName = targetProfile,
|
||||
),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
} else if (chatViewModel.currentSessionId.value != sessionId) {
|
||||
chatViewModel.switchSession(sessionId)
|
||||
}
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
}
|
||||
|
||||
val screenChatLabel = stringResource(R.string.screen_chat_label)
|
||||
|
||||
@@ -1832,6 +2008,11 @@ fun RelayApp() {
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToSignIn = {
|
||||
navController.navigate(Screen.DashboardSignIn.route()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Standard back: return to wherever Manage was opened
|
||||
// from (Settings → Hermes management, the agent sheet,
|
||||
// etc.). The prior forced navigate(Chat) with
|
||||
@@ -1865,6 +2046,39 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = Screen.DashboardSignIn.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.DashboardSignIn.ARG_SOURCE) {
|
||||
type = NavType.StringType
|
||||
defaultValue = Screen.DashboardSignIn.SOURCE_GENERAL
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val source = backStackEntry.arguments
|
||||
?.getString(Screen.DashboardSignIn.ARG_SOURCE)
|
||||
?: Screen.DashboardSignIn.SOURCE_GENERAL
|
||||
DashboardSignInScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onAuthenticated = {
|
||||
when (source) {
|
||||
Screen.DashboardSignIn.SOURCE_ONBOARDING -> {
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(Screen.Chat.route()) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
Screen.DashboardSignIn.SOURCE_PAIR -> {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack()
|
||||
}
|
||||
else -> navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Terminal.route) {
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
TerminalScreen(
|
||||
@@ -2082,6 +2296,7 @@ fun RelayApp() {
|
||||
voiceClient = voiceClient,
|
||||
connectionId = activeConnectionId,
|
||||
selectedProfile = selectedProfile,
|
||||
displayProfile = effectiveDisplayProfile,
|
||||
standardVoiceAvailability = standardVoiceAvailability,
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
@@ -2230,18 +2445,20 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.ConnectionDetail.route(id))
|
||||
},
|
||||
onAddConnection = {
|
||||
connectionSwitchScope.launch {
|
||||
// Create and switch to the placeholder before
|
||||
// opening the wizard. Otherwise a fast scan or
|
||||
// standard save can write into the outgoing
|
||||
// connection's auth store.
|
||||
val id = connectionViewModel.beginAddConnection(
|
||||
preAllocatedId = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
navController.navigate(
|
||||
Screen.Pair.route(connectionId = id)
|
||||
)
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
// Draw step 1 immediately. Placeholder persistence
|
||||
// and the heavy connection-context switch continue
|
||||
// underneath the discovery UI instead of blocking
|
||||
// navigation on encrypted-store/client setup.
|
||||
navController.navigate(Screen.Pair.route(connectionId = id))
|
||||
val job = connectionSwitchScope.launch {
|
||||
try {
|
||||
connectionViewModel.beginAddConnection(preAllocatedId = id)
|
||||
} finally {
|
||||
pendingAddConnectionJobs.remove(id)
|
||||
}
|
||||
}
|
||||
pendingAddConnectionJobs[id] = job
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// Pass the VM so the list cards can read live status
|
||||
@@ -2283,7 +2500,7 @@ fun RelayApp() {
|
||||
onRepair = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.switchConnection(id).join()
|
||||
navController.navigate(Screen.Pair.route(id))
|
||||
navController.navigate(Screen.Pair.route(id, autoStart = "relay"))
|
||||
}
|
||||
},
|
||||
onRevoke = { id ->
|
||||
@@ -2333,9 +2550,14 @@ fun RelayApp() {
|
||||
?.getString(Screen.Pair.ARG_CONNECTION_ID)
|
||||
val autoStartArg = backStackEntry.arguments
|
||||
?.getString(Screen.Pair.ARG_AUTO_START)
|
||||
val pairConnections by connectionViewModel.connections.collectAsState()
|
||||
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val pairSetupReady = connectionIdArg == null ||
|
||||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
|
||||
com.hermesandroid.relay.ui.screens.PairScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
autoStart = autoStartArg,
|
||||
setupReady = pairSetupReady,
|
||||
// Offer demo only on the bare "Connect" entry (the
|
||||
// "No Hermes connection" path) — not on add-connection /
|
||||
// re-pair flows, which have a placeholder connection in
|
||||
@@ -2354,8 +2576,9 @@ fun RelayApp() {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onManageSignIn = {
|
||||
navController.popBackStack()
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
navController.navigate(
|
||||
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_PAIR),
|
||||
) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
@@ -2367,6 +2590,10 @@ fun RelayApp() {
|
||||
// never got a pairedAt stamp.
|
||||
if (connectionIdArg != null) {
|
||||
connectionSwitchScope.launch {
|
||||
// If Back wins the race with background
|
||||
// preparation, wait until the placeholder
|
||||
// exists before attempting to discard it.
|
||||
pendingAddConnectionJobs.remove(connectionIdArg)?.join()
|
||||
connectionViewModel.discardPlaceholderConnection(connectionIdArg)
|
||||
}
|
||||
}
|
||||
@@ -2502,9 +2729,9 @@ fun RelayApp() {
|
||||
// doesn't happen to match the one we're inspecting
|
||||
// (shouldn't normally happen since the entry is
|
||||
// keyed off the same Profile).
|
||||
val selectedProfile by connectionViewModel
|
||||
.selectedProfile.collectAsState()
|
||||
val modelLabel = selectedProfile
|
||||
val inspectorDisplayProfile by connectionViewModel
|
||||
.effectiveDisplayProfile.collectAsState()
|
||||
val modelLabel = inspectorDisplayProfile
|
||||
?.takeIf { it.name == profileNameArg }
|
||||
?.model
|
||||
|
||||
|
||||
+748
-174
File diff suppressed because it is too large
Load Diff
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
|
||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||
if (!content.contains("![")) return content to emptyList()
|
||||
val images = mutableListOf<ChatInlineImage>()
|
||||
var inlineDataImages = 0
|
||||
var inlineDataBytes = 0L
|
||||
var inlineOverflowNoticeAdded = false
|
||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
|
||||
val alt = m.groupValues[2].trim()
|
||||
val src = normalizeImageSrc(m.groupValues[3].trim())
|
||||
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
|
||||
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
|
||||
val exceedsInlineBudget = isInlineData && (
|
||||
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
|
||||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
|
||||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
|
||||
)
|
||||
if (exceedsInlineBudget) {
|
||||
return@replace if (inlineOverflowNoticeAdded) {
|
||||
""
|
||||
} else {
|
||||
inlineOverflowNoticeAdded = true
|
||||
"\n\n_Additional inline images omitted for memory safety._\n\n"
|
||||
}
|
||||
}
|
||||
images += ChatInlineImage(
|
||||
alt = alt,
|
||||
src = normalizeImageSrc(m.groupValues[3].trim()),
|
||||
src = src,
|
||||
sensitive = spoilerWrapped || isSensitiveAltText(alt),
|
||||
)
|
||||
if (inlineDataSize != null) {
|
||||
inlineDataImages += 1
|
||||
inlineDataBytes += inlineDataSize
|
||||
}
|
||||
""
|
||||
}
|
||||
if (images.isEmpty()) return content to emptyList()
|
||||
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
|
||||
return s.startsWith("http://") || s.startsWith("https://")
|
||||
}
|
||||
|
||||
private fun ChatInlineImage.isInlineDataImage(): Boolean =
|
||||
src.startsWith("data:image/", ignoreCase = true)
|
||||
|
||||
/**
|
||||
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
|
||||
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
|
||||
@@ -232,6 +258,7 @@ fun ChatInlineImages(
|
||||
images.forEach { image ->
|
||||
when {
|
||||
image.isRemote() -> RemoteChatImage(image, maxWidth)
|
||||
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
|
||||
// A server-local file the agent referenced — fetch it through
|
||||
// /media/by-path and render inline; on failure the notice shows
|
||||
// the ACTUAL reason (for debugging) instead of a generic message.
|
||||
@@ -253,6 +280,94 @@ fun ChatInlineImages(
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface DataUrlImagePhase {
|
||||
data object Loading : DataUrlImagePhase
|
||||
data class Loaded(
|
||||
val bitmap: ImageBitmap,
|
||||
val mime: String,
|
||||
) : DataUrlImagePhase
|
||||
data object Rejected : DataUrlImagePhase
|
||||
}
|
||||
|
||||
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
|
||||
@Composable
|
||||
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
|
||||
var viewerOpen by remember(image.src) { mutableStateOf(false) }
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(image.src) { mutableStateOf(false) }
|
||||
LaunchedEffect(image.src) {
|
||||
phase = withInlineImageDecodeLock {
|
||||
val decoded = decodeInlineImageDataUrl(image.src)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
|
||||
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bitmap = BitmapFactory.decodeByteArray(
|
||||
decoded.bytes,
|
||||
0,
|
||||
decoded.bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
|
||||
}
|
||||
}
|
||||
when (val current = phase) {
|
||||
DataUrlImagePhase.Loading -> Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
|
||||
image.copy(src = "inline image"),
|
||||
reason = "Unsupported or oversized inline image.",
|
||||
)
|
||||
is DataUrlImagePhase.Loaded -> {
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Bitmap(
|
||||
bitmap = current.bitmap,
|
||||
displayName = image.alt.ifBlank { "image" },
|
||||
mime = current.mime,
|
||||
// Decode the already-retained data URL only when the
|
||||
// user requests Save/Share; don't keep a second 5 MiB
|
||||
// byte array beside every thumbnail.
|
||||
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
sensitive = image.sensitive,
|
||||
initiallyRevealed = revealed,
|
||||
)
|
||||
}
|
||||
InlineImageColumn(image, maxWidth) {
|
||||
BlurredMedia(
|
||||
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
|
||||
onReveal = { revealed = true },
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = current.bitmap,
|
||||
contentDescription = image.alt.ifBlank { "Generated image" },
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
|
||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||
* !isStreaming -> VOICE // GraphicEq, primary
|
||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
||||
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||
* ```
|
||||
*/
|
||||
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
|
||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||
* reflows the bar, the overline doesn't.
|
||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
||||
* (steer/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* (correct/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* is STEER, muted otherwise. Null collapses the row.
|
||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||
@@ -191,7 +191,7 @@ fun ChatInputBar(
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Caption row — steer/queue hinting, single line, no buttons.
|
||||
// Caption row — correct/queue hinting, single line, no buttons.
|
||||
AnimatedVisibility(visible = caption != null) {
|
||||
Text(
|
||||
text = caption ?: lastCaption.orEmpty(),
|
||||
|
||||
@@ -84,12 +84,16 @@ import com.hermesandroid.relay.data.ProfilePresence
|
||||
import com.hermesandroid.relay.data.ProfilePresenceResolver
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -656,6 +660,7 @@ fun AgentInfoSheet(
|
||||
// Profile + personality state — same flows the old pickers consumed.
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val resolvedDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
|
||||
var showProfileManager by remember { mutableStateOf(false) }
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
@@ -667,7 +672,7 @@ fun AgentInfoSheet(
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
@@ -710,9 +715,12 @@ fun AgentInfoSheet(
|
||||
// Connection summary state.
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
@@ -735,6 +743,26 @@ fun AgentInfoSheet(
|
||||
val allConnections by connectionViewModel.connectionStore.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.connectionStore
|
||||
.activeConnectionId.collectAsState()
|
||||
val activeConnection = remember(allConnections, activeConnectionId) {
|
||||
allConnections.firstOrNull { it.id == activeConnectionId }
|
||||
}
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown -> if (
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
|
||||
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
apiServerReachable -> ChatTransportReadiness.Ready
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
|
||||
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
)
|
||||
|
||||
// Mid-stream gate — mirrors what ProfilePicker's `enabled` flag was doing:
|
||||
// a radio tap during an in-flight chat turn would race the request. Apply
|
||||
@@ -758,12 +786,8 @@ fun AgentInfoSheet(
|
||||
|
||||
val profileOverridesPersonality =
|
||||
selectedProfile?.systemMessage?.isNotBlank() == true
|
||||
val effectiveDisplayProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
val serverResolvedAgentName = AgentDisplay.agentName(
|
||||
profile = effectiveDisplayProfile,
|
||||
profile = resolvedDisplayProfile,
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
connectionLabel = null,
|
||||
@@ -816,7 +840,7 @@ fun AgentInfoSheet(
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
AgentSheetHeader(
|
||||
profile = effectiveDisplayProfile,
|
||||
profile = resolvedDisplayProfile,
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
localDisplayAlias = profileDisplayAlias,
|
||||
@@ -826,8 +850,7 @@ fun AgentInfoSheet(
|
||||
// default with the session provider.
|
||||
sessionModelName = selectedModelOverride ?: gatewayCurrentModel,
|
||||
modelProviderLabel = currentProviderLabel,
|
||||
apiServerReachable = apiServerReachable,
|
||||
chatMode = chatMode,
|
||||
chatRuntimeStatus = chatRuntimeStatus,
|
||||
isCustomized = selectedProfile != null ||
|
||||
selectedPersonality != "default" ||
|
||||
profileDisplayAlias != null,
|
||||
@@ -1133,8 +1156,7 @@ fun AgentInfoSheet(
|
||||
}
|
||||
|
||||
val inspectProfileText = stringResource(R.string.conn_info_inspect_profile)
|
||||
val inspectorTarget = selectedProfile
|
||||
?: serverDefaultProfile
|
||||
val inspectorTarget = resolvedDisplayProfile
|
||||
?: visibleProfileKeys
|
||||
.asSequence()
|
||||
.mapNotNull { key -> agentProfiles.firstOrNull { it.name == key } }
|
||||
@@ -1271,11 +1293,13 @@ fun AgentInfoSheet(
|
||||
// SSE fallback model list — /v1/models plus the configured profiles'
|
||||
// models (used only when the gateway model.options groups aren't
|
||||
// available, e.g. on an SSE transport).
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
||||
.distinct()
|
||||
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
|
||||
(apiModelOptions +
|
||||
agentProfiles.mapNotNull { profile ->
|
||||
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
|
||||
} +
|
||||
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
// Always show the Model picker — choosing a model is always possible
|
||||
// (Server default at minimum). While the provider/model list is still
|
||||
@@ -1344,14 +1368,14 @@ fun AgentInfoSheet(
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
primary = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model)
|
||||
toast(modelToast.format(model))
|
||||
if (selectedModelOverride != model.id) {
|
||||
chatViewModel.selectApiModel(model.id)
|
||||
toast(modelToast.format(model.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1526,10 +1550,8 @@ fun AgentInfoSheet(
|
||||
// Pre-resolve strings for Connection section
|
||||
val connectionTitle = stringResource(R.string.conn_info_connection_title)
|
||||
val switchServersHint = stringResource(R.string.conn_info_switch_servers_hint)
|
||||
val authLabel = stringResource(R.string.conn_info_auth)
|
||||
val apiReachableLabel = stringResource(R.string.conn_info_api_reachable)
|
||||
val pairedLabel = stringResource(R.string.conn_info_paired)
|
||||
val hermesLabel = stringResource(R.string.conn_info_hermes)
|
||||
val relayAuthLabel = stringResource(R.string.conn_info_relay_auth)
|
||||
val apiFallbackLabel = stringResource(R.string.active_section_optional_api_fallback)
|
||||
|
||||
SectionLabel(
|
||||
title = connectionTitle,
|
||||
@@ -1546,10 +1568,25 @@ fun AgentInfoSheet(
|
||||
val sessionTransport = sessionPathTransport(
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint),
|
||||
)
|
||||
val routeLabel = activeEndpoint?.displayLabel()
|
||||
?: com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(apiServerUrl)
|
||||
.takeIf { it.isNotBlank() }
|
||||
val routeLabel = if (sessionTransport.isGateway) {
|
||||
activeConnection?.routeCandidates
|
||||
?.firstOrNull {
|
||||
it.dashboard?.url?.trimEnd('/') == effectiveDashboardUrl.trimEnd('/')
|
||||
}
|
||||
?.displayLabel()
|
||||
?: effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
|
||||
com.hermesandroid.relay.data.Connection.endpointCandidateFromDashboardUrl(
|
||||
role = com.hermesandroid.relay.data.Connection.inferRouteRole(url),
|
||||
priority = 0,
|
||||
dashboardUrl = url,
|
||||
)?.displayLabel()
|
||||
}
|
||||
} else {
|
||||
activeEndpoint?.displayLabel()
|
||||
?: com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(effectiveApiServerUrl)
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val relayConnected = relayConnectionState == ConnectionState.Connected
|
||||
val threadsActive = connectionViewModel.proactiveEnabled.collectAsState().value &&
|
||||
connectionViewModel.authState.collectAsState().value is
|
||||
@@ -1570,7 +1607,7 @@ fun AgentInfoSheet(
|
||||
|
||||
// Multi-connection switcher. Renders inline as a radio list
|
||||
// (mirrors the Profile + Personality sections above) when the
|
||||
// user has ≥2 paired connections. Replaces the separate top-
|
||||
// user has ≥2 connections. Replaces the separate top-
|
||||
// bar ConnectionChip that used to be the only switch surface
|
||||
// — folding it here keeps all agent/connection controls in
|
||||
// one place, matching Bailey's ask in the 2026-04-20 audit.
|
||||
@@ -1583,11 +1620,10 @@ fun AgentInfoSheet(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
allConnections.forEach { connection ->
|
||||
val isActive = connection.id == activeConnectionId
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> stringResource(R.string.conn_info_hostname_hermes, hostname)
|
||||
else -> stringResource(R.string.conn_info_hostname_paired, hostname)
|
||||
else -> stringResource(R.string.conn_info_hostname_relay_paired, hostname)
|
||||
}
|
||||
ProfileRadioRow(
|
||||
primary = connection.label,
|
||||
@@ -1605,9 +1641,17 @@ fun AgentInfoSheet(
|
||||
}
|
||||
}
|
||||
|
||||
ChipRow(label = authLabel) { authStateChip(authState) }
|
||||
ChipRow(label = apiReachableLabel) {
|
||||
val (label, bg, fg) = if (apiServerReachable) {
|
||||
if (relayUrl.isNotBlank() || authState is AuthState.Paired) {
|
||||
ChipRow(label = relayAuthLabel) { authStateChip(authState) }
|
||||
}
|
||||
ChipRow(label = apiFallbackLabel) {
|
||||
val (label, bg, fg) = if (apiServerUrl.isBlank()) {
|
||||
Triple(
|
||||
stringResource(R.string.active_section_not_configured),
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (apiServerReachable) {
|
||||
Triple(
|
||||
stringResource(R.string.conn_info_yes),
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
@@ -1674,8 +1718,8 @@ fun AgentInfoSheet(
|
||||
routeLabel = routeLabel,
|
||||
relayConnectionState = relayConnectionState,
|
||||
capabilities = sessionCaps,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
apiServerUrl = effectiveApiServerUrl,
|
||||
relayUrl = effectiveRelayUrl,
|
||||
streamingEndpoint = streamingEndpoint,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
serverCapabilities = serverCapabilities,
|
||||
@@ -2307,8 +2351,7 @@ private fun AgentSheetHeader(
|
||||
serverModelName: String,
|
||||
sessionModelName: String? = null,
|
||||
modelProviderLabel: String? = null,
|
||||
apiServerReachable: Boolean,
|
||||
chatMode: ChatMode,
|
||||
chatRuntimeStatus: ChatRuntimeStatus,
|
||||
isCustomized: Boolean,
|
||||
) {
|
||||
val agentName = AgentDisplay.agentName(
|
||||
@@ -2326,17 +2369,18 @@ private fun AgentSheetHeader(
|
||||
// The global default — surfaced as a quiet caption only when THIS session
|
||||
// runs something different (the always-visible global-vs-session split).
|
||||
val serverDefaultLabel = AgentDisplay.displayModelName(serverModelName)
|
||||
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
|
||||
val isConnected = chatRuntimeStatus is ChatRuntimeStatus.Connected
|
||||
val isConnecting = chatRuntimeStatus is ChatRuntimeStatus.Connecting
|
||||
val connectedText = stringResource(R.string.conn_info_connected)
|
||||
val connectingText = stringResource(R.string.conn_info_connecting)
|
||||
val disconnectedText = stringResource(R.string.conn_info_disconnected)
|
||||
val statusText = when {
|
||||
apiServerReachable -> connectedText
|
||||
isConnected -> connectedText
|
||||
isConnecting -> connectingText
|
||||
else -> disconnectedText
|
||||
}
|
||||
val statusColor = when {
|
||||
apiServerReachable -> MaterialTheme.colorScheme.primary
|
||||
isConnected -> MaterialTheme.colorScheme.primary
|
||||
isConnecting -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class ConnectionSetupTimelineStep(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
/** Compact completed-state timeline shared by connection and auth flows. */
|
||||
@Composable
|
||||
fun ConnectionSetupTimeline(
|
||||
steps: List<ConnectionSetupTimelineStep>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
steps.forEachIndexed { index, step ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
if (index != steps.lastIndex) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(width = 2.dp, height = 34.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = if (index == steps.lastIndex) 0.dp else 10.dp),
|
||||
) {
|
||||
Text(step.title, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
step.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -44,8 +47,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
@@ -55,9 +60,12 @@ import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.data.isTlsUrl
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
@@ -72,9 +80,9 @@ import kotlinx.coroutines.launch
|
||||
* on app start, tried first on every resolve; cleared by
|
||||
* [onClearPreferred].
|
||||
*
|
||||
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
|
||||
* do NOT collapse into a master row. The card itself is wrapped in a
|
||||
* [SettingsExpandableCard] by the caller for page layout hygiene.
|
||||
* Each route stays visible, while its Dashboard/API/Relay topology uses
|
||||
* progressive disclosure: the active route opens by default and fallback
|
||||
* routes keep a compact surface-and-port summary.
|
||||
*
|
||||
* Not visible on legacy installs: when [endpoints] is empty we render a
|
||||
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
||||
@@ -106,6 +114,9 @@ fun EndpointsCard(
|
||||
* resolver's cache-key scheme.
|
||||
*/
|
||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
||||
/** Auth state applies only to the currently active Dashboard route. */
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
/**
|
||||
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||
@@ -170,11 +181,17 @@ fun EndpointsCard(
|
||||
candidate = candidate,
|
||||
isActive = activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
isProbing = isProbing,
|
||||
outcome = outcomeFor(candidate),
|
||||
dashboardAuthenticated = dashboardAuthenticated.takeIf { activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority()
|
||||
},
|
||||
dashboardSignInRequired = dashboardSignInRequired && activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
|
||||
onUseNow = { onUseNow(candidate) },
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onClearPrefer = onClearPreferred,
|
||||
@@ -219,6 +236,8 @@ private fun EndpointRow(
|
||||
isPreferred: Boolean,
|
||||
isProbing: Boolean = false,
|
||||
outcome: RouteProbeOutcome? = null,
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
onUseNow: () -> Unit,
|
||||
onPrefer: () -> Unit,
|
||||
onClearPrefer: () -> Unit,
|
||||
@@ -230,6 +249,7 @@ private fun EndpointRow(
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
|
||||
|
||||
@@ -278,17 +298,6 @@ private fun EndpointRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Full URL, scheme included: http vs https decides whether
|
||||
// the health probe TLS-handshakes, so two rows that both
|
||||
// read "host:8642" can behave completely differently. The
|
||||
// scheme must be visible to be debuggable.
|
||||
Text(
|
||||
text = candidate.api.url +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = stringResource(R.string.endpoints_checking),
|
||||
@@ -307,6 +316,47 @@ private fun EndpointRow(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
val dashboardSurfaceUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val dashboardLabel = stringResource(R.string.active_section_dashboard)
|
||||
val apiLabel = stringResource(R.string.active_section_api_server)
|
||||
val relayLabel = stringResource(R.string.active_section_relay)
|
||||
val surfaceSummary = listOfNotNull(
|
||||
dashboardSurfaceUrl?.let { "$dashboardLabel ${displayPort(it)}" },
|
||||
candidate.api?.url?.let { "$apiLabel ${displayPort(it)}" },
|
||||
candidate.relay?.url?.let { "$relayLabel ${displayPort(it)}" },
|
||||
).joinToString(" · ")
|
||||
if (surfaceSummary.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { detailsExpanded = !detailsExpanded }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = surfaceSummary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (detailsExpanded) {
|
||||
Icons.Filled.ExpandLess
|
||||
} else {
|
||||
Icons.Filled.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
@@ -392,6 +442,15 @@ private fun EndpointRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (detailsExpanded) {
|
||||
RouteSurfaceMap(
|
||||
candidate = candidate,
|
||||
dashboardAuthenticated = dashboardAuthenticated,
|
||||
dashboardSignInRequired = dashboardSignInRequired,
|
||||
modifier = Modifier.padding(start = 26.dp, end = 4.dp, top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove && onRemove != null) {
|
||||
@@ -400,7 +459,7 @@ private fun EndpointRow(
|
||||
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
|
||||
text = stringResource(R.string.endpoints_remove_route_body, candidate.routeAuthority().orEmpty()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
@@ -421,7 +480,7 @@ private fun EndpointRow(
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
|
||||
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.routeAuthority().orEmpty())) },
|
||||
text = {
|
||||
Text(
|
||||
text = body,
|
||||
@@ -436,6 +495,106 @@ private fun EndpointRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteSurfaceMap(
|
||||
candidate: EndpointCandidate,
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dashboardUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val apiUrl = candidate.api?.url
|
||||
val relayUrl = candidate.relay?.url
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_dashboard),
|
||||
url = dashboardUrl,
|
||||
status = when {
|
||||
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
|
||||
dashboardAuthenticated == true -> stringResource(R.string.active_section_signed_in)
|
||||
dashboardUrl != null -> stringResource(R.string.active_section_configured)
|
||||
else -> stringResource(R.string.active_section_not_configured)
|
||||
},
|
||||
warning = dashboardSignInRequired,
|
||||
)
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_api_server),
|
||||
url = apiUrl,
|
||||
status = stringResource(
|
||||
if (apiUrl != null) R.string.active_section_configured
|
||||
else R.string.active_section_not_configured,
|
||||
),
|
||||
)
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
url = relayUrl,
|
||||
status = stringResource(
|
||||
if (relayUrl != null) R.string.active_section_configured
|
||||
else R.string.active_section_not_configured,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteSurfaceRow(
|
||||
label: String,
|
||||
url: String?,
|
||||
status: String,
|
||||
warning: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = url ?: "—",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (warning) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayPort(url: String): String {
|
||||
val uri = runCatching { URI(url) }.getOrNull()
|
||||
val port = uri?.port?.takeIf { it > 0 } ?: when (uri?.scheme?.lowercase()) {
|
||||
"https", "wss" -> 443
|
||||
else -> 80
|
||||
}
|
||||
return ":$port"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveChip(label: String) {
|
||||
Row(
|
||||
@@ -521,7 +680,7 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
* be classified independently before it's the active route.
|
||||
*/
|
||||
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
|
||||
isTlsUrl(primaryRouteUrl().orEmpty()) -> SurfaceSecurityKind.Tls
|
||||
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
|
||||
else -> SurfaceSecurityKind.Plain
|
||||
}
|
||||
@@ -531,21 +690,24 @@ private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||
*
|
||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
||||
* The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
|
||||
* fallback uses `:8642`, and an already-enabled Relay uses `:8767`.
|
||||
* Routes that need custom per-surface hosts or ports still come from a QR.
|
||||
*
|
||||
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||
* URL, keeps the stored priority).
|
||||
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
|
||||
* @param onSave invoked with (role, dashboardUrl, resultCallback); the callback
|
||||
* receives a user-facing error string to render inline, or null on
|
||||
* success (the dialog then closes itself).
|
||||
*/
|
||||
@Composable
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
relayEnabled: Boolean = false,
|
||||
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val knownRoles = listOf("tailscale", "public")
|
||||
var selectedRole by remember {
|
||||
mutableStateOf(
|
||||
@@ -561,7 +723,7 @@ fun RouteEditorDialog(
|
||||
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
|
||||
)
|
||||
}
|
||||
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
|
||||
var url by remember(original) { mutableStateOf(original?.primaryRouteUrl().orEmpty()) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -615,13 +777,20 @@ fun RouteEditorDialog(
|
||||
// Live preview of what will actually be saved — scheme and
|
||||
// port defaults applied — so "what, which port, http or
|
||||
// https?" is answered before Save, not after a failed probe.
|
||||
val previewCandidate = remember(url, effectiveRole) {
|
||||
val previewCandidate = remember(url, effectiveRole, relayEnabled) {
|
||||
url.takeIf { it.isNotBlank() }?.let {
|
||||
Connection.endpointCandidateFromApiUrl(
|
||||
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
|
||||
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
apiServerUrl = Connection.normalizeApiUrlInput(it),
|
||||
relayUrl = "",
|
||||
dashboardUrl = dashboardUrl,
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = if (relayEnabled) {
|
||||
apiUrl?.let(Connection::deriveDefaultRelayUrl)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -643,7 +812,7 @@ fun RouteEditorDialog(
|
||||
url.isBlank() ->
|
||||
supportingBlank
|
||||
previewCandidate != null ->
|
||||
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
|
||||
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.primaryRouteUrl().orEmpty())
|
||||
else ->
|
||||
supportingEnter
|
||||
},
|
||||
@@ -652,6 +821,21 @@ fun RouteEditorDialog(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
previewCandidate?.let { candidate ->
|
||||
RouteSurfaceMap(candidate = candidate)
|
||||
}
|
||||
if (selectedRole == "tailscale") {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_tailscale_setup_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { uriHandler.openUri(REMOTE_ACCESS_DOCS_URL) },
|
||||
) {
|
||||
Text(stringResource(R.string.endpoints_setup_help))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -683,4 +867,7 @@ fun RouteEditorDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private const val REMOTE_ACCESS_DOCS_URL =
|
||||
"https://hermes-relay.dev/docs/guide/remote-access"
|
||||
|
||||
private const val CUSTOM_ROLE = "__custom__"
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val IMAGE_GENERATION_TOOL = "image_generate"
|
||||
private const val GRID_COLUMNS = 42
|
||||
private const val GRID_ROWS = 24
|
||||
|
||||
internal fun ToolCall.showsImageGenerationPlaceholder(): Boolean =
|
||||
!isComplete && name.trim().lowercase() == IMAGE_GENERATION_TOOL
|
||||
|
||||
/**
|
||||
* Keep the image canvas alive across the short tool-complete → media-arrival
|
||||
* handoff. Once a result surface exists it can crossfade into the same bubble;
|
||||
* a failed or fully-finished turn never leaves a stale canvas behind.
|
||||
*/
|
||||
internal fun shouldShowImageGenerationPlaceholder(
|
||||
toolCalls: List<ToolCall>,
|
||||
isStreaming: Boolean,
|
||||
hasMediaResult: Boolean,
|
||||
): Boolean {
|
||||
val imageCalls = toolCalls.filter {
|
||||
it.name.trim().lowercase() == IMAGE_GENERATION_TOOL
|
||||
}
|
||||
if (imageCalls.any { !it.isComplete }) return true
|
||||
return !hasMediaResult &&
|
||||
isStreaming &&
|
||||
imageCalls.any { it.isComplete && it.success != false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Image generation is a user-visible result lifecycle, not generic tool
|
||||
* diagnostics. Keep its active canvas visible even when upstream
|
||||
* `display.tool_progress` hides ordinary tool cards.
|
||||
*/
|
||||
internal fun ToolCall.isVisibleForToolDisplay(toolDisplay: String): Boolean =
|
||||
toolDisplay != "off" || showsImageGenerationPlaceholder()
|
||||
|
||||
/**
|
||||
* Theme-aware latent diffusion preview for an active Hermes image-generation
|
||||
* tool. It specializes the generic tool lifecycle already emitted by vanilla
|
||||
* Hermes; no Relay-only protocol or server patch is required.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageGenerationPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(R.string.image_generation_rendering)
|
||||
val transition = rememberInfiniteTransition(label = "imageGenerationDiffusion")
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 4_800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "imageGenerationDiffusionPhase",
|
||||
)
|
||||
val background = MaterialTheme.colorScheme.surfaceVariant
|
||||
val foreground = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val tertiary = MaterialTheme.colorScheme.tertiary
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(background)
|
||||
.semantics {
|
||||
contentDescription = description
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
},
|
||||
) {
|
||||
DiffusionCanvas(
|
||||
phase = phase,
|
||||
background = background,
|
||||
foreground = foreground,
|
||||
primary = primary,
|
||||
tertiary = tertiary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffusionCanvas(
|
||||
phase: Float,
|
||||
background: Color,
|
||||
foreground: Color,
|
||||
primary: Color,
|
||||
tertiary: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
drawRect(background)
|
||||
val cellWidth = size.width / GRID_COLUMNS
|
||||
val cellHeight = size.height / GRID_ROWS
|
||||
val denoise = diffusionDenoise(phase)
|
||||
val time = phase * 9f
|
||||
|
||||
repeat(GRID_ROWS) { row ->
|
||||
repeat(GRID_COLUMNS) { column ->
|
||||
val signal = diffusionSignal(column, row, time, denoise)
|
||||
if (signal < 0.2f) return@repeat
|
||||
|
||||
val x = column * cellWidth + cellWidth * 0.5f
|
||||
val y = row * cellHeight + cellHeight * 0.5f
|
||||
val radius = (cellWidth.coerceAtMost(cellHeight) * (0.14f + signal * 0.24f))
|
||||
.coerceAtLeast(0.7.dp.toPx())
|
||||
val warmMix = ((signal - 0.35f) / 0.65f).coerceIn(0f, 1f)
|
||||
val base = lerpColor(foreground, primary, warmMix)
|
||||
val color = lerpColor(base, tertiary, hash01(column + 17, row - 11) * 0.32f)
|
||||
|
||||
drawRoundRect(
|
||||
color = color.copy(alpha = (0.08f + signal * 0.76f).coerceAtMost(0.84f)),
|
||||
topLeft = Offset(x - radius, y - radius),
|
||||
size = Size(radius * 2f, radius * 2f),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius * 0.45f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffusionDenoise(phase: Float): Float {
|
||||
val normalized = phase - floor(phase)
|
||||
return if (normalized < 0.82f) {
|
||||
smoothstep(0.02f, 0.82f, normalized)
|
||||
} else {
|
||||
1f - smoothstep(0.82f, 1f, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffusionSignal(column: Int, row: Int, time: Float, denoise: Float): Float {
|
||||
val nx = (column + 0.5f) / GRID_COLUMNS - 0.52f
|
||||
val ny = (row + 0.5f) / GRID_ROWS - 0.5f
|
||||
val radius = kotlin.math.sqrt(nx * nx * 1.35f + ny * ny)
|
||||
val bloom = (1f - radius * 2.35f).coerceIn(0f, 1f)
|
||||
val ring = (1f - abs(radius - (0.23f + sin(time * 0.44f) * 0.025f)) * 15f)
|
||||
.coerceIn(0f, 1f)
|
||||
val latent = (bloom * 0.75f + ring * 0.5f).coerceIn(0f, 1f)
|
||||
val staticNoise = hash01(column + floor(time * 3f).toInt() * 19, row - floor(time * 3f).toInt() * 11)
|
||||
val livingNoise = hash01(column + floor(time * 7f).toInt(), row + floor(time * 5f).toInt())
|
||||
return (
|
||||
staticNoise * (1f - denoise) +
|
||||
latent * denoise +
|
||||
(livingNoise - 0.5f) * (0.42f - denoise * 0.2f)
|
||||
).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun hash01(x: Int, y: Int): Float {
|
||||
val value = sin(x * 127.1 + y * 311.7) * 43_758.5453
|
||||
return (value - floor(value)).toFloat()
|
||||
}
|
||||
|
||||
private fun smoothstep(edge0: Float, edge1: Float, value: Float): Float {
|
||||
val t = ((value - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
|
||||
return t * t * (3f - 2f * t)
|
||||
}
|
||||
|
||||
private fun lerpColor(from: Color, to: Color, amount: Float): Color = Color(
|
||||
red = from.red + (to.red - from.red) * amount,
|
||||
green = from.green + (to.green - from.green) * amount,
|
||||
blue = from.blue + (to.blue - from.blue) * amount,
|
||||
alpha = from.alpha + (to.alpha - from.alpha) * amount,
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import java.util.Base64
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
|
||||
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
|
||||
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
|
||||
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
|
||||
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
|
||||
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
private const val MAX_HEADER_CHARS = 64
|
||||
private val inlineImageDecodeMutex = Mutex()
|
||||
private val DATA_IMAGE_HEADER = Regex(
|
||||
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
internal data class DecodedInlineImageData(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Strict decoder for upstream API-server inline images.
|
||||
*
|
||||
* Only the five raster formats upstream emits for this path are accepted. The
|
||||
* encoded input is bounded before allocation, strict base64 is required, and
|
||||
* the decoded magic bytes must agree with the declared MIME type.
|
||||
*/
|
||||
internal fun decodeInlineImageDataUrl(
|
||||
source: String,
|
||||
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
|
||||
): DecodedInlineImageData? {
|
||||
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
|
||||
val comma = source.indexOf(',')
|
||||
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
|
||||
?.groupValues?.get(1)?.lowercase() ?: return null
|
||||
val encoded = source.substring(comma + 1)
|
||||
if (encoded.isEmpty()) return null
|
||||
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
|
||||
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
|
||||
if (encoded.length.toLong() > maxEncoded) return null
|
||||
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
|
||||
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
|
||||
return DecodedInlineImageData(bytes, mime)
|
||||
}
|
||||
|
||||
/** Conservative decoded-size estimate that does not allocate a byte array. */
|
||||
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
|
||||
if (!source.startsWith("data:image/", ignoreCase = true)) return null
|
||||
val comma = source.indexOf(',')
|
||||
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||
val encodedLength = source.length.toLong() - comma - 1L
|
||||
if (encodedLength <= 0) return null
|
||||
val padding = when {
|
||||
source.endsWith("==") -> 2L
|
||||
source.endsWith('=') -> 1L
|
||||
else -> 0L
|
||||
}
|
||||
return ((encodedLength + 3L) / 4L) * 3L - padding
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
|
||||
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
|
||||
* allocations from overlapping.
|
||||
*/
|
||||
internal suspend fun <T> withInlineImageDecodeLock(
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
block: () -> T,
|
||||
): T = withContext(dispatcher) {
|
||||
inlineImageDecodeMutex.withLock { block() }
|
||||
}
|
||||
|
||||
internal suspend fun decodeInlineImageDataUrlOffMain(
|
||||
source: String,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
|
||||
decodeInlineImageDataUrl(source)
|
||||
}
|
||||
|
||||
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
|
||||
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
)
|
||||
"image/jpeg" -> bytes.size >= 3 &&
|
||||
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
|
||||
"image/webp" -> bytes.size >= 12 &&
|
||||
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
|
||||
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
|
||||
"image/gif" -> bytes.size >= 6 &&
|
||||
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
|
||||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
|
||||
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
|
||||
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
|
||||
*/
|
||||
internal fun inlineImageSampleSize(
|
||||
width: Int,
|
||||
height: Int,
|
||||
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
|
||||
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
|
||||
): Int? {
|
||||
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
|
||||
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
|
||||
) return null
|
||||
var sample = 1
|
||||
while (true) {
|
||||
val sampledWidth = (width + sample - 1L) / sample
|
||||
val sampledHeight = (height + sample - 1L) / sample
|
||||
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
|
||||
sampledWidth <= maxPixels / sampledHeight
|
||||
) return sample
|
||||
if (sample >= 1 shl 15) return null
|
||||
sample *= 2
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTable
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||
@@ -152,7 +150,7 @@ fun MarkdownContent(
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
SafeMarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
@@ -160,7 +158,7 @@ fun MarkdownContent(
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownHighlightedCodeFence(
|
||||
SafeMarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -205,6 +206,11 @@ fun MessageBubble(
|
||||
extractChatInlineImages(message.content)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -309,6 +315,7 @@ fun MessageBubble(
|
||||
val showBubble = isUser || isSystem ||
|
||||
message.content.isNotBlank() ||
|
||||
message.isStreaming ||
|
||||
showImageGeneration ||
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
@@ -478,32 +485,41 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments — two or more loaded images collapse into one
|
||||
// grid + swipe-across gallery. Every other item stays on the
|
||||
// unified InboundAttachmentCard path, and layout items retain
|
||||
// their original ChatMessage.attachments indices so retry /
|
||||
// manual-fetch callbacks cannot drift after grouping.
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = remember(message.attachments) {
|
||||
attachmentLayoutItems(message.attachments)
|
||||
}
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
// Image generation owns the bubble's progress slot. It replaces
|
||||
// the generic first-token dots, remains mounted through the
|
||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
||||
// attachment renderer in this same Surface.
|
||||
Crossfade(
|
||||
targetState = showImageGeneration,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "imageGenerationToResult",
|
||||
) { generating ->
|
||||
if (generating) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationPlaceholder()
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,7 +530,11 @@ fun MessageBubble(
|
||||
// signal, so the pulsing dots stop (Messenger/Telegram drop the
|
||||
// typing bubble the moment content appears) instead of throbbing
|
||||
// under the text for the whole turn.
|
||||
if (message.isStreaming && message.content.isBlank()) {
|
||||
if (
|
||||
message.isStreaming &&
|
||||
message.content.isBlank() &&
|
||||
!showImageGeneration
|
||||
) {
|
||||
// After a few seconds with no content yet, escalate the bare
|
||||
// dots to a labeled "Still working…" so a slow first token
|
||||
// never reads as a hang on the SSE / sessions paths.
|
||||
|
||||
@@ -219,6 +219,16 @@ data class RelayPairing(
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/** True only when a scoped Relay scan can immediately begin authentication. */
|
||||
internal fun HermesPairingPayload.hasUsableRelayPairing(): Boolean {
|
||||
val relay = relay ?: return false
|
||||
if (relay.code.isBlank()) return false
|
||||
val uri = runCatching { URI(relay.url.trim()) }.getOrNull() ?: return false
|
||||
val supportedScheme = uri.scheme.equals("ws", ignoreCase = true) ||
|
||||
uri.scheme.equals("wss", ignoreCase = true)
|
||||
return supportedScheme && !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -258,8 +268,8 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
// Quick check: must contain a `host` field and be valid JSON. We no
|
||||
// longer reject based on the `hermes` version int — future v4+ QRs
|
||||
// should still parse on this phone so Bailey doesn't have to ship a
|
||||
// whole release to keep up with wire-format growth.
|
||||
// should still parse so wire-format growth does not require an app
|
||||
// release for every compatible payload version.
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
|
||||
if (version < 1) return null
|
||||
@@ -532,7 +542,8 @@ private fun mapBoxToViewport(
|
||||
@Composable
|
||||
fun QrPairingScanner(
|
||||
onPairingDetected: (HermesPairingPayload) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
relayOnly: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -628,7 +639,10 @@ fun QrPairingScanner(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.qr_scanner_title),
|
||||
text = stringResource(
|
||||
if (relayOnly) R.string.qr_scanner_relay_title
|
||||
else R.string.qr_scanner_title,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
@@ -740,7 +754,11 @@ fun QrPairingScanner(
|
||||
) {
|
||||
val rawValue = barcode.rawValue ?: continue
|
||||
val payload = parseHermesPairingQr(rawValue)
|
||||
if (payload != null && hasDetected.compareAndSet(false, true)) {
|
||||
if (
|
||||
payload != null &&
|
||||
(!relayOnly || payload.hasUsableRelayPairing()) &&
|
||||
hasDetected.compareAndSet(false, true)
|
||||
) {
|
||||
lockedPayload = payload
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
@@ -798,13 +816,19 @@ fun QrPairingScanner(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.qr_scanner_instruction),
|
||||
text = stringResource(
|
||||
if (relayOnly) R.string.qr_scanner_relay_instruction
|
||||
else R.string.qr_scanner_instruction,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.qr_scanner_subtext),
|
||||
text = stringResource(
|
||||
if (relayOnly) R.string.qr_scanner_relay_subtext
|
||||
else R.string.qr_scanner_subtext,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBackground
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
|
||||
@Composable
|
||||
internal fun SafeMarkdownHighlightedCodeFence(
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
style: TextStyle = LocalMarkdownTypography.current.code,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean = false,
|
||||
) {
|
||||
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
|
||||
SafeMarkdownHighlightedCode(
|
||||
code = code,
|
||||
language = language,
|
||||
style = codeStyle,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SafeMarkdownHighlightedCodeBlock(
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
style: TextStyle = LocalMarkdownTypography.current.code,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean = false,
|
||||
) {
|
||||
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
|
||||
SafeMarkdownHighlightedCode(
|
||||
code = code,
|
||||
language = language,
|
||||
style = codeStyle,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafeMarkdownHighlightedCode(
|
||||
code: String,
|
||||
language: String?,
|
||||
style: TextStyle,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean,
|
||||
) {
|
||||
val codeHighlights by produceState(
|
||||
initialValue = AnnotatedString(code),
|
||||
key1 = code,
|
||||
key2 = language,
|
||||
key3 = highlightsBuilder,
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
buildSafeHighlightedAnnotatedString(code, language, highlightsBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
val codeBackgroundCornerSize = LocalMarkdownDimens.current.codeBackgroundCornerSize
|
||||
MarkdownCodeBackground(
|
||||
color = LocalMarkdownColors.current.codeBackground,
|
||||
shape = RoundedCornerShape(codeBackgroundCornerSize),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
showHeader = showHeader,
|
||||
language = language,
|
||||
code = code,
|
||||
) {
|
||||
MarkdownBasicText(
|
||||
text = codeHighlights,
|
||||
style = style,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(LocalMarkdownPadding.current.codeBlock),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Highlights ranges into Compose spans without trusting dependency
|
||||
* offsets. Highlights 1.1.0 can return a reversed multiline-comment range
|
||||
* when a multiline-comment closing delimiter precedes its opening delimiter.
|
||||
* Streaming can expose that shape before a code block is complete.
|
||||
*/
|
||||
internal fun buildSafeHighlightedAnnotatedString(
|
||||
code: String,
|
||||
language: String?,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
): AnnotatedString {
|
||||
val syntaxLanguage = language?.let(SyntaxLanguage::getByName)
|
||||
val highlights = highlightsBuilder
|
||||
.code(code)
|
||||
.let { if (syntaxLanguage != null) it.language(syntaxLanguage) else it }
|
||||
.build()
|
||||
.getHighlights()
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
highlights.forEach { highlight ->
|
||||
val start = highlight.location.start.coerceIn(0, code.length)
|
||||
val end = highlight.location.end.coerceIn(0, code.length)
|
||||
if (start >= end) return@forEach
|
||||
|
||||
val style = when (highlight) {
|
||||
is ColorHighlight -> SpanStyle(color = Color(highlight.rgb).copy(alpha = 1f))
|
||||
is BoldHighlight -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
}
|
||||
addStyle(style = style, start = start, end = end)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,15 @@ data class SourceBadge(val label: String, val color: Color)
|
||||
* The app's own chats — no badge (they're "your" conversations from this app /
|
||||
* desktop / API, not a distinct gateway lane).
|
||||
*/
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "")
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "webui", "")
|
||||
|
||||
/**
|
||||
* Map a session `source` to a drawer badge, or null for the app's own chats and
|
||||
* the phone **Thread** (which renders its own thread-spool chip). External
|
||||
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
|
||||
* small colored chip so the drawer reads like the desktop's per-channel tags.
|
||||
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron,
|
||||
* webhook, phone.
|
||||
* Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
|
||||
* cron, webhook, phone.
|
||||
*/
|
||||
fun sourceBadge(source: String?): SourceBadge? {
|
||||
val s = source?.trim()?.lowercase() ?: return null
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
@@ -140,6 +141,8 @@ fun VoiceWaveform(
|
||||
amplitude: Float,
|
||||
state: VoiceState,
|
||||
outputAudioActive: Boolean = false,
|
||||
height: Dp = 56.dp,
|
||||
compactBars: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// No downstream smoothing. The VoiceViewModel already runs an
|
||||
@@ -154,6 +157,7 @@ fun VoiceWaveform(
|
||||
// doesn't look abrupt.
|
||||
val dim = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val compactColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val targetPrimary = when (state) {
|
||||
VoiceState.Idle -> dim.copy(alpha = 0.3f)
|
||||
@@ -198,16 +202,38 @@ fun VoiceWaveform(
|
||||
)
|
||||
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
val canvasModifier = if (compactBars) {
|
||||
modifier.height(height)
|
||||
} else {
|
||||
modifier.fillMaxWidth().height(height)
|
||||
}
|
||||
Canvas(modifier = canvasModifier) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
if (width <= 0f || height <= 0f) return@Canvas
|
||||
|
||||
val centerY = height / 2f
|
||||
|
||||
if (compactBars) {
|
||||
val barCount = 10
|
||||
val gap = 3.dp.toPx()
|
||||
val barWidth = 2.5.dp.toPx()
|
||||
val totalWidth = barWidth * barCount + gap * (barCount - 1)
|
||||
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
|
||||
repeat(barCount) { index ->
|
||||
val wave = ((sin(phases[0] + index * 0.82f) + 1f) * 0.5f)
|
||||
val activeHeight = height * (0.22f + wave * (0.28f + displayAmplitude * 0.5f))
|
||||
val x = startX + index * (barWidth + gap)
|
||||
drawRoundRect(
|
||||
color = compactColor.copy(alpha = if (index < 5) 0.96f else 0.22f),
|
||||
topLeft = Offset(x, centerY - activeHeight / 2f),
|
||||
size = Size(barWidth, activeHeight),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
|
||||
)
|
||||
}
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
val peakPixels = height * PEAK_FRACTION
|
||||
val strokePx = STROKE_WIDTH_DP.dp.toPx()
|
||||
val centerX = width / 2f
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -20,23 +19,21 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
|
||||
@Composable
|
||||
fun OnboardingPage(
|
||||
@@ -44,7 +41,6 @@ fun OnboardingPage(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
transparentHero: Boolean = false,
|
||||
heroContent: @Composable BoxScope.() -> Unit = {
|
||||
FeatureHero(
|
||||
icon = icon,
|
||||
@@ -53,101 +49,44 @@ fun OnboardingPage(
|
||||
},
|
||||
content: @Composable ColumnScope.() -> Unit = {}
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val heroShape = RoundedCornerShape(30.dp)
|
||||
val bodyShape = RoundedCornerShape(26.dp)
|
||||
val heroBrush = Brush.radialGradient(
|
||||
colors = if (isDarkTheme) {
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f),
|
||||
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f),
|
||||
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.70f),
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
|
||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.60f),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Short viewports (small phones, large font scale, split screen) shrink or
|
||||
// drop the hero so the body text fits; the vertical scroll below is the
|
||||
// safety net when even that isn't enough. The enclosing pager Box centers
|
||||
// short content, so no Arrangement.Center here — it conflicts with
|
||||
// verticalScroll when content overflows.
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val heroHeight = when {
|
||||
maxHeight < 480.dp -> 0.dp
|
||||
maxHeight < 620.dp -> 160.dp
|
||||
else -> 232.dp
|
||||
}
|
||||
Column(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 42.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.height(210.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (heroHeight > 0.dp) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
|
||||
shape = bodyShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
heroContent()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 40.sp,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 25.sp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,26 +95,45 @@ private fun FeatureHero(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(136.dp)
|
||||
.size(176.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f)
|
||||
Color(0xFF7B55F6).copy(alpha = 0.07f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(74.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF7B55F6).copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(86.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f),
|
||||
),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(46.dp),
|
||||
tint = Color(0xFF7B55F6),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -18,19 +17,33 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -45,28 +58,64 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Page identifiers for dynamic onboarding flow. */
|
||||
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
|
||||
/** Page identifiers for the standard-first onboarding flow. */
|
||||
internal enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect, Permissions }
|
||||
|
||||
internal val standardOnboardingPages = listOf(
|
||||
OnboardingPage.Welcome,
|
||||
OnboardingPage.Chat,
|
||||
OnboardingPage.Manage,
|
||||
OnboardingPage.Power,
|
||||
OnboardingPage.Connect,
|
||||
OnboardingPage.Permissions,
|
||||
)
|
||||
|
||||
internal enum class OnboardingNotificationAction {
|
||||
RequestPermission,
|
||||
Finish,
|
||||
}
|
||||
|
||||
internal fun onboardingNotificationAction(
|
||||
sdkInt: Int,
|
||||
notificationsPermitted: Boolean,
|
||||
): OnboardingNotificationAction {
|
||||
return if (
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
!notificationsPermitted
|
||||
) {
|
||||
OnboardingNotificationAction.RequestPermission
|
||||
} else {
|
||||
OnboardingNotificationAction.Finish
|
||||
}
|
||||
}
|
||||
|
||||
private val OnboardingAccent = Color(0xFF7B55F6)
|
||||
|
||||
/**
|
||||
* Standard-first onboarding:
|
||||
@@ -112,21 +161,23 @@ fun OnboardingScreen(
|
||||
*/
|
||||
onTryDemo: () -> Unit = {},
|
||||
) {
|
||||
val pages = remember {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
add(OnboardingPage.Manage)
|
||||
add(OnboardingPage.Power)
|
||||
add(OnboardingPage.Connect)
|
||||
}
|
||||
}
|
||||
val pages = standardOnboardingPages
|
||||
val pageCount = pages.size
|
||||
val lastPage = pageCount - 1
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
var notificationsPermitted by remember {
|
||||
mutableStateOf(AppPermissionStatusProbe.snapshot(context).notificationsPermitted)
|
||||
}
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
notificationsPermitted =
|
||||
granted || AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -156,22 +207,35 @@ fun OnboardingScreen(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Top bar with Skip — only on informational pages, not the wizard
|
||||
// (which has its own "Skip for now" affordance).
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
val currentPage = pagerState.currentPage
|
||||
val currentPageType = pages[currentPage]
|
||||
|
||||
// The selected welcome frame has no toolbar. Later information
|
||||
// pages keep quiet navigation without reserving space above it.
|
||||
if (
|
||||
currentPageType == OnboardingPage.Chat ||
|
||||
currentPageType == OnboardingPage.Manage ||
|
||||
currentPageType == OnboardingPage.Power
|
||||
) {
|
||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(currentPage - 1) }
|
||||
},
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_back))
|
||||
}
|
||||
TextButton(onClick = { showSkipConfirm = true }) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_skip),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(stringResource(R.string.onboarding_skip))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +243,13 @@ fun OnboardingScreen(
|
||||
// Pager content
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
// Connect and permission setup advance only through their
|
||||
// explicit actions. This keeps the post-connect setup page
|
||||
// unreachable until the wizard reports a successful save.
|
||||
userScrollEnabled =
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions,
|
||||
) { pageIndex ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -194,243 +264,332 @@ fun OnboardingScreen(
|
||||
)
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onComplete = {
|
||||
notificationsPermitted =
|
||||
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
pages.indexOf(OnboardingPage.Permissions),
|
||||
)
|
||||
}
|
||||
},
|
||||
onManageSignIn = onManageSignIn,
|
||||
onSkip = { showSkipConfirm = true },
|
||||
onTryDemo = onTryDemo,
|
||||
)
|
||||
OnboardingPage.Permissions -> PermissionSetupPage(
|
||||
notificationAction = onboardingNotificationAction(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
notificationsPermitted = notificationsPermitted,
|
||||
),
|
||||
onEnableNotifications = {
|
||||
notificationPermissionLauncher.launch(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
},
|
||||
onReviewPermissions = onOpenPermissions,
|
||||
onFinish = onComplete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom navigation only on informational pages — the wizard
|
||||
// owns its own back/pair affordances.
|
||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
||||
// Short viewports get a tighter footer so more of the pager
|
||||
// content stays above the fold; indicator + Back/Next remain
|
||||
// pinned outside the (scrollable) pager pages either way.
|
||||
if (
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions
|
||||
) {
|
||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.padding(horizontal = 42.dp)
|
||||
.padding(bottom = if (compactHeight) 10.dp else 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(if (compactHeight) 8.dp else 12.dp),
|
||||
) {
|
||||
PageIndicator(
|
||||
pageCount = pageCount,
|
||||
currentPage = pagerState.currentPage
|
||||
GradientOnboardingButton(
|
||||
label = when {
|
||||
currentPage == 0 -> stringResource(R.string.onboarding_get_started)
|
||||
currentPage == lastPage - 1 -> stringResource(R.string.onboarding_connect)
|
||||
else -> stringResource(R.string.onboarding_next)
|
||||
},
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage((currentPage + 1).coerceAtMost(lastPage))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = pagerState.currentPage > 0,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage - 1)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.onboarding_back))
|
||||
}
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
(pagerState.currentPage + 1).coerceAtMost(lastPage)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (currentPage == 0) {
|
||||
TextButton(onClick = onTryDemo) {
|
||||
Text(
|
||||
text = if (pagerState.currentPage == lastPage - 1) {
|
||||
stringResource(R.string.onboarding_connect)
|
||||
} else {
|
||||
stringResource(R.string.onboarding_next)
|
||||
}
|
||||
text = stringResource(R.string.chat_try_demo),
|
||||
color = OnboardingAccent,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SegmentedOnboardingProgress(
|
||||
pageCount = pageCount,
|
||||
currentPage = currentPage,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_step_count, currentPage + 1, pageCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GradientOnboardingButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Spacer(Modifier.width(24.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SegmentedOnboardingProgress(
|
||||
pageCount: Int,
|
||||
currentPage: Int,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 36.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repeat(pageCount) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(
|
||||
if (index == currentPage) {
|
||||
OnboardingAccent
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomePage() {
|
||||
val context = LocalContext.current
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
title = stringResource(R.string.onboarding_welcome_title),
|
||||
description = stringResource(R.string.onboarding_welcome_description),
|
||||
transparentHero = true,
|
||||
heroContent = {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = SphereState.Idle,
|
||||
intensity = 0.12f,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.80f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_welcome_badge),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
|
||||
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.onboarding_hermes_logo),
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(380.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = stringResource(R.string.onboarding_chat_manage_label),
|
||||
description = stringResource(R.string.onboarding_chat_manage_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = stringResource(R.string.onboarding_power_tools_label),
|
||||
description = stringResource(R.string.onboarding_power_tools_description),
|
||||
Image(
|
||||
painter = painterResource(R.drawable.onboarding_hero_option1),
|
||||
contentDescription = stringResource(R.string.onboarding_hermes_logo),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_setup_guide_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = buildAnnotatedString {
|
||||
append(titleParts.first())
|
||||
if (titleParts.size > 1) append("\n")
|
||||
withStyle(SpanStyle(color = OnboardingAccent)) {
|
||||
if (titleParts.size > 1) append(titleParts[1])
|
||||
}
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/guide/getting-started"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_setup_guide))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_hermes_docs))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_api_server_docs),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
text = stringResource(R.string.onboarding_welcome_description),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
lineHeight = 27.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
|
||||
}
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
WelcomeCapabilityRow(
|
||||
icon = Icons.Filled.Dashboard,
|
||||
label = stringResource(R.string.onboarding_chat_manage_label),
|
||||
description = stringResource(R.string.onboarding_chat_manage_description),
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
WelcomeCapabilityRow(
|
||||
icon = Icons.Filled.Bolt,
|
||||
label = stringResource(R.string.onboarding_power_tools_label),
|
||||
description = stringResource(R.string.onboarding_power_tools_description),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeCapabilityRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 47.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(30.dp),
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
modifier = Modifier.size(58.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f),
|
||||
),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(30.dp),
|
||||
tint = OnboardingAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF57E389)),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPathSummary(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f),
|
||||
),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = OnboardingAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(96.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -448,14 +607,17 @@ private fun ChatPage() {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Bolt,
|
||||
label = stringResource(R.string.onboarding_streaming_label),
|
||||
description = stringResource(R.string.onboarding_streaming_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Person,
|
||||
label = stringResource(R.string.onboarding_profiles_label),
|
||||
description = stringResource(R.string.onboarding_profiles_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Mic,
|
||||
label = stringResource(R.string.onboarding_voice_label),
|
||||
description = stringResource(R.string.onboarding_voice_description),
|
||||
)
|
||||
@@ -475,14 +637,17 @@ private fun ManagePage() {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Tune,
|
||||
label = stringResource(R.string.onboarding_control_label),
|
||||
description = stringResource(R.string.onboarding_control_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Extension,
|
||||
label = stringResource(R.string.onboarding_skills_hub_label),
|
||||
description = stringResource(R.string.onboarding_skills_hub_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Lock,
|
||||
label = stringResource(R.string.onboarding_one_sign_in_label),
|
||||
description = stringResource(R.string.onboarding_one_sign_in_description),
|
||||
)
|
||||
@@ -504,14 +669,17 @@ private fun PowerToolsPage(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Outlined.Terminal,
|
||||
label = stringResource(R.string.onboarding_terminal_label),
|
||||
description = stringResource(R.string.onboarding_terminal_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
label = stringResource(R.string.onboarding_bridge_label),
|
||||
description = stringResource(R.string.onboarding_bridge_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
label = stringResource(R.string.onboarding_realtime_label),
|
||||
description = stringResource(R.string.onboarding_realtime_description),
|
||||
)
|
||||
@@ -531,6 +699,80 @@ private fun PowerToolsPage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PermissionSetupPage(
|
||||
notificationAction: OnboardingNotificationAction,
|
||||
onEnableNotifications: () -> Unit,
|
||||
onReviewPermissions: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.onboarding_finish_setup_title),
|
||||
description = stringResource(R.string.onboarding_finish_setup_description),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
label = stringResource(R.string.perms_chat_and_manage),
|
||||
description = stringResource(R.string.onboarding_chat_manage_ready),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Notifications,
|
||||
label = stringResource(R.string.onboarding_chat_alerts),
|
||||
description = if (
|
||||
notificationAction == OnboardingNotificationAction.Finish
|
||||
) {
|
||||
stringResource(R.string.onboarding_chat_alerts_ready)
|
||||
} else {
|
||||
stringResource(R.string.onboarding_chat_alerts_description)
|
||||
},
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Tune,
|
||||
label = stringResource(R.string.onboarding_optional_features),
|
||||
description = stringResource(R.string.onboarding_optional_features_description),
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onReviewPermissions,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_review_optional_permissions))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
if (notificationAction == OnboardingNotificationAction.RequestPermission) {
|
||||
GradientOnboardingButton(
|
||||
label = stringResource(R.string.onboarding_enable_chat_alerts),
|
||||
onClick = onEnableNotifications,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_not_now))
|
||||
}
|
||||
} else {
|
||||
GradientOnboardingButton(
|
||||
label = stringResource(R.string.onboarding_finish),
|
||||
onClick = onFinish,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
|
||||
@@ -432,7 +432,7 @@ fun AboutScreen(
|
||||
// Privacy policy link
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay/blob/main/docs/privacy.md"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/privacy.html"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -115,6 +115,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
@@ -195,8 +196,11 @@ import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
|
||||
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
|
||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||
import com.hermesandroid.relay.ui.components.SlashCommand
|
||||
import com.hermesandroid.relay.ui.components.StreamingDots
|
||||
import com.hermesandroid.relay.ui.components.SubagentLane
|
||||
import com.hermesandroid.relay.ui.components.ToolProgressCard
|
||||
import com.hermesandroid.relay.ui.components.isVisibleForToolDisplay
|
||||
import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
@@ -208,7 +212,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import kotlin.math.roundToInt
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatConnectState
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import com.hermesandroid.relay.voice.VoiceOverlayHost
|
||||
import com.hermesandroid.relay.voice.VoiceOverlaySession
|
||||
@@ -220,6 +227,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||
|
||||
internal fun resolveChatHeaderSubtitle(
|
||||
isStreaming: Boolean,
|
||||
statusText: String,
|
||||
projectName: String?,
|
||||
personalityName: String?,
|
||||
modelName: String?,
|
||||
): String = if (isStreaming) {
|
||||
statusText
|
||||
} else {
|
||||
listOfNotNull(
|
||||
projectName?.takeIf { it.isNotBlank() },
|
||||
personalityName?.takeIf { it.isNotBlank() },
|
||||
modelName?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" \u00B7 ").ifBlank { statusText }
|
||||
}
|
||||
|
||||
/**
|
||||
* A same-author run breaks into a new visual group once the gap to the
|
||||
* neighboring message exceeds this — so a conversation resumed after a pause
|
||||
@@ -521,6 +545,7 @@ fun ChatScreen(
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
@@ -539,6 +564,7 @@ fun ChatScreen(
|
||||
// and is consumed by the AgentInfoSheet for the Profile section. The list
|
||||
// of available profiles itself now lives entirely inside the sheet.
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
// Server-advertised profile catalog — used to locate the "default" profile
|
||||
// so the header can render its description/model when no explicit pick
|
||||
// has been made (the /api/config fallback is more useful than the bare
|
||||
@@ -547,11 +573,12 @@ fun ChatScreen(
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
|
||||
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
@@ -583,7 +610,7 @@ fun ChatScreen(
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
// Whether the NEXT turn would ride the gateway transport — gates the
|
||||
// "Edit & resend" menu entry (conversation rewind needs the gateway).
|
||||
// Per-turn steerability comes from [steerableTurn], which also covers
|
||||
// Per-turn correction availability comes from [steerableTurn], which also covers
|
||||
// the preflight-SSE-fallback window.
|
||||
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
|
||||
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
@@ -809,8 +836,7 @@ fun ChatScreen(
|
||||
provider = activeVoiceProvider,
|
||||
model = activeVoiceModel,
|
||||
voice = activeVoiceName,
|
||||
profileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
|
||||
?: selectedProfile?.name,
|
||||
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||
configScope = activeVoiceScope,
|
||||
outputEnabled = activeVoiceEnabled,
|
||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
@@ -1379,23 +1405,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Display profile - the one the header should reflect. Priority:
|
||||
// 1. explicit user pick (selectedProfile)
|
||||
// 2. server-advertised profile named "default"
|
||||
// 3. null (fall back to personality-derived name)
|
||||
//
|
||||
// Computed as a derived state so the header cross-fades when the user
|
||||
// picks a new profile OR when the server's profile catalog finishes
|
||||
// loading and a "default" entry shows up.
|
||||
val effectiveProfile by remember(selectedProfile, agentProfiles) {
|
||||
derivedStateOf {
|
||||
AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent display name — used in header and info dialog.
|
||||
//
|
||||
// Precedence mirrors the messaging-app pattern: show the profile's
|
||||
@@ -1439,7 +1448,7 @@ fun ChatScreen(
|
||||
// drawer itself, so the overlay's pointer scrim alone can't block it.
|
||||
gesturesEnabled = !voiceUiState.voiceMode,
|
||||
drawerContent = {
|
||||
val drawerTitle = if (selectedProfile != null) {
|
||||
val drawerTitle = if (effectiveProfile != null) {
|
||||
stringResource(R.string.chat_profile_sessions, agentDisplayName)
|
||||
} else {
|
||||
stringResource(R.string.chat_server_default_sessions)
|
||||
@@ -1517,17 +1526,36 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
title = {
|
||||
val headerApiReachable = apiReachable || isStreaming
|
||||
val isConnecting = isChatConnecting ||
|
||||
(!headerApiReachable && chatMode != ChatMode.DISCONNECTED)
|
||||
// Resolve the same Gateway-first, API-fallback runtime
|
||||
// model used elsewhere; Relay is intentionally unrelated
|
||||
// to Chat health.
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (chatGatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown -> if (
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
|
||||
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
apiReachable -> ChatTransportReadiness.Ready
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
|
||||
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
)
|
||||
val headerChatReady = chatRuntimeStatus is ChatRuntimeStatus.Connected || isStreaming
|
||||
val isConnecting = isChatConnecting || chatRuntimeStatus is ChatRuntimeStatus.Connecting
|
||||
// Once we've been connected this session, a later drop reads as
|
||||
// "Reconnecting…" (we had it, we're getting it back) rather than
|
||||
// a first-time "Connecting…". Honest wording for the WhatsApp-
|
||||
// style subtitle status.
|
||||
var everConnected by remember { mutableStateOf(false) }
|
||||
if (headerApiReachable) everConnected = true
|
||||
if (headerChatReady) everConnected = true
|
||||
val statusText = when {
|
||||
headerApiReachable -> if (isStreaming) {
|
||||
headerChatReady -> if (isStreaming) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
stringResource(R.string.chat_connected_label)
|
||||
@@ -1540,7 +1568,7 @@ fun ChatScreen(
|
||||
else -> stringResource(R.string.chat_disconnected_label)
|
||||
}
|
||||
val statusColor = when {
|
||||
headerApiReachable -> Color(0xFF4CAF50)
|
||||
headerChatReady -> Color(0xFF4CAF50)
|
||||
isConnecting -> Color(0xFFFFA726)
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
@@ -1578,14 +1606,18 @@ fun ChatScreen(
|
||||
// yet (server config still loading), fall back to the plain
|
||||
// connection status \u2014 never the literal "None"/"Default"
|
||||
// personality label.
|
||||
val subtitleText = when {
|
||||
!headerApiReachable -> statusText
|
||||
else -> listOfNotNull(
|
||||
nonDefaultPersonality,
|
||||
modelName?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" \u00B7 ").ifBlank { statusText }
|
||||
val subtitleText = if (!headerChatReady) {
|
||||
statusText
|
||||
} else {
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = isStreaming,
|
||||
statusText = statusText,
|
||||
projectName = gatewayProjectName,
|
||||
personalityName = nonDefaultPersonality,
|
||||
modelName = modelName,
|
||||
)
|
||||
}
|
||||
val subtitleColor = if (headerApiReachable) {
|
||||
val subtitleColor = if (headerChatReady) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
statusColor
|
||||
@@ -1643,7 +1675,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerApiReachable,
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
@@ -1716,6 +1748,10 @@ fun ChatScreen(
|
||||
transitionSpec = { loadedContentTransform() },
|
||||
label = "chatHeaderSubtitle",
|
||||
) { line ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1723,6 +1759,13 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1924,6 +1967,9 @@ fun ChatScreen(
|
||||
chatReady = chatReady,
|
||||
isLoadingHistory = isLoadingHistory,
|
||||
isLoadingSessions = isLoadingSessions,
|
||||
gatewayAvailability = chatGatewayAvailability,
|
||||
dashboardRouteMovedHint = dashboardRouteMovedHint,
|
||||
onNavigateToManage = onNavigateToManage,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -1972,7 +2018,7 @@ fun ChatScreen(
|
||||
// thread itself (not just the header) -
|
||||
// the desktop's intro.
|
||||
ChatConnectState.Ready ->
|
||||
if (selectedProfile != null) {
|
||||
if (effectiveProfile != null) {
|
||||
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
||||
} else {
|
||||
stringResource(R.string.chat_start_conversation)
|
||||
@@ -2310,7 +2356,7 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Steered sends live inside a server-side tool
|
||||
// Legacy steered sends live inside a server-side tool
|
||||
// result, not a user message — flag the local
|
||||
// bubble so the scrollback explains itself.
|
||||
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
|
||||
@@ -2327,12 +2373,21 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (toolDisplay != "off" && !hasBackgroundTask) {
|
||||
if (!hasBackgroundTask) {
|
||||
// Subagent children (taskIndex != null) group
|
||||
// into lanes after the top-level tool cards;
|
||||
// the null group renders exactly as before.
|
||||
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
|
||||
laneGroups[null]?.forEach { toolCall ->
|
||||
// Image generation renders inside MessageBubble
|
||||
// so the progress canvas and final media share
|
||||
// one stable Surface and transition in place.
|
||||
if (toolCall.showsImageGenerationPlaceholder()) {
|
||||
return@forEach
|
||||
}
|
||||
if (!toolCall.isVisibleForToolDisplay(toolDisplay)) {
|
||||
return@forEach
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
when (toolDisplay) {
|
||||
"compact" -> CompactToolCall(toolCall = toolCall)
|
||||
@@ -2342,12 +2397,14 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = laneGroups.getValue(taskIndex),
|
||||
)
|
||||
if (toolDisplay != "off") {
|
||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = laneGroups.getValue(taskIndex),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2675,7 +2732,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Input bar — pill field with ONE trailing slot morphing
|
||||
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker,
|
||||
// Send / Voice / Stop / Correct / Queue. "+" taps the file picker,
|
||||
// long-press opens the CommandPalette (the dedicated "/" button
|
||||
// is gone — typing "/" still surfaces InlineAutocomplete).
|
||||
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
@@ -2702,13 +2759,16 @@ fun ChatScreen(
|
||||
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
|
||||
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
|
||||
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
||||
.distinct()
|
||||
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
|
||||
(apiModelOptions +
|
||||
agentProfiles.mapNotNull { profile ->
|
||||
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
|
||||
} +
|
||||
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
val currentModelForInput = AgentDisplay.displayModelName(selectedModelOverride)
|
||||
val currentModelForInput = apiModelOptions.firstOrNull { it.id == selectedModelOverride }?.id
|
||||
?: AgentDisplay.displayModelName(selectedModelOverride)
|
||||
?: AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||
?: AgentDisplay.displayModelName(effectiveProfile?.model)
|
||||
?: AgentDisplay.displayModelName(serverModelName)
|
||||
@@ -2780,9 +2840,10 @@ fun ChatScreen(
|
||||
sseModelOptions.forEach { model ->
|
||||
add(
|
||||
ChatInputPickerOption(
|
||||
label = model,
|
||||
value = model,
|
||||
selected = selectedModelOverride == model,
|
||||
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
value = model.id,
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2927,7 +2988,11 @@ fun ChatScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modelControl = modelControl,
|
||||
onModelOptionSelected = { option ->
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
|
||||
option.value?.let(chatViewModel::selectApiModel)
|
||||
} else {
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
}
|
||||
},
|
||||
effortControl = effortControl,
|
||||
onEffortOptionSelected = { option ->
|
||||
@@ -2944,7 +3009,11 @@ fun ChatScreen(
|
||||
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
|
||||
onSelect = { option ->
|
||||
showModelSheet = false
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
|
||||
option.value?.let(chatViewModel::selectApiModel)
|
||||
} else {
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
}
|
||||
},
|
||||
onDismiss = { showModelSheet = false },
|
||||
)
|
||||
@@ -3081,8 +3150,7 @@ fun ChatScreen(
|
||||
voiceOutputProvider = activeVoiceProvider,
|
||||
voiceOutputModel = activeVoiceModel,
|
||||
voiceOutputVoice = activeVoiceName,
|
||||
voiceProfileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
|
||||
?: selectedProfile?.name,
|
||||
voiceProfileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||
voiceConfigScope = activeVoiceScope,
|
||||
voiceOutputEnabled = activeVoiceEnabled,
|
||||
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
@@ -3222,6 +3290,9 @@ private fun ChatColdStartLoadingState(
|
||||
chatReady: Boolean,
|
||||
isLoadingHistory: Boolean,
|
||||
isLoadingSessions: Boolean,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
dashboardRouteMovedHint: String?,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -3284,14 +3355,51 @@ private fun ChatColdStartLoadingState(
|
||||
)
|
||||
}
|
||||
|
||||
ChatLoadingCommandPanel(
|
||||
commands = commands,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
)
|
||||
val dashboardSignInRequired =
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
|
||||
if (dashboardSignInRequired) {
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_required_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = dashboardRouteMovedHint?.let { route ->
|
||||
stringResource(R.string.dashboard_signin_route_hint, route)
|
||||
} ?: stringResource(R.string.chat_settings_gateway_needs_signin_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = onNavigateToManage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.voice_settings_sign_in_via_manage))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatLoadingCommandPanel(
|
||||
commands = commands,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Turn-complete notification toggle. First enable on
|
||||
// Background chat-alert toggle. First enable on
|
||||
// API 33+ runs the POST_NOTIFICATIONS request (the
|
||||
// BridgeScreen master-toggle precedent); if the user
|
||||
// denies, the notifier silently no-ops at post time.
|
||||
|
||||
@@ -8,12 +8,16 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Button
|
||||
@@ -42,12 +46,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||
@@ -60,7 +66,6 @@ import com.hermesandroid.relay.ui.components.SessionInfoSheet
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
|
||||
/**
|
||||
* Tabbed detail for a single Hermes connection — the level-2 screen the
|
||||
@@ -105,8 +110,6 @@ fun ConnectionDetailScreen(
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRow by connectionViewModel.relayRowState.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
@@ -200,6 +203,12 @@ fun ConnectionDetailScreen(
|
||||
menuExpanded = false
|
||||
onRepair(connectionId)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
if (connection.pairedAt != null) {
|
||||
DropdownMenuItem(
|
||||
@@ -265,8 +274,6 @@ fun ConnectionDetailScreen(
|
||||
ActiveOverview(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
relayConfigured = relayConfigured,
|
||||
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
|
||||
relayUiState = relayUiState,
|
||||
relayEnabled = relayEnabled,
|
||||
onReconnect = onReconnect,
|
||||
@@ -289,18 +296,23 @@ fun ConnectionDetailScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
liveState = relayUiState,
|
||||
onEditDashboard = {
|
||||
selectedTab = tabs.indexOf(DetailTab.Advanced)
|
||||
},
|
||||
)
|
||||
|
||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onPairRelay = { onRepair(connectionId) },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
)
|
||||
|
||||
DetailTab.Security -> ActiveCardSecurityPosture(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToPairedDevices = onNavigateToPairedDevices,
|
||||
onRevokeRelay = { showRevokeConfirm = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -410,8 +422,6 @@ private enum class DetailTab {
|
||||
private fun ActiveOverview(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
relayConfigured: Boolean,
|
||||
relayStatusText: String,
|
||||
relayUiState: RelayUiState,
|
||||
relayEnabled: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
@@ -421,37 +431,67 @@ private fun ActiveOverview(
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val dashboardReady = connection.dashboardLastStatus?.reachable == true
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = headerLine,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = hostname,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Dashboard,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_dashboard_primary),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = connection.resolvedDashboardUrl.ifBlank { hostname },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (dashboardReady) {
|
||||
stringResource(R.string.detail_core_ready)
|
||||
} else {
|
||||
stringResource(R.string.detail_core_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.detail_routes_help),
|
||||
text = stringResource(R.string.detail_overview_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -463,6 +503,7 @@ private fun ActiveOverview(
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
onPairRelay = onRepair,
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -472,9 +513,6 @@ private fun ActiveOverview(
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
|
||||
}
|
||||
TextButton(onClick = onRepair) {
|
||||
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,10 +527,10 @@ private fun InactiveOverview(
|
||||
onSwitch: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val statusLine = when {
|
||||
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
|
||||
connection.apiServerUrl.isNotBlank() -> stringResource(R.string.detail_standard_not_paired)
|
||||
connection.capabilities.chatConfigured -> stringResource(R.string.detail_standard_not_paired)
|
||||
else -> stringResource(R.string.detail_not_configured)
|
||||
}
|
||||
|
||||
|
||||
+385
-137
@@ -1,6 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -21,12 +24,24 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.CellTower
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -39,21 +54,33 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Level-1 list of Hermes connections. Reachable via Settings → Connections.
|
||||
@@ -91,6 +118,15 @@ fun ConnectionsSettingsScreen(
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val startupConnectionId: String? = if (connectionViewModel != null) {
|
||||
val startupId by connectionViewModel.startupConnectionId.collectAsState()
|
||||
startupId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
var switchingConnectionId by remember { mutableStateOf<String?>(null) }
|
||||
var justSwitchedConnectionId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Kick a WSS reconnect on entry in case the user landed here from a Stale
|
||||
// chip (same intent as the old inline screen).
|
||||
@@ -101,7 +137,22 @@ fun ConnectionsSettingsScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.conn_title)) },
|
||||
title = {
|
||||
Column {
|
||||
Text(stringResource(R.string.conn_title))
|
||||
if (connections.isNotEmpty()) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.conn_server_count,
|
||||
connections.size,
|
||||
connections.size,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
@@ -115,13 +166,6 @@ fun ConnectionsSettingsScreen(
|
||||
),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAddConnection,
|
||||
icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.conn_add_connection)) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (connections.isEmpty()) {
|
||||
// Shown in practice only during tests / after a wipe; cold start
|
||||
@@ -149,6 +193,15 @@ fun ConnectionsSettingsScreen(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (connections.size > 1 && connectionViewModel != null) {
|
||||
item {
|
||||
StartupConnectionSelector(
|
||||
connections = connections,
|
||||
startupConnectionId = startupConnectionId,
|
||||
onSelect = connectionViewModel::setStartupConnection,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(connections, key = { it.id }) { connection ->
|
||||
val isActive = connection.id == activeConnectionId
|
||||
ConnectionListCard(
|
||||
@@ -162,10 +215,118 @@ fun ConnectionsSettingsScreen(
|
||||
connection.hasConfiguredRelay()
|
||||
},
|
||||
onClick = { onOpenConnection(connection.id) },
|
||||
onSwitch = if (!isActive && connectionViewModel != null) {
|
||||
{
|
||||
if (switchingConnectionId == null) {
|
||||
scope.launch {
|
||||
switchingConnectionId = connection.id
|
||||
connectionViewModel.switchConnection(connection.id).join()
|
||||
switchingConnectionId = null
|
||||
if (connectionViewModel.activeConnectionId.value == connection.id) {
|
||||
justSwitchedConnectionId = connection.id
|
||||
delay(800)
|
||||
if (justSwitchedConnectionId == connection.id) {
|
||||
justSwitchedConnectionId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
isSwitching = switchingConnectionId == connection.id,
|
||||
justSwitched = justSwitchedConnectionId == connection.id,
|
||||
)
|
||||
}
|
||||
// Footer spacer so the last card isn't hidden by the FAB.
|
||||
item { Spacer(modifier = Modifier.height(72.dp)) }
|
||||
item {
|
||||
Button(
|
||||
onClick = onAddConnection,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.conn_add_connection),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartupConnectionSelector(
|
||||
connections: List<Connection>,
|
||||
startupConnectionId: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedLabel = startupConnectionId
|
||||
?.let { id -> connections.firstOrNull { it.id == id }?.label }
|
||||
?: stringResource(R.string.conn_startup_last_used)
|
||||
|
||||
Box {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true },
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccessTime,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.conn_startup_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(text = selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = stringResource(R.string.conn_startup_choose),
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.conn_startup_last_used))
|
||||
Text(
|
||||
text = stringResource(R.string.conn_startup_recommended),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(null)
|
||||
},
|
||||
)
|
||||
connections.forEach { connection ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(connection.label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(connection.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,16 +346,28 @@ private fun ConnectionListCard(
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayConfigured: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSwitch: (() -> Unit)?,
|
||||
isSwitching: Boolean,
|
||||
justSwitched: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Active card: muted indigo wash instead of full-strength primaryContainer —
|
||||
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
|
||||
// feedback); small accents keep the vivid blue.
|
||||
val containerColor = if (isActive) {
|
||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
|
||||
val targetContainerColor = if (isActive || isSwitching || justSwitched) {
|
||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.25f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val containerColor by animateColorAsState(targetValue = targetContainerColor, label = "connectionCardColor")
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isSwitching || justSwitched) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0f)
|
||||
},
|
||||
label = "connectionCardBorder",
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -202,6 +375,7 @@ private fun ConnectionListCard(
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
border = BorderStroke(if (isSwitching || justSwitched) 1.5.dp else 0.dp, borderColor),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -209,52 +383,87 @@ private fun ConnectionListCard(
|
||||
) {
|
||||
// ── Title row ────────────────────────────────────────────────
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
if (isActive) com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = connection.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp),
|
||||
)
|
||||
if (isActive) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Text(text = stringResource(R.string.conn_active), modifier = Modifier.padding(horizontal = 6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.conn_active).uppercase(),
|
||||
modifier = Modifier.padding(horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onSwitch != null || isSwitching || justSwitched) {
|
||||
OutlinedButton(
|
||||
onClick = { onSwitch?.invoke() },
|
||||
enabled = !isSwitching && !justSwitched,
|
||||
) {
|
||||
when {
|
||||
isSwitching -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
justSwitched -> {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
else -> Text(stringResource(R.string.conn_switch))
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Subtitle: hostname + status ──────────────────────────────
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hasStandardApi = connection.apiServerUrl.isNotBlank()
|
||||
val pairedStatus = when {
|
||||
liveState != null &&
|
||||
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
|
||||
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected))
|
||||
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt)
|
||||
hasStandardApi -> stringResource(R.string.conn_standard_not_paired)
|
||||
else -> stringResource(R.string.conn_not_paired)
|
||||
}
|
||||
val statusColor = if (liveState == RelayUiState.Stale) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val dashboardStatus = connection.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val connectionStatus = when {
|
||||
dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
|
||||
dashboardStatus?.reachable == true -> stringResource(R.string.conn_dashboard_available)
|
||||
dashboardStatus != null && !dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
|
||||
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_unchecked)
|
||||
else -> stringResource(R.string.conn_dashboard_missing)
|
||||
}
|
||||
Text(
|
||||
text = "$hostname • $pairedStatus",
|
||||
text = if (isSwitching) {
|
||||
stringResource(R.string.conn_connecting_to, connection.label)
|
||||
} else {
|
||||
"Dashboard · $hostname"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = statusColor,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = if (isActive) {
|
||||
stringResource(R.string.conn_last_used_now)
|
||||
} else {
|
||||
connection.lastUsedAt?.let { formatUsedRelative(context, it) } ?: connectionStatus
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// ── Steps/timeline summary (every card) ──────────────────────
|
||||
ConnectionSurfaceSummary(
|
||||
@@ -267,6 +476,42 @@ private fun ConnectionListCard(
|
||||
// detail — the same destination as the card tap.
|
||||
onOpenDashboard = onClick,
|
||||
)
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lan,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.padding(end = 2.dp),
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
connection.routeCandidates.isNotEmpty() -> connection.routeCandidates
|
||||
.sortedBy { it.priority }
|
||||
.joinToString(" · ") { route ->
|
||||
route.role.replaceFirstChar { it.titlecase() }
|
||||
}
|
||||
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_only_route)
|
||||
else -> stringResource(R.string.conn_no_routes)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +549,12 @@ private fun ConnectionSurfaceSummary(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val gatewayAvailability: GatewayAvailability? = if (activeConnectionViewModel != null) {
|
||||
val availability by activeConnectionViewModel.gatewayAvailability.collectAsState()
|
||||
availability
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val standardVoiceAvailability: StandardVoiceAvailability? =
|
||||
if (activeConnectionViewModel != null) {
|
||||
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
@@ -315,17 +566,41 @@ private fun ConnectionSurfaceSummary(
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
val apiText = when {
|
||||
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing)
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking)
|
||||
activeApiReachable == true -> stringResource(R.string.conn_api_ready)
|
||||
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline)
|
||||
else -> stringResource(R.string.conn_api_configured)
|
||||
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
|
||||
resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown, null -> ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
connection.apiServerUrl.isBlank() -> ChatTransportReadiness.NotConfigured
|
||||
activeApiReachable == true -> ChatTransportReadiness.Ready
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
|
||||
else -> ChatTransportReadiness.Connecting
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val apiTone = when {
|
||||
activeApiReachable == true -> SummaryTone.Good
|
||||
connection.apiServerUrl.isBlank() -> SummaryTone.Warning
|
||||
isActive && activeApiReachable == false -> SummaryTone.Warning
|
||||
|
||||
val chatText = when {
|
||||
chatRuntimeStatus is ChatRuntimeStatus.Connected -> stringResource(R.string.conn_api_ready)
|
||||
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> stringResource(R.string.conn_api_checking)
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
|
||||
dashboardStatus?.reachable == true && !dashboardSignInRequired -> stringResource(R.string.conn_dashboard_available)
|
||||
connection.capabilities.chatConfigured -> stringResource(R.string.conn_api_configured)
|
||||
else -> stringResource(R.string.conn_api_missing)
|
||||
}
|
||||
val chatTone = when {
|
||||
chatRuntimeStatus is ChatRuntimeStatus.Connected -> SummaryTone.Good
|
||||
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> SummaryTone.Info
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> SummaryTone.Info
|
||||
dashboardStatus?.reachable == true && !dashboardSignInRequired -> SummaryTone.Good
|
||||
connection.capabilities.chatConfigured -> SummaryTone.Neutral
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
@@ -374,99 +649,78 @@ private fun ConnectionSurfaceSummary(
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
// A single grouped surface with dot + label + value rows — the
|
||||
// steps/timeline vocabulary shared with the detail's Features list.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
|
||||
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone)
|
||||
SurfaceRowDivider()
|
||||
ConnectionSurfaceRow(
|
||||
label = stringResource(R.string.conn_dashboard_label),
|
||||
value = dashboardText,
|
||||
tone = dashboardTone,
|
||||
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
SurfaceRowDivider()
|
||||
ConnectionSurfaceRow(
|
||||
label = stringResource(R.string.conn_voice_label),
|
||||
value = voiceText,
|
||||
tone = voiceTone,
|
||||
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
SurfaceRowDivider()
|
||||
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone)
|
||||
}
|
||||
ConnectionStatusChip(Icons.Filled.Chat, stringResource(R.string.conn_chat_label), chatText, chatTone, Modifier.weight(1f))
|
||||
ConnectionStatusChip(
|
||||
Icons.Filled.Dashboard,
|
||||
stringResource(R.string.conn_manage_label),
|
||||
dashboardText,
|
||||
dashboardTone,
|
||||
Modifier.weight(1f),
|
||||
if (dashboardSignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
ConnectionStatusChip(
|
||||
Icons.Filled.GraphicEq,
|
||||
stringResource(R.string.conn_voice_label),
|
||||
voiceText,
|
||||
voiceTone,
|
||||
Modifier.weight(1f),
|
||||
if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
ConnectionStatusChip(Icons.Filled.CellTower, stringResource(R.string.conn_relay_label), relayText, relayTone, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SummaryTone { Neutral, Good, Info, Warning }
|
||||
|
||||
/** Hairline divider between summary rows — inset so it reads as a list. */
|
||||
@Composable
|
||||
private fun SurfaceRowDivider() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One health line: status dot + label + value. The dot carries the tone so
|
||||
* the value text stays short and the row stays light.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConnectionSurfaceRow(
|
||||
private fun ConnectionStatusChip(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
tone: SummaryTone,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val dotColor = when (tone) {
|
||||
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.primary
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.error
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
val valueColor = when (tone) {
|
||||
val accent = when (tone) {
|
||||
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.primary
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.error
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
Surface(
|
||||
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = accent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,21 +731,15 @@ private fun Connection.hasConfiguredRelay(): Boolean {
|
||||
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils`
|
||||
* returns awkward copy ("in 0 minutes") for small deltas.
|
||||
*/
|
||||
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String {
|
||||
val deltaMs = System.currentTimeMillis() - pairedAtMillis
|
||||
if (deltaMs < 0) return context.getString(R.string.conn_just_paired)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
|
||||
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
|
||||
return when {
|
||||
minutes < 1L -> context.getString(R.string.conn_just_paired)
|
||||
minutes < 60L -> context.resources.getQuantityString(R.plurals.conn_paired_minutes_ago, minutes.toInt(), minutes)
|
||||
hours < 24L -> context.resources.getQuantityString(R.plurals.conn_paired_hours_ago, hours.toInt(), hours)
|
||||
days < 30L -> context.resources.getQuantityString(R.plurals.conn_paired_days_ago, days.toInt(), days)
|
||||
else -> context.getString(R.string.conn_paired)
|
||||
private fun formatUsedRelative(context: Context, usedAtMillis: Long): String {
|
||||
val relative = if (System.currentTimeMillis() - usedAtMillis < DateUtils.MINUTE_IN_MILLIS) {
|
||||
context.getString(R.string.conn_just_now)
|
||||
} else {
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
usedAtMillis,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
}
|
||||
return context.getString(R.string.conn_last_used_format, relative)
|
||||
}
|
||||
|
||||
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
|
||||
EnableMcp,
|
||||
DisableMcp,
|
||||
TestMcp,
|
||||
AuthenticateMcp,
|
||||
RemoveMcp,
|
||||
InstallMcpCatalog,
|
||||
ViewProfileSoul,
|
||||
ActivateProfile,
|
||||
DeleteProfile,
|
||||
EditCustomEndpoint,
|
||||
ValidateCustomEndpoint,
|
||||
ActivateCustomEndpoint,
|
||||
DeleteCustomEndpoint,
|
||||
|
||||
// Input-backed kinds — intercepted before runAction and routed to a
|
||||
// text-input or model-picker dialog instead of firing immediately.
|
||||
|
||||
+590
-115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
* Manage so onboarding, connection setup, Voice, Chat, and Manage can all use
|
||||
* the same cookie/session flow without inheriting Manage's navigation stack.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardSignInScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val routeHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
val connectionId = activeConnection?.id ?: "default"
|
||||
var providers by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf<List<DashboardAuthProvider>>(emptyList())
|
||||
}
|
||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||
var actionInFlight by remember { mutableStateOf(false) }
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
|
||||
val cookieStoreFactory = remember(context, connectionId) {
|
||||
{
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||
val status = client.getStatus().getOrNull()
|
||||
val session = client.currentSession().getOrNull()
|
||||
val ticketAvailable = if (session?.authenticated == true) {
|
||||
client.requestWsTicket().isSuccess
|
||||
} else {
|
||||
null
|
||||
}
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
session = session,
|
||||
reachable = status != null,
|
||||
gatewayTicketAvailable = ticketAvailable,
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
fun finishAuthentication() {
|
||||
scope.launch {
|
||||
invalidateDashboardManageCache(context.cacheDir)
|
||||
connectionViewModel.refreshStandardVoice()
|
||||
connectionViewModel.refreshDashboardProfiles()
|
||||
authenticationComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dashboardUrl, connectionId) {
|
||||
if (dashboardUrl.isBlank()) {
|
||||
loading = false
|
||||
actionMessage = context.getString(R.string.dashboard_no_url_configured)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providers = status.authProviderDetails.ifEmpty {
|
||||
client.getAuthProviders().getOrNull().orEmpty()
|
||||
}
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
session = session,
|
||||
reachable = true,
|
||||
gatewayTicketAvailable = null,
|
||||
)
|
||||
if (!status.authRequired || session?.authenticated == true) {
|
||||
finishAuthentication()
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
fun submitPassword(provider: String, username: String, password: String) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val result = client.loginPassword(provider, username, password)
|
||||
val session = if (result.isSuccess) verifyAndRecord(client) else null
|
||||
if (result.isSuccess && session?.authenticated == true) {
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oauthProvider?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
onDismiss = { oauthProvider = null },
|
||||
onAuthenticated = { session ->
|
||||
oauthProvider = null
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
verifyAndRecord(client)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
onError = { actionMessage = it },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.dashboard_sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (authenticationComplete) {
|
||||
DashboardAuthenticationComplete(onContinue = onAuthenticated)
|
||||
} else if (loading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
DashboardSignInForm(
|
||||
dashboardUrl = dashboardUrl,
|
||||
routeHint = routeHint,
|
||||
providers = providers,
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = { oauthProvider = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cw_step_3_3),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.cw_dashboard_connected_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.cw_dashboard_connected_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
ConnectionSetupTimeline(
|
||||
steps = listOf(
|
||||
ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.cw_timeline_discovered),
|
||||
stringResource(R.string.cw_timeline_discovered_detail),
|
||||
),
|
||||
ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.cw_timeline_access),
|
||||
stringResource(R.string.cw_timeline_authenticated),
|
||||
),
|
||||
ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.cw_timeline_ready),
|
||||
stringResource(R.string.cw_timeline_ready_detail),
|
||||
),
|
||||
),
|
||||
)
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.cw_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardSignInForm(
|
||||
dashboardUrl: String,
|
||||
routeHint: String?,
|
||||
providers: List<DashboardAuthProvider>,
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val passwordProvider = providers.firstOrNull { it.supportsPassword }
|
||||
val redirectProviders = providers.filter { it.isRedirectProvider }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_required_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_required_body, dashboardUrl),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
routeHint?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_route_hint, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
redirectProviders.forEach { provider ->
|
||||
Button(
|
||||
onClick = { onOAuthSignIn(provider) },
|
||||
enabled = !actionInFlight,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(R.string.dashboard_username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.dashboard_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
Button(
|
||||
onClick = { onSignIn(passwordProvider?.name ?: "basic", username, password) },
|
||||
enabled = !actionInFlight && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (actionInFlight) stringResource(R.string.dashboard_signing_in) else stringResource(R.string.dashboard_sign_in))
|
||||
}
|
||||
}
|
||||
actionMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardOAuthDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val initialStatus = stringResource(R.string.dashboard_oauth_initial_status)
|
||||
val verifyingStatus = stringResource(R.string.dashboard_oauth_verifying)
|
||||
val notAcceptedStatus = stringResource(R.string.dashboard_oauth_not_accepted)
|
||||
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
||||
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||
DashboardApiClient.authLoginUrl(
|
||||
baseUrl = dashboardUrl,
|
||||
provider = provider.name,
|
||||
next = DashboardApiClient.authLandingPath(dashboardUrl),
|
||||
)
|
||||
}
|
||||
|
||||
fun maybeVerify(url: String?) {
|
||||
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||
val root = dashboardUrl.trim().trimEnd('/')
|
||||
val relative = loadedUrl.trim().removePrefix(root)
|
||||
val stillAuthenticating = relative.startsWith("/login", true) ||
|
||||
relative.startsWith("/auth/login", true) ||
|
||||
relative.startsWith("/auth/callback", true)
|
||||
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
|
||||
val manager = CookieManager.getInstance()
|
||||
manager.flush()
|
||||
val imported = importDashboardCookieHeader(
|
||||
cookieStoreFactory(),
|
||||
loadedUrl,
|
||||
manager.getCookie(loadedUrl),
|
||||
)
|
||||
if (checking || imported == 0) return
|
||||
checking = true
|
||||
statusText = verifyingStatus
|
||||
scope.launch {
|
||||
val client = DashboardApiClient(
|
||||
dashboardUrl,
|
||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
try {
|
||||
val session = client.currentSession().getOrNull()
|
||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||
checking = false
|
||||
statusText = notAcceptedStatus
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
checking = false
|
||||
val message = e.message ?: verifyFailedStatus
|
||||
statusText = message
|
||||
onError(message)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
|
||||
}
|
||||
Text(statusText, style = MaterialTheme.typography.bodySmall)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean = false
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
maybeVerify(url)
|
||||
}
|
||||
}
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,16 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.StatusCheck
|
||||
import com.hermesandroid.relay.network.relay.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.shared.ConnectivityObserver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
|
||||
/**
|
||||
* Dedicated Diagnostics screen — replaces the old modal bottom sheet. Hosts a
|
||||
@@ -62,17 +67,21 @@ fun DiagnosticsScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val network by connectionViewModel.networkStatus.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val capabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val chatReady by connectionViewModel.chatReady.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayHealth by connectionViewModel.relayServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val relayUpdateInfo by connectionViewModel.relayUpdateInfo.collectAsState()
|
||||
val relayInfo by connectionViewModel.relayInfo.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveSessionProfileName by
|
||||
connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val toolsets by connectionViewModel.toolsetInventory.collectAsState()
|
||||
val checkedAt by connectionViewModel.diagnosticsCheckedAt.collectAsState()
|
||||
val refreshing by connectionViewModel.diagnosticsRefreshing.collectAsState()
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
@@ -82,17 +91,20 @@ fun DiagnosticsScreen(
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
val checks = remember(
|
||||
network, apiHealth, apiUrl, capabilities, authState, chatReady,
|
||||
network, activeConnection, dashboardUrl, gatewayAvailability,
|
||||
apiHealth, apiUrl, capabilities, authState,
|
||||
relayConfigured, relayHealth, relayReady, relayUpdateInfo,
|
||||
voiceReady, relayVoiceReady, entries,
|
||||
) {
|
||||
buildStatusChecks(
|
||||
network = network,
|
||||
dashboardUrl = dashboardUrl,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
apiConfigured = activeConnection?.apiServerUrl?.isNotBlank() == true,
|
||||
apiHealth = apiHealth,
|
||||
apiUrl = apiUrl,
|
||||
capabilities = capabilities,
|
||||
authState = authState,
|
||||
chatReady = chatReady,
|
||||
relayConfigured = relayConfigured,
|
||||
relayHealth = relayHealth,
|
||||
relayReady = relayReady,
|
||||
@@ -171,7 +183,19 @@ fun DiagnosticsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val profileKey = selectedProfile?.name ?: "(default)"
|
||||
info.gatewayHeartbeat?.let { heartbeat ->
|
||||
val detail = heartbeat.ageSeconds?.let { " · ${it}s" }.orEmpty()
|
||||
Text(
|
||||
text = stringResource(R.string.diag_gateway_heartbeat, heartbeat.status, detail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (heartbeat.status in setOf("stale", "malformed", "pid_mismatch", "start_mismatch")) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
val profileKey = effectiveSessionProfileName ?: "(default)"
|
||||
val profileState = info.profiles.firstOrNull { it.name == profileKey }?.relayState
|
||||
?: stringResource(R.string.diag_check_not_checked)
|
||||
Text(
|
||||
@@ -180,6 +204,22 @@ fun DiagnosticsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
toolsets?.let { inventory ->
|
||||
val enabled = inventory.count { it.enabled }
|
||||
val relayVisible = inventory.any { item ->
|
||||
item.tools.any { it.startsWith("relay_") || it.startsWith("android_") }
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.diag_toolsets_inventory,
|
||||
enabled,
|
||||
inventory.size,
|
||||
if (relayVisible) stringResource(R.string.diag_yes) else stringResource(R.string.diag_no),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
StatusCheckTimeline(
|
||||
checks = checks,
|
||||
@@ -233,11 +273,13 @@ fun DiagnosticsScreen(
|
||||
*/
|
||||
internal fun buildStatusChecks(
|
||||
network: ConnectivityObserver.Status,
|
||||
dashboardUrl: String,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
apiConfigured: Boolean,
|
||||
apiHealth: ConnectionViewModel.HealthStatus,
|
||||
apiUrl: String,
|
||||
capabilities: ServerCapabilities,
|
||||
authState: AuthState,
|
||||
chatReady: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
relayHealth: ConnectionViewModel.HealthStatus,
|
||||
relayReady: Boolean,
|
||||
@@ -279,37 +321,94 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 2) API server reachability.
|
||||
val apiLabel = context.getString(R.string.diag_check_api_server)
|
||||
// 2) Dashboard/Gateway — the standard upstream Hermes connection.
|
||||
val dashboardLabel = context.getString(R.string.cw_dashboard) + " / " +
|
||||
context.getString(R.string.chat_settings_gateway)
|
||||
val reachableAt = context.getString(R.string.diag_check_reachable_at)
|
||||
val reachable = context.getString(R.string.diag_check_reachable)
|
||||
val notReachableAt = context.getString(R.string.diag_check_not_reachable_at)
|
||||
val notReachable = context.getString(R.string.diag_check_not_reachable)
|
||||
val probing = context.getString(R.string.diag_check_probing)
|
||||
val notChecked = context.getString(R.string.diag_check_not_checked)
|
||||
val dashboardHost = DiagnosticsLog.sanitizeUrl(dashboardUrl)
|
||||
val dashboardErr = recentError(DiagnosticCategory.Endpoint)
|
||||
checks += when {
|
||||
dashboardUrl.isBlank() ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_not_configured),
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Ready ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Pass,
|
||||
reason = dashboardHost?.let {
|
||||
context.getString(R.string.diag_check_reachable_at, it)
|
||||
} ?: reachable,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Warn,
|
||||
reason = context.getString(R.string.cw_dashboard_sign_in_required),
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Unreachable ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Fail,
|
||||
reason = dashboardErr?.message()
|
||||
?: dashboardHost?.let {
|
||||
context.getString(R.string.diag_check_not_reachable_at, it)
|
||||
}
|
||||
?: notReachable,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
timestampMs = dashboardErr?.timestampMs,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Unsupported ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Fail,
|
||||
reason = context.getString(R.string.diag_check_no_chat_endpoint),
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
else ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Unknown,
|
||||
reason = notChecked,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Optional API-server fallback reachability.
|
||||
val apiLabel = context.getString(R.string.active_section_optional_api_fallback)
|
||||
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
|
||||
val apiErr = recentError(DiagnosticCategory.Api)
|
||||
checks += when (apiHealth) {
|
||||
ConnectionViewModel.HealthStatus.Reachable ->
|
||||
checks += when {
|
||||
!apiConfigured ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_not_configured),
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Pass,
|
||||
reason = host?.let { context.getString(R.string.diag_check_reachable_at, it) } ?: reachable,
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Fail,
|
||||
reason = apiErr?.message() ?: (host?.let { context.getString(R.string.diag_check_not_reachable_at, it) } ?: notReachable),
|
||||
category = DiagnosticCategory.Api,
|
||||
timestampMs = apiErr?.timestampMs,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Probing ->
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Unknown,
|
||||
reason = probing,
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Unknown ->
|
||||
else ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Unknown,
|
||||
reason = notChecked,
|
||||
@@ -317,13 +416,19 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Server capabilities (which chat surfaces the server advertises).
|
||||
// 4) Optional API-server capabilities.
|
||||
val capsLabel = context.getString(R.string.diag_check_server_capabilities)
|
||||
val capsNoHealthy = context.getString(R.string.diag_check_no_server_yet)
|
||||
val capsNativeSessions = context.getString(R.string.diag_check_native_sessions)
|
||||
val capsSseFallback = context.getString(R.string.diag_check_sse_fallback)
|
||||
val capsNoEndpoint = context.getString(R.string.diag_check_no_chat_endpoint)
|
||||
checks += when {
|
||||
!apiConfigured ->
|
||||
StatusCheck(
|
||||
capsLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_not_configured),
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
!capabilities.healthy ->
|
||||
StatusCheck(
|
||||
capsLabel, CheckStatus.Unknown,
|
||||
@@ -350,55 +455,96 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 4) Chat transport readiness.
|
||||
// 5) Chat transport readiness.
|
||||
val chatLabel = context.getString(R.string.diag_check_chat_transport)
|
||||
val chatReadyFmt = context.getString(R.string.diag_check_ready_with)
|
||||
val chatNotReady = context.getString(R.string.diag_check_not_ready)
|
||||
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
|
||||
checks += if (chatReady) {
|
||||
StatusCheck(
|
||||
chatLabel, CheckStatus.Pass,
|
||||
reason = context.getString(R.string.diag_check_ready_with, capabilities.preferredChatEndpoint()),
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
} else {
|
||||
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
|
||||
StatusCheck(
|
||||
chatLabel,
|
||||
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
|
||||
reason = chatErr?.message() ?: chatNotReady,
|
||||
category = DiagnosticCategory.Session,
|
||||
timestampMs = chatErr?.timestampMs,
|
||||
)
|
||||
val chatErr = recentError(DiagnosticCategory.Session)
|
||||
?: recentError(DiagnosticCategory.Endpoint)
|
||||
?: recentError(DiagnosticCategory.Api)
|
||||
val chatRuntime = resolveChatRuntimeStatus(
|
||||
gateway = when {
|
||||
dashboardUrl.isBlank() -> ChatTransportReadiness.NotConfigured
|
||||
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
!apiConfigured -> ChatTransportReadiness.NotConfigured
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
)
|
||||
checks += when (chatRuntime) {
|
||||
is ChatRuntimeStatus.Connected -> {
|
||||
val route = when (chatRuntime.transport) {
|
||||
ChatTransportPath.Gateway -> context.getString(R.string.chat_settings_gateway)
|
||||
ChatTransportPath.ApiSse -> capabilities.preferredChatEndpoint()
|
||||
}
|
||||
StatusCheck(
|
||||
chatLabel,
|
||||
if (chatRuntime.fallback) CheckStatus.Warn else CheckStatus.Pass,
|
||||
reason = context.getString(R.string.diag_check_ready_with, route),
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
}
|
||||
ChatRuntimeStatus.Connecting ->
|
||||
StatusCheck(
|
||||
chatLabel, CheckStatus.Unknown,
|
||||
reason = probing,
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
ChatRuntimeStatus.Unavailable ->
|
||||
StatusCheck(
|
||||
chatLabel,
|
||||
if (gatewayAvailability == GatewayAvailability.SignInRequired) {
|
||||
CheckStatus.Warn
|
||||
} else {
|
||||
CheckStatus.Fail
|
||||
},
|
||||
reason = if (gatewayAvailability == GatewayAvailability.SignInRequired) {
|
||||
context.getString(R.string.cw_dashboard_sign_in_required)
|
||||
} else {
|
||||
chatErr?.message() ?: chatNotReady
|
||||
},
|
||||
category = DiagnosticCategory.Session,
|
||||
timestampMs = chatErr?.timestampMs,
|
||||
)
|
||||
}
|
||||
|
||||
// 5) Relay / pairing auth.
|
||||
// 6) Optional Relay / pairing auth.
|
||||
val authLabel = context.getString(R.string.diag_check_pairing_auth)
|
||||
val authRelayActive = context.getString(R.string.diag_check_relay_active)
|
||||
val authPairingProg = context.getString(R.string.diag_check_pairing_progress)
|
||||
val authNotPaired = context.getString(R.string.diag_check_not_paired)
|
||||
val authErr = recentError(DiagnosticCategory.Auth)
|
||||
checks += when (authState) {
|
||||
is AuthState.Paired ->
|
||||
checks += when {
|
||||
!relayConfigured ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_optional),
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
authState is AuthState.Paired ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Pass,
|
||||
reason = authRelayActive,
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
is AuthState.Pairing ->
|
||||
authState is AuthState.Pairing ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Warn,
|
||||
reason = authPairingProg,
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
is AuthState.Failed ->
|
||||
authState is AuthState.Failed ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Fail,
|
||||
reason = authState.reason,
|
||||
category = DiagnosticCategory.Auth,
|
||||
timestampMs = authErr?.timestampMs,
|
||||
)
|
||||
is AuthState.Unpaired ->
|
||||
else ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Unknown,
|
||||
reason = authNotPaired,
|
||||
@@ -406,8 +552,8 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 6) Relay server (optional — Unknown when not paired/configured).
|
||||
val relayLabel = context.getString(R.string.diag_check_relay_server)
|
||||
// 7) Relay server (optional — Unknown when not paired/configured).
|
||||
val relayLabel = context.getString(R.string.active_section_optional_relay)
|
||||
val relayNotConfigured = context.getString(R.string.diag_check_relay_not_configured)
|
||||
val relayConnected = context.getString(R.string.diag_check_connected)
|
||||
val relayReachableNotReady = context.getString(R.string.diag_check_reachable_not_ready)
|
||||
@@ -442,7 +588,7 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 7) Relay plugin version + release availability. The update route is
|
||||
// 8) Relay plugin version + release availability. The update route is
|
||||
// optional on older plugin versions, so a connected relay with no result is
|
||||
// explicitly Unknown rather than incorrectly reported as current.
|
||||
val pluginLabel = context.getString(R.string.diag_check_relay_plugin)
|
||||
@@ -494,7 +640,7 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 8) Voice readiness.
|
||||
// 9) Voice readiness.
|
||||
val voiceLabel = context.getString(R.string.diag_check_voice)
|
||||
val voiceRelayReady = context.getString(R.string.diag_check_voice_relay)
|
||||
val voiceStandardReady = context.getString(R.string.diag_check_voice_standard)
|
||||
|
||||
@@ -32,10 +32,9 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
* button), and pops back to wherever it came from on complete or cancel.
|
||||
*
|
||||
* [autoStart] lets the caller deep-link into a specific pair method. When
|
||||
* set to `"scan"`, the wizard jumps straight to camera-permission-request
|
||||
* → scanner on first composition. Null (default) shows the full Method
|
||||
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
|
||||
* method.
|
||||
* set to `"scan"`, the wizard jumps straight to the scanner. `"relay"`
|
||||
* opens the connection-scoped Relay method chooser without exposing the
|
||||
* new-server flow. Null shows the full connection chooser.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -45,6 +44,7 @@ fun PairScreen(
|
||||
onCancel: () -> Unit,
|
||||
onManageSignIn: (() -> Unit)? = null,
|
||||
autoStart: String? = null,
|
||||
setupReady: Boolean = true,
|
||||
/**
|
||||
* Optional offline "Try the demo" entry, forwarded to [ConnectionWizard].
|
||||
* Wired by [RelayApp] only for the bare Connect entry (no placeholder
|
||||
@@ -65,7 +65,14 @@ fun PairScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.pair_connect_to_hermes)) },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (autoStart == "relay") R.string.detail_pair_relay
|
||||
else R.string.pair_connect_to_hermes,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
@@ -93,6 +100,7 @@ fun PairScreen(
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = false,
|
||||
autoStart = autoStart,
|
||||
setupReady = setupReady,
|
||||
onTryDemo = onTryDemo,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.ui.components.SessionTtlPickerDialog
|
||||
import com.hermesandroid.relay.ui.components.TransportSecurityBadge
|
||||
import com.hermesandroid.relay.ui.components.TransportSecuritySize
|
||||
@@ -872,8 +873,7 @@ private fun EndpointsSubList(
|
||||
for (candidate in endpoints) {
|
||||
val isActive = activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -899,7 +899,7 @@ private fun EndpointsSubList(
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}",
|
||||
text = candidate.routeAuthority().orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -91,15 +96,19 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.ui.components.AgentAvatarFace
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
|
||||
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
|
||||
/**
|
||||
* Root Settings destination. After the 2026-04-11 split, Settings is a
|
||||
@@ -176,6 +185,7 @@ fun SettingsScreen(
|
||||
// ring-accent, and subtitle.
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
@@ -183,27 +193,45 @@ fun SettingsScreen(
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val relayPaired = authState is AuthState.Paired
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
|
||||
apiServerReachable -> ChatTransportReadiness.Ready
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
|
||||
else -> ChatTransportReadiness.Connecting
|
||||
},
|
||||
)
|
||||
// Status pills are exception-only: a pill appears only when the surface
|
||||
// needs attention (missing / checking / offline / sign-in). When it's
|
||||
// healthy the pill is null so the card + agent summary stay clean.
|
||||
val apiPill: SettingsStatusPillModel? = when {
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_api_missing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_api_checking),
|
||||
// Chat is transport-level: Gateway is primary and API is only fallback.
|
||||
// A healthy transport suppresses warnings from the other optional surface.
|
||||
val chatPill: SettingsStatusPillModel? = when {
|
||||
chatRuntimeStatus is ChatRuntimeStatus.Connected -> null
|
||||
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> null
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_sign_in),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
apiServerReachable -> null
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_api_offline),
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_missing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Unreachable ||
|
||||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_offline),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
else -> null
|
||||
@@ -213,38 +241,18 @@ fun SettingsScreen(
|
||||
label = stringResource(R.string.settings_dashboard_missing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
dashboardStatus == null -> null
|
||||
!dashboardStatus.reachable -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_offline),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
dashboardSignInRequired -> SettingsStatusPillModel(
|
||||
gatewayAvailability == GatewayAvailability.Ready -> null
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_sign_in),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
dashboardStatus.authenticated == true -> null
|
||||
gatewayAvailability == GatewayAvailability.Unreachable ||
|
||||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_offline),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
val relayPill: SettingsStatusPillModel? = when (relayUiState) {
|
||||
RelayUiState.Connected -> null
|
||||
RelayUiState.Connecting -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_relay_reconnecting),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
RelayUiState.Stale -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_relay_stale),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.Expired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_pairing_expired),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.Disconnected -> SettingsStatusPillModel(
|
||||
label = if (relayPaired) stringResource(R.string.settings_relay_offline) else stringResource(R.string.settings_requires_pairing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.NotConfigured -> null
|
||||
}
|
||||
// The Power tools below all ride the relay plugin. Rather than stamp an
|
||||
// identical badge on every card (noise, not signal), the dependency is
|
||||
// surfaced ONCE on the section header as a single plugin-state badge.
|
||||
@@ -323,10 +331,6 @@ fun SettingsScreen(
|
||||
// + one-line `connection · model · personality` subtitle).
|
||||
// Tapping opens AgentInfoSheet inline so users can change
|
||||
// Connection / Profile / Personality without leaving Settings.
|
||||
val effectiveProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
ActiveAgentCard(
|
||||
agentName = AgentDisplay.agentName(
|
||||
profile = effectiveProfile,
|
||||
@@ -342,7 +346,7 @@ fun SettingsScreen(
|
||||
defaultPersonality = defaultPersonality,
|
||||
),
|
||||
isCustomized = selectedProfile != null || selectedPersonality != "default",
|
||||
statusPills = listOfNotNull(apiPill, dashboardPill, relayPill),
|
||||
statusPills = listOfNotNull(chatPill),
|
||||
onClick = { showAgentSheet = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
@@ -354,8 +358,7 @@ fun SettingsScreen(
|
||||
// `default` profile — the relay always advertises one, and
|
||||
// it IS the effective agent. Still falls back to disabled
|
||||
// when no profiles have loaded yet (unpaired / pre-auth).
|
||||
val inspectorTarget = selectedProfile
|
||||
?: agentProfiles.firstOrNull { it.name == "default" }
|
||||
val inspectorTarget = effectiveProfile
|
||||
?: agentProfiles.firstOrNull()
|
||||
ProfileInspectorCard(
|
||||
activeProfile = inspectorTarget,
|
||||
@@ -438,7 +441,7 @@ fun SettingsScreen(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
title = stringResource(R.string.settings_chat),
|
||||
subtitle = stringResource(R.string.settings_chat_desc),
|
||||
badge = apiPill,
|
||||
badge = chatPill,
|
||||
onClick = onNavigateToChatSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
@@ -814,6 +817,20 @@ private fun QuickControlsCard(
|
||||
) {
|
||||
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Posting re-checks the grant. */ }
|
||||
val requestNotificationPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -846,7 +863,10 @@ private fun QuickControlsCard(
|
||||
stringResource(R.string.settings_connect_on_demand)
|
||||
},
|
||||
checked = gatewayKeepAlive,
|
||||
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) },
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setGatewayKeepAlive(enabled)
|
||||
if (enabled && notifyTurnComplete) requestNotificationPermission()
|
||||
},
|
||||
)
|
||||
// Doze: even with the keep-alive service running, a specialUse FGS
|
||||
// still gets its network deferred in deep sleep unless the app is
|
||||
@@ -865,7 +885,10 @@ private fun QuickControlsCard(
|
||||
stringResource(R.string.settings_turn_complete_alerts_off)
|
||||
},
|
||||
checked = notifyTurnComplete,
|
||||
onCheckedChange = { connectionViewModel.setNotifyTurnComplete(it) },
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setNotifyTurnComplete(enabled)
|
||||
if (enabled) requestNotificationPermission()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -227,6 +227,18 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
// Upstream rejects new API work with this stable code while an intentional
|
||||
// shutdown/external drain is in progress. Keep it distinct from provider
|
||||
// 503s: the server is healthy and will accept work after the drain clears.
|
||||
if (msg.startsWith("api error 503: gateway_draining:")) {
|
||||
return HumanError(
|
||||
title = "Hermes is restarting",
|
||||
body = "Hermes is draining active work. Wait a moment, then retry this message.",
|
||||
retryable = true,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_retry) ?: "Retry",
|
||||
)
|
||||
}
|
||||
|
||||
// Typed exceptions are checked first because an IOException message scan
|
||||
// would otherwise swallow SSL/timeout/connect errors whose messages
|
||||
// happen to contain HTTP-ish substrings. Only fall through to the
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
/** Runtime readiness for one independently optional chat transport. */
|
||||
enum class ChatTransportReadiness {
|
||||
NotConfigured,
|
||||
Connecting,
|
||||
Ready,
|
||||
Unavailable,
|
||||
}
|
||||
enum class ChatTransportPath {
|
||||
Gateway,
|
||||
ApiSse,
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral UI state for chat connectivity. Relay is intentionally
|
||||
* absent: it is an optional bridge surface and cannot make chat unhealthy.
|
||||
*/
|
||||
sealed interface ChatRuntimeStatus {
|
||||
data class Connected(
|
||||
val transport: ChatTransportPath,
|
||||
val fallback: Boolean,
|
||||
) : ChatRuntimeStatus
|
||||
|
||||
data object Connecting : ChatRuntimeStatus
|
||||
|
||||
data object Unavailable : ChatRuntimeStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve chat health in product priority order:
|
||||
* Gateway primary, API/SSE fallback, pending connection, then unavailable.
|
||||
*/
|
||||
fun resolveChatRuntimeStatus(
|
||||
gateway: ChatTransportReadiness,
|
||||
apiSse: ChatTransportReadiness,
|
||||
): ChatRuntimeStatus = when {
|
||||
gateway == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
|
||||
transport = ChatTransportPath.Gateway,
|
||||
fallback = false,
|
||||
)
|
||||
|
||||
apiSse == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
|
||||
transport = ChatTransportPath.ApiSse,
|
||||
fallback = true,
|
||||
)
|
||||
|
||||
gateway == ChatTransportReadiness.Connecting ||
|
||||
apiSse == ChatTransportReadiness.Connecting -> ChatRuntimeStatus.Connecting
|
||||
|
||||
else -> ChatRuntimeStatus.Unavailable
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -59,18 +59,16 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* 8. Update URL flows via [setApiServerUrl] and [setRelayUrl] so
|
||||
* [HermesApiClient] and [RelayHttpClient] pick up the new endpoints.
|
||||
* [persistUrls] writes both values to the app DataStore in one pass.
|
||||
* 9. Rebuild the API client via [rebuildApiClient] so subsequent chat
|
||||
* calls hit the new API server with the new API key.
|
||||
* 10. Await the new AuthManager's first non-[AuthState.Unpaired] /
|
||||
* 9. Persist the new active-connection id so Dashboard/Gateway probes resolve
|
||||
* against the target connection rather than the outgoing one.
|
||||
* 10. Rebuild the API client (or clear it for Dashboard-only) and probe the
|
||||
* target Dashboard/Gateway surface.
|
||||
* 11. Await the new AuthManager's first non-[AuthState.Unpaired] /
|
||||
* non-[AuthState.Loading] emission (or a short timeout) before deciding
|
||||
* whether to kick the WSS handshake. Connecting too early fires an
|
||||
* auth envelope with no pair context, which tick the relay's rate
|
||||
* limiter — exactly the trap [AuthManager.hasPairContext] was
|
||||
* introduced to avoid.
|
||||
* 11. Persist the new active-connection id via
|
||||
* [ConnectionStore.setActiveConnection] last so an early failure leaves
|
||||
* the previous active connection in place.
|
||||
*
|
||||
* The whole sequence runs under [switchMutex] so rapid-fire switches from
|
||||
* the UI queue cleanly instead of interleaving partial teardowns.
|
||||
*/
|
||||
@@ -269,11 +267,22 @@ class ConnectionSwitchCoordinator(
|
||||
runCatching { persistUrls(target.apiServerUrl, targetRelayUrl) }
|
||||
.onFailure { Log.w(TAG, "persistUrls failed: ${it.message}") }
|
||||
|
||||
// 9 — rebuild the API client against the new URL + key.
|
||||
// 9 — publish the target context BEFORE rebuilding/probing. The
|
||||
// Dashboard URL and cookie store are resolved from activeConnection;
|
||||
// probing first would accidentally re-probe the outgoing connection
|
||||
// and leave a Dashboard-only target stuck at Gateway.Unknown.
|
||||
runCatching { connectionStore.setActiveConnection(connectionId) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}")
|
||||
return@withLock
|
||||
}
|
||||
|
||||
// 10 — rebuild the API client against the new URL + key (or clear
|
||||
// it and probe Dashboard/Gateway when this is Dashboard-only).
|
||||
runCatching { rebuildApiClient() }
|
||||
.onFailure { Log.w(TAG, "rebuildApiClient failed: ${it.message}") }
|
||||
|
||||
// 10 — wait briefly for the new AuthManager to hydrate its
|
||||
// 11 — wait briefly for the new AuthManager to hydrate its
|
||||
// stored token (if any) so hasPairContext returns the right
|
||||
// answer. The authState flow seeds to Unpaired and flips to
|
||||
// Paired asynchronously; without this await we'd almost
|
||||
@@ -309,24 +318,16 @@ class ConnectionSwitchCoordinator(
|
||||
)
|
||||
}
|
||||
|
||||
if (newAuth.hasPairContext) {
|
||||
if (newAuth.hasPairContext && targetRelayUrl.isNotBlank()) {
|
||||
runCatching { connectionManager.connect(targetRelayUrl) }
|
||||
.onFailure { Log.w(TAG, "connectionManager.connect failed: ${it.message}") }
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"switchConnection: new connection has no pair context — skipping WSS connect " +
|
||||
"(user will pair from the Connection screen)",
|
||||
"switchConnection: Relay is not ready for this connection — " +
|
||||
"skipping WSS connect",
|
||||
)
|
||||
}
|
||||
|
||||
// 11 — persist last so an earlier failure leaves the previous
|
||||
// active pointer in place. setActiveConnection updates the
|
||||
// connectionStore.activeConnectionId StateFlow synchronously
|
||||
// after the DataStore write lands, so the UI reacts without
|
||||
// waiting for a recomposition pass.
|
||||
runCatching { connectionStore.setActiveConnection(connectionId) }
|
||||
.onFailure { Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/** Non-secret handle required to resume a dashboard-owned MCP OAuth flow. */
|
||||
data class PendingMcpOAuth(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val profile: String?,
|
||||
/** Non-secret connection + normalized dashboard identity that owns this flow. */
|
||||
val routeIdentity: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Process-recreatable Manage OAuth state. Authorization URLs, OAuth codes,
|
||||
* callback state, and tokens are deliberately never written here.
|
||||
*/
|
||||
class DashboardManageOAuthViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
private val flowId = savedStateHandle.getStateFlow<String?>(KEY_FLOW_ID, null)
|
||||
private val serverName = savedStateHandle.getStateFlow<String?>(KEY_SERVER_NAME, null)
|
||||
private val profile = savedStateHandle.getStateFlow<String?>(KEY_PROFILE, null)
|
||||
private val routeIdentity = savedStateHandle.getStateFlow<String?>(KEY_ROUTE_IDENTITY, null)
|
||||
|
||||
val pending: StateFlow<PendingMcpOAuth?> = combine(
|
||||
flowId,
|
||||
serverName,
|
||||
profile,
|
||||
routeIdentity,
|
||||
) { id, server, scope, route ->
|
||||
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) null
|
||||
else PendingMcpOAuth(id, server, scope, route)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, currentPending())
|
||||
|
||||
val unsupportedRoutes: StateFlow<ArrayList<String>> =
|
||||
savedStateHandle.getStateFlow(KEY_UNSUPPORTED_ROUTES, arrayListOf())
|
||||
val supportedRoutes: StateFlow<ArrayList<String>> =
|
||||
savedStateHandle.getStateFlow(KEY_SUPPORTED_ROUTES, arrayListOf())
|
||||
|
||||
fun remember(flowId: String, serverName: String, profile: String?, routeIdentity: String) {
|
||||
if (routeIdentity.isBlank()) return
|
||||
savedStateHandle[KEY_FLOW_ID] = flowId
|
||||
savedStateHandle[KEY_SERVER_NAME] = serverName
|
||||
savedStateHandle[KEY_PROFILE] = profile
|
||||
savedStateHandle[KEY_ROUTE_IDENTITY] = routeIdentity
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
savedStateHandle[KEY_FLOW_ID] = null
|
||||
savedStateHandle[KEY_SERVER_NAME] = null
|
||||
savedStateHandle[KEY_PROFILE] = null
|
||||
savedStateHandle[KEY_ROUTE_IDENTITY] = null
|
||||
}
|
||||
|
||||
fun markUnsupported(routeKey: String) {
|
||||
if (routeKey.isBlank()) return
|
||||
val next = ArrayList(unsupportedRoutes.value)
|
||||
if (routeKey !in next) {
|
||||
next += routeKey
|
||||
savedStateHandle[KEY_UNSUPPORTED_ROUTES] = next
|
||||
}
|
||||
removeRoute(KEY_SUPPORTED_ROUTES, supportedRoutes.value, routeKey)
|
||||
}
|
||||
|
||||
fun markSupported(routeKey: String) {
|
||||
if (routeKey.isBlank()) return
|
||||
val next = ArrayList(supportedRoutes.value)
|
||||
if (routeKey !in next) {
|
||||
next += routeKey
|
||||
savedStateHandle[KEY_SUPPORTED_ROUTES] = next
|
||||
}
|
||||
removeRoute(KEY_UNSUPPORTED_ROUTES, unsupportedRoutes.value, routeKey)
|
||||
}
|
||||
|
||||
private fun removeRoute(key: String, routes: ArrayList<String>, routeKey: String) {
|
||||
if (routeKey !in routes) return
|
||||
val next = ArrayList(routes)
|
||||
next.remove(routeKey)
|
||||
savedStateHandle[key] = next
|
||||
}
|
||||
|
||||
private fun currentPending(): PendingMcpOAuth? {
|
||||
val id = savedStateHandle.get<String>(KEY_FLOW_ID)
|
||||
val server = savedStateHandle.get<String>(KEY_SERVER_NAME)
|
||||
val route = savedStateHandle.get<String>(KEY_ROUTE_IDENTITY)
|
||||
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) return null
|
||||
return PendingMcpOAuth(id, server, savedStateHandle.get(KEY_PROFILE), route)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_FLOW_ID = "manage_mcp_oauth_flow_id"
|
||||
const val KEY_SERVER_NAME = "manage_mcp_oauth_server"
|
||||
const val KEY_PROFILE = "manage_mcp_oauth_profile"
|
||||
const val KEY_ROUTE_IDENTITY = "manage_mcp_oauth_route_identity"
|
||||
const val KEY_UNSUPPORTED_ROUTES = "manage_mcp_oauth_unsupported_routes"
|
||||
const val KEY_SUPPORTED_ROUTES = "manage_mcp_oauth_supported_routes"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ import kotlinx.coroutines.launch
|
||||
* live here.
|
||||
*/
|
||||
data class VoiceConfigUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val hasLoaded: Boolean = false,
|
||||
val voiceConfig: VoiceConfig? = null,
|
||||
val voiceConfigError: String? = null,
|
||||
val voiceOutputConfig: VoiceOutputConfig? = null,
|
||||
@@ -107,6 +109,7 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
|
||||
val configErrorEvents: SharedFlow<HumanError> = _configErrorEvents.asSharedFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var loadGeneration: Long = 0L
|
||||
|
||||
fun setBargeInEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { bargeInRepo.setEnabled(enabled) }
|
||||
@@ -132,65 +135,78 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
|
||||
*/
|
||||
fun loadVoiceConfig(client: RelayVoiceClient?, relayVoiceReady: Boolean) {
|
||||
loadJob?.cancel()
|
||||
val generation = ++loadGeneration
|
||||
if (client == null || !relayVoiceReady) {
|
||||
_configState.value = VoiceConfigUiState()
|
||||
return
|
||||
}
|
||||
_configState.value = VoiceConfigUiState(isLoading = true)
|
||||
loadJob = viewModelScope.launch {
|
||||
val voiceResult = client.getVoiceConfig()
|
||||
if (voiceResult.isSuccess) {
|
||||
_configState.update {
|
||||
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
|
||||
try {
|
||||
val voiceResult = client.getVoiceConfig()
|
||||
if (generation != loadGeneration) return@launch
|
||||
if (voiceResult.isSuccess) {
|
||||
_configState.update {
|
||||
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
|
||||
val outputResult = client.getVoiceOutputConfig()
|
||||
if (outputResult.isSuccess) {
|
||||
val config = outputResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
voiceOutputProviderOptions =
|
||||
it.voiceOutputProviderOptions + (provider.id to provider),
|
||||
)
|
||||
val outputResult = client.getVoiceOutputConfig()
|
||||
if (generation != loadGeneration) return@launch
|
||||
if (outputResult.isSuccess) {
|
||||
val config = outputResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
|
||||
if (generation != loadGeneration) return@launch
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
voiceOutputProviderOptions =
|
||||
it.voiceOutputProviderOptions + (provider.id to provider),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceOutputConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceOutputConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
|
||||
val realtimeResult = client.getRealtimeAgentConfig()
|
||||
if (realtimeResult.isSuccess) {
|
||||
val config = realtimeResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(realtimeConfig = config, realtimeConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
realtimeProviderOptions =
|
||||
it.realtimeProviderOptions + (provider.id to provider),
|
||||
)
|
||||
val realtimeResult = client.getRealtimeAgentConfig()
|
||||
if (generation != loadGeneration) return@launch
|
||||
if (realtimeResult.isSuccess) {
|
||||
val config = realtimeResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(realtimeConfig = config, realtimeConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
|
||||
if (generation != loadGeneration) return@launch
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
realtimeProviderOptions =
|
||||
it.realtimeProviderOptions + (provider.id to provider),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
|
||||
}
|
||||
} finally {
|
||||
if (generation == loadGeneration) {
|
||||
_configState.update { it.copy(isLoading = false, hasLoaded = true) }
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
|
||||
import com.hermesandroid.relay.network.relay.VoiceHandoffEvent
|
||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.voice.VoiceIntentSyncBuilder
|
||||
@@ -53,6 +57,7 @@ import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -68,6 +73,7 @@ import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -82,6 +88,17 @@ import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
*/
|
||||
enum class VoiceState { Idle, Listening, Transcribing, Thinking, Speaking, Error }
|
||||
|
||||
private enum class StandardSpeechStreamState {
|
||||
Idle,
|
||||
Opening,
|
||||
Streaming,
|
||||
LegacyFallback,
|
||||
Consumed,
|
||||
}
|
||||
|
||||
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
||||
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
|
||||
|
||||
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
||||
|
||||
@@ -155,6 +172,16 @@ data class VoiceUiState(
|
||||
val backgroundRun: BackgroundRunState? = null,
|
||||
)
|
||||
|
||||
data class VoicePreviewUiState(
|
||||
val selectionKey: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val amplitude: Float = 0f,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isActive: Boolean get() = isLoading || isPlaying
|
||||
}
|
||||
|
||||
/** ADR 33 background/promoted Hermes run surface for the voice overlay. */
|
||||
data class BackgroundRunState(
|
||||
val runId: String? = null,
|
||||
@@ -579,6 +606,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(VoiceUiState())
|
||||
val uiState: StateFlow<VoiceUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _voicePreviewState = MutableStateFlow(VoicePreviewUiState())
|
||||
val voicePreviewState: StateFlow<VoicePreviewUiState> = _voicePreviewState.asStateFlow()
|
||||
private var voicePreviewJob: Job? = null
|
||||
private var voicePreviewGeneration: Long = 0L
|
||||
|
||||
// Rolling voice pipeline telemetry for StatsForNerds → Voice section.
|
||||
// Updated on discrete lifecycle events (turn start/stop, STT call
|
||||
// complete, TTS call complete, barge-in fire, threshold read, state
|
||||
@@ -658,6 +690,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var pendingRawDelta: StringBuilder = StringBuilder()
|
||||
|
||||
private var streamObserverJob: Job? = null
|
||||
private var standardSpeechStreamJob: Job? = null
|
||||
private var standardSpeechStream: VoiceSpeechStream? = null
|
||||
private var standardSpeechStreamGeneration: Long = 0L
|
||||
private var standardSpeechStreamState: StandardSpeechStreamState = StandardSpeechStreamState.Idle
|
||||
private var standardSpeechStreamText: StringBuilder = StringBuilder()
|
||||
private var standardSpeechStreamFinishRequested: Boolean = false
|
||||
private val standardSpeechStreamAudioSeen = AtomicBoolean(false)
|
||||
private val standardSpeechStreamAudioBytes = AtomicInteger(0)
|
||||
private val standardSpeechStreamBargeInStarted = AtomicBoolean(false)
|
||||
private var ttsConsumerJob: Job? = null
|
||||
private var realtimeTtsConsumerJob: Job? = null
|
||||
private var amplitudeBridgeJob: Job? = null
|
||||
@@ -928,6 +969,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
voiceRelayPreflight: (suspend () -> Result<Unit>)? = null,
|
||||
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
|
||||
) {
|
||||
cancelStandardSpeechStream("voice dependencies rewired")
|
||||
this.voiceClient = voiceClient
|
||||
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
|
||||
this.chatViewModel = chatViewModel
|
||||
@@ -1647,6 +1689,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// B4: tear down the barge-in listener + timers before we kill the
|
||||
// player so AEC doesn't try to track a released audio session.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("voice mode exited")
|
||||
duckingWatchdog?.cancel(); duckingWatchdog = null
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
handoffStatusClearJob?.cancel(); handoffStatusClearJob = null
|
||||
@@ -1742,6 +1785,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// listener and cancel any pending resume. Normal "tap mic to
|
||||
// talk" path also lands here, so we always leave Speaking cleanly.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("microphone capture started")
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
lastInterruptedAtChunkIndex = null
|
||||
clearSpokenChunksState()
|
||||
@@ -2053,6 +2097,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// bargeInDetected while the resume watchdog is deliberating.
|
||||
// stopBargeInListener is null-safe.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("speech interrupted")
|
||||
// 2026-04-18: the silence watchdog only runs during Listening, but
|
||||
// cancel defensively so a stale job from the prior turn can't
|
||||
// fire stopListening() after we've already returned to Idle.
|
||||
@@ -2254,6 +2299,222 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays an ephemeral Relay voice-output session using the editor's draft
|
||||
* selection. None of these values are persisted; saving remains an
|
||||
* explicit, separate settings action.
|
||||
*
|
||||
* Calling this with the currently-playing key acts as a pause/stop toggle.
|
||||
* Starting another key always stops the previous preview first, so two
|
||||
* providers can never talk over one another.
|
||||
*/
|
||||
fun previewVoiceOutput(
|
||||
selectionKey: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
voice: String,
|
||||
sampleRate: Int,
|
||||
language: String,
|
||||
sample: String = "Hello, this is Hermes. This is how this voice sounds.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
if (_voicePreviewState.value.isActive &&
|
||||
_voicePreviewState.value.selectionKey == selectionKey
|
||||
) {
|
||||
stopVoicePreview()
|
||||
return
|
||||
}
|
||||
|
||||
val client = voiceClient
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
if (client == null || pcmPlayer == null) {
|
||||
val error = IllegalStateException("Relay voice preview is not available")
|
||||
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
|
||||
onResult(Result.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
val previousJob = voicePreviewJob
|
||||
val generation = ++voicePreviewGeneration
|
||||
_voicePreviewState.value = VoicePreviewUiState(
|
||||
selectionKey = selectionKey,
|
||||
isLoading = true,
|
||||
)
|
||||
voicePreviewJob = viewModelScope.launch {
|
||||
val audioBytes = AtomicInteger(0)
|
||||
try {
|
||||
previousJob?.cancelAndJoin()
|
||||
if (generation != voicePreviewGeneration) return@launch
|
||||
pcmPlayer.stop()
|
||||
val result = client.runVoiceOutput(
|
||||
text = sample,
|
||||
renderMode = "verbatim",
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
) { event ->
|
||||
if (!event.isAudioDelta) return@runVoiceOutput
|
||||
val encoded = event.audioBase64 ?: return@runVoiceOutput
|
||||
val audio = try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
} catch (_: Exception) {
|
||||
return@runVoiceOutput
|
||||
}
|
||||
if (audio.isEmpty()) return@runVoiceOutput
|
||||
if (generation != voicePreviewGeneration) return@runVoiceOutput
|
||||
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
|
||||
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalResult = result.fold(
|
||||
onSuccess = {
|
||||
if (audioBytes.get() <= 0) {
|
||||
Result.failure(IllegalStateException("Voice preview returned no audio"))
|
||||
} else {
|
||||
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
|
||||
delay(drainMs)
|
||||
Result.success(Unit)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
if (generation == voicePreviewGeneration) {
|
||||
onResult(finalResult)
|
||||
_voicePreviewState.value = VoicePreviewUiState(
|
||||
error = finalResult.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} finally {
|
||||
if (generation == voicePreviewGeneration) {
|
||||
pcmPlayer.stop()
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey) {
|
||||
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a one-shot provider-native session with the Realtime editor's
|
||||
* unsaved provider/model/voice selection. The session endpoint accepts
|
||||
* these as request-scoped overrides, so auditioning never mutates relay
|
||||
* configuration or the active chat session.
|
||||
*/
|
||||
fun previewRealtimeAgent(
|
||||
selectionKey: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
voice: String,
|
||||
sampleRate: Int,
|
||||
sample: String = "Introduce this voice in one short sentence.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
if (_voicePreviewState.value.isActive && _voicePreviewState.value.selectionKey == selectionKey) {
|
||||
stopVoicePreview()
|
||||
return
|
||||
}
|
||||
val client = voiceClient
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
if (client == null || pcmPlayer == null) {
|
||||
val error = IllegalStateException("Realtime voice preview is not available")
|
||||
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
|
||||
onResult(Result.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
val previousJob = voicePreviewJob
|
||||
val generation = ++voicePreviewGeneration
|
||||
_voicePreviewState.value = VoicePreviewUiState(selectionKey = selectionKey, isLoading = true)
|
||||
voicePreviewJob = viewModelScope.launch {
|
||||
val audioBytes = AtomicInteger(0)
|
||||
try {
|
||||
previousJob?.cancelAndJoin()
|
||||
if (generation != voicePreviewGeneration) return@launch
|
||||
pcmPlayer.stop()
|
||||
val result = client.runRealtimeAgent(
|
||||
prompt = sample,
|
||||
inputPcm = ByteArray(0),
|
||||
inputSampleRate = 16_000,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
) { event, _ ->
|
||||
if (!event.isAudioDelta) return@runRealtimeAgent
|
||||
val encoded = event.audioBase64 ?: return@runRealtimeAgent
|
||||
val audio = try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
} catch (_: Exception) {
|
||||
return@runRealtimeAgent
|
||||
}
|
||||
if (audio.isEmpty()) return@runRealtimeAgent
|
||||
if (generation != voicePreviewGeneration) return@runRealtimeAgent
|
||||
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
|
||||
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalResult = result.fold(
|
||||
onSuccess = {
|
||||
if (audioBytes.get() <= 0) {
|
||||
Result.failure(IllegalStateException("Realtime preview returned no audio"))
|
||||
} else {
|
||||
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
|
||||
delay(drainMs)
|
||||
Result.success(Unit)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
if (generation == voicePreviewGeneration) {
|
||||
onResult(finalResult)
|
||||
_voicePreviewState.value = VoicePreviewUiState(error = finalResult.exceptionOrNull()?.message)
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} finally {
|
||||
if (generation == voicePreviewGeneration) {
|
||||
pcmPlayer.stop()
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey) {
|
||||
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopVoicePreview() {
|
||||
voicePreviewGeneration += 1
|
||||
voicePreviewJob?.cancel()
|
||||
voicePreviewJob = null
|
||||
realtimePcmPlayer?.stop()
|
||||
_voicePreviewState.value = VoicePreviewUiState()
|
||||
}
|
||||
|
||||
fun testRealtimeAgent(
|
||||
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
@@ -2820,6 +3081,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ignoreAssistantId = chatVm.messages.value
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
|
||||
// Kick off streaming observer BEFORE sending the message so we don't
|
||||
// miss early deltas that arrive synchronously from the callback.
|
||||
startStreamObserver(chatVm)
|
||||
@@ -2874,6 +3137,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun sessionIsCurrent(): Boolean =
|
||||
realtimeSessionGeneration.get() == sessionGeneration
|
||||
if (!prewarm) providerRealtimeAgentTurnActive.set(true)
|
||||
cancelStandardSpeechStream("provider-native realtime turn")
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
streamObserverJob?.cancel()
|
||||
@@ -3778,6 +4042,186 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Sentence-boundary streaming from ChatViewModel
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Optimistically open upstream's per-reply PCM socket while Hermes starts
|
||||
* the chat turn. Deltas are buffered until the fresh single-use dashboard
|
||||
* ticket is minted and the WebSocket opens. Older hosts, expired auth, and
|
||||
* non-streaming providers all converge on [activateStandardSpeechFallback]
|
||||
* before any PCM is heard, preserving the existing POST pipeline.
|
||||
*/
|
||||
private fun prepareStandardSpeechStream() {
|
||||
cancelStandardSpeechStream("new Standard voice turn")
|
||||
val client = voiceAudioClient ?: return
|
||||
if (client.effectiveRoute != VoiceAudioRoute.Standard || realtimePcmPlayer == null) return
|
||||
|
||||
val generation = ++standardSpeechStreamGeneration
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Opening
|
||||
standardSpeechStreamText = StringBuilder()
|
||||
standardSpeechStreamFinishRequested = false
|
||||
standardSpeechStreamAudioSeen.set(false)
|
||||
standardSpeechStreamAudioBytes.set(0)
|
||||
standardSpeechStreamBargeInStarted.set(false)
|
||||
|
||||
standardSpeechStreamJob = viewModelScope.launch {
|
||||
var openedSession: VoiceSpeechStream? = null
|
||||
try {
|
||||
val result = client.openSpeechStream(
|
||||
VoiceSpeechStreamCallbacks(
|
||||
onStart = { sampleRate, channels ->
|
||||
Log.i(
|
||||
TAG,
|
||||
"Standard speech stream ready sampleRate=$sampleRate channels=$channels",
|
||||
)
|
||||
},
|
||||
onPcm = { pcm, sampleRate ->
|
||||
viewModelScope.launch {
|
||||
if (generation != standardSpeechStreamGeneration ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Idle ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
handleStandardSpeechPcm(pcm, sampleRate)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
if (generation != standardSpeechStreamGeneration) {
|
||||
result.getOrNull()?.stop()
|
||||
return@launch
|
||||
}
|
||||
openedSession = result.getOrNull()
|
||||
if (openedSession == null) {
|
||||
activateStandardSpeechFallback(
|
||||
generation,
|
||||
result.exceptionOrNull() ?: IOException("Streaming voice unavailable"),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
standardSpeechStream = openedSession
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Streaming
|
||||
val buffered = standardSpeechStreamText.toString()
|
||||
if (buffered.isNotEmpty()) openedSession.append(buffered)
|
||||
if (standardSpeechStreamFinishRequested) openedSession.finish()
|
||||
|
||||
val outcome = openedSession.awaitOutcome()
|
||||
if (generation != standardSpeechStreamGeneration) return@launch
|
||||
standardSpeechStream = null
|
||||
if (shouldFallbackStandardSpeech(outcome)) {
|
||||
activateStandardSpeechFallback(generation, outcome.error)
|
||||
return@launch
|
||||
}
|
||||
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Consumed
|
||||
if (outcome.audioStarted) {
|
||||
realtimePcmPlayer?.flushBufferedPlayback()
|
||||
Log.i(
|
||||
TAG,
|
||||
"Standard speech stream finished status=${outcome.status} " +
|
||||
"bytes=${standardSpeechStreamAudioBytes.get()}",
|
||||
)
|
||||
}
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
if (generation == standardSpeechStreamGeneration) {
|
||||
activateStandardSpeechFallback(generation, error)
|
||||
}
|
||||
} finally {
|
||||
if (generation != standardSpeechStreamGeneration) openedSession?.stop()
|
||||
if (generation == standardSpeechStreamGeneration) standardSpeechStreamJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when the Standard WebSocket owns this text delta. */
|
||||
private fun offerStandardSpeechText(text: String): Boolean {
|
||||
if (text.isEmpty()) return standardSpeechStreamOwnsReply()
|
||||
return when (standardSpeechStreamState) {
|
||||
StandardSpeechStreamState.Opening -> {
|
||||
standardSpeechStreamText.append(text)
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Streaming -> {
|
||||
standardSpeechStreamText.append(text)
|
||||
standardSpeechStream?.append(text)
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Consumed -> true
|
||||
StandardSpeechStreamState.Idle,
|
||||
StandardSpeechStreamState.LegacyFallback,
|
||||
-> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun standardSpeechStreamOwnsReply(): Boolean =
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Opening ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Streaming ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Consumed
|
||||
|
||||
/** Returns true when stream completion is owned by the Standard socket. */
|
||||
private fun finishStandardSpeechStream(): Boolean {
|
||||
return when (standardSpeechStreamState) {
|
||||
StandardSpeechStreamState.Opening -> {
|
||||
standardSpeechStreamFinishRequested = true
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Streaming -> {
|
||||
standardSpeechStreamFinishRequested = true
|
||||
standardSpeechStream?.finish()
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Consumed -> true
|
||||
StandardSpeechStreamState.Idle,
|
||||
StandardSpeechStreamState.LegacyFallback,
|
||||
-> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun activateStandardSpeechFallback(generation: Long, error: Throwable?) {
|
||||
if (generation != standardSpeechStreamGeneration ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Idle
|
||||
) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Standard speech streaming unavailable; using POST fallback: ${error?.message}")
|
||||
standardSpeechStream?.stop()
|
||||
standardSpeechStream = null
|
||||
standardSpeechStreamState = StandardSpeechStreamState.LegacyFallback
|
||||
val buffered = standardSpeechStreamText.toString()
|
||||
standardSpeechStreamText = StringBuilder()
|
||||
if (buffered.isNotEmpty()) {
|
||||
appendSanitizedDelta(buffered)
|
||||
if (standardSpeechStreamFinishRequested) {
|
||||
flushRemainingBuffer()
|
||||
} else {
|
||||
drainSentences()
|
||||
rearmIdleFlush()
|
||||
}
|
||||
}
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
|
||||
private fun cancelStandardSpeechStream(reason: String) {
|
||||
if (standardSpeechStreamState != StandardSpeechStreamState.Idle) {
|
||||
Log.i(TAG, "Stopping Standard speech stream: $reason")
|
||||
}
|
||||
standardSpeechStreamGeneration += 1
|
||||
standardSpeechStream?.stop()
|
||||
standardSpeechStream = null
|
||||
standardSpeechStreamJob?.cancel()
|
||||
standardSpeechStreamJob = null
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Idle
|
||||
standardSpeechStreamText = StringBuilder()
|
||||
standardSpeechStreamFinishRequested = false
|
||||
standardSpeechStreamAudioSeen.set(false)
|
||||
standardSpeechStreamAudioBytes.set(0)
|
||||
standardSpeechStreamBargeInStarted.set(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe [ChatViewModel.messages]. When the last assistant message
|
||||
* grows (isStreaming=true), diff the content against our last snapshot,
|
||||
@@ -3806,7 +4250,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else if (lastObservedMessageId != msgId) {
|
||||
// A new assistant turn appeared — flush whatever's left
|
||||
// from the previous one, then switch tracking.
|
||||
flushRemainingBuffer()
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
resetBrokeredToolSpeechState()
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
@@ -3829,7 +4273,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
flushRemainingBuffer()
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
// Speaking state will naturally end when TTS queue drains.
|
||||
// We can't easily wait here without blocking the collector;
|
||||
// the TTS consumer transitions back to Idle.
|
||||
@@ -3848,7 +4292,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* (the chat surface has its own renderer and wants the markdown).
|
||||
*/
|
||||
private fun onStreamDelta(delta: String, fullContent: String) {
|
||||
appendSanitizedDelta(delta)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Speaking,
|
||||
@@ -3856,6 +4299,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = fullContent,
|
||||
)
|
||||
}
|
||||
if (offerStandardSpeechText(delta)) return
|
||||
appendSanitizedDelta(delta)
|
||||
drainSentences()
|
||||
rearmIdleFlush()
|
||||
}
|
||||
@@ -3894,7 +4339,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun enqueueBrokeredToolStatus(status: String) {
|
||||
if (status.isBlank()) return
|
||||
if (enqueueSentenceForTts(status, immediate = true)) {
|
||||
val offeredToStandardStream = offerStandardSpeechText("$status ")
|
||||
if (offeredToStandardStream || enqueueSentenceForTts(status, immediate = true)) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
state = VoiceState.Speaking,
|
||||
@@ -4287,7 +4733,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
if (audio.isEmpty()) return
|
||||
handlePcmAudioChunk(
|
||||
audio = audio,
|
||||
sampleRate = event.sampleRate ?: 24_000,
|
||||
source = "Realtime",
|
||||
pcmPlayer = pcmPlayer,
|
||||
audioSeen = audioSeen,
|
||||
audioBytes = audioBytes,
|
||||
bargeInStarted = bargeInStarted,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleStandardSpeechPcm(audio: ByteArray, sampleRate: Int) {
|
||||
val pcmPlayer = realtimePcmPlayer ?: return
|
||||
handlePcmAudioChunk(
|
||||
audio = audio,
|
||||
sampleRate = sampleRate,
|
||||
source = "Standard",
|
||||
pcmPlayer = pcmPlayer,
|
||||
audioSeen = standardSpeechStreamAudioSeen,
|
||||
audioBytes = standardSpeechStreamAudioBytes,
|
||||
bargeInStarted = standardSpeechStreamBargeInStarted,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePcmAudioChunk(
|
||||
audio: ByteArray,
|
||||
sampleRate: Int,
|
||||
source: String,
|
||||
pcmPlayer: RealtimePcmPlayer,
|
||||
audioSeen: AtomicBoolean,
|
||||
audioBytes: AtomicInteger,
|
||||
bargeInStarted: AtomicBoolean,
|
||||
) {
|
||||
if (audio.isEmpty() || sampleRate <= 0) return
|
||||
audioSeen.set(true)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
lastRealtimeAudioDeltaAtMs = System.currentTimeMillis()
|
||||
@@ -4298,11 +4777,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
deliveringChipClearJob?.cancel()
|
||||
settleBackgroundRunChip(reason = "summary_audio_started")
|
||||
}
|
||||
val sampleRate = event.sampleRate ?: 24_000
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime audio delta event=${event.audioEventId ?: 0} bytes=${audio.size} " +
|
||||
"sampleRate=$sampleRate rms=${event.rmsLevel ?: -1f} peak=${event.peakLevel ?: -1f}",
|
||||
"$source audio delta bytes=${audio.size} sampleRate=$sampleRate",
|
||||
)
|
||||
val level = pcmPlayer.write(audio, sampleRate)
|
||||
scheduleRealtimeAmplitudeRelease(audio.size, sampleRate, lastRealtimeAudioDeltaAtMs)
|
||||
@@ -4727,7 +5204,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return decideAgentAudioCompletion(
|
||||
voiceMode = _uiState.value.voiceMode,
|
||||
observerStopped = streamObserverJob?.isActive != true,
|
||||
pendingTtsWork = pendingInTtsQueue.get(),
|
||||
pendingTtsWork = pendingInTtsQueue.get() +
|
||||
if (standardSpeechStreamState == StandardSpeechStreamState.Opening ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Streaming
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
hasPendingSynthFiles = pendingTtsFiles.isNotEmpty(),
|
||||
realtimePlaybackRemainingMs = effectiveRemaining,
|
||||
realtimeTailGuardRemainingMs = continuousResumeTailGuardRemainingMs(),
|
||||
@@ -4737,6 +5221,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private fun finishAgentAudioOutput() {
|
||||
continuousResumeJob = null
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("audio output finished")
|
||||
realtimeAmplitudeDecayJob?.cancel()
|
||||
realtimeAmplitudeDecayJob = null
|
||||
firstFrameWatchdogJob?.cancel()
|
||||
@@ -5444,10 +5929,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
voicePreviewJob?.cancel()
|
||||
voicePreviewJob = null
|
||||
closeRealtimeSession()
|
||||
// B4: release the listener + VAD engine native resources before
|
||||
// anything else. stopBargeInListener is defensive / idempotent.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("view model cleared")
|
||||
duckingWatchdog?.cancel()
|
||||
resumeWatchdog?.cancel()
|
||||
silenceWatchdogJob?.cancel()
|
||||
|
||||
@@ -132,6 +132,19 @@ class ProfileController(
|
||||
AgentDisplay.effectiveSessionProfileName(selected?.name, serverDefault?.active)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/** Display identity resolved through the same sticky server default used by session routing. */
|
||||
val effectiveDisplayProfile: StateFlow<Profile?> = combine(
|
||||
selectedProfile,
|
||||
agentProfiles,
|
||||
serverDefaultProfileScope,
|
||||
) { selected, profiles, serverDefault ->
|
||||
AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selected,
|
||||
profiles = profiles,
|
||||
serverDefaultProfileName = serverDefault?.active,
|
||||
)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* True once the active connection's persisted profile selection has SETTLED
|
||||
* — i.e. profile-scoped reads (session drawer, transcript restore, voice
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 523 KiB |
@@ -12,14 +12,16 @@
|
||||
<string name="onboarding_back">Voltar</string>
|
||||
<string name="onboarding_next">Avançar</string>
|
||||
<string name="onboarding_connect">Conectar</string>
|
||||
<string name="onboarding_welcome_title">Hermes-Relay para Android</string>
|
||||
<string name="onboarding_welcome_description">Converse com o Hermes e gerencie seu painel pelo celular.</string>
|
||||
<string name="onboarding_get_started">Começar</string>
|
||||
<string name="onboarding_step_count">Etapa %1$d de %2$d</string>
|
||||
<string name="onboarding_welcome_title">Hermes,\nno seu bolso</string>
|
||||
<string name="onboarding_welcome_description">Converse, gerencie e use voz\ncom o seu próprio Hermes.</string>
|
||||
<string name="onboarding_welcome_badge">Boas-vindas</string>
|
||||
<string name="onboarding_hermes_logo">Logotipo do Hermes</string>
|
||||
<string name="onboarding_chat_manage_label">Chat e gerenciamento</string>
|
||||
<string name="onboarding_chat_manage_description">Conecte-se ao painel e à API do Hermes em execução. Não é necessário instalar nem parear o Relay.</string>
|
||||
<string name="onboarding_power_tools_label">Ferramentas avançadas</string>
|
||||
<string name="onboarding_power_tools_description">Adicione o Hermes-Relay para usar Terminal, Bridge, sessões do relay e permissões de canais.</string>
|
||||
<string name="onboarding_chat_manage_label">Painel primeiro</string>
|
||||
<string name="onboarding_chat_manage_description">Não requer Relay</string>
|
||||
<string name="onboarding_power_tools_label">Ferramentas depois</string>
|
||||
<string name="onboarding_power_tools_description">Pareie o Relay para usar\nTerminal ou Bridge</string>
|
||||
<string name="onboarding_setup_guide_hint">O guia de configuração tem comandos para copiar e colar quando você precisar iniciar o Hermes em um computador ou servidor.</string>
|
||||
<string name="onboarding_setup_guide">Guia de configuração</string>
|
||||
<string name="onboarding_hermes_docs">Documentação do Hermes</string>
|
||||
@@ -49,6 +51,18 @@
|
||||
<string name="onboarding_realtime_label">Tempo real</string>
|
||||
<string name="onboarding_realtime_description">Agente de voz em tempo real nativo do provedor e provedores de voz específicos para cada perfil.</string>
|
||||
<string name="onboarding_review_permissions">Revisar permissões</string>
|
||||
<string name="onboarding_finish_setup_title">Concluir configuração</string>
|
||||
<string name="onboarding_finish_setup_description">Sua conexão com o Hermes está pronta. Escolha agora o que este telefone pode fazer ou altere depois nas Configurações.</string>
|
||||
<string name="onboarding_chat_manage_ready">Pronto — nenhuma permissão do telefone é necessária.</string>
|
||||
<string name="onboarding_chat_alerts">Alertas do chat</string>
|
||||
<string name="onboarding_chat_alerts_description">Permita notificações do Android para que os alertas ativados cheguem em segundo plano.</string>
|
||||
<string name="onboarding_chat_alerts_ready">As notificações estão ativadas para este app.</string>
|
||||
<string name="onboarding_optional_features">Recursos opcionais</string>
|
||||
<string name="onboarding_optional_features_description">Câmera, microfone, assistente de notificações e ferramentas disponíveis do dispositivo ficam desativados até você escolhê-los.</string>
|
||||
<string name="onboarding_review_optional_permissions">Revisar permissões opcionais</string>
|
||||
<string name="onboarding_enable_chat_alerts">Ativar alertas do chat</string>
|
||||
<string name="onboarding_not_now">Agora não</string>
|
||||
<string name="onboarding_finish">Concluir configuração</string>
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">Conversa por voz ao vivo</string>
|
||||
<string name="chat_input_add_attachment">Adicionar anexo</string>
|
||||
@@ -62,7 +76,7 @@
|
||||
<string name="chat_input_start_voice">Iniciar conversa por voz</string>
|
||||
<string name="chat_input_voice_setup_needed">Conversa por voz — configuração necessária</string>
|
||||
<string name="chat_input_stop_streaming">Parar transmissão</string>
|
||||
<string name="chat_input_steer_response">Direcionar a resposta</string>
|
||||
<string name="chat_input_steer_response">Corrigir a resposta</string>
|
||||
<string name="chat_input_queue_message">Colocar mensagem na fila</string>
|
||||
<!-- Bridge return labels -->
|
||||
<string name="bridge_return_chat_label">Chat</string>
|
||||
@@ -88,7 +102,7 @@
|
||||
<string name="demo_feature_manage">Gerenciar</string>
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">Edite sua mensagem…</string>
|
||||
<string name="chat_placeholder_steer">Direcione a resposta…</string>
|
||||
<string name="chat_placeholder_steer">Corrija a resposta…</string>
|
||||
<string name="chat_placeholder_queue">Coloque uma mensagem na fila…</string>
|
||||
<string name="chat_placeholder_message">Mensagem…</string>
|
||||
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
|
||||
@@ -253,6 +267,39 @@
|
||||
<string name="error_classify_open_settings">Abrir Configurações</string>
|
||||
<!-- Connection wizard — Connect page essentials -->
|
||||
<string name="cw_connect_to_hermes">Conectar ao Hermes</string>
|
||||
<string name="cw_nearby_description">Vamos procurar o Hermes nesta rede. O Painel e o Gateway fornecem a conexão padrão para Chat, Gerenciar e Voz.</string>
|
||||
<string name="cw_before_connecting">Antes de conectar</string>
|
||||
<string name="cw_connect_step_server_title">Inicie o Hermes no computador</string>
|
||||
<string name="cw_connect_step_server_body">Inicie o Painel no computador com Hermes. No primeiro acesso pela rede local, use o guia abaixo para torná-lo acessível pelo celular e ativar o login.</string>
|
||||
<string name="cw_connect_step_network_title">Deixe o servidor acessível</string>
|
||||
<string name="cw_connect_step_network_body">Use a mesma rede Wi-Fi ou conecte os dois dispositivos ao Tailscale.</string>
|
||||
<string name="cw_connect_step_phone_title">Conecte por este celular</string>
|
||||
<string name="cw_connect_step_phone_body">Pesquise abaixo ou informe o endereço do Painel, como 192.168.1.10:9119. Entre na conta se solicitado; nenhuma chave de API é necessária.</string>
|
||||
<string name="cw_nearby_searching">Procurando por perto…</string>
|
||||
<string name="cw_nearby_searching_hint">Mantenha o celular na mesma rede que o servidor Hermes.</string>
|
||||
<string name="cw_nearby_heading">Hermes por perto</string>
|
||||
<string name="cw_nearby_empty">Nenhum servidor Hermes encontrado</string>
|
||||
<string name="cw_nearby_failed">Não foi possível pesquisar nesta rede</string>
|
||||
<string name="cw_nearby_empty_hint">Verifique se o Hermes está em execução e pesquise novamente ou informe o endereço.</string>
|
||||
<string name="cw_nearby_search_again">Pesquisar novamente</string>
|
||||
<string name="cw_nearby_enter_address">Informar endereço</string>
|
||||
<string name="cw_other_connection_methods">Outros métodos de conexão</string>
|
||||
<string name="cw_manual_hermes_title">Informe o endereço do Hermes</string>
|
||||
<string name="cw_manual_hermes_description">Informe o endereço do Painel usado no navegador. Se ele não abrir neste celular, inicie hermes dashboard e verifique o Wi-Fi ou o Tailscale.</string>
|
||||
<string name="cw_hermes_address">Endereço do Hermes</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 ou hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">Nenhuma chave de API é necessária. A porta 9119 é usada quando nenhuma porta é informada.</string>
|
||||
<string name="cw_find_hermes">Procurar Hermes</string>
|
||||
<string name="cw_hermes_found">Hermes encontrado</string>
|
||||
<string name="cw_ready_to_connect">Pronto para conectar</string>
|
||||
<string name="cw_ready">Pronto</string>
|
||||
<string name="cw_available_after_signin">Disponível após entrar</string>
|
||||
<string name="cw_unavailable_server">Indisponível neste servidor</string>
|
||||
<string name="cw_could_not_verify">Não foi possível verificar</string>
|
||||
<string name="cw_choose_another">Escolher outro</string>
|
||||
<string name="cw_sign_in_to_hermes">Entrar no Hermes</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes disponível</string>
|
||||
<string name="cw_semantics_capability">%1$s: %2$s</string>
|
||||
<string name="cw_connect_description">Inicie a API/o painel do Hermes no host e conecte este app. O pareamento com o Relay é opcional e necessário apenas para Terminal, Bridge, sessões do relay e permissões de canais.</string>
|
||||
<string name="cw_try_demo">Experimentar a demonstração</string>
|
||||
<string name="cw_try_demo_subtitle">Explore offline — nenhum servidor é necessário.</string>
|
||||
@@ -295,7 +342,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 ou http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">API do Hermes usada pelo Chat e pelas sessões — a porta 8642 da API e http:// são presumidos para hosts sem esquema (a porta 9119 do painel é determinada separadamente)</string>
|
||||
<string name="cw_scan_message">Procurando o painel/a API do Hermes nesta LAN…</string>
|
||||
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e a voz — a chave da API é opcional para o Chat.</string>
|
||||
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e voz — a chave da API é usada apenas no fallback opcional pela API direta.</string>
|
||||
<string name="cw_pair_relay_section">Parear o Relay (opcional)</string>
|
||||
<string name="cw_pair_relay_section_desc">O plugin do Relay já está em execução? Faça o pareamento aqui para ativar Terminal, Bridge e permissões de canais.</string>
|
||||
<string name="cw_pair_relay_url_label">URL do Relay</string>
|
||||
@@ -417,7 +464,7 @@
|
||||
<string name="endpoints_public">Pública</string>
|
||||
<string name="endpoints_custom">Personalizada</string>
|
||||
<string name="endpoints_route_name">Nome da rota</string>
|
||||
<string name="endpoints_api_url_host">URL ou host do servidor API</string>
|
||||
<string name="endpoints_api_url_host">URL ou host do Dashboard/Gateway</string>
|
||||
<string name="endpoints_saving">Salvando…</string>
|
||||
<string name="endpoints_save">Salvar</string>
|
||||
<string name="endpoints_close">Fechar</string>
|
||||
@@ -544,7 +591,7 @@
|
||||
<string name="settings_hermes_management">Gerenciamento do Hermes</string>
|
||||
<string name="settings_hermes_management_desc">Recursos do painel: habilidades, cron, MCP, perfis e modelos</string>
|
||||
<string name="settings_chat">Chat</string>
|
||||
<string name="settings_chat_desc">Comportamento do chat da API, endpoints, exibição de ferramentas e tamanho das mensagens</string>
|
||||
<string name="settings_chat_desc">Comportamento do chat, Gateway, fallback da API, exibição de ferramentas e tamanho das mensagens</string>
|
||||
<string name="settings_voice_mode">Modo de voz</string>
|
||||
<string name="settings_voice_mode_desc">Voz do painel, opções de relay em tempo real e provedores</string>
|
||||
<string name="settings_threads">Threads</string>
|
||||
@@ -586,9 +633,9 @@
|
||||
<string name="settings_persistent_connection">Conexão persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantém sua conexão com o Hermes aberta em segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de turno concluído</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notifique quando uma resposta terminar enquanto o app estiver em segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sem alerta quando uma resposta em segundo plano terminar</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Manter conectado em suspensão profunda</string>
|
||||
<string name="settings_keep_connected_battery_desc">O Android ainda pode pausar a conexão depois que a tela fica desligada por algum tempo (Doze). Permita o uso irrestrito da bateria para que a Conexão persistente continue funcionando em segundo plano.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Permitir uso irrestrito da bateria</string>
|
||||
@@ -617,8 +664,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Mostre suas mensagens recentes como chips tocáveis acima do editor para enviá-las novamente. Desativado por padrão.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Manter o teclado aberto ao enviar</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Permaneça no editor após enviar. Desative para fechar o teclado depois de cada mensagem enviada.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Notificar quando o Hermes terminar</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Publique uma notificação quando uma resposta for concluída enquanto o app estiver em segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat em segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartilhar o status do celular com o agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Inclua uma breve mensagem do sistema sobre o app e o celular em cada turno do chat</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
|
||||
@@ -699,6 +746,8 @@
|
||||
<string name="conn_voice_relay">Relay</string>
|
||||
<string name="conn_voice_optional">Opcional</string>
|
||||
<string name="conn_api_label">API</string>
|
||||
<string name="conn_chat_label">Chat</string>
|
||||
<string name="conn_manage_label">Gerenciar</string>
|
||||
<string name="conn_dashboard_label">Painel</string>
|
||||
<string name="conn_voice_label">Voz</string>
|
||||
<string name="conn_relay_label">Relay</string>
|
||||
@@ -1375,7 +1424,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Voz do plugin do Relay — provedores específicos por perfil e saída de voz em streaming.</string>
|
||||
<string name="voice_settings_optional">Opcional</string>
|
||||
<string name="voice_settings_signin_route_hint">Você está conectado pela rota %1$s, e os logins do painel são específicos por host — o login feito na sua rede doméstica não é transferido. Entre uma vez em Gerenciar enquanto estiver nesta rota para liberar a voz aqui também.</string>
|
||||
<string name="voice_settings_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Abra Gerenciar para entrar novamente e liberar a voz.</string>
|
||||
<string name="voice_settings_signin_default_hint">Seu painel do Hermes exige login antes que a voz padrão possa transcrever ou falar. Entrar uma vez em Gerenciar libera a voz para esta conexão.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Entrar por Gerenciar</string>
|
||||
<string name="voice_settings_unsupported_build_body">Esta versão do servidor Hermes ainda não oferece as rotas de áudio do painel. Atualize o hermes-agent no servidor ou pareie o Relay para usar a voz do Relay.</string>
|
||||
@@ -1605,7 +1654,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">É necessário entrar no painel</string>
|
||||
<string name="dashboard_signin_required_body">O Gerenciar usa a sessão do painel do Hermes em %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">Você está na rota %1$s. Os logins do painel são específicos por host, então o login da outra rota não é transferido — entre uma vez aqui, e o app manterá as duas sessões.</string>
|
||||
<string name="dashboard_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Entre novamente para atualizá-la nas rotas confiáveis desta conexão.</string>
|
||||
<string name="dashboard_signin_with_provider">Entrar com %1$s</string>
|
||||
<string name="dashboard_username_password">Nome de usuário e senha</string>
|
||||
<string name="dashboard_username">Nome de usuário</string>
|
||||
@@ -1651,6 +1700,31 @@
|
||||
<string name="dashboard_action_use">Usar</string>
|
||||
<string name="dashboard_action_describe">Descrever</string>
|
||||
<string name="dashboard_action_model">Modelo</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Endpoints personalizados</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">Provedores compatíveis com OpenAI</string>
|
||||
<string name="dashboard_action_authenticate">Autenticar</string>
|
||||
<string name="dashboard_action_validate">Validar</string>
|
||||
<string name="dashboard_action_edit">Editar</string>
|
||||
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">O Hermes abrirá o provedor no navegador. Volte aqui depois de aprovar o acesso; as credenciais permanecem no servidor Hermes.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">Autenticação MCP aprovada</string>
|
||||
<string name="dashboard_mcp_oauth_failed">Falha na autenticação MCP</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">Nenhum navegador está disponível para concluir a autenticação MCP.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Adicionar endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Editar endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_name">Nome</string>
|
||||
<string name="dashboard_custom_endpoint_url">URL base</string>
|
||||
<string name="dashboard_custom_endpoint_model">Modelo padrão</string>
|
||||
<string name="dashboard_custom_endpoint_key">Chave de API (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Deixe em branco para preservar uma chave existente ao salvar. A validação usa somente a chave inserida aqui; o Hermes não expõe nem apaga as chaves salvas.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Tamanho do contexto (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Descobrir modelos em /models</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Endpoint acessível · %1$d modelo(s)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Falha ao validar o endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Falha ao salvar o endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Endpoint personalizado salvo</string>
|
||||
<string name="dashboard_action_completed">%1$s concluído</string>
|
||||
<string name="dashboard_action_failed">Falha em %1$s</string>
|
||||
<string name="dashboard_more">Mais</string>
|
||||
@@ -1945,9 +2019,9 @@
|
||||
<string name="active_section_allow_plain_connections">Permitir conexões sem criptografia</string>
|
||||
<string name="active_section_api_hermes_voice_reachable">API acessível — a voz do Hermes está configurada</string>
|
||||
<string name="active_section_api_key_already_set">A chave da API já está definida</string>
|
||||
<string name="active_section_api_key_needed_hint">Insira uma chave se o servidor API exigir.</string>
|
||||
<string name="active_section_api_key_needed_hint">Use o API_SERVER_KEY criado no seu servidor Hermes. O aplicativo não fornece essa chave.</string>
|
||||
<string name="active_section_api_key_not_configured">Chave da API não configurada</string>
|
||||
<string name="active_section_api_key_optional">Chave da API (opcional)</string>
|
||||
<string name="active_section_api_key_optional">Chave da API direta</string>
|
||||
<string name="active_section_api_key_stored_hint">A chave está armazenada com segurança.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API acessível — revise a configuração de voz</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API acessível — a voz do Relay está configurada</string>
|
||||
@@ -2054,6 +2128,7 @@
|
||||
<string name="conn_info_api_server_title">Servidor API</string>
|
||||
<string name="conn_info_approvals_off">As aprovações estão DESATIVADAS</string>
|
||||
<string name="conn_info_auth">Autenticação</string>
|
||||
<string name="conn_info_relay_auth">Autenticação do Relay</string>
|
||||
<string name="conn_info_avg_ttft">TTFT médio</string>
|
||||
<string name="conn_info_channel_grants">Permissões de canais</string>
|
||||
<string name="conn_info_checking">Verificando…</string>
|
||||
@@ -2093,6 +2168,7 @@
|
||||
<string name="conn_info_hermes">Hermes</string>
|
||||
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
|
||||
<string name="conn_info_hostname_paired">%1$s (pareado)</string>
|
||||
<string name="conn_info_hostname_relay_paired">%1$s · Relay pareado</string>
|
||||
<string name="conn_info_idle_suffix"> · Ocioso</string>
|
||||
<string name="conn_info_insecure_mode_allowed">Modo inseguro permitido</string>
|
||||
<string name="conn_info_inspect_profile">Inspecionar %1$s</string>
|
||||
@@ -2304,6 +2380,7 @@
|
||||
<string name="tool_progress_status_completed">concluída</string>
|
||||
<string name="tool_progress_status_failed">falhou</string>
|
||||
<string name="tool_progress_status_running">em execução</string>
|
||||
<string name="image_generation_rendering">Gerando imagem</string>
|
||||
<string name="tool_progress_cd_collapse">Recolher</string>
|
||||
<string name="tool_progress_cd_expand">Expandir</string>
|
||||
<!-- AgentIconRow -->
|
||||
@@ -2399,7 +2476,10 @@
|
||||
<!-- QrPairingScanner -->
|
||||
<string name="qr_scanner_title">Ler QR do Hermes</string>
|
||||
<string name="qr_scanner_instruction">Leia um QR de configuração do Hermes</string>
|
||||
<string name="qr_scanner_subtext">Peça ao Hermes: "Gere um código QR com a URL da API e a chave da API."</string>
|
||||
<string name="qr_scanner_subtext">Escaneie um QR de configuração do Hermes ou de pareamento do Relay. Conexões padrão pelo Dashboard não exigem uma chave de API.</string>
|
||||
<string name="qr_scanner_relay_title">Escanear QR do Relay</string>
|
||||
<string name="qr_scanner_relay_instruction">Escaneie um QR de pareamento do Relay</string>
|
||||
<string name="qr_scanner_relay_subtext">Abra o QR de pareamento do Relay no seu servidor Hermes. A conexão do Dashboard e as rotas existentes não serão alteradas.</string>
|
||||
<string name="qr_scanner_camera_error">Não foi possível iniciar a câmera neste dispositivo.</string>
|
||||
<string name="qr_scanner_fallback_message">Você pode parear sem usar a câmera.</string>
|
||||
<string name="qr_scanner_pair_manual">Parear manualmente</string>
|
||||
@@ -2818,12 +2898,12 @@
|
||||
<string name="diag_check_voice_relay">Voz (Relay)</string>
|
||||
<string name="diag_check_voice_standard">Voz (Padrão)</string>
|
||||
<string name="endpoints_pin_title">Endpoints fixados</string>
|
||||
<string name="endpoints_route_editor_desc">Editar rota</string>
|
||||
<string name="endpoints_route_editor_desc">Adicione o endereço do Dashboard/Gateway que este celular deve usar nessa rede.</string>
|
||||
<string name="endpoints_route_name_placeholder">Nome da rota</string>
|
||||
<string name="endpoints_url_host_placeholder">Host</string>
|
||||
<string name="endpoints_url_supporting_blank">Sem URLs de apoio</string>
|
||||
<string name="endpoints_url_supporting_enter">Insira uma URL</string>
|
||||
<string name="endpoints_url_supporting_preview">Prévia</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z ou host.ts.net</string>
|
||||
<string name="endpoints_url_supporting_blank">Insira o endereço do Dashboard do servidor</string>
|
||||
<string name="endpoints_url_supporting_enter">Use um endereço de Dashboard http:// ou https://</string>
|
||||
<string name="endpoints_url_supporting_preview">Será testado: %1$s</string>
|
||||
<string name="image_viewer_error">Erro ao salvar a imagem</string>
|
||||
<string name="image_viewer_failed">Falha ao salvar a imagem</string>
|
||||
<string name="image_viewer_failed_template">Falha ao salvar a imagem: %s</string>
|
||||
@@ -2979,4 +3059,122 @@
|
||||
<string name="tool_output_risk_a11y">, risco %1$s na saída</string>
|
||||
<string name="tool_output_risk_findings">Riscos encontrados na saída</string>
|
||||
<string name="tool_output_risk_redacted">Trechos sensíveis foram ocultados na origem.</string>
|
||||
<string name="conn_startup_title">Ao iniciar o app</string>
|
||||
<string name="conn_startup_last_used">Última usada</string>
|
||||
<string name="conn_startup_recommended">Recomendado</string>
|
||||
<string name="conn_startup_choose">Escolher conexão inicial</string>
|
||||
<string name="active_section_primary_dashboard">Dashboard principal</string>
|
||||
<string name="active_section_dashboard_reachable_signed_in">Acessível · Conectado</string>
|
||||
<string name="active_section_dashboard_reachable">Acessível</string>
|
||||
<string name="active_section_dashboard_not_checked">Ainda não verificado</string>
|
||||
<string name="active_section_dashboard_unreachable">Inacessível</string>
|
||||
<string name="active_section_no_fallback_routes">Ainda não há rotas alternativas</string>
|
||||
<string name="active_section_no_fallback_routes_desc">Adicione uma rota de API opcional para chat direto alternativo e troca de rede.</string>
|
||||
<string name="active_section_add_api_fallback">Adicionar rota alternativa</string>
|
||||
<string name="active_section_security_authentication">Autenticação</string>
|
||||
<string name="active_section_dashboard_session">Sessão do Dashboard</string>
|
||||
<string name="active_section_credential_storage">Armazenamento de credenciais</string>
|
||||
<string name="active_section_encrypted_storage">Armazenamento criptografado</string>
|
||||
<string name="active_section_no_relay_credential">Sem credencial do Relay</string>
|
||||
<string name="active_section_sign_out_dashboard">Sair do Dashboard</string>
|
||||
<string name="active_section_credentials_encrypted">As credenciais permanecem criptografadas neste dispositivo.</string>
|
||||
<string name="cw_preparing_connection">Preparando a conexão…</string>
|
||||
<string name="cw_preparing_connection_hint">Configurando o armazenamento local seguro. Isso levará apenas um instante.</string>
|
||||
<string name="cw_relay_entry_title">Hermes Relay</string>
|
||||
<string name="cw_relay_entry_desc">Emparelhe o Relay para adicionar Terminal, Bridge, ferramentas do dispositivo, sessões do Relay e permissões.</string>
|
||||
<string name="cw_relay_pair_qr">Emparelhar Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">Escaneie um QR de configuração do Relay</string>
|
||||
<string name="cw_relay_enter_code">Inserir código de emparelhamento</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard e Gateway estão prontos. Você pode começar a conversar ou abrir Manage.</string>
|
||||
<string name="cw_continue">Continuar</string>
|
||||
<string name="cw_timeline_discovered">Hermes encontrado</string>
|
||||
<string name="cw_timeline_discovered_detail">Identidade do Dashboard e endpoint de status verificados</string>
|
||||
<string name="cw_timeline_access">Acesso ao Dashboard</string>
|
||||
<string name="cw_timeline_access_ready">Nenhum login adicional necessário</string>
|
||||
<string name="cw_timeline_authenticated">Autenticação verificada</string>
|
||||
<string name="cw_timeline_ready">Conexão pronta</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage e Voice podem usar este Dashboard</string>
|
||||
<string name="active_section_optional_api_fallback">Fallback opcional pela API direta</string>
|
||||
<string name="active_section_api_not_required">Não é necessário quando esta conexão usa o Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">Onde obtenho essa chave?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY é criada no seu servidor Hermes; este aplicativo não fornece a chave. Configure-a somente ao ativar o servidor de API opcional, que exige uma chave utilizável, e insira aqui o mesmo valor.</string>
|
||||
<string name="active_section_scan_relay_qr">Escanear QR do Relay</string>
|
||||
<string name="active_section_other_relay_methods">Outros métodos de pareamento</string>
|
||||
<string name="cw_pair_relay_for">Emparelhar Relay com %1$s</string>
|
||||
<string name="cw_pair_relay_scoped_desc">Adicione a extensão opcional Relay a esta conexão Hermes salva. Isso não adiciona nem substitui um servidor.</string>
|
||||
<string name="cw_current_connection">Conexão atual</string>
|
||||
<string name="cw_current_hermes_connection">CONEXÃO HERMES ATUAL</string>
|
||||
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice e suas rotas salvas permanecem inalterados.</string>
|
||||
<string name="detail_dashboard_primary">Dashboard principal</string>
|
||||
<string name="detail_core_ready">Núcleo do Hermes pronto</string>
|
||||
<string name="detail_core_configured">Núcleo do Hermes configurado</string>
|
||||
<string name="detail_overview_summary">Chat, Manage e Voice usam o Hermes padrão. O Relay é uma extensão opcional para recursos avançados do dispositivo.</string>
|
||||
<plurals name="conn_server_count">
|
||||
<item quantity="one">%1$d servidor</item>
|
||||
<item quantity="other">%1$d servidores</item>
|
||||
</plurals>
|
||||
<string name="conn_switch">Trocar</string>
|
||||
<string name="conn_switching">Trocando…</string>
|
||||
<string name="conn_switched">Ativa</string>
|
||||
<string name="conn_connecting_to">Conectando a %1$s…</string>
|
||||
<string name="conn_last_used_now">Último uso: agora mesmo</string>
|
||||
<string name="conn_last_used_format">Último uso: %1$s</string>
|
||||
<string name="conn_just_now">agora mesmo</string>
|
||||
<string name="conn_dashboard_only_route">Somente Dashboard</string>
|
||||
<string name="conn_no_routes">Nenhuma rota configurada</string>
|
||||
<string name="active_section_reachable_badge">ACESSÍVEL</string>
|
||||
<string name="active_section_unchecked_badge">NÃO VERIFICADO</string>
|
||||
<string name="active_section_edit">Editar</string>
|
||||
<string name="active_section_fallback_routes">Rotas alternativas</string>
|
||||
<string name="active_section_route_selection">Seleção de rota</string>
|
||||
<string name="active_section_automatic">Automática</string>
|
||||
<string name="active_section_api_access">Acesso à API</string>
|
||||
<string name="active_section_core_hermes">Núcleo do Hermes</string>
|
||||
<string name="active_section_optional_relay">Relay opcional</string>
|
||||
<string name="active_section_extend_connection">Ampliar esta conexão</string>
|
||||
<string name="active_section_relay_connected_features">Extensões do Relay</string>
|
||||
<string name="active_section_relay_optional_summary">Adicione Terminal, Bridge, ferramentas do dispositivo, sessões do Relay e rotas remotas seguras.</string>
|
||||
<string name="active_section_view_relay_details">Ver detalhes do Relay</string>
|
||||
<string name="active_section_core_unchanged">Sua conexão Hermes atual permanece inalterada.</string>
|
||||
<string name="active_section_api_optional_direct">Opcional para chat direto e integrações</string>
|
||||
<string name="active_section_configure_test">Configurar e testar</string>
|
||||
<string name="active_section_relay_optional_bridge">Recursos opcionais do Bridge e acesso remoto</string>
|
||||
<string name="active_section_configure_relay">Configurar Relay</string>
|
||||
<string name="active_section_pair_device_using_code">Emparelhe este dispositivo usando um código do servidor.</string>
|
||||
<string name="active_section_enter_pairing_code">Inserir código de emparelhamento</string>
|
||||
<string name="active_section_done">Concluído</string>
|
||||
<string name="active_section_connection_behavior">Comportamento da conexão</string>
|
||||
<string name="active_section_manual_pairing">Emparelhamento manual</string>
|
||||
<string name="active_section_paired">Emparelhado</string>
|
||||
<string name="active_section_not_paired">Não emparelhado</string>
|
||||
<string name="active_section_transport">Transporte</string>
|
||||
<string name="active_section_hardware_backed">Protegido por hardware</string>
|
||||
<string name="active_section_relay_session">Sessão do Relay</string>
|
||||
<string name="active_section_access">Acesso</string>
|
||||
<string name="active_section_paired_devices">Dispositivos emparelhados</string>
|
||||
<string name="active_section_device_count">%1$d dispositivos</string>
|
||||
<string name="active_section_session_activity">Atividade da sessão</string>
|
||||
<string name="active_section_last_checked_just_now">Verificado agora mesmo</string>
|
||||
<string name="active_section_protected">Protegido</string>
|
||||
<string name="active_section_not_encrypted">Não criptografado</string>
|
||||
<string name="active_section_no_security_issues">Nenhum problema de segurança detectado</string>
|
||||
<string name="active_section_unencrypted_transport">Esta conexão usa um transporte não criptografado</string>
|
||||
<string name="active_section_actions">Ações</string>
|
||||
<string name="active_section_revoke_relay">Revogar emparelhamento do Relay</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">Como este celular acessa %1$s</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · LAN doméstica</string>
|
||||
<string name="endpoints_tailscale_setup_hint">O Tailscale deve estar conectado nos dois dispositivos, e o Hermes Dashboard deve estar acessível na porta 9119.</string>
|
||||
<string name="endpoints_setup_help">Ajuda para configurar o Tailscale</string>
|
||||
<string name="voice_settings_provider_desc">Onde a fala e gerada. As opcoes disponiveis vêm deste host Hermes e dos provedores instalados.</string>
|
||||
<string name="voice_settings_model_desc">Latest acompanha atualizacoes do provedor. Escolha um modelo versionado para manter o comportamento de voz fixo.</string>
|
||||
<string name="voice_settings_voice_desc">Escolha como as respostas soam. Previsualizar uma voz nao salva a selecao.</string>
|
||||
<string name="voice_settings_language_desc">Automatico deixa o provedor detectar o idioma falado. Escolha um idioma somente quando a deteccao nao for confiavel.</string>
|
||||
<string name="voice_settings_language_auto">Automatico</string>
|
||||
<string name="voice_settings_provider_options_title">Opcoes de voz</string>
|
||||
<string name="voice_settings_provider_options_desc">Somente configuracoes aceitas pelo provedor selecionado sao mostradas.</string>
|
||||
<string name="voice_settings_auto_speak">Ler respostas em voz alta</string>
|
||||
<string name="voice_settings_auto_speak_desc">Fala automaticamente as respostas do assistente em superficies Hermes que respeitam esta configuracao do host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla a sessao de fala ao vivo, nao o modelo de chat Hermes. Latest acompanha atualizacoes do provedor; um modelo versionado fica fixo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">A voz usada dentro da sessao ao vivo. Vozes integradas e personalizadas aparecem quando o provedor as anuncia.</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
<string name="onboarding_next">下一步</string>
|
||||
<string name="onboarding_connect">连接</string>
|
||||
|
||||
<string name="onboarding_welcome_title">Hermes-Relay Android 版</string>
|
||||
<string name="onboarding_welcome_description">在手机上和 Hermes 对话,并管理你的仪表盘。</string>
|
||||
<string name="onboarding_get_started">开始使用</string>
|
||||
<string name="onboarding_step_count">第 %1$d 步,共 %2$d 步</string>
|
||||
<string name="onboarding_welcome_title">Hermes,\n尽在掌中</string>
|
||||
<string name="onboarding_welcome_description">与你自己的 Hermes 聊天、管理\n并使用语音。</string>
|
||||
<string name="onboarding_welcome_badge">欢迎</string>
|
||||
<string name="onboarding_hermes_logo">Hermes 标志</string>
|
||||
<string name="onboarding_chat_manage_label">聊天与管理</string>
|
||||
<string name="onboarding_chat_manage_description">连接正在运行的 Hermes 仪表盘和 API。无需安装 Relay,也无需配对。</string>
|
||||
<string name="onboarding_power_tools_label">高级工具</string>
|
||||
<string name="onboarding_power_tools_description">需要终端、Bridge、Relay 会话或频道授权时,再添加 Hermes-Relay。</string>
|
||||
<string name="onboarding_chat_manage_label">仪表盘优先</string>
|
||||
<string name="onboarding_chat_manage_description">无需 Relay</string>
|
||||
<string name="onboarding_power_tools_label">高级工具稍后添加</string>
|
||||
<string name="onboarding_power_tools_description">需要终端或 Bridge 时\n再配对 Relay</string>
|
||||
<string name="onboarding_setup_guide_hint">设置指南里提供了复制即用的命令,方便你在电脑或服务器上启动 Hermes。</string>
|
||||
<string name="onboarding_setup_guide">安装指南</string>
|
||||
<string name="onboarding_hermes_docs">Hermes 文档</string>
|
||||
@@ -54,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">实时语音</string>
|
||||
<string name="onboarding_realtime_description">提供商原生的实时语音代理,以及按配置文件区分的语音提供商。</string>
|
||||
<string name="onboarding_review_permissions">查看权限</string>
|
||||
<string name="onboarding_finish_setup_title">完成设置</string>
|
||||
<string name="onboarding_finish_setup_description">Hermes 连接已就绪。现在选择这部手机可以使用的功能,或稍后在设置中更改。</string>
|
||||
<string name="onboarding_chat_manage_ready">已就绪 — 无需手机权限。</string>
|
||||
<string name="onboarding_chat_alerts">聊天提醒</string>
|
||||
<string name="onboarding_chat_alerts_description">允许 Android 通知,以便已启用的提醒可在后台送达。</string>
|
||||
<string name="onboarding_chat_alerts_ready">此应用的通知已启用。</string>
|
||||
<string name="onboarding_optional_features">可选功能</string>
|
||||
<string name="onboarding_optional_features_description">相机、麦克风、通知助手和可用的设备工具会保持关闭,直到你主动选择。</string>
|
||||
<string name="onboarding_review_optional_permissions">查看可选权限</string>
|
||||
<string name="onboarding_enable_chat_alerts">启用聊天提醒</string>
|
||||
<string name="onboarding_not_now">暂不</string>
|
||||
<string name="onboarding_finish">完成设置</string>
|
||||
|
||||
<!-- 聊天输入栏 -->
|
||||
<string name="chat_input_live_voice_hint">实时语音对话</string>
|
||||
@@ -68,7 +82,7 @@
|
||||
<string name="chat_input_start_voice">开始语音对话</string>
|
||||
<string name="chat_input_voice_setup_needed">语音对话——需要先设置</string>
|
||||
<string name="chat_input_stop_streaming">停止流式响应</string>
|
||||
<string name="chat_input_steer_response">引导回复方向</string>
|
||||
<string name="chat_input_steer_response">修正回复</string>
|
||||
<string name="chat_input_queue_message">排队发送消息</string>
|
||||
|
||||
<!-- Bridge 返回标签 -->
|
||||
@@ -99,7 +113,7 @@
|
||||
|
||||
<!-- 聊天界面占位符 -->
|
||||
<string name="chat_placeholder_edit">编辑你的消息…</string>
|
||||
<string name="chat_placeholder_steer">引导回复方向…</string>
|
||||
<string name="chat_placeholder_steer">修正回复…</string>
|
||||
<string name="chat_placeholder_queue">排队发送消息…</string>
|
||||
<string name="chat_placeholder_message">输入消息…</string>
|
||||
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
|
||||
@@ -274,6 +288,39 @@
|
||||
|
||||
<!-- 连接向导 —— Connect 页核心文案 -->
|
||||
<string name="cw_connect_to_hermes">连接 Hermes</string>
|
||||
<string name="cw_nearby_description">我们将在此网络中查找 Hermes。仪表板和网关提供聊天、管理和语音的标准连接。</string>
|
||||
<string name="cw_before_connecting">连接之前</string>
|
||||
<string name="cw_connect_step_server_title">在电脑上启动 Hermes</string>
|
||||
<string name="cw_connect_step_server_body">在 Hermes 电脑上启动仪表板。首次通过局域网访问时,请使用下方的设置指南,让手机能够访问并启用登录。</string>
|
||||
<string name="cw_connect_step_network_title">让服务器可访问</string>
|
||||
<string name="cw_connect_step_network_body">使用同一个 Wi-Fi,或将两台设备都连接到 Tailscale。</string>
|
||||
<string name="cw_connect_step_phone_title">从此手机连接</string>
|
||||
<string name="cw_connect_step_phone_body">在下方搜索或输入仪表板地址,例如 192.168.1.10:9119。如有提示请登录;无需 API 密钥。</string>
|
||||
<string name="cw_nearby_searching">正在搜索附近设备…</string>
|
||||
<string name="cw_nearby_searching_hint">请确保手机与 Hermes 服务器位于同一网络。</string>
|
||||
<string name="cw_nearby_heading">附近的 Hermes</string>
|
||||
<string name="cw_nearby_empty">未找到 Hermes 服务器</string>
|
||||
<string name="cw_nearby_failed">无法搜索此网络</string>
|
||||
<string name="cw_nearby_empty_hint">请确认 Hermes 正在运行,然后重新搜索或输入其地址。</string>
|
||||
<string name="cw_nearby_search_again">重新搜索</string>
|
||||
<string name="cw_nearby_enter_address">改为输入地址</string>
|
||||
<string name="cw_other_connection_methods">其他连接方式</string>
|
||||
<string name="cw_manual_hermes_title">输入 Hermes 地址</string>
|
||||
<string name="cw_manual_hermes_description">请输入你在浏览器中打开的仪表板地址。如果此手机无法打开,请启动 hermes dashboard 并检查 Wi-Fi 或 Tailscale。</string>
|
||||
<string name="cw_hermes_address">Hermes 地址</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 或 hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">无需 API 密钥。未指定端口时使用仪表板端口 9119。</string>
|
||||
<string name="cw_find_hermes">查找 Hermes</string>
|
||||
<string name="cw_hermes_found">已找到 Hermes</string>
|
||||
<string name="cw_ready_to_connect">可以连接</string>
|
||||
<string name="cw_ready">就绪</string>
|
||||
<string name="cw_available_after_signin">登录后可用</string>
|
||||
<string name="cw_unavailable_server">此服务器不支持</string>
|
||||
<string name="cw_could_not_verify">无法验证</string>
|
||||
<string name="cw_choose_another">选择其他服务器</string>
|
||||
<string name="cw_sign_in_to_hermes">登录 Hermes</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes 可用</string>
|
||||
<string name="cw_semantics_capability">%1$s:%2$s</string>
|
||||
<string name="cw_connect_description">先在主机上启动 Hermes API 或仪表盘,再在此连接。Relay 配对是可选的,仅在需要终端、Bridge、Relay 会话或频道授权时才使用。</string>
|
||||
<string name="cw_try_demo">体验演示</string>
|
||||
<string name="cw_try_demo_subtitle">离线探索——无需服务器。</string>
|
||||
@@ -318,8 +365,13 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 或 http://你的服务器:8642</string>
|
||||
<string name="cw_api_url_supporting">聊天和会话使用的 Hermes API——裸主机名默认使用 API 端口 8642 和 http://(仪表盘的 9119 端口单独推导)</string>
|
||||
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥对聊天是可选的。</string>
|
||||
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥仅用于可选的直接 API 回退。</string>
|
||||
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
|
||||
<string name="cw_pair_relay_for">为 %1$s 配对 Relay</string>
|
||||
<string name="cw_pair_relay_scoped_desc">为这个已保存的 Hermes 连接添加可选的 Relay 扩展。这不会添加或替换服务器。</string>
|
||||
<string name="cw_current_connection">当前连接</string>
|
||||
<string name="cw_current_hermes_connection">当前 HERMES 连接</string>
|
||||
<string name="cw_existing_connection_unchanged">Chat、Manage、Voice 和已保存的路由均保持不变。</string>
|
||||
<string name="cw_pair_relay_section_desc">已经在运行 Relay 插件?在此配对以启用终端、Bridge 和频道授权。</string>
|
||||
<string name="cw_pair_relay_url_label">Relay 地址</string>
|
||||
<string name="cw_pair_relay_code_label">配对码</string>
|
||||
@@ -441,7 +493,7 @@
|
||||
<string name="endpoints_public">公网</string>
|
||||
<string name="endpoints_custom">自定义</string>
|
||||
<string name="endpoints_route_name">路由名称</string>
|
||||
<string name="endpoints_api_url_host">API 服务器地址或主机名</string>
|
||||
<string name="endpoints_api_url_host">Dashboard/Gateway 地址或主机名</string>
|
||||
<string name="endpoints_saving">保存中…</string>
|
||||
<string name="endpoints_save">保存</string>
|
||||
<string name="endpoints_close">关闭</string>
|
||||
@@ -580,7 +632,7 @@
|
||||
<string name="settings_hermes_management">Hermes 管理</string>
|
||||
<string name="settings_hermes_management_desc">仪表盘功能:技能、定时任务、MCP、配置文件、模型</string>
|
||||
<string name="settings_chat">聊天</string>
|
||||
<string name="settings_chat_desc">API 聊天行为、端点、工具显示、消息长度</string>
|
||||
<string name="settings_chat_desc">聊天行为、Gateway、API 回退、工具显示、消息长度</string>
|
||||
<string name="settings_voice_mode">语音模式</string>
|
||||
<string name="settings_voice_mode_desc">仪表盘语音、实时 Relay 选项、提供商</string>
|
||||
<string name="settings_threads">话题</string>
|
||||
@@ -622,9 +674,9 @@
|
||||
<string name="settings_persistent_connection">持久连接</string>
|
||||
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
|
||||
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
|
||||
<string name="settings_turn_complete_alerts">回合完成提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">应用在后台时回复完成则通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">后台回复完成时不提醒</string>
|
||||
<string name="settings_turn_complete_alerts">聊天提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</string>
|
||||
<string name="settings_keep_connected_deep_sleep">在深度睡眠中保持连接</string>
|
||||
<string name="settings_keep_connected_battery_desc">屏幕关闭一段时间后 Android 仍可能暂停连接(Doze)。允许不受限制的电池使用,让持久连接在后台继续工作。</string>
|
||||
<string name="settings_allow_unrestricted_battery">允许不受限制的电池</string>
|
||||
@@ -654,8 +706,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">在输入栏上方显示最近消息为可点击的标签以便重发。默认关闭。</string>
|
||||
<string name="chat_settings_keep_keyboard_open">发送时保持键盘打开</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">发送后留在输入栏。关闭则在每条消息发送后收起键盘。</string>
|
||||
<string name="chat_settings_notify_when_finishes">Hermes 完成时通知</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">应用在后台时回复完成则发送通知</string>
|
||||
<string name="chat_settings_notify_when_finishes">后台聊天提醒</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="chat_settings_share_phone_status">与代理分享手机状态</string>
|
||||
<string name="chat_settings_share_phone_status_desc">每轮聊天附带一条关于应用和手机的简短系统消息</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
|
||||
@@ -737,6 +789,8 @@
|
||||
<string name="conn_voice_relay">Relay</string>
|
||||
<string name="conn_voice_optional">可选</string>
|
||||
<string name="conn_api_label">API</string>
|
||||
<string name="conn_chat_label">聊天</string>
|
||||
<string name="conn_manage_label">管理</string>
|
||||
<string name="conn_dashboard_label">仪表盘</string>
|
||||
<string name="conn_voice_label">语音</string>
|
||||
<string name="conn_relay_label">Relay</string>
|
||||
@@ -776,6 +830,10 @@
|
||||
<string name="detail_inactive_body">此连接未激活。切换到它以查看实时状态并管理路由、高级设置和 Relay 会话。</string>
|
||||
<string name="detail_switch_to">切换到此连接</string>
|
||||
<string name="detail_rename_title">重命名连接</string>
|
||||
<string name="detail_dashboard_primary">Dashboard 为主要连接方式</string>
|
||||
<string name="detail_core_ready">Hermes 核心已就绪</string>
|
||||
<string name="detail_core_configured">Hermes 核心已配置</string>
|
||||
<string name="detail_overview_summary">Chat、Manage 和 Voice 使用标准 Hermes。Relay 是为高级设备功能提供的可选扩展。</string>
|
||||
<string name="detail_tab_overview">概览</string>
|
||||
<string name="detail_tab_routes">路由</string>
|
||||
<string name="detail_tab_advanced">高级</string>
|
||||
@@ -1427,7 +1485,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Relay 插件语音——支持按个人资料的提供商和流式语音输出。</string>
|
||||
<string name="voice_settings_optional">可选</string>
|
||||
<string name="voice_settings_signin_route_hint">您正通过 %1$s 路由连接,而仪表板登录是按主机区分的——家庭网络上的登录不会延续到此。请在此路由上在 Manage 中登录一次,以在此处解锁语音。</string>
|
||||
<string name="voice_settings_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请打开“管理”重新登录并解锁语音。</string>
|
||||
<string name="voice_settings_signin_default_hint">您的 Hermes 仪表板需要先登录,标准语音才能转录或朗读。在 Manage 中登录一次即可为此连接解锁。</string>
|
||||
<string name="voice_settings_sign_in_via_manage">通过 Manage 登录</string>
|
||||
<string name="voice_settings_unsupported_build_body">此 Hermes 服务器构建尚未公开仪表板音频路由。请在服务器上更新 hermes-agent,或配对 Relay 以使用 Relay 语音。</string>
|
||||
@@ -1666,7 +1724,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">需要登录仪表盘</string>
|
||||
<string name="dashboard_signin_required_body">管理功能使用位于 %1$s 的 Hermes 仪表盘会话。</string>
|
||||
<string name="dashboard_signin_route_hint">你当前使用 %1$s 路由。仪表盘登录按主机区分,所以在另一条路由上的登录不会自动延续——在这里登录一次,应用会同时保留两个会话。</string>
|
||||
<string name="dashboard_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请重新登录,以便在此连接的可信路由之间刷新会话。</string>
|
||||
<string name="dashboard_signin_with_provider">使用 %1$s 登录</string>
|
||||
<string name="dashboard_username_password">用户名与密码</string>
|
||||
<string name="dashboard_username">用户名</string>
|
||||
@@ -1717,6 +1775,31 @@
|
||||
<string name="dashboard_action_use">使用</string>
|
||||
<string name="dashboard_action_describe">描述</string>
|
||||
<string name="dashboard_action_model">模型</string>
|
||||
<string name="dashboard_tab_custom_endpoints">端点</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">端点</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">自定义端点</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 兼容提供商</string>
|
||||
<string name="dashboard_action_authenticate">认证</string>
|
||||
<string name="dashboard_action_validate">验证</string>
|
||||
<string name="dashboard_action_edit">编辑</string>
|
||||
<string name="dashboard_mcp_oauth_title">认证 %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes 将在浏览器中打开提供商。批准访问后请返回此页面;凭据将保留在 Hermes 服务器上。</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP 认证已获批准</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP 认证失败</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">没有可用于完成 MCP 认证的浏览器。</string>
|
||||
<string name="dashboard_custom_endpoint_add">添加端点</string>
|
||||
<string name="dashboard_custom_endpoint_edit">编辑端点</string>
|
||||
<string name="dashboard_custom_endpoint_name">名称</string>
|
||||
<string name="dashboard_custom_endpoint_url">基础 URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">默认模型</string>
|
||||
<string name="dashboard_custom_endpoint_key">API 密钥(可选)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">保存时留空可保留现有密钥。验证仅使用在此输入的密钥;Hermes 不会显示或清除已保存的密钥。</string>
|
||||
<string name="dashboard_custom_endpoint_context">上下文长度(可选)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">从 /models 发现模型</string>
|
||||
<string name="dashboard_custom_endpoint_valid">端点可访问 · %1$d 个模型</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">端点验证失败</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">端点保存失败</string>
|
||||
<string name="dashboard_custom_endpoint_saved">自定义端点已保存</string>
|
||||
<string name="dashboard_action_completed">%1$s 已完成</string>
|
||||
<string name="dashboard_action_failed">%1$s 失败</string>
|
||||
<string name="dashboard_more">更多</string>
|
||||
@@ -2029,9 +2112,9 @@
|
||||
<string name="active_section_allow_plain_connections">允许明文连接</string>
|
||||
<string name="active_section_api_hermes_voice_reachable">API 可达——Hermes 语音已配置</string>
|
||||
<string name="active_section_api_key_already_set">API 密钥已设置</string>
|
||||
<string name="active_section_api_key_needed_hint">如果 API 服务器需要密钥,请在此输入。</string>
|
||||
<string name="active_section_api_key_needed_hint">使用在 Hermes 服务器上创建的 API_SERVER_KEY。此密钥并非由应用提供。</string>
|
||||
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
|
||||
<string name="active_section_api_key_optional">API 密钥(可选)</string>
|
||||
<string name="active_section_api_key_optional">直接 API 的 API 密钥</string>
|
||||
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
|
||||
<string name="active_section_api_reachable_voice_review">API 可达——请检查语音配置</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API 可达——Relay 语音已配置</string>
|
||||
@@ -2139,6 +2222,7 @@
|
||||
<string name="conn_info_api_server_title">API 服务器</string>
|
||||
<string name="conn_info_approvals_off">审批已关闭</string>
|
||||
<string name="conn_info_auth">认证</string>
|
||||
<string name="conn_info_relay_auth">Relay 认证</string>
|
||||
<string name="conn_info_avg_ttft">平均 TTFT</string>
|
||||
<string name="conn_info_channel_grants">频道授权</string>
|
||||
<string name="conn_info_checking">检查中…</string>
|
||||
@@ -2178,6 +2262,7 @@
|
||||
<string name="conn_info_hermes">Hermes</string>
|
||||
<string name="conn_info_hostname_hermes">%1$s(Hermes)</string>
|
||||
<string name="conn_info_hostname_paired">%1$s(已配对)</string>
|
||||
<string name="conn_info_hostname_relay_paired">%1$s · Relay 已配对</string>
|
||||
<string name="conn_info_idle_suffix">· 空闲</string>
|
||||
<string name="conn_info_insecure_mode_allowed">允许不安全模式</string>
|
||||
<string name="conn_info_inspect_profile">查看 %1$s</string>
|
||||
@@ -2404,6 +2489,7 @@
|
||||
<string name="tool_progress_status_completed">已完成</string>
|
||||
<string name="tool_progress_status_failed">失败</string>
|
||||
<string name="tool_progress_status_running">运行中</string>
|
||||
<string name="image_generation_rendering">正在生成图像</string>
|
||||
<string name="tool_progress_cd_collapse">折叠</string>
|
||||
<string name="tool_progress_cd_expand">展开</string>
|
||||
|
||||
@@ -2510,7 +2596,10 @@
|
||||
<!-- QrPairingScanner -->
|
||||
<string name="qr_scanner_title">扫描 Hermes QR</string>
|
||||
<string name="qr_scanner_instruction">扫描 Hermes 设置二维码</string>
|
||||
<string name="qr_scanner_subtext">询问 Hermes:"生成一个包含我的 API URL 和 API 密钥的二维码。"</string>
|
||||
<string name="qr_scanner_subtext">扫描 Hermes 设置二维码或 Relay 配对二维码。标准 Dashboard 连接不需要 API 密钥。</string>
|
||||
<string name="qr_scanner_relay_title">扫描 Relay 二维码</string>
|
||||
<string name="qr_scanner_relay_instruction">扫描 Relay 配对二维码</string>
|
||||
<string name="qr_scanner_relay_subtext">在 Hermes 服务器上打开 Relay 配对二维码。现有 Dashboard 连接和路由将保持不变。</string>
|
||||
<string name="qr_scanner_camera_error">无法在此设备上启动相机。</string>
|
||||
<string name="qr_scanner_fallback_message">您可以在没有相机的情况下配对。</string>
|
||||
<string name="qr_scanner_pair_manual">手动配对</string>
|
||||
@@ -2913,12 +3002,12 @@
|
||||
<string name="diag_check_voice_relay">语音(Relay)</string>
|
||||
<string name="diag_check_voice_standard">语音(标准)</string>
|
||||
<string name="endpoints_pin_title">固定端点</string>
|
||||
<string name="endpoints_route_editor_desc">编辑路由</string>
|
||||
<string name="endpoints_route_editor_desc">添加此手机在该网络上应使用的 Dashboard/Gateway 地址。</string>
|
||||
<string name="endpoints_route_name_placeholder">路由名称</string>
|
||||
<string name="endpoints_url_host_placeholder">主机</string>
|
||||
<string name="endpoints_url_supporting_blank">无支持的 URL</string>
|
||||
<string name="endpoints_url_supporting_enter">输入 URL</string>
|
||||
<string name="endpoints_url_supporting_preview">预览</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z 或 host.ts.net</string>
|
||||
<string name="endpoints_url_supporting_blank">输入服务器的 Dashboard 地址</string>
|
||||
<string name="endpoints_url_supporting_enter">使用 http:// 或 https:// Dashboard 地址</string>
|
||||
<string name="endpoints_url_supporting_preview">将测试:%1$s</string>
|
||||
<string name="image_viewer_error">保存图片时出错</string>
|
||||
<string name="image_viewer_failed">保存图片失败</string>
|
||||
<string name="image_viewer_failed_template">保存图片失败:%s</string>
|
||||
@@ -3074,4 +3163,112 @@
|
||||
<string name="tool_output_risk_a11y">,%1$s 输出风险</string>
|
||||
<string name="tool_output_risk_findings">输出风险发现</string>
|
||||
<string name="tool_output_risk_redacted">敏感片段已在上游被隐去。</string>
|
||||
<plurals name="conn_server_count">
|
||||
<item quantity="other">%1$d 台服务器</item>
|
||||
</plurals>
|
||||
<string name="conn_switch">切换</string>
|
||||
<string name="conn_switching">正在切换…</string>
|
||||
<string name="conn_switched">使用中</string>
|
||||
<string name="conn_connecting_to">正在连接到 %1$s…</string>
|
||||
<string name="conn_last_used_now">刚刚使用过</string>
|
||||
<string name="conn_last_used_format">上次使用:%1$s</string>
|
||||
<string name="conn_just_now">刚刚</string>
|
||||
<string name="conn_dashboard_only_route">仅 Dashboard</string>
|
||||
<string name="conn_no_routes">未配置路由</string>
|
||||
<string name="conn_startup_title">应用启动时</string>
|
||||
<string name="conn_startup_last_used">上次使用</string>
|
||||
<string name="conn_startup_recommended">推荐</string>
|
||||
<string name="conn_startup_choose">选择启动连接</string>
|
||||
<string name="active_section_primary_dashboard">主 Dashboard</string>
|
||||
<string name="active_section_dashboard_reachable_signed_in">可访问 · 已登录</string>
|
||||
<string name="active_section_dashboard_reachable">可访问</string>
|
||||
<string name="active_section_dashboard_not_checked">尚未检查</string>
|
||||
<string name="active_section_dashboard_unreachable">无法访问</string>
|
||||
<string name="active_section_no_fallback_routes">尚无备用路由</string>
|
||||
<string name="active_section_no_fallback_routes_desc">可添加 API 路由,用于直接聊天回退和网络切换。</string>
|
||||
<string name="active_section_add_api_fallback">添加备用路由</string>
|
||||
<string name="active_section_security_authentication">身份验证</string>
|
||||
<string name="active_section_dashboard_session">Dashboard 会话</string>
|
||||
<string name="active_section_credential_storage">凭据存储</string>
|
||||
<string name="active_section_encrypted_storage">加密存储</string>
|
||||
<string name="active_section_no_relay_credential">无 Relay 凭据</string>
|
||||
<string name="active_section_sign_out_dashboard">退出 Dashboard</string>
|
||||
<string name="active_section_credentials_encrypted">凭据始终在此设备上加密存储。</string>
|
||||
<string name="active_section_reachable_badge">可访问</string>
|
||||
<string name="active_section_unchecked_badge">未检查</string>
|
||||
<string name="active_section_edit">编辑</string>
|
||||
<string name="active_section_fallback_routes">备用路由</string>
|
||||
<string name="active_section_route_selection">路由选择</string>
|
||||
<string name="active_section_automatic">自动</string>
|
||||
<string name="active_section_api_access">API 访问</string>
|
||||
<string name="active_section_core_hermes">Hermes 核心</string>
|
||||
<string name="active_section_optional_relay">可选 Relay</string>
|
||||
<string name="active_section_extend_connection">扩展此连接</string>
|
||||
<string name="active_section_relay_connected_features">Relay 扩展</string>
|
||||
<string name="active_section_relay_optional_summary">添加 Terminal、Bridge、设备工具、Relay 会话和安全远程路由。</string>
|
||||
<string name="active_section_view_relay_details">查看 Relay 详情</string>
|
||||
<string name="active_section_core_unchanged">当前 Hermes 连接保持不变。</string>
|
||||
<string name="active_section_api_optional_direct">可选,用于直接聊天和集成</string>
|
||||
<string name="active_section_configure_test">配置并测试</string>
|
||||
<string name="active_section_relay_optional_bridge">可选的 Bridge 功能和远程访问</string>
|
||||
<string name="active_section_configure_relay">配置 Relay</string>
|
||||
<string name="active_section_pair_device_using_code">使用服务器生成的代码配对此设备。</string>
|
||||
<string name="active_section_enter_pairing_code">输入配对码</string>
|
||||
<string name="active_section_done">完成</string>
|
||||
<string name="active_section_connection_behavior">连接行为</string>
|
||||
<string name="active_section_manual_pairing">手动配对</string>
|
||||
<string name="active_section_paired">已配对</string>
|
||||
<string name="active_section_not_paired">未配对</string>
|
||||
<string name="active_section_transport">传输方式</string>
|
||||
<string name="active_section_hardware_backed">硬件级保护</string>
|
||||
<string name="active_section_relay_session">Relay 会话</string>
|
||||
<string name="active_section_access">访问权限</string>
|
||||
<string name="active_section_paired_devices">已配对设备</string>
|
||||
<string name="active_section_device_count">%1$d 台设备</string>
|
||||
<string name="active_section_session_activity">会话活动</string>
|
||||
<string name="active_section_last_checked_just_now">刚刚检查过</string>
|
||||
<string name="active_section_protected">受保护</string>
|
||||
<string name="active_section_not_encrypted">未加密</string>
|
||||
<string name="active_section_no_security_issues">未检测到安全问题</string>
|
||||
<string name="active_section_unencrypted_transport">此连接使用未加密的传输方式</string>
|
||||
<string name="active_section_actions">操作</string>
|
||||
<string name="active_section_revoke_relay">撤销 Relay 配对</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">此手机如何连接到 %1$s</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · 家庭局域网</string>
|
||||
<string name="cw_preparing_connection">正在准备连接…</string>
|
||||
<string name="cw_preparing_connection_hint">正在设置安全的本地存储,片刻即可完成。</string>
|
||||
<string name="cw_relay_entry_title">Hermes Relay</string>
|
||||
<string name="cw_relay_entry_desc">配对 Relay 以添加 Terminal、Bridge、设备工具、Relay 会话和权限。</string>
|
||||
<string name="cw_relay_pair_qr">配对 Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">扫描 Relay 设置二维码</string>
|
||||
<string name="cw_relay_enter_code">输入配对码</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes 已连接</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard 和 Gateway 已就绪。你可以开始聊天或打开 Manage。</string>
|
||||
<string name="cw_continue">继续</string>
|
||||
<string name="cw_timeline_discovered">已发现 Hermes</string>
|
||||
<string name="cw_timeline_discovered_detail">已验证 Dashboard 标识和状态端点</string>
|
||||
<string name="cw_timeline_access">Dashboard 访问</string>
|
||||
<string name="cw_timeline_access_ready">无需额外登录</string>
|
||||
<string name="cw_timeline_authenticated">身份验证已确认</string>
|
||||
<string name="cw_timeline_ready">连接已就绪</string>
|
||||
<string name="cw_timeline_ready_detail">Chat、Manage 和 Voice 可以使用此 Dashboard</string>
|
||||
<string name="active_section_optional_api_fallback">可选的直接 API 回退</string>
|
||||
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
|
||||
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时配置;该服务器需要可用密钥,并在此输入相同的值。</string>
|
||||
<string name="active_section_scan_relay_qr">扫描 Relay 二维码</string>
|
||||
<string name="active_section_other_relay_methods">其他配对方式</string>
|
||||
<string name="endpoints_tailscale_setup_hint">两台设备都必须连接 Tailscale,并且 Hermes Dashboard 必须可通过端口 9119 访问。</string>
|
||||
<string name="endpoints_setup_help">Tailscale 设置帮助</string>
|
||||
<string name="voice_settings_provider_desc">语音生成的位置。可用选项来自此 Hermes 主机及其已安装的提供商。</string>
|
||||
<string name="voice_settings_model_desc">Latest 会跟随提供商升级。选择带版本的模型可固定语音行为。</string>
|
||||
<string name="voice_settings_voice_desc">选择回复的声音。预览语音不会保存选择。</string>
|
||||
<string name="voice_settings_language_desc">自动会让提供商检测口语语言。仅在检测不可靠时选择语言。</string>
|
||||
<string name="voice_settings_language_auto">自动</string>
|
||||
<string name="voice_settings_provider_options_title">语音选项</string>
|
||||
<string name="voice_settings_provider_options_desc">只显示所选提供商支持的设置。</string>
|
||||
<string name="voice_settings_auto_speak">朗读回复</string>
|
||||
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
|
||||
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
<string name="onboarding_next">Weiter</string>
|
||||
<string name="onboarding_connect">Verbinden</string>
|
||||
|
||||
<string name="onboarding_welcome_title">Hermes-Relay für Android</string>
|
||||
<string name="onboarding_welcome_description">Chatte mit Hermes und verwalte dein Dashboard vom Smartphone aus.</string>
|
||||
<string name="onboarding_get_started">Loslegen</string>
|
||||
<string name="onboarding_step_count">Schritt %1$d von %2$d</string>
|
||||
<string name="onboarding_welcome_title">Hermes,\nin deiner Tasche</string>
|
||||
<string name="onboarding_welcome_description">Chatte, verwalte und nutze Sprache\nmit deinem eigenen Hermes.</string>
|
||||
<string name="onboarding_welcome_badge">Willkommen</string>
|
||||
<string name="onboarding_hermes_logo">Hermes-Logo</string>
|
||||
<string name="onboarding_chat_manage_label">Chat & Verwaltung</string>
|
||||
<string name="onboarding_chat_manage_description">Verbinde dich mit deinem laufenden Hermes-Dashboard und der API. Keine Relay-Installation oder -Kopplung erforderlich.</string>
|
||||
<string name="onboarding_power_tools_label">Profiwerkzeuge</string>
|
||||
<string name="onboarding_power_tools_description">Füge Hermes-Relay für Terminal, Bridge, Relay-Sitzungen und Kanalberechtigungen hinzu.</string>
|
||||
<string name="onboarding_chat_manage_label">Dashboard zuerst</string>
|
||||
<string name="onboarding_chat_manage_description">Kein Relay erforderlich</string>
|
||||
<string name="onboarding_power_tools_label">Profiwerkzeuge später</string>
|
||||
<string name="onboarding_power_tools_description">Kopple Relay für\nTerminal oder Bridge</string>
|
||||
<string name="onboarding_setup_guide_hint">Die Einrichtungsanleitung enthält kopierbare Befehle, wenn du Hermes auf einem Computer oder Server starten möchtest.</string>
|
||||
<string name="onboarding_setup_guide">Einrichtungsanleitung</string>
|
||||
<string name="onboarding_hermes_docs">Hermes-Dokumentation</string>
|
||||
@@ -54,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">Echtzeit</string>
|
||||
<string name="onboarding_realtime_description">Anbietereigener Echtzeit-Sprachagent und profilbezogene Sprachanbieter.</string>
|
||||
<string name="onboarding_review_permissions">Berechtigungen prüfen</string>
|
||||
<string name="onboarding_finish_setup_title">Einrichtung abschließen</string>
|
||||
<string name="onboarding_finish_setup_description">Deine Hermes-Verbindung ist bereit. Wähle jetzt aus, was dieses Telefon tun darf, oder ändere es später in den Einstellungen.</string>
|
||||
<string name="onboarding_chat_manage_ready">Bereit — keine Telefonberechtigung erforderlich.</string>
|
||||
<string name="onboarding_chat_alerts">Chat-Benachrichtigungen</string>
|
||||
<string name="onboarding_chat_alerts_description">Erlaube Android-Benachrichtigungen, damit aktivierte Hinweise dich im Hintergrund erreichen.</string>
|
||||
<string name="onboarding_chat_alerts_ready">Benachrichtigungen sind für diese App aktiviert.</string>
|
||||
<string name="onboarding_optional_features">Optionale Funktionen</string>
|
||||
<string name="onboarding_optional_features_description">Kamera, Mikrofon, Benachrichtigungsbegleiter und verfügbare Gerätewerkzeuge bleiben aus, bis du sie auswählst.</string>
|
||||
<string name="onboarding_review_optional_permissions">Optionale Berechtigungen prüfen</string>
|
||||
<string name="onboarding_enable_chat_alerts">Chat-Benachrichtigungen aktivieren</string>
|
||||
<string name="onboarding_not_now">Jetzt nicht</string>
|
||||
<string name="onboarding_finish">Einrichtung abschließen</string>
|
||||
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">Live-Sprachgespräch</string>
|
||||
@@ -68,7 +82,7 @@
|
||||
<string name="chat_input_start_voice">Sprachgespräch starten</string>
|
||||
<string name="chat_input_voice_setup_needed">Sprachgespräch — Einrichtung erforderlich</string>
|
||||
<string name="chat_input_stop_streaming">Streaming stoppen</string>
|
||||
<string name="chat_input_steer_response">Antwort steuern</string>
|
||||
<string name="chat_input_steer_response">Antwort korrigieren</string>
|
||||
<string name="chat_input_queue_message">Nachricht einreihen</string>
|
||||
|
||||
<!-- Bridge return labels -->
|
||||
@@ -99,7 +113,7 @@
|
||||
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">Nachricht bearbeiten…</string>
|
||||
<string name="chat_placeholder_steer">Antwort steuern…</string>
|
||||
<string name="chat_placeholder_steer">Antwort korrigieren…</string>
|
||||
<string name="chat_placeholder_queue">Nachricht einreihen…</string>
|
||||
<string name="chat_placeholder_message">Nachricht…</string>
|
||||
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
|
||||
@@ -274,6 +288,39 @@
|
||||
|
||||
<!-- Connection wizard — Connect page essentials -->
|
||||
<string name="cw_connect_to_hermes">Mit Hermes verbinden</string>
|
||||
<string name="cw_nearby_description">Wir suchen in diesem Netzwerk nach Hermes. Dashboard und Gateway stellen die Standardverbindung für Chat, Verwaltung und Sprache bereit.</string>
|
||||
<string name="cw_before_connecting">Vor dem Verbinden</string>
|
||||
<string name="cw_connect_step_server_title">Hermes auf dem Computer starten</string>
|
||||
<string name="cw_connect_step_server_body">Starte das Dashboard auf dem Hermes-Computer. Verwende für den ersten LAN-Zugriff die Anleitung unten, um es vom Smartphone erreichbar zu machen und die Anmeldung einzurichten.</string>
|
||||
<string name="cw_connect_step_network_title">Server erreichbar machen</string>
|
||||
<string name="cw_connect_step_network_body">Verwende dasselbe WLAN oder verbinde beide Geräte mit Tailscale.</string>
|
||||
<string name="cw_connect_step_phone_title">Von diesem Smartphone verbinden</string>
|
||||
<string name="cw_connect_step_phone_body">Suche unten oder gib die Dashboard-Adresse ein, z. B. 192.168.1.10:9119. Melde dich bei Aufforderung an – ein API-Schlüssel ist nicht erforderlich.</string>
|
||||
<string name="cw_nearby_searching">Suche in der Nähe…</string>
|
||||
<string name="cw_nearby_searching_hint">Das Smartphone muss sich im selben Netzwerk wie der Hermes-Server befinden.</string>
|
||||
<string name="cw_nearby_heading">Hermes in der Nähe</string>
|
||||
<string name="cw_nearby_empty">Keine Hermes-Server gefunden</string>
|
||||
<string name="cw_nearby_failed">Dieses Netzwerk konnte nicht durchsucht werden</string>
|
||||
<string name="cw_nearby_empty_hint">Prüfe, ob Hermes ausgeführt wird, und suche erneut oder gib die Adresse ein.</string>
|
||||
<string name="cw_nearby_search_again">Erneut suchen</string>
|
||||
<string name="cw_nearby_enter_address">Stattdessen Adresse eingeben</string>
|
||||
<string name="cw_other_connection_methods">Andere Verbindungsmethoden</string>
|
||||
<string name="cw_manual_hermes_title">Hermes-Adresse eingeben</string>
|
||||
<string name="cw_manual_hermes_description">Gib die Dashboard-Adresse ein, die du im Browser öffnest. Wenn sie auf diesem Smartphone nicht geöffnet wird, starte hermes dashboard und prüfe WLAN oder Tailscale.</string>
|
||||
<string name="cw_hermes_address">Hermes-Adresse</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 oder hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">Kein API-Schlüssel erforderlich. Ohne Port wird Dashboard-Port 9119 verwendet.</string>
|
||||
<string name="cw_find_hermes">Hermes suchen</string>
|
||||
<string name="cw_hermes_found">Hermes gefunden</string>
|
||||
<string name="cw_ready_to_connect">Verbindungsbereit</string>
|
||||
<string name="cw_ready">Bereit</string>
|
||||
<string name="cw_available_after_signin">Nach der Anmeldung verfügbar</string>
|
||||
<string name="cw_unavailable_server">Auf diesem Server nicht verfügbar</string>
|
||||
<string name="cw_could_not_verify">Konnte nicht überprüft werden</string>
|
||||
<string name="cw_choose_another">Andere auswählen</string>
|
||||
<string name="cw_sign_in_to_hermes">Bei Hermes anmelden</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes verfügbar</string>
|
||||
<string name="cw_semantics_capability">%1$s: %2$s</string>
|
||||
<string name="cw_connect_description">Starte die Hermes-API bzw. das Dashboard auf deinem Host und verbinde dann diese App. Die Relay-Kopplung ist optional und wird nur für Terminal, Bridge, Relay-Sitzungen und Kanalberechtigungen benötigt.</string>
|
||||
<string name="cw_try_demo">Demo ausprobieren</string>
|
||||
<string name="cw_try_demo_subtitle">Offline erkunden — kein Server erforderlich.</string>
|
||||
@@ -318,8 +365,13 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 oder http://dein-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Von Chat und Sitzungen verwendete Hermes-API — bei reinen Hosts werden API-Port 8642 und http:// angenommen (Dashboard-Port 9119 wird separat abgeleitet)</string>
|
||||
<string name="cw_scan_message">Dieses LAN wird nach Hermes-Dashboard/API durchsucht…</string>
|
||||
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel ist für Chat optional.</string>
|
||||
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel gilt nur für den optionalen direkten API-Fallback.</string>
|
||||
<string name="cw_pair_relay_section">Relay koppeln (optional)</string>
|
||||
<string name="cw_pair_relay_for">Relay mit %1$s koppeln</string>
|
||||
<string name="cw_pair_relay_scoped_desc">Füge dieser gespeicherten Hermes-Verbindung die optionale Relay-Erweiterung hinzu. Dadurch wird kein Server hinzugefügt oder ersetzt.</string>
|
||||
<string name="cw_current_connection">Aktuelle Verbindung</string>
|
||||
<string name="cw_current_hermes_connection">AKTUELLE HERMES-VERBINDUNG</string>
|
||||
<string name="cw_existing_connection_unchanged">Chat, Verwaltung, Sprache und deine gespeicherten Routen bleiben unverändert.</string>
|
||||
<string name="cw_pair_relay_section_desc">Das Relay-Plugin läuft bereits? Kopple es hier, um Terminal, Bridge und Kanalberechtigungen zu aktivieren.</string>
|
||||
<string name="cw_pair_relay_url_label">Relay-URL</string>
|
||||
<string name="cw_pair_relay_code_label">Kopplungscode</string>
|
||||
@@ -441,7 +493,7 @@
|
||||
<string name="endpoints_public">Öffentlich</string>
|
||||
<string name="endpoints_custom">Benutzerdefiniert</string>
|
||||
<string name="endpoints_route_name">Routenname</string>
|
||||
<string name="endpoints_api_url_host">API-Server-URL oder Host</string>
|
||||
<string name="endpoints_api_url_host">Dashboard-/Gateway-URL oder Host</string>
|
||||
<string name="endpoints_saving">Speichern…</string>
|
||||
<string name="endpoints_save">Speichern</string>
|
||||
<string name="endpoints_close">Schließen</string>
|
||||
@@ -580,7 +632,7 @@
|
||||
<string name="settings_hermes_management">Hermes-Verwaltung</string>
|
||||
<string name="settings_hermes_management_desc">Dashboard-Funktionen: Skills, Cron, MCP, Profile, Modelle</string>
|
||||
<string name="settings_chat">Chat</string>
|
||||
<string name="settings_chat_desc">API-Chatverhalten, Endpunkte, Werkzeuganzeige, Nachrichtenlänge</string>
|
||||
<string name="settings_chat_desc">Chatverhalten, Gateway, API-Fallback, Werkzeuganzeige, Nachrichtenlänge</string>
|
||||
<string name="settings_voice_mode">Sprachmodus</string>
|
||||
<string name="settings_voice_mode_desc">Dashboard-Sprache, Echtzeit-Relay-Optionen, Anbieter</string>
|
||||
<string name="settings_threads">Threads</string>
|
||||
@@ -622,9 +674,9 @@
|
||||
<string name="settings_persistent_connection">Dauerhafte Verbindung</string>
|
||||
<string name="settings_persistent_connection_desc">Verbindung zu Hermes im Hintergrund offen halten</string>
|
||||
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</string>
|
||||
<string name="settings_turn_complete_alerts">Hinweise bei Abschluss</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
|
||||
<string name="settings_turn_complete_alerts_off">Kein Hinweis, wenn eine Antwort im Hintergrund abgeschlossen wird</string>
|
||||
<string name="settings_turn_complete_alerts">Chat-Benachrichtigungen</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Im Tiefschlaf verbunden bleiben</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android kann die Verbindung weiterhin pausieren, nachdem der Bildschirm eine Weile ausgeschaltet war (Doze). Erlaube uneingeschränkte Akkunutzung, damit die dauerhafte Verbindung im Hintergrund funktioniert.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Uneingeschränkte Akkunutzung erlauben</string>
|
||||
@@ -654,8 +706,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Letzte Nachrichten als antippbare Chips über dem Eingabefeld anzeigen, um sie erneut zu senden. Standardmäßig aus.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Tastatur nach dem Senden geöffnet lassen</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Nach dem Senden im Eingabefeld bleiben. Ausschalten, um die Tastatur nach jeder gesendeten Nachricht zu schließen.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Benachrichtigen, wenn Hermes fertig ist</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigung senden, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
|
||||
<string name="chat_settings_notify_when_finishes">Chat-Benachrichtigungen im Hintergrund</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="chat_settings_share_phone_status">Smartphone-Status mit Agent teilen</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Bei jedem Chatdurchlauf eine kurze Systemnachricht über App und Smartphone einfügen</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
|
||||
@@ -737,6 +789,8 @@
|
||||
<string name="conn_voice_relay">Relay</string>
|
||||
<string name="conn_voice_optional">Optional</string>
|
||||
<string name="conn_api_label">API</string>
|
||||
<string name="conn_chat_label">Chat</string>
|
||||
<string name="conn_manage_label">Verwaltung</string>
|
||||
<string name="conn_dashboard_label">Dashboard</string>
|
||||
<string name="conn_voice_label">Sprache</string>
|
||||
<string name="conn_relay_label">Relay</string>
|
||||
@@ -761,6 +815,10 @@
|
||||
<string name="detail_more_actions">Weitere Aktionen</string>
|
||||
<string name="detail_rename">Umbenennen</string>
|
||||
<string name="detail_pair_relay">Relay koppeln</string>
|
||||
<string name="detail_dashboard_primary">Dashboard als primärer Zugang</string>
|
||||
<string name="detail_core_ready">Hermes-Kern bereit</string>
|
||||
<string name="detail_core_configured">Hermes-Kern konfiguriert</string>
|
||||
<string name="detail_overview_summary">Chat, Verwaltung und Sprache nutzen die Standardfunktionen von Hermes. Relay ist eine optionale Erweiterung für erweiterte Gerätefunktionen.</string>
|
||||
<string name="detail_repair">Erneut koppeln</string>
|
||||
<string name="detail_revoke">Widerrufen</string>
|
||||
<string name="detail_remove">Entfernen</string>
|
||||
@@ -1430,7 +1488,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Sprachfunktion des Relay-Plugins — profilbezogene Anbieter und Streaming-Sprachausgabe.</string>
|
||||
<string name="voice_settings_optional">Optional</string>
|
||||
<string name="voice_settings_signin_route_hint">Du bist über die Route %1$s verbunden, und Dashboard-Anmeldungen gelten pro Host — eine Anmeldung aus deinem Heimnetz wird nicht übernommen. Melde dich unter Verwalten einmal über diese Route an, um auch hier Sprache freizuschalten.</string>
|
||||
<string name="voice_settings_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Öffne Verwalten, um dich erneut anzumelden und Sprache freizuschalten.</string>
|
||||
<string name="voice_settings_signin_default_hint">Dein Hermes-Dashboard erfordert eine Anmeldung, bevor Standardsprache transkribieren oder sprechen kann. Eine einmalige Anmeldung unter Verwalten schaltet sie für diese Verbindung frei.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Über Verwalten anmelden</string>
|
||||
<string name="voice_settings_unsupported_build_body">Dieser Hermes-Server-Build stellt die Dashboard-Audiorouten noch nicht bereit. Aktualisiere hermes-agent auf dem Server oder kopple Relay, um Relay-Sprache zu verwenden.</string>
|
||||
@@ -1669,7 +1727,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">Dashboard-Anmeldung erforderlich</string>
|
||||
<string name="dashboard_signin_required_body">Verwalten verwendet die Hermes-Dashboard-Sitzung unter %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">Du verwendest die Route %1$s. Dashboard-Anmeldungen gelten pro Host, daher wird deine Anmeldung über die andere Route nicht übernommen — melde dich hier einmal an, dann behält die App beide Sitzungen.</string>
|
||||
<string name="dashboard_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Melde dich erneut an, um sie für die vertrauenswürdigen Routen dieser Verbindung zu aktualisieren.</string>
|
||||
<string name="dashboard_signin_with_provider">Mit %1$s anmelden</string>
|
||||
<string name="dashboard_username_password">Benutzername & Passwort</string>
|
||||
<string name="dashboard_username">Benutzername</string>
|
||||
@@ -1720,6 +1778,31 @@
|
||||
<string name="dashboard_action_use">Verwenden</string>
|
||||
<string name="dashboard_action_describe">Beschreiben</string>
|
||||
<string name="dashboard_action_model">Modell</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Endpunkte</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">Endpunkte</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Benutzerdefinierte Endpunkte</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-kompatible Anbieter</string>
|
||||
<string name="dashboard_action_authenticate">Authentifizieren</string>
|
||||
<string name="dashboard_action_validate">Validieren</string>
|
||||
<string name="dashboard_action_edit">Bearbeiten</string>
|
||||
<string name="dashboard_mcp_oauth_title">%1$s authentifizieren</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes öffnet den Anbieter in Ihrem Browser. Kehren Sie nach der Genehmigung des Zugriffs hierher zurück; die Anmeldedaten verbleiben auf dem Hermes-Server.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP-Authentifizierung genehmigt</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP-Authentifizierung fehlgeschlagen</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">Zum Abschluss der MCP-Authentifizierung ist kein Browser verfügbar.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Endpunkt hinzufügen</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Endpunkt bearbeiten</string>
|
||||
<string name="dashboard_custom_endpoint_name">Name</string>
|
||||
<string name="dashboard_custom_endpoint_url">Basis-URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">Standardmodell</string>
|
||||
<string name="dashboard_custom_endpoint_key">API-Schlüssel (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Lassen Sie das Feld leer, um beim Speichern einen vorhandenen Schlüssel beizubehalten. Für die Validierung wird nur ein hier eingegebener Schlüssel verwendet; Hermes legt gespeicherte Schlüssel weder offen noch löscht es sie.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Kontextlänge (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Modelle über /models ermitteln</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Endpunkt erreichbar · %1$d Modell(e)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Endpunktvalidierung fehlgeschlagen</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Speichern des Endpunkts fehlgeschlagen</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Benutzerdefinierter Endpunkt gespeichert</string>
|
||||
<string name="dashboard_action_completed">%1$s abgeschlossen</string>
|
||||
<string name="dashboard_action_failed">%1$s fehlgeschlagen</string>
|
||||
<string name="dashboard_more">Mehr</string>
|
||||
@@ -2032,9 +2115,9 @@
|
||||
<string name="active_section_allow_plain_connections">Unverschlüsselte Verbindungen erlauben</string>
|
||||
<string name="active_section_api_hermes_voice_reachable">API erreichbar — Hermes-Sprache ist konfiguriert</string>
|
||||
<string name="active_section_api_key_already_set">API-Schlüssel ist bereits festgelegt</string>
|
||||
<string name="active_section_api_key_needed_hint">Gib einen Schlüssel ein, falls der API-Server einen erfordert.</string>
|
||||
<string name="active_section_api_key_needed_hint">Verwende den auf deinem Hermes-Server erstellten API_SERVER_KEY. Die App stellt diesen Schlüssel nicht aus.</string>
|
||||
<string name="active_section_api_key_not_configured">API-Schlüssel nicht konfiguriert</string>
|
||||
<string name="active_section_api_key_optional">API-Schlüssel (optional)</string>
|
||||
<string name="active_section_api_key_optional">API-Schlüssel für direkte API</string>
|
||||
<string name="active_section_api_key_stored_hint">Schlüssel ist sicher gespeichert.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API erreichbar — Sprachkonfiguration prüfen</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API erreichbar — Relay-Sprache ist konfiguriert</string>
|
||||
@@ -2048,6 +2131,8 @@
|
||||
<string name="active_section_checking">Prüfung läuft…</string>
|
||||
<string name="active_section_checking_routes">Routen werden geprüft…</string>
|
||||
<string name="active_section_choose_how_phone_reaches">Wähle, wie dieses Smartphone den Server erreicht</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">So erreicht dieses Smartphone %1$s</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · Heimnetzwerk</string>
|
||||
<string name="active_section_command_copied">Befehl kopiert</string>
|
||||
<string name="active_section_configured">Konfiguriert</string>
|
||||
<string name="active_section_connect">Verbinden</string>
|
||||
@@ -2142,6 +2227,7 @@
|
||||
<string name="conn_info_api_server_title">API-Server</string>
|
||||
<string name="conn_info_approvals_off">Genehmigungen sind AUS</string>
|
||||
<string name="conn_info_auth">Authentifizierung</string>
|
||||
<string name="conn_info_relay_auth">Relay-Authentifizierung</string>
|
||||
<string name="conn_info_avg_ttft">Durchschn. TTFT</string>
|
||||
<string name="conn_info_channel_grants">Kanalberechtigungen</string>
|
||||
<string name="conn_info_checking">Prüfung läuft…</string>
|
||||
@@ -2181,6 +2267,7 @@
|
||||
<string name="conn_info_hermes">Hermes</string>
|
||||
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
|
||||
<string name="conn_info_hostname_paired">%1$s (gekoppelt)</string>
|
||||
<string name="conn_info_hostname_relay_paired">%1$s · Relay gekoppelt</string>
|
||||
<string name="conn_info_idle_suffix"> · Inaktiv</string>
|
||||
<string name="conn_info_insecure_mode_allowed">Unsicherer Modus erlaubt</string>
|
||||
<string name="conn_info_inspect_profile">%1$s prüfen</string>
|
||||
@@ -2405,6 +2492,7 @@
|
||||
<string name="tool_progress_status_completed">abgeschlossen</string>
|
||||
<string name="tool_progress_status_failed">fehlgeschlagen</string>
|
||||
<string name="tool_progress_status_running">läuft</string>
|
||||
<string name="image_generation_rendering">Bild wird erstellt</string>
|
||||
<string name="tool_progress_cd_collapse">Einklappen</string>
|
||||
<string name="tool_progress_cd_expand">Ausklappen</string>
|
||||
|
||||
@@ -2511,7 +2599,10 @@
|
||||
<!-- QrPairingScanner -->
|
||||
<string name="qr_scanner_title">Hermes-QR-Code scannen</string>
|
||||
<string name="qr_scanner_instruction">Hermes-Einrichtungs-QR-Code scannen</string>
|
||||
<string name="qr_scanner_subtext">Frage Hermes: "Erzeuge einen QR-Code mit meiner API-URL und meinem API-Schlüssel."</string>
|
||||
<string name="qr_scanner_subtext">Scanne einen Hermes-Einrichtungs- oder Relay-Kopplungs-QR-Code. Standard-Dashboard-Verbindungen benötigen keinen API-Schlüssel.</string>
|
||||
<string name="qr_scanner_relay_title">Relay-QR scannen</string>
|
||||
<string name="qr_scanner_relay_instruction">Einen Relay-Kopplungs-QR-Code scannen</string>
|
||||
<string name="qr_scanner_relay_subtext">Öffne den Relay-Kopplungs-QR-Code auf deinem Hermes-Server. Deine bestehende Dashboard-Verbindung und Routen bleiben unverändert.</string>
|
||||
<string name="qr_scanner_camera_error">Kamera kann auf diesem Gerät nicht gestartet werden.</string>
|
||||
<string name="qr_scanner_fallback_message">Du kannst ohne Kamera koppeln.</string>
|
||||
<string name="qr_scanner_pair_manual">Manuell koppeln</string>
|
||||
@@ -2978,12 +3069,12 @@
|
||||
<string name="diag_check_voice_relay">Sprache (Relay)</string>
|
||||
<string name="diag_check_voice_standard">Sprache (Standard)</string>
|
||||
<string name="endpoints_pin_title">Angeheftete Endpunkte</string>
|
||||
<string name="endpoints_route_editor_desc">Route bearbeiten</string>
|
||||
<string name="endpoints_route_editor_desc">Füge die Dashboard-/Gateway-Adresse hinzu, die dieses Telefon in diesem Netzwerk verwenden soll.</string>
|
||||
<string name="endpoints_route_name_placeholder">Routenname</string>
|
||||
<string name="endpoints_url_host_placeholder">Host</string>
|
||||
<string name="endpoints_url_supporting_blank">Keine unterstützenden URLs</string>
|
||||
<string name="endpoints_url_supporting_enter">URL eingeben</string>
|
||||
<string name="endpoints_url_supporting_preview">Vorschau</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z oder host.ts.net</string>
|
||||
<string name="endpoints_url_supporting_blank">Dashboard-Adresse des Servers eingeben</string>
|
||||
<string name="endpoints_url_supporting_enter">Eine http://- oder https://-Dashboard-Adresse verwenden</string>
|
||||
<string name="endpoints_url_supporting_preview">Wird getestet: %1$s</string>
|
||||
<string name="image_viewer_error">Fehler beim Speichern des Bildes</string>
|
||||
<string name="image_viewer_failed">Bild konnte nicht gespeichert werden</string>
|
||||
<string name="image_viewer_failed_template">Bild konnte nicht gespeichert werden: %s</string>
|
||||
@@ -3139,4 +3230,111 @@
|
||||
<string name="tool_output_risk_a11y">, Ausgaberisiko: %1$s</string>
|
||||
<string name="tool_output_risk_findings">Erkannte Ausgaberisiken</string>
|
||||
<string name="tool_output_risk_redacted">Vertrauliche Textabschnitte wurden bereits serverseitig geschwärzt.</string>
|
||||
<string name="conn_startup_title">Beim App-Start</string>
|
||||
<string name="conn_startup_last_used">Zuletzt verwendet</string>
|
||||
<string name="conn_startup_recommended">Empfohlen</string>
|
||||
<string name="conn_startup_choose">Startverbindung auswählen</string>
|
||||
<plurals name="conn_server_count">
|
||||
<item quantity="one">%1$d Server</item>
|
||||
<item quantity="other">%1$d Server</item>
|
||||
</plurals>
|
||||
<string name="conn_switch">Wechseln</string>
|
||||
<string name="conn_switching">Wird gewechselt…</string>
|
||||
<string name="conn_switched">Aktiv</string>
|
||||
<string name="conn_connecting_to">Verbindung mit %1$s wird hergestellt…</string>
|
||||
<string name="conn_last_used_now">Gerade eben verwendet</string>
|
||||
<string name="conn_last_used_format">Zuletzt verwendet: %1$s</string>
|
||||
<string name="conn_just_now">gerade eben</string>
|
||||
<string name="conn_dashboard_only_route">Nur Dashboard</string>
|
||||
<string name="conn_no_routes">Keine Routen konfiguriert</string>
|
||||
<string name="active_section_primary_dashboard">Primäres Dashboard</string>
|
||||
<string name="active_section_dashboard_reachable_signed_in">Erreichbar · Angemeldet</string>
|
||||
<string name="active_section_dashboard_reachable">Erreichbar</string>
|
||||
<string name="active_section_dashboard_not_checked">Noch nicht geprüft</string>
|
||||
<string name="active_section_dashboard_unreachable">Nicht erreichbar</string>
|
||||
<string name="active_section_no_fallback_routes">Noch keine Ausweichrouten</string>
|
||||
<string name="active_section_no_fallback_routes_desc">Füge optional eine API-Route für direkten Chat-Ausweichbetrieb und Netzwerkwechsel hinzu.</string>
|
||||
<string name="active_section_add_api_fallback">Ausweichroute hinzufügen</string>
|
||||
<string name="active_section_security_authentication">Authentifizierung</string>
|
||||
<string name="active_section_dashboard_session">Dashboard-Sitzung</string>
|
||||
<string name="active_section_credential_storage">Anmeldedatenspeicher</string>
|
||||
<string name="active_section_encrypted_storage">Verschlüsselter Speicher</string>
|
||||
<string name="active_section_no_relay_credential">Keine Relay-Anmeldedaten</string>
|
||||
<string name="active_section_sign_out_dashboard">Vom Dashboard abmelden</string>
|
||||
<string name="active_section_credentials_encrypted">Anmeldedaten bleiben auf diesem Gerät verschlüsselt.</string>
|
||||
<string name="active_section_reachable_badge">ERREICHBAR</string>
|
||||
<string name="active_section_unchecked_badge">UNGEPRÜFT</string>
|
||||
<string name="active_section_edit">Bearbeiten</string>
|
||||
<string name="active_section_fallback_routes">Ausweichrouten</string>
|
||||
<string name="active_section_route_selection">Routenauswahl</string>
|
||||
<string name="active_section_automatic">Automatisch</string>
|
||||
<string name="active_section_api_access">API-Zugriff</string>
|
||||
<string name="active_section_core_hermes">Hermes-Kern</string>
|
||||
<string name="active_section_optional_relay">Optionales Relay</string>
|
||||
<string name="active_section_extend_connection">Diese Verbindung erweitern</string>
|
||||
<string name="active_section_relay_connected_features">Relay-Erweiterungen</string>
|
||||
<string name="active_section_relay_optional_summary">Füge Terminal, Bridge, Gerätewerkzeuge, Relay-Sitzungen und sichere Fernzugriffsrouten hinzu.</string>
|
||||
<string name="active_section_view_relay_details">Relay-Details anzeigen</string>
|
||||
<string name="active_section_core_unchanged">Deine aktuelle Hermes-Verbindung bleibt unverändert.</string>
|
||||
<string name="active_section_api_optional_direct">Optional für direkten Chat und Integrationen</string>
|
||||
<string name="active_section_configure_test">Konfigurieren & testen</string>
|
||||
<string name="active_section_relay_optional_bridge">Optionale Bridge-Funktionen und Fernzugriff</string>
|
||||
<string name="active_section_configure_relay">Relay konfigurieren</string>
|
||||
<string name="active_section_pair_device_using_code">Kopple dieses Gerät mit einem Code vom Server.</string>
|
||||
<string name="active_section_enter_pairing_code">Kopplungscode eingeben</string>
|
||||
<string name="active_section_done">Fertig</string>
|
||||
<string name="active_section_connection_behavior">Verbindungsverhalten</string>
|
||||
<string name="active_section_manual_pairing">Manuelle Kopplung</string>
|
||||
<string name="active_section_paired">Gekoppelt</string>
|
||||
<string name="active_section_not_paired">Nicht gekoppelt</string>
|
||||
<string name="active_section_transport">Transport</string>
|
||||
<string name="active_section_hardware_backed">Hardwaregestützt</string>
|
||||
<string name="active_section_relay_session">Relay-Sitzung</string>
|
||||
<string name="active_section_access">Zugriff</string>
|
||||
<string name="active_section_paired_devices">Gekoppelte Geräte</string>
|
||||
<string name="active_section_device_count">%1$d Geräte</string>
|
||||
<string name="active_section_session_activity">Sitzungsaktivität</string>
|
||||
<string name="active_section_last_checked_just_now">Gerade eben geprüft</string>
|
||||
<string name="active_section_protected">Geschützt</string>
|
||||
<string name="active_section_not_encrypted">Nicht verschlüsselt</string>
|
||||
<string name="active_section_no_security_issues">Keine Sicherheitsprobleme erkannt</string>
|
||||
<string name="active_section_unencrypted_transport">Diese Verbindung verwendet einen unverschlüsselten Transport</string>
|
||||
<string name="active_section_actions">Aktionen</string>
|
||||
<string name="active_section_revoke_relay">Relay-Kopplung widerrufen</string>
|
||||
<string name="cw_preparing_connection">Verbindung wird vorbereitet…</string>
|
||||
<string name="cw_preparing_connection_hint">Sicherer lokaler Speicher wird eingerichtet. Dies dauert nur einen Moment.</string>
|
||||
<string name="cw_relay_entry_title">Hermes Relay</string>
|
||||
<string name="cw_relay_entry_desc">Kopple Relay, um Terminal, Bridge, Gerätewerkzeuge, Relay-Sitzungen und Berechtigungen hinzuzufügen.</string>
|
||||
<string name="cw_relay_pair_qr">Hermes Relay koppeln</string>
|
||||
<string name="cw_relay_pair_qr_desc">Einen Relay-Einrichtungs-QR-Code scannen</string>
|
||||
<string name="cw_relay_enter_code">Pairing-Code eingeben</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes ist verbunden</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard und Gateway sind bereit. Du kannst chatten oder Manage öffnen.</string>
|
||||
<string name="cw_continue">Weiter</string>
|
||||
<string name="cw_timeline_discovered">Hermes gefunden</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard-Identität und Status-Endpunkt bestätigt</string>
|
||||
<string name="cw_timeline_access">Dashboard-Zugriff</string>
|
||||
<string name="cw_timeline_access_ready">Keine weitere Anmeldung erforderlich</string>
|
||||
<string name="cw_timeline_authenticated">Authentifizierung bestätigt</string>
|
||||
<string name="cw_timeline_ready">Verbindung bereit</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage und Voice können dieses Dashboard verwenden</string>
|
||||
<string name="active_section_optional_api_fallback">Optionaler direkter API-Fallback</string>
|
||||
<string name="active_section_api_not_required">Nicht erforderlich, wenn diese Verbindung das Hermes Dashboard verwendet.</string>
|
||||
<string name="active_section_where_api_key">Wo erhalte ich diesen Schlüssel?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY wird auf deinem Hermes-Server erstellt und nicht von dieser App bereitgestellt. Konfiguriere ihn nur für den optionalen API-Server, der einen verwendbaren Schlüssel verlangt, und gib hier denselben Wert ein.</string>
|
||||
<string name="active_section_scan_relay_qr">Relay-QR scannen</string>
|
||||
<string name="active_section_other_relay_methods">Andere Kopplungsmethoden</string>
|
||||
<string name="endpoints_tailscale_setup_hint">Tailscale muss auf beiden Geräten verbunden und Hermes Dashboard auf Port 9119 erreichbar sein.</string>
|
||||
<string name="endpoints_setup_help">Tailscale-Einrichtungshilfe</string>
|
||||
<string name="voice_settings_provider_desc">Wo Sprache erzeugt wird. Verfuegbare Optionen kommen von diesem Hermes-Host und seinen installierten Anbietern.</string>
|
||||
<string name="voice_settings_model_desc">Latest folgt Anbieter-Upgrades. Waehle ein versioniertes Modell, um das Sprachverhalten festzuhalten.</string>
|
||||
<string name="voice_settings_voice_desc">Waehle, wie Antworten klingen. Eine Stimmvorschau speichert die Auswahl nicht.</string>
|
||||
<string name="voice_settings_language_desc">Automatisch laesst den Anbieter die gesprochene Sprache erkennen. Waehle nur dann eine Sprache, wenn die Erkennung unzuverlaessig ist.</string>
|
||||
<string name="voice_settings_language_auto">Automatisch</string>
|
||||
<string name="voice_settings_provider_options_title">Sprachoptionen</string>
|
||||
<string name="voice_settings_provider_options_desc">Nur Einstellungen, die der ausgewaehlte Anbieter unterstuetzt, werden angezeigt.</string>
|
||||
<string name="voice_settings_auto_speak">Antworten vorlesen</string>
|
||||
<string name="voice_settings_auto_speak_desc">Spricht Assistentenantworten automatisch auf Hermes-Oberflaechen, die diese Host-Einstellung beachten.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Steuert die Live-Sprachsitzung, nicht das Hermes-Chatmodell. Latest folgt Anbieter-Upgrades; ein versioniertes Modell bleibt festgelegt.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">Die Stimme innerhalb der Live-Sitzung. Eingebaute und benutzerdefinierte Stimmen erscheinen, wenn der Anbieter sie meldet.</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
<string name="onboarding_back">Atrás</string>
|
||||
<string name="onboarding_next">Siguiente</string>
|
||||
<string name="onboarding_connect">Conectar</string>
|
||||
<string name="onboarding_welcome_title">Hermes-Relay para Android</string>
|
||||
<string name="onboarding_welcome_description">Chatea con Hermes y administra tu tablero desde tu teléfono.</string>
|
||||
<string name="onboarding_get_started">Comenzar</string>
|
||||
<string name="onboarding_step_count">Paso %1$d de %2$d</string>
|
||||
<string name="onboarding_welcome_title">Hermes,\nen tu bolsillo</string>
|
||||
<string name="onboarding_welcome_description">Chatea, administra y usa la voz\ncon tu propio Hermes.</string>
|
||||
<string name="onboarding_welcome_badge">Bienvenido</string>
|
||||
<string name="onboarding_hermes_logo">Logotipo de Hermes</string>
|
||||
<string name="onboarding_chat_manage_label">Chatear y administrar</string>
|
||||
<string name="onboarding_chat_manage_description">Conéctese a su tablero Hermes en ejecución y a API. No se requiere instalación ni emparejamiento de Relay.</string>
|
||||
<string name="onboarding_power_tools_label">Herramientas avanzadas</string>
|
||||
<string name="onboarding_power_tools_description">Agregue Hermes-Relay para sesiones de Terminal, Bridge, relay y concesiones de canales.</string>
|
||||
<string name="onboarding_chat_manage_label">Primero el panel</string>
|
||||
<string name="onboarding_chat_manage_description">No requiere Relay</string>
|
||||
<string name="onboarding_power_tools_label">Herramientas después</string>
|
||||
<string name="onboarding_power_tools_description">Empareja Relay para usar\nTerminal o Bridge</string>
|
||||
<string name="onboarding_setup_guide_hint">La guía de configuración tiene comandos copy/paste cuando necesita iniciar Hermes en una computadora o servidor.</string>
|
||||
<string name="onboarding_setup_guide">Guía de configuración</string>
|
||||
<string name="onboarding_hermes_docs">Documentos Hermes</string>
|
||||
@@ -49,6 +51,18 @@
|
||||
<string name="onboarding_realtime_label">En tiempo real</string>
|
||||
<string name="onboarding_realtime_description">Agente de voz en tiempo real nativo del proveedor y proveedores de voz con reconocimiento de perfil.</string>
|
||||
<string name="onboarding_review_permissions">Revisar permisos</string>
|
||||
<string name="onboarding_finish_setup_title">Finalizar configuración</string>
|
||||
<string name="onboarding_finish_setup_description">Tu conexión con Hermes está lista. Elige ahora lo que puede hacer este teléfono o cámbialo más tarde en Ajustes.</string>
|
||||
<string name="onboarding_chat_manage_ready">Listo — no se necesitan permisos del teléfono.</string>
|
||||
<string name="onboarding_chat_alerts">Alertas de chat</string>
|
||||
<string name="onboarding_chat_alerts_description">Permite las notificaciones de Android para que las alertas activadas te lleguen en segundo plano.</string>
|
||||
<string name="onboarding_chat_alerts_ready">Las notificaciones están activadas para esta aplicación.</string>
|
||||
<string name="onboarding_optional_features">Funciones opcionales</string>
|
||||
<string name="onboarding_optional_features_description">La cámara, el micrófono, el asistente de notificaciones y las herramientas disponibles del dispositivo permanecen desactivados hasta que los elijas.</string>
|
||||
<string name="onboarding_review_optional_permissions">Revisar permisos opcionales</string>
|
||||
<string name="onboarding_enable_chat_alerts">Activar alertas de chat</string>
|
||||
<string name="onboarding_not_now">Ahora no</string>
|
||||
<string name="onboarding_finish">Finalizar configuración</string>
|
||||
<string name="chat_input_live_voice_hint">Conversación de voz en vivo</string>
|
||||
<string name="chat_input_add_attachment">Agregar archivo adjunto</string>
|
||||
<string name="chat_input_browse_commands">Comandos de exploración</string>
|
||||
@@ -61,7 +75,7 @@
|
||||
<string name="chat_input_start_voice">Iniciar conversación de voz</string>
|
||||
<string name="chat_input_voice_setup_needed">Conversación de voz: se necesita configuración</string>
|
||||
<string name="chat_input_stop_streaming">dejar de transmitir</string>
|
||||
<string name="chat_input_steer_response">Dirigir la respuesta</string>
|
||||
<string name="chat_input_steer_response">Corregir la respuesta</string>
|
||||
<string name="chat_input_queue_message">mensaje de cola</string>
|
||||
<string name="bridge_return_chat_label">Charlar</string>
|
||||
<string name="bridge_return_manage_label">Administrar</string>
|
||||
@@ -82,7 +96,7 @@
|
||||
<string name="status_profile_format">perfil: %1$s</string>
|
||||
<string name="demo_feature_manage">Administrar</string>
|
||||
<string name="chat_placeholder_edit">Edita tu mensaje...</string>
|
||||
<string name="chat_placeholder_steer">Dirige la respuesta...</string>
|
||||
<string name="chat_placeholder_steer">Corrige la respuesta...</string>
|
||||
<string name="chat_placeholder_queue">Poner en cola un mensaje...</string>
|
||||
<string name="chat_placeholder_message">Mensaje…</string>
|
||||
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
|
||||
@@ -236,6 +250,39 @@
|
||||
<string name="error_classify_retry">Reintentar</string>
|
||||
<string name="error_classify_open_settings">Abrir configuración</string>
|
||||
<string name="cw_connect_to_hermes">Conéctese a Hermes</string>
|
||||
<string name="cw_nearby_description">Buscaremos Hermes en esta red. El panel y la puerta de enlace proporcionan la conexión estándar para Chat, Administrar y Voz.</string>
|
||||
<string name="cw_before_connecting">Antes de conectar</string>
|
||||
<string name="cw_connect_step_server_title">Inicia Hermes en tu ordenador</string>
|
||||
<string name="cw_connect_step_server_body">Inicia el panel en el ordenador con Hermes. Para el primer acceso por LAN, usa la guía inferior para hacerlo accesible desde el teléfono y habilitar el inicio de sesión.</string>
|
||||
<string name="cw_connect_step_network_title">Haz que el servidor sea accesible</string>
|
||||
<string name="cw_connect_step_network_body">Usa la misma red Wi-Fi o conecta ambos dispositivos a Tailscale.</string>
|
||||
<string name="cw_connect_step_phone_title">Conecta desde este teléfono</string>
|
||||
<string name="cw_connect_step_phone_body">Busca abajo o introduce la dirección del panel, como 192.168.1.10:9119. Inicia sesión si se solicita; no se necesita una clave de API.</string>
|
||||
<string name="cw_nearby_searching">Buscando cerca…</string>
|
||||
<string name="cw_nearby_searching_hint">Mantenga el teléfono en la misma red que el servidor de Hermes.</string>
|
||||
<string name="cw_nearby_heading">Hermes cercanos</string>
|
||||
<string name="cw_nearby_empty">No se encontraron servidores de Hermes</string>
|
||||
<string name="cw_nearby_failed">No se pudo buscar en esta red</string>
|
||||
<string name="cw_nearby_empty_hint">Compruebe que Hermes esté en ejecución y vuelva a buscar o introduzca su dirección.</string>
|
||||
<string name="cw_nearby_search_again">Buscar de nuevo</string>
|
||||
<string name="cw_nearby_enter_address">Introducir la dirección</string>
|
||||
<string name="cw_other_connection_methods">Otros métodos de conexión</string>
|
||||
<string name="cw_manual_hermes_title">Introduzca la dirección de Hermes</string>
|
||||
<string name="cw_manual_hermes_description">Introduce la dirección del panel que abres en el navegador. Si no abre en este teléfono, inicia hermes dashboard y comprueba Wi-Fi o Tailscale.</string>
|
||||
<string name="cw_hermes_address">Dirección de Hermes</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 o hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">No se necesita una clave de API. Se usa el puerto 9119 si no incluyes uno.</string>
|
||||
<string name="cw_find_hermes">Buscar Hermes</string>
|
||||
<string name="cw_hermes_found">Hermes encontrado</string>
|
||||
<string name="cw_ready_to_connect">Listo para conectar</string>
|
||||
<string name="cw_ready">Listo</string>
|
||||
<string name="cw_available_after_signin">Disponible después de iniciar sesión</string>
|
||||
<string name="cw_unavailable_server">No disponible en este servidor</string>
|
||||
<string name="cw_could_not_verify">No se pudo verificar</string>
|
||||
<string name="cw_choose_another">Elegir otro</string>
|
||||
<string name="cw_sign_in_to_hermes">Iniciar sesión en Hermes</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes disponible</string>
|
||||
<string name="cw_semantics_capability">%1$s: %2$s</string>
|
||||
<string name="cw_connect_description">Inicie Hermes API/dashboard en su host, luego conecte esta aplicación. El emparejamiento de Relay es opcional y solo es necesario para sesiones de Terminal, Bridge, relay y concesiones de canales.</string>
|
||||
<string name="cw_try_demo">Pruebe la demostración</string>
|
||||
<string name="cw_try_demo_subtitle">Explora sin conexión: no se necesita servidor.</string>
|
||||
@@ -276,7 +323,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 o http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API utilizado por Chat y sesiones — API puerto 8642 y http:// asumido para hosts desnudos (el 9119 del tablero se deriva por separado)</string>
|
||||
<string name="cw_scan_message">Escaneando esta LAN en busca de Hermes dashboard/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API es opcional para Chat.</string>
|
||||
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API solo se usa para la alternativa directa opcional mediante API.</string>
|
||||
<string name="cw_pair_relay_section">Emparejar Relay (opcional)</string>
|
||||
<string name="cw_pair_relay_section_desc">¿Ya estás ejecutando el complemento Relay? Emparéjelo aquí para habilitar Terminal, Bridge y concesiones de canales.</string>
|
||||
<string name="cw_pair_relay_url_label">URL de Relay</string>
|
||||
@@ -397,7 +444,7 @@
|
||||
<string name="endpoints_public">Público</string>
|
||||
<string name="endpoints_custom">Costumbre</string>
|
||||
<string name="endpoints_route_name">Nombre de la ruta</string>
|
||||
<string name="endpoints_api_url_host">URL o host del servidor API</string>
|
||||
<string name="endpoints_api_url_host">URL o host de Dashboard/Gateway</string>
|
||||
<string name="endpoints_saving">Ahorro…</string>
|
||||
<string name="endpoints_save">Guardar</string>
|
||||
<string name="endpoints_close">Cerrar</string>
|
||||
@@ -512,7 +559,7 @@
|
||||
<string name="settings_hermes_management">Gestión Hermes</string>
|
||||
<string name="settings_hermes_management_desc">Funciones del panel: habilidades, cron, MCP, perfiles, modelos</string>
|
||||
<string name="settings_chat">Charlar</string>
|
||||
<string name="settings_chat_desc">Comportamiento del chat API, puntos finales, visualización de herramientas, longitud del mensaje</string>
|
||||
<string name="settings_chat_desc">Comportamiento del chat, Gateway, respaldo de API, visualización de herramientas y longitud del mensaje</string>
|
||||
<string name="settings_voice_mode">Modo de voz</string>
|
||||
<string name="settings_voice_mode_desc">Panel de voz, opciones relay en tiempo real, proveedores</string>
|
||||
<string name="settings_threads">Threads</string>
|
||||
@@ -554,9 +601,9 @@
|
||||
<string name="settings_persistent_connection">Conexión persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantener abierta su conexión a Hermes en segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de turno completo</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notificar cuando finaliza una respuesta mientras la aplicación está en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">No hay alerta cuando finaliza una respuesta en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Mantenlo conectado mientras duermes profundamente</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android todavía puede pausar la conexión una vez que la pantalla ha estado apagada por un tiempo (Doze). Permita la batería sin restricciones para que la conexión persistente siga funcionando en segundo plano.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Permitir batería sin restricciones</string>
|
||||
@@ -584,8 +631,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Muestre sus mensajes recientes como chips que se pueden tocar encima del redactor para enviarlos nuevamente. Desactivado de forma predeterminada.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Mantener el teclado abierto al enviar</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Permanece en el compositor después del envío. Desactívelo para descartar el teclado después de cada mensaje enviado.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Notificar cuando finalice Hermes</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Publicar una notificación cuando se complete una respuesta mientras la aplicación está en segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat en segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartir el estado del teléfono con el agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluya un breve mensaje del sistema sobre la aplicación y el teléfono en cada turno de chat.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
|
||||
@@ -665,6 +712,8 @@
|
||||
<string name="conn_voice_relay">Relay</string>
|
||||
<string name="conn_voice_optional">Opcional</string>
|
||||
<string name="conn_api_label">API</string>
|
||||
<string name="conn_chat_label">Chat</string>
|
||||
<string name="conn_manage_label">Administrar</string>
|
||||
<string name="conn_dashboard_label">Panel</string>
|
||||
<string name="conn_voice_label">Voz</string>
|
||||
<string name="conn_relay_label">Relay</string>
|
||||
@@ -1315,7 +1364,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Complemento de voz Relay: proveedores con reconocimiento de perfiles y salida de voz en streaming.</string>
|
||||
<string name="voice_settings_optional">Opcional</string>
|
||||
<string name="voice_settings_signin_route_hint">Estás conectado a través de la ruta %1$s y los inicios de sesión en el panel de control son por anfitrión: un inicio de sesión desde tu red doméstica no se transfiere. Inicia sesión una vez en Gestionar mientras estás en esta ruta para desbloquear la voz aquí también.</string>
|
||||
<string name="voice_settings_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Abre Gestionar para volver a iniciar sesión y desbloquear la voz.</string>
|
||||
<string name="voice_settings_signin_default_hint">Su panel Hermes requiere iniciar sesión antes de que la voz estándar pueda transcribir o hablar. Iniciar sesión una vez en Administrar lo desbloquea para esta conexión.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Iniciar sesión a través de Administrar</string>
|
||||
<string name="voice_settings_unsupported_build_body">Esta compilación del servidor Hermes aún no expone las rutas de audio del panel. Actualice el agente hermes en el servidor o empareje Relay para usar la voz Relay.</string>
|
||||
@@ -1534,7 +1583,7 @@
|
||||
<string name="dashboard_hours_ago">Hace %1$dh</string>
|
||||
<string name="dashboard_signin_required_title">Es necesario iniciar sesión en el panel</string>
|
||||
<string name="dashboard_signin_required_body">Administrar utiliza la sesión del panel Hermes en %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">Estás en la ruta %1$s. Los inicios de sesión en el panel de control son por anfitrión, por lo que tu inicio de sesión desde la otra ruta no se transfiere. Inicia sesión una vez aquí y la aplicación mantendrá ambas sesiones.</string>
|
||||
<string name="dashboard_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Vuelve a iniciar sesión para actualizarla en las rutas de confianza de esta conexión.</string>
|
||||
<string name="dashboard_signin_with_provider">Iniciar sesión con %1$s</string>
|
||||
<string name="dashboard_username_password">Nombre de usuario y contraseña</string>
|
||||
<string name="dashboard_username">Nombre de usuario</string>
|
||||
@@ -1575,6 +1624,31 @@
|
||||
<string name="dashboard_action_use">Usar</string>
|
||||
<string name="dashboard_action_describe">Describir</string>
|
||||
<string name="dashboard_action_model">Modelo</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Puntos de conexión</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">puntos de conexión</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Puntos de conexión personalizados</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">Proveedores compatibles con OpenAI</string>
|
||||
<string name="dashboard_action_authenticate">Autenticar</string>
|
||||
<string name="dashboard_action_validate">Validar</string>
|
||||
<string name="dashboard_action_edit">Editar</string>
|
||||
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes abrirá el proveedor en el navegador. Regrese aquí después de aprobar el acceso; las credenciales permanecen en el servidor de Hermes.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">Autenticación MCP aprobada</string>
|
||||
<string name="dashboard_mcp_oauth_failed">Error de autenticación MCP</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">No hay ningún navegador disponible para completar la autenticación MCP.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Agregar punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Editar punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_name">Nombre</string>
|
||||
<string name="dashboard_custom_endpoint_url">URL base</string>
|
||||
<string name="dashboard_custom_endpoint_model">Modelo predeterminado</string>
|
||||
<string name="dashboard_custom_endpoint_key">Clave de API (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Déjelo en blanco para conservar una clave existente al guardar. La validación solo usa la clave introducida aquí; Hermes no muestra ni borra las claves guardadas.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Longitud de contexto (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Detectar modelos desde /models</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Punto de conexión accesible · %1$d modelo(s)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Error al validar el punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Error al guardar el punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Punto de conexión personalizado guardado</string>
|
||||
<string name="dashboard_action_completed">%1$s completado</string>
|
||||
<string name="dashboard_action_failed">%1$s falló</string>
|
||||
<string name="dashboard_more">Más</string>
|
||||
@@ -1851,9 +1925,9 @@
|
||||
<string name="active_section_allow_plain_connections">Permitir conexiones simples</string>
|
||||
<string name="active_section_api_hermes_voice_reachable">API accesible: la voz Hermes está configurada</string>
|
||||
<string name="active_section_api_key_already_set">La clave API ya está configurada</string>
|
||||
<string name="active_section_api_key_needed_hint">Ingrese una clave si el servidor API la requiere.</string>
|
||||
<string name="active_section_api_key_needed_hint">Use el API_SERVER_KEY creado en su servidor Hermes. La aplicación no proporciona esta clave.</string>
|
||||
<string name="active_section_api_key_not_configured">Clave API no configurada</string>
|
||||
<string name="active_section_api_key_optional">Clave API (opcional)</string>
|
||||
<string name="active_section_api_key_optional">Clave API para API directa</string>
|
||||
<string name="active_section_api_key_stored_hint">La clave se almacena de forma segura.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API accesible: revisar la configuración de voz</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API accesible: la voz Relay está configurada</string>
|
||||
@@ -1959,6 +2033,7 @@
|
||||
<string name="conn_info_api_server_title">Servidor API</string>
|
||||
<string name="conn_info_approvals_off">Las aprobaciones están desactivadas</string>
|
||||
<string name="conn_info_auth">autenticación</string>
|
||||
<string name="conn_info_relay_auth">Autenticación de Relay</string>
|
||||
<string name="conn_info_avg_ttft">Promedio TTFT</string>
|
||||
<string name="conn_info_channel_grants">Subvenciones de canal</string>
|
||||
<string name="conn_info_checking">De cheques…</string>
|
||||
@@ -1992,6 +2067,7 @@
|
||||
<string name="conn_info_hermes">Hermes</string>
|
||||
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
|
||||
<string name="conn_info_hostname_paired">%1$s (emparejado)</string>
|
||||
<string name="conn_info_hostname_relay_paired">%1$s · Relay emparejado</string>
|
||||
<string name="conn_info_idle_suffix"> · Inactivo</string>
|
||||
<string name="conn_info_insecure_mode_allowed">Modo inseguro permitido</string>
|
||||
<string name="conn_info_inspect_profile">Inspeccionar %1$s</string>
|
||||
@@ -2190,6 +2266,7 @@
|
||||
<string name="tool_progress_status_completed">terminado</string>
|
||||
<string name="tool_progress_status_failed">fallido</string>
|
||||
<string name="tool_progress_status_running">correr</string>
|
||||
<string name="image_generation_rendering">Generando imagen</string>
|
||||
<string name="tool_progress_cd_collapse">Colapsar</string>
|
||||
<string name="tool_progress_cd_expand">Expandir</string>
|
||||
<string name="agent_icon_title">Icono de agente</string>
|
||||
@@ -2274,7 +2351,10 @@
|
||||
<string name="crash_toast_copied">Informe de fallo copiado</string>
|
||||
<string name="qr_scanner_title">Escanear Hermes QR</string>
|
||||
<string name="qr_scanner_instruction">Escanear una configuración Hermes QR</string>
|
||||
<string name="qr_scanner_subtext">Pregúntele a Hermes: "Genere un código QR con mi URL API y mi clave API".</string>
|
||||
<string name="qr_scanner_subtext">Escanee un QR de configuración de Hermes o de vinculación con Relay. Las conexiones estándar mediante Dashboard no necesitan una clave API.</string>
|
||||
<string name="qr_scanner_relay_title">Escanear QR de Relay</string>
|
||||
<string name="qr_scanner_relay_instruction">Escanee un QR de vinculación con Relay</string>
|
||||
<string name="qr_scanner_relay_subtext">Abra el QR de vinculación con Relay en su servidor Hermes. La conexión de Dashboard y las rutas existentes no cambiarán.</string>
|
||||
<string name="qr_scanner_camera_error">No se puede iniciar la cámara en este dispositivo.</string>
|
||||
<string name="qr_scanner_fallback_message">Puedes emparejar sin la cámara.</string>
|
||||
<string name="qr_scanner_pair_manual">Emparejar manualmente</string>
|
||||
@@ -2648,12 +2728,12 @@
|
||||
<string name="diag_check_voice_relay">Voz (Relay)</string>
|
||||
<string name="diag_check_voice_standard">Voz (Estándar)</string>
|
||||
<string name="endpoints_pin_title">Puntos finales fijados</string>
|
||||
<string name="endpoints_route_editor_desc">Editar ruta</string>
|
||||
<string name="endpoints_route_editor_desc">Añada la dirección de Dashboard/Gateway que debe usar este teléfono en esa red.</string>
|
||||
<string name="endpoints_route_name_placeholder">Nombre de la ruta</string>
|
||||
<string name="endpoints_url_host_placeholder">Anfitrión</string>
|
||||
<string name="endpoints_url_supporting_blank">No hay URL compatibles</string>
|
||||
<string name="endpoints_url_supporting_enter">Introduce una URL</string>
|
||||
<string name="endpoints_url_supporting_preview">Avance</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z o host.ts.net</string>
|
||||
<string name="endpoints_url_supporting_blank">Introduzca la dirección de Dashboard del servidor</string>
|
||||
<string name="endpoints_url_supporting_enter">Use una dirección de Dashboard http:// o https://</string>
|
||||
<string name="endpoints_url_supporting_preview">Se probará: %1$s</string>
|
||||
<string name="image_viewer_error">Error al guardar la imagen</string>
|
||||
<string name="image_viewer_failed">No se pudo guardar la imagen</string>
|
||||
<string name="image_viewer_failed_template">No se pudo guardar la imagen: %s</string>
|
||||
@@ -2824,4 +2904,122 @@
|
||||
<string name="conn_info_profile_offline_desc">Sin conexión; no se puede acceder a este perfil en este momento</string>
|
||||
<string name="conn_info_profile_online">En línea</string>
|
||||
<string name="conn_info_profile_online_desc">En línea; la puerta de enlace y los canales de mensajería están en funcionamiento</string>
|
||||
<string name="cw_pair_relay_for">Vincular Relay con %1$s</string>
|
||||
<string name="cw_pair_relay_scoped_desc">Añade la extensión opcional Relay a esta conexión Hermes guardada. Esto no añade ni sustituye ningún servidor.</string>
|
||||
<string name="cw_current_connection">Conexión actual</string>
|
||||
<string name="cw_current_hermes_connection">CONEXIÓN HERMES ACTUAL</string>
|
||||
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice y tus rutas guardadas permanecen sin cambios.</string>
|
||||
<string name="detail_dashboard_primary">Dashboard principal</string>
|
||||
<string name="detail_core_ready">Núcleo de Hermes listo</string>
|
||||
<string name="detail_core_configured">Núcleo de Hermes configurado</string>
|
||||
<string name="detail_overview_summary">Chat, Manage y Voice usan Hermes estándar. Relay es una extensión opcional para funciones avanzadas del dispositivo.</string>
|
||||
<string name="conn_startup_title">Al iniciar la aplicación</string>
|
||||
<string name="conn_startup_last_used">Última usada</string>
|
||||
<string name="conn_startup_recommended">Recomendado</string>
|
||||
<string name="conn_startup_choose">Elegir conexión de inicio</string>
|
||||
<plurals name="conn_server_count">
|
||||
<item quantity="one">%1$d servidor</item>
|
||||
<item quantity="other">%1$d servidores</item>
|
||||
</plurals>
|
||||
<string name="conn_switch">Cambiar</string>
|
||||
<string name="conn_switching">Cambiando…</string>
|
||||
<string name="conn_switched">Activa</string>
|
||||
<string name="conn_connecting_to">Conectando con %1$s…</string>
|
||||
<string name="conn_last_used_now">Usada por última vez ahora mismo</string>
|
||||
<string name="conn_last_used_format">Usada por última vez %1$s</string>
|
||||
<string name="conn_just_now">ahora mismo</string>
|
||||
<string name="conn_dashboard_only_route">Solo Dashboard</string>
|
||||
<string name="conn_no_routes">No hay rutas configuradas</string>
|
||||
<string name="active_section_primary_dashboard">Dashboard principal</string>
|
||||
<string name="active_section_dashboard_reachable_signed_in">Accesible · Sesión iniciada</string>
|
||||
<string name="active_section_dashboard_reachable">Accesible</string>
|
||||
<string name="active_section_dashboard_not_checked">Aún no comprobado</string>
|
||||
<string name="active_section_dashboard_unreachable">No accesible</string>
|
||||
<string name="active_section_no_fallback_routes">Aún no hay rutas alternativas</string>
|
||||
<string name="active_section_no_fallback_routes_desc">Añade una ruta API opcional para chat directo alternativo y cambios de red.</string>
|
||||
<string name="active_section_add_api_fallback">Añadir ruta alternativa</string>
|
||||
<string name="active_section_security_authentication">Autenticación</string>
|
||||
<string name="active_section_dashboard_session">Sesión del Dashboard</string>
|
||||
<string name="active_section_credential_storage">Almacenamiento de credenciales</string>
|
||||
<string name="active_section_encrypted_storage">Almacenamiento cifrado</string>
|
||||
<string name="active_section_no_relay_credential">Sin credencial de Relay</string>
|
||||
<string name="active_section_sign_out_dashboard">Cerrar sesión del Dashboard</string>
|
||||
<string name="active_section_credentials_encrypted">Las credenciales permanecen cifradas en este dispositivo.</string>
|
||||
<string name="active_section_reachable_badge">ACCESIBLE</string>
|
||||
<string name="active_section_unchecked_badge">SIN COMPROBAR</string>
|
||||
<string name="active_section_edit">Editar</string>
|
||||
<string name="active_section_fallback_routes">Rutas alternativas</string>
|
||||
<string name="active_section_route_selection">Selección de ruta</string>
|
||||
<string name="active_section_automatic">Automática</string>
|
||||
<string name="active_section_api_access">Acceso a la API</string>
|
||||
<string name="active_section_core_hermes">Núcleo de Hermes</string>
|
||||
<string name="active_section_optional_relay">Relay opcional</string>
|
||||
<string name="active_section_extend_connection">Amplía esta conexión</string>
|
||||
<string name="active_section_relay_connected_features">Extensiones de Relay</string>
|
||||
<string name="active_section_relay_optional_summary">Añade Terminal, Bridge, herramientas del dispositivo, sesiones de Relay y rutas remotas seguras.</string>
|
||||
<string name="active_section_view_relay_details">Ver detalles de Relay</string>
|
||||
<string name="active_section_core_unchanged">Tu conexión Hermes actual permanece sin cambios.</string>
|
||||
<string name="active_section_api_optional_direct">Opcional para chat directo e integraciones</string>
|
||||
<string name="active_section_configure_test">Configurar y probar</string>
|
||||
<string name="active_section_relay_optional_bridge">Funciones opcionales de Bridge y acceso remoto</string>
|
||||
<string name="active_section_configure_relay">Configurar Relay</string>
|
||||
<string name="active_section_pair_device_using_code">Vincula este dispositivo con un código del servidor.</string>
|
||||
<string name="active_section_enter_pairing_code">Introducir código de vinculación</string>
|
||||
<string name="active_section_done">Listo</string>
|
||||
<string name="active_section_connection_behavior">Comportamiento de la conexión</string>
|
||||
<string name="active_section_manual_pairing">Vinculación manual</string>
|
||||
<string name="active_section_paired">Vinculado</string>
|
||||
<string name="active_section_not_paired">No vinculado</string>
|
||||
<string name="active_section_transport">Transporte</string>
|
||||
<string name="active_section_hardware_backed">Protegido por hardware</string>
|
||||
<string name="active_section_relay_session">Sesión de Relay</string>
|
||||
<string name="active_section_access">Acceso</string>
|
||||
<string name="active_section_paired_devices">Dispositivos vinculados</string>
|
||||
<string name="active_section_device_count">%1$d dispositivos</string>
|
||||
<string name="active_section_session_activity">Actividad de la sesión</string>
|
||||
<string name="active_section_last_checked_just_now">Comprobado ahora mismo</string>
|
||||
<string name="active_section_protected">Protegida</string>
|
||||
<string name="active_section_not_encrypted">Sin cifrar</string>
|
||||
<string name="active_section_no_security_issues">No se detectaron problemas de seguridad</string>
|
||||
<string name="active_section_unencrypted_transport">Esta conexión usa un transporte sin cifrar</string>
|
||||
<string name="active_section_actions">Acciones</string>
|
||||
<string name="active_section_revoke_relay">Revocar vinculación de Relay</string>
|
||||
<string name="cw_preparing_connection">Preparando la conexión…</string>
|
||||
<string name="cw_preparing_connection_hint">Configurando el almacenamiento local seguro. Solo tardará un momento.</string>
|
||||
<string name="cw_relay_entry_title">Hermes Relay</string>
|
||||
<string name="cw_relay_entry_desc">Vincula Relay para añadir Terminal, Bridge, herramientas del dispositivo, sesiones de Relay y permisos.</string>
|
||||
<string name="cw_relay_pair_qr">Vincular Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">Escanea un QR de configuración de Relay</string>
|
||||
<string name="cw_relay_enter_code">Introducir código de vinculación</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard y Gateway están listos. Puedes empezar a chatear o abrir Manage.</string>
|
||||
<string name="cw_continue">Continuar</string>
|
||||
<string name="cw_timeline_discovered">Hermes encontrado</string>
|
||||
<string name="cw_timeline_discovered_detail">Identidad del Dashboard y endpoint de estado verificados</string>
|
||||
<string name="cw_timeline_access">Acceso al Dashboard</string>
|
||||
<string name="cw_timeline_access_ready">No se requiere otro inicio de sesión</string>
|
||||
<string name="cw_timeline_authenticated">Autenticación verificada</string>
|
||||
<string name="cw_timeline_ready">Conexión lista</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage y Voice pueden usar este Dashboard</string>
|
||||
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
|
||||
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional, que requiere una clave utilizable, e introduzca aquí el mismo valor.</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">Cómo accede este teléfono a %1$s</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · Red LAN doméstica</string>
|
||||
<string name="active_section_scan_relay_qr">Escanear QR de Relay</string>
|
||||
<string name="active_section_other_relay_methods">Otros métodos de vinculación</string>
|
||||
<string name="endpoints_tailscale_setup_hint">Tailscale debe estar conectado en ambos dispositivos y Hermes Dashboard debe ser accesible por el puerto 9119.</string>
|
||||
<string name="endpoints_setup_help">Ayuda para configurar Tailscale</string>
|
||||
<string name="voice_settings_provider_desc">Dónde se genera la voz. Las opciones disponibles vienen de este host Hermes y sus proveedores instalados.</string>
|
||||
<string name="voice_settings_model_desc">Latest sigue las actualizaciones del proveedor. Elige un modelo con versión para mantener fija la voz.</string>
|
||||
<string name="voice_settings_voice_desc">Elige cómo suenan las respuestas. La vista previa de una voz no guarda la selección.</string>
|
||||
<string name="voice_settings_language_desc">Automático permite que el proveedor detecte el idioma hablado. Elige un idioma solo si la detección no es fiable.</string>
|
||||
<string name="voice_settings_language_auto">Automático</string>
|
||||
<string name="voice_settings_provider_options_title">Opciones de voz</string>
|
||||
<string name="voice_settings_provider_options_desc">Solo se muestran los ajustes compatibles con el proveedor seleccionado.</string>
|
||||
<string name="voice_settings_auto_speak">Leer respuestas en voz alta</string>
|
||||
<string name="voice_settings_auto_speak_desc">Habla automáticamente las respuestas del asistente en las superficies de Hermes que respetan este ajuste del host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla la sesión de voz en vivo, no el modelo de chat de Hermes. Latest sigue las actualizaciones del proveedor; un modelo con versión queda fijo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">La voz usada dentro de la sesión en vivo. Las voces integradas y personalizadas aparecen cuando el proveedor las anuncia.</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
<string name="onboarding_next">次</string>
|
||||
<string name="onboarding_connect">接続する</string>
|
||||
|
||||
<string name="onboarding_welcome_title">Hermes-Relay 用 Android</string>
|
||||
<string name="onboarding_welcome_description">Hermes とチャットし、携帯電話からダッシュボードを管理します。</string>
|
||||
<string name="onboarding_get_started">始める</string>
|
||||
<string name="onboarding_step_count">%2$d ステップ中 %1$d</string>
|
||||
<string name="onboarding_welcome_title">Hermes を\nポケットに</string>
|
||||
<string name="onboarding_welcome_description">自分の Hermes とチャット、管理、\n音声会話ができます。</string>
|
||||
<string name="onboarding_welcome_badge">いらっしゃいませ</string>
|
||||
<string name="onboarding_hermes_logo">Hermes ロゴ</string>
|
||||
<string name="onboarding_chat_manage_label">チャット & 管理</string>
|
||||
<string name="onboarding_chat_manage_description">実行中の Hermes ダッシュボードと API に接続します。 Relay のインストールやペアリングは必要ありません。</string>
|
||||
<string name="onboarding_power_tools_label">パワーツール</string>
|
||||
<string name="onboarding_power_tools_description">ターミナル、Bridge、Relay セッション、およびチャネル許可に Hermes-Relay を追加します。</string>
|
||||
<string name="onboarding_chat_manage_label">まずダッシュボード</string>
|
||||
<string name="onboarding_chat_manage_description">Relay は不要です</string>
|
||||
<string name="onboarding_power_tools_label">高度なツールは後から</string>
|
||||
<string name="onboarding_power_tools_description">Terminal や Bridge が必要なら\nRelay をペアリング</string>
|
||||
<string name="onboarding_setup_guide_hint">セットアップ ガイドには、コンピューターまたはサーバー上で Hermes を起動する必要がある場合のコピー/貼り付けコマンドが記載されています。</string>
|
||||
<string name="onboarding_setup_guide">セットアップガイド</string>
|
||||
<string name="onboarding_hermes_docs">Hermes ドキュメント</string>
|
||||
@@ -54,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">リアルタイム</string>
|
||||
<string name="onboarding_realtime_description">プロバイダーネイティブのリアルタイム音声エージェントとプロファイル認識音声プロバイダー。</string>
|
||||
<string name="onboarding_review_permissions">権限の確認</string>
|
||||
<string name="onboarding_finish_setup_title">セットアップを完了</string>
|
||||
<string name="onboarding_finish_setup_description">Hermes への接続準備ができました。このスマートフォンで許可する機能を選ぶか、後で設定から変更できます。</string>
|
||||
<string name="onboarding_chat_manage_ready">準備完了 — スマートフォンの権限は必要ありません。</string>
|
||||
<string name="onboarding_chat_alerts">チャット通知</string>
|
||||
<string name="onboarding_chat_alerts_description">有効にした通知をバックグラウンドでも受け取れるよう、Android の通知を許可します。</string>
|
||||
<string name="onboarding_chat_alerts_ready">このアプリの通知は有効です。</string>
|
||||
<string name="onboarding_optional_features">オプション機能</string>
|
||||
<string name="onboarding_optional_features_description">カメラ、マイク、通知コンパニオン、利用可能な端末ツールは、選択するまで無効のままです。</string>
|
||||
<string name="onboarding_review_optional_permissions">オプション権限を確認</string>
|
||||
<string name="onboarding_enable_chat_alerts">チャット通知を有効にする</string>
|
||||
<string name="onboarding_not_now">今はしない</string>
|
||||
<string name="onboarding_finish">セットアップを完了</string>
|
||||
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">ライブ音声会話</string>
|
||||
@@ -68,7 +82,7 @@
|
||||
<string name="chat_input_start_voice">音声会話を開始する</string>
|
||||
<string name="chat_input_voice_setup_needed">音声会話 - 設定が必要です</string>
|
||||
<string name="chat_input_stop_streaming">ストリーミングを停止する</string>
|
||||
<string name="chat_input_steer_response">応答を制御する</string>
|
||||
<string name="chat_input_steer_response">応答を修正</string>
|
||||
<string name="chat_input_queue_message">キューメッセージ</string>
|
||||
|
||||
<!-- Bridge return labels -->
|
||||
@@ -99,7 +113,7 @@
|
||||
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">メッセージを編集してください…</string>
|
||||
<string name="chat_placeholder_steer">応答を制御します…</string>
|
||||
<string name="chat_placeholder_steer">応答を修正…</string>
|
||||
<string name="chat_placeholder_queue">メッセージをキューに入れます…</string>
|
||||
<string name="chat_placeholder_message">メッセージ…</string>
|
||||
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
|
||||
@@ -274,6 +288,39 @@
|
||||
|
||||
<!-- Connection wizard — Connect page essentials -->
|
||||
<string name="cw_connect_to_hermes">Hermes に接続します</string>
|
||||
<string name="cw_nearby_description">このネットワーク上の Hermes を検索します。ダッシュボードとゲートウェイが、チャット、管理、音声の標準接続を提供します。</string>
|
||||
<string name="cw_before_connecting">接続する前に</string>
|
||||
<string name="cw_connect_step_server_title">パソコンで Hermes を起動</string>
|
||||
<string name="cw_connect_step_server_body">Hermes のパソコンでダッシュボードを起動します。初めて LAN から接続する場合は、下のセットアップガイドでスマートフォンからアクセス可能にし、サインインを有効にしてください。</string>
|
||||
<string name="cw_connect_step_network_title">サーバーに接続できる状態にする</string>
|
||||
<string name="cw_connect_step_network_body">同じ Wi-Fi を使うか、両方のデバイスを Tailscale に接続します。</string>
|
||||
<string name="cw_connect_step_phone_title">このスマートフォンから接続</string>
|
||||
<string name="cw_connect_step_phone_body">下で検索するか、192.168.1.10:9119 のようなダッシュボードアドレスを入力します。表示された場合はサインインしてください。API キーは不要です。</string>
|
||||
<string name="cw_nearby_searching">付近を検索中…</string>
|
||||
<string name="cw_nearby_searching_hint">スマートフォンを Hermes サーバーと同じネットワークに接続してください。</string>
|
||||
<string name="cw_nearby_heading">付近の Hermes</string>
|
||||
<string name="cw_nearby_empty">Hermes サーバーが見つかりません</string>
|
||||
<string name="cw_nearby_failed">このネットワークを検索できませんでした</string>
|
||||
<string name="cw_nearby_empty_hint">Hermes が実行中であることを確認して再検索するか、アドレスを入力してください。</string>
|
||||
<string name="cw_nearby_search_again">再検索</string>
|
||||
<string name="cw_nearby_enter_address">アドレスを入力</string>
|
||||
<string name="cw_other_connection_methods">その他の接続方法</string>
|
||||
<string name="cw_manual_hermes_title">Hermes のアドレスを入力</string>
|
||||
<string name="cw_manual_hermes_description">ブラウザで開くダッシュボードアドレスを入力します。このスマートフォンで開けない場合は hermes dashboard を起動し、Wi-Fi または Tailscale を確認してください。</string>
|
||||
<string name="cw_hermes_address">Hermes アドレス</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 または hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">API キーは不要です。ポートを省略するとダッシュボードの 9119 番ポートを使用します。</string>
|
||||
<string name="cw_find_hermes">Hermes を検索</string>
|
||||
<string name="cw_hermes_found">Hermes が見つかりました</string>
|
||||
<string name="cw_ready_to_connect">接続できます</string>
|
||||
<string name="cw_ready">準備完了</string>
|
||||
<string name="cw_available_after_signin">サインイン後に利用可能</string>
|
||||
<string name="cw_unavailable_server">このサーバーでは利用不可</string>
|
||||
<string name="cw_could_not_verify">確認できませんでした</string>
|
||||
<string name="cw_choose_another">別のものを選択</string>
|
||||
<string name="cw_sign_in_to_hermes">Hermes にサインイン</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes を利用できます</string>
|
||||
<string name="cw_semantics_capability">%1$s:%2$s</string>
|
||||
<string name="cw_connect_description">ホスト上で Hermes API/ダッシュボードを起動し、このアプリを接続します。 Relay ペアリングはオプションであり、端末、Bridge、Relay セッション、およびチャネル許可の場合にのみ必要です。</string>
|
||||
<string name="cw_try_demo">デモを試してみる</string>
|
||||
<string name="cw_try_demo_subtitle">オフラインで探索 — サーバーは必要ありません。</string>
|
||||
@@ -318,7 +365,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 または http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API チャットとセッションで使用されます — API ポート 8642 および http:// はベア ホストとして想定されます (ダッシュボードの 9119 は個別に派生します)</string>
|
||||
<string name="cw_scan_message">この LAN をスキャンして Hermes ダッシュボード/API を探しています…</string>
|
||||
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。チャットでは API キーはオプションです。</string>
|
||||
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。API キーは任意の直接 API フォールバックでのみ使います。</string>
|
||||
<string name="cw_pair_relay_section">Relay のペア (オプション)</string>
|
||||
<string name="cw_pair_relay_section_desc">すでに Relay プラグインを実行していますか?ここでペアリングすると、ターミナル、Bridge、およびチャネル許可が有効になります。</string>
|
||||
<string name="cw_pair_relay_url_label">Relay URL</string>
|
||||
@@ -337,6 +384,11 @@
|
||||
<string name="cw_start_chat">チャットを開始する</string>
|
||||
<string name="cw_sign_in_to_manage">サインインして管理する</string>
|
||||
<string name="cw_pair_code_subtitle">これは、ホストが Relay ペアリング コードを登録した後にのみ使用してください。標準のチャットと管理ではこれは必要ありません。</string>
|
||||
<string name="cw_pair_relay_for">%1$s と Relay をペアリング</string>
|
||||
<string name="cw_pair_relay_scoped_desc">この保存済み Hermes 接続にオプションの Relay 拡張機能を追加します。サーバーが追加または置換されることはありません。</string>
|
||||
<string name="cw_current_connection">現在の接続</string>
|
||||
<string name="cw_current_hermes_connection">現在の HERMES 接続</string>
|
||||
<string name="cw_existing_connection_unchanged">Chat、Manage、Voice、保存済みルートは変更されません。</string>
|
||||
<string name="cw_api_url_label_field">API サーバー URL</string>
|
||||
<string name="cw_api_url_field_placeholder">http://your-server:8642</string>
|
||||
<string name="cw_api_url_field_supporting">Hermes API — チャットとセッション (デフォルトのポート 8642)</string>
|
||||
@@ -441,7 +493,7 @@
|
||||
<string name="endpoints_public">パブリック</string>
|
||||
<string name="endpoints_custom">カスタム</string>
|
||||
<string name="endpoints_route_name">路線名</string>
|
||||
<string name="endpoints_api_url_host">API サーバー URL またはホスト</string>
|
||||
<string name="endpoints_api_url_host">Dashboard/Gateway の URL またはホスト</string>
|
||||
<string name="endpoints_saving">保存中…</string>
|
||||
<string name="endpoints_save">保存</string>
|
||||
<string name="endpoints_close">閉じる</string>
|
||||
@@ -580,7 +632,7 @@
|
||||
<string name="settings_hermes_management">Hermes 管理</string>
|
||||
<string name="settings_hermes_management_desc">ダッシュボードの機能: スキル、cron、MCP、プロファイル、モデル</string>
|
||||
<string name="settings_chat">チャット</string>
|
||||
<string name="settings_chat_desc">API チャットの動作、エンドポイント、ツールの表示、メッセージの長さ</string>
|
||||
<string name="settings_chat_desc">チャット動作、Gateway、API フォールバック、ツール表示、メッセージ長</string>
|
||||
<string name="settings_voice_mode">ボイスモード</string>
|
||||
<string name="settings_voice_mode_desc">ダッシュボード音声、リアルタイムRelayオプション、プロバイダー</string>
|
||||
<string name="settings_threads">Threads</string>
|
||||
@@ -622,9 +674,9 @@
|
||||
<string name="settings_persistent_connection">永続的な接続</string>
|
||||
<string name="settings_persistent_connection_desc">Hermes への接続をバックグラウンドで開いたままにします</string>
|
||||
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
|
||||
<string name="settings_turn_complete_alerts">ターン完了アラート</string>
|
||||
<string name="settings_turn_complete_alerts_desc">アプリがバックグラウンドで動作しているときに返信が完了したときに通知する</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドでの返信が終了してもアラートは表示されません</string>
|
||||
<string name="settings_turn_complete_alerts">チャット通知</string>
|
||||
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</string>
|
||||
<string name="settings_keep_connected_deep_sleep">深い睡眠中も接続を維持する</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android は、画面がしばらくオフになった後でも接続を一時停止できます (Doze)。永続的な接続がバックグラウンドで動作し続けるように、無制限のバッテリーを許可します。</string>
|
||||
<string name="settings_allow_unrestricted_battery">無制限のバッテリーを許可する</string>
|
||||
@@ -654,8 +706,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">最近のメッセージをコンポーザーの上にタップ可能なチップとして表示し、再送信します。デフォルトではオフです。</string>
|
||||
<string name="chat_settings_keep_keyboard_open">送信時にキーボードを開いたままにする</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">送信後はコンポーザー内に留まります。メッセージを送信するたびにキーボードを閉じるには、オフにします。</string>
|
||||
<string name="chat_settings_notify_when_finishes">Hermes が終了したら通知する</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">アプリがバックグラウンドで動作中に返信が完了したときに通知を投稿する</string>
|
||||
<string name="chat_settings_notify_when_finishes">バックグラウンドのチャット通知</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="chat_settings_share_phone_status">電話のステータスをエージェントと共有する</string>
|
||||
<string name="chat_settings_share_phone_status_desc">すべてのチャット ターンにアプリと電話に関する短いシステム メッセージを含めます</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
|
||||
@@ -737,6 +789,8 @@
|
||||
<string name="conn_voice_relay">Relay</string>
|
||||
<string name="conn_voice_optional">オプション</string>
|
||||
<string name="conn_api_label">API</string>
|
||||
<string name="conn_chat_label">チャット</string>
|
||||
<string name="conn_manage_label">管理</string>
|
||||
<string name="conn_dashboard_label">ダッシュボード</string>
|
||||
<string name="conn_voice_label">声</string>
|
||||
<string name="conn_relay_label">Relay</string>
|
||||
@@ -754,6 +808,19 @@
|
||||
<item quantity="one">%1$d 日前にペアリングしました</item>
|
||||
<item quantity="other">%1$d日前にペアリングしました</item>
|
||||
</plurals>
|
||||
<plurals name="conn_server_count">
|
||||
<item quantity="one">%1$d 台のサーバー</item>
|
||||
<item quantity="other">%1$d 台のサーバー</item>
|
||||
</plurals>
|
||||
<string name="conn_switch">切り替え</string>
|
||||
<string name="conn_switching">切り替え中…</string>
|
||||
<string name="conn_switched">使用中</string>
|
||||
<string name="conn_connecting_to">%1$s に接続中…</string>
|
||||
<string name="conn_last_used_now">たった今使用</string>
|
||||
<string name="conn_last_used_format">最終使用: %1$s</string>
|
||||
<string name="conn_just_now">たった今</string>
|
||||
<string name="conn_dashboard_only_route">Dashboard のみ</string>
|
||||
<string name="conn_no_routes">ルート未設定</string>
|
||||
|
||||
<!-- P0: ConnectionDetailScreen -->
|
||||
<string name="detail_active">アクティブ</string>
|
||||
@@ -783,6 +850,10 @@
|
||||
<string name="detail_tab_routes">ルート</string>
|
||||
<string name="detail_tab_advanced">高度な</string>
|
||||
<string name="detail_tab_security">安全</string>
|
||||
<string name="detail_dashboard_primary">Dashboard を優先</string>
|
||||
<string name="detail_core_ready">Hermes コアは準備完了</string>
|
||||
<string name="detail_core_configured">Hermes コアを設定済み</string>
|
||||
<string name="detail_overview_summary">Chat、Manage、Voice では標準の Hermes を使用します。Relay は高度な端末機能を追加するオプションの拡張機能です。</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="drawer_filter_by_source">ソースによるフィルター</string>
|
||||
@@ -1430,7 +1501,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Relay プラグイン音声 — プロファイル対応プロバイダーとストリーミング音声出力。</string>
|
||||
<string name="voice_settings_optional">オプション</string>
|
||||
<string name="voice_settings_signin_route_hint">%1$s ルート経由で接続されており、ダッシュボードのサインインはホストごとに行われます。ホーム ネットワークからのサインインは引き継がれません。このルートの途中で [管理] に一度サインインすると、ここでも音声のロックが解除されます。</string>
|
||||
<string name="voice_settings_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。[管理] を開いて再度サインインし、音声を有効にしてください。</string>
|
||||
<string name="voice_settings_signin_default_hint">Hermes ダッシュボードでは、標準音声を書き起こしたり話したりする前にサインインする必要があります。管理に一度サインインすると、この接続のロックが解除されます。</string>
|
||||
<string name="voice_settings_sign_in_via_manage">「管理」経由でサインインする</string>
|
||||
<string name="voice_settings_unsupported_build_body">この Hermes サーバー ビルドでは、ダッシュボードのオーディオ ルートがまだ公開されていません。サーバー上の hermes-agent を更新するか、Relay をペアにして Relay 音声を使用します。</string>
|
||||
@@ -1669,7 +1740,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">ダッシュボードへのサインインが必要です</string>
|
||||
<string name="dashboard_signin_required_body">管理は、%1$s で Hermes ダッシュボード セッションを使用します。</string>
|
||||
<string name="dashboard_signin_route_hint">あなたは %1$s ルート上にいます。ダッシュボードのサインインはホストごとに行われるため、他のルートからのサインインは引き継がれません。ここで 1 回サインインすると、アプリは両方のセッションを維持します。</string>
|
||||
<string name="dashboard_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。再度サインインして、この接続の信頼済みルート全体でセッションを更新してください。</string>
|
||||
<string name="dashboard_signin_with_provider">%1$s でサインインします</string>
|
||||
<string name="dashboard_username_password">ユーザー名 & パスワード</string>
|
||||
<string name="dashboard_username">ユーザー名</string>
|
||||
@@ -1720,6 +1791,31 @@
|
||||
<string name="dashboard_action_use">使用</string>
|
||||
<string name="dashboard_action_describe">説明する</string>
|
||||
<string name="dashboard_action_model">モデル</string>
|
||||
<string name="dashboard_tab_custom_endpoints">エンドポイント</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">エンドポイント</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">カスタムエンドポイント</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 互換プロバイダー</string>
|
||||
<string name="dashboard_action_authenticate">認証</string>
|
||||
<string name="dashboard_action_validate">検証</string>
|
||||
<string name="dashboard_action_edit">編集</string>
|
||||
<string name="dashboard_mcp_oauth_title">%1$s を認証</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes はブラウザでプロバイダーを開きます。アクセスを承認したら、この画面に戻ってください。認証情報は Hermes サーバーに保存されます。</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP 認証が承認されました</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP 認証に失敗しました</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">MCP 認証を完了するためのブラウザを利用できません。</string>
|
||||
<string name="dashboard_custom_endpoint_add">エンドポイントを追加</string>
|
||||
<string name="dashboard_custom_endpoint_edit">エンドポイントを編集</string>
|
||||
<string name="dashboard_custom_endpoint_name">名前</string>
|
||||
<string name="dashboard_custom_endpoint_url">ベース URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">デフォルトモデル</string>
|
||||
<string name="dashboard_custom_endpoint_key">API キー(任意)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">保存時に既存のキーを保持するには空欄にしてください。検証にはここで入力したキーのみが使用されます。Hermes が保存済みのキーを表示または削除することはありません。</string>
|
||||
<string name="dashboard_custom_endpoint_context">コンテキスト長(任意)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">/models からモデルを検出</string>
|
||||
<string name="dashboard_custom_endpoint_valid">エンドポイントに接続できました · %1$d モデル</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">エンドポイントの検証に失敗しました</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">エンドポイントの保存に失敗しました</string>
|
||||
<string name="dashboard_custom_endpoint_saved">カスタムエンドポイントを保存しました</string>
|
||||
<string name="dashboard_action_completed">%1$s 完了</string>
|
||||
<string name="dashboard_action_failed">%1$s が失敗しました</string>
|
||||
<string name="dashboard_more">もっと</string>
|
||||
@@ -2032,9 +2128,9 @@
|
||||
<string name="active_section_allow_plain_connections">プレーン接続を許可する</string>
|
||||
<string name="active_section_api_hermes_voice_reachable">API 到達可能 — Hermes 音声が設定されています</string>
|
||||
<string name="active_section_api_key_already_set">API キーはすでに設定されています</string>
|
||||
<string name="active_section_api_key_needed_hint">API サーバーでキーが必要な場合は、キーを入力します。</string>
|
||||
<string name="active_section_api_key_needed_hint">Hermes サーバーで作成した API_SERVER_KEY を使用します。このキーはアプリから発行されません。</string>
|
||||
<string name="active_section_api_key_not_configured">API キーが構成されていません</string>
|
||||
<string name="active_section_api_key_optional">API キー (オプション)</string>
|
||||
<string name="active_section_api_key_optional">直接 API の API キー</string>
|
||||
<string name="active_section_api_key_stored_hint">鍵は安全に保管されます。</string>
|
||||
<string name="active_section_api_reachable_voice_review">API に到達可能 — 音声設定を確認してください</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API 到達可能 — Relay 音声が設定されています</string>
|
||||
@@ -2142,6 +2238,7 @@
|
||||
<string name="conn_info_api_server_title">API サーバー</string>
|
||||
<string name="conn_info_approvals_off">承認はオフです</string>
|
||||
<string name="conn_info_auth">認証</string>
|
||||
<string name="conn_info_relay_auth">Relay 認証</string>
|
||||
<string name="conn_info_avg_ttft">平均TTFT</string>
|
||||
<string name="conn_info_channel_grants">チャンネル許可</string>
|
||||
<string name="conn_info_checking">チェック中…</string>
|
||||
@@ -2181,6 +2278,7 @@
|
||||
<string name="conn_info_hermes">Hermes</string>
|
||||
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
|
||||
<string name="conn_info_hostname_paired">%1$s (ペア)</string>
|
||||
<string name="conn_info_hostname_relay_paired">%1$s · Relay ペア済み</string>
|
||||
<string name="conn_info_idle_suffix">・アイドル</string>
|
||||
<string name="conn_info_insecure_mode_allowed">安全でないモードは許可されています</string>
|
||||
<string name="conn_info_inspect_profile">%1$sを検査する</string>
|
||||
@@ -2405,6 +2503,7 @@
|
||||
<string name="tool_progress_status_completed">完成した</string>
|
||||
<string name="tool_progress_status_failed">失敗した</string>
|
||||
<string name="tool_progress_status_running">走っている</string>
|
||||
<string name="image_generation_rendering">画像を生成中</string>
|
||||
<string name="tool_progress_cd_collapse">崩壊</string>
|
||||
<string name="tool_progress_cd_expand">拡大する</string>
|
||||
|
||||
@@ -2511,7 +2610,10 @@
|
||||
<!-- QrPairingScanner -->
|
||||
<string name="qr_scanner_title">Hermes QR をスキャン</string>
|
||||
<string name="qr_scanner_instruction">Hermes セットアップ QR をスキャンします</string>
|
||||
<string name="qr_scanner_subtext">Hermes に質問します: "私の API URL および API キーを使用して QR コードを生成します。"</string>
|
||||
<string name="qr_scanner_subtext">Hermes セットアップ QR または Relay ペアリング QR をスキャンします。標準の Dashboard 接続に API キーは必要ありません。</string>
|
||||
<string name="qr_scanner_relay_title">Relay QR をスキャン</string>
|
||||
<string name="qr_scanner_relay_instruction">Relay ペアリング QR をスキャンします</string>
|
||||
<string name="qr_scanner_relay_subtext">Hermes サーバーで Relay ペアリング QR を開きます。既存の Dashboard 接続とルートは変更されません。</string>
|
||||
<string name="qr_scanner_camera_error">このデバイスではカメラを起動できません。</string>
|
||||
<string name="qr_scanner_fallback_message">カメラがなくてもペアリングできます。</string>
|
||||
<string name="qr_scanner_pair_manual">手動でペアリングする</string>
|
||||
@@ -2978,12 +3080,12 @@
|
||||
<string name="diag_check_voice_relay">音声 (Relay)</string>
|
||||
<string name="diag_check_voice_standard">音声(標準)</string>
|
||||
<string name="endpoints_pin_title">固定されたエンドポイント</string>
|
||||
<string name="endpoints_route_editor_desc">ルートを編集する</string>
|
||||
<string name="endpoints_route_editor_desc">このネットワークで電話が使用する Dashboard/Gateway アドレスを追加します。</string>
|
||||
<string name="endpoints_route_name_placeholder">路線名</string>
|
||||
<string name="endpoints_url_host_placeholder">ホスト</string>
|
||||
<string name="endpoints_url_supporting_blank">サポートする URL がありません</string>
|
||||
<string name="endpoints_url_supporting_enter">URL を入力してください</string>
|
||||
<string name="endpoints_url_supporting_preview">プレビュー</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z または host.ts.net</string>
|
||||
<string name="endpoints_url_supporting_blank">サーバーの Dashboard アドレスを入力します</string>
|
||||
<string name="endpoints_url_supporting_enter">http:// または https:// の Dashboard アドレスを使用します</string>
|
||||
<string name="endpoints_url_supporting_preview">テスト対象: %1$s</string>
|
||||
<string name="image_viewer_error">画像保存エラー</string>
|
||||
<string name="image_viewer_failed">画像の保存に失敗しました</string>
|
||||
<string name="image_viewer_failed_template">画像の保存に失敗しました: %s</string>
|
||||
@@ -3139,4 +3241,100 @@
|
||||
<string name="tool_output_risk_a11y">、%1$s 出力リスク</string>
|
||||
<string name="tool_output_risk_findings">リスク所見を出力する</string>
|
||||
<string name="tool_output_risk_redacted">機密性の高いスパンは上流で編集されました。</string>
|
||||
<string name="conn_startup_title">アプリ起動時</string>
|
||||
<string name="conn_startup_last_used">最後に使用した接続</string>
|
||||
<string name="conn_startup_recommended">推奨</string>
|
||||
<string name="conn_startup_choose">起動時の接続を選択</string>
|
||||
<string name="active_section_primary_dashboard">メイン Dashboard</string>
|
||||
<string name="active_section_dashboard_reachable_signed_in">接続可能 · サインイン済み</string>
|
||||
<string name="active_section_dashboard_reachable">接続可能</string>
|
||||
<string name="active_section_dashboard_not_checked">未確認</string>
|
||||
<string name="active_section_dashboard_unreachable">接続できません</string>
|
||||
<string name="active_section_no_fallback_routes">フォールバックルートはまだありません</string>
|
||||
<string name="active_section_no_fallback_routes_desc">直接チャットのフォールバックやネットワーク切り替え用に、任意の API ルートを追加できます。</string>
|
||||
<string name="active_section_add_api_fallback">フォールバックルートを追加</string>
|
||||
<string name="active_section_security_authentication">認証</string>
|
||||
<string name="active_section_dashboard_session">Dashboard セッション</string>
|
||||
<string name="active_section_credential_storage">認証情報ストレージ</string>
|
||||
<string name="active_section_encrypted_storage">暗号化ストレージ</string>
|
||||
<string name="active_section_no_relay_credential">Relay 認証情報なし</string>
|
||||
<string name="active_section_sign_out_dashboard">Dashboard からサインアウト</string>
|
||||
<string name="active_section_credentials_encrypted">認証情報はこの端末上で暗号化されたまま保存されます。</string>
|
||||
<string name="active_section_reachable_badge">接続可能</string>
|
||||
<string name="active_section_unchecked_badge">未確認</string>
|
||||
<string name="active_section_edit">編集</string>
|
||||
<string name="active_section_fallback_routes">フォールバックルート</string>
|
||||
<string name="active_section_route_selection">ルート選択</string>
|
||||
<string name="active_section_automatic">自動</string>
|
||||
<string name="active_section_api_access">API アクセス</string>
|
||||
<string name="active_section_core_hermes">Hermes コア</string>
|
||||
<string name="active_section_optional_relay">オプションの Relay</string>
|
||||
<string name="active_section_extend_connection">この接続を拡張</string>
|
||||
<string name="active_section_relay_connected_features">Relay 拡張機能</string>
|
||||
<string name="active_section_relay_optional_summary">Terminal、Bridge、端末ツール、Relay セッション、安全なリモートルートを追加します。</string>
|
||||
<string name="active_section_view_relay_details">Relay の詳細を表示</string>
|
||||
<string name="active_section_core_unchanged">現在の Hermes 接続は変更されません。</string>
|
||||
<string name="active_section_api_optional_direct">ダイレクトチャットと連携ではオプション</string>
|
||||
<string name="active_section_configure_test">設定してテスト</string>
|
||||
<string name="active_section_relay_optional_bridge">オプションの Bridge 機能とリモートアクセス</string>
|
||||
<string name="active_section_configure_relay">Relay を設定</string>
|
||||
<string name="active_section_pair_device_using_code">サーバーから取得したコードでこの端末をペアリングします。</string>
|
||||
<string name="active_section_enter_pairing_code">ペアリングコードを入力</string>
|
||||
<string name="active_section_done">完了</string>
|
||||
<string name="active_section_connection_behavior">接続動作</string>
|
||||
<string name="active_section_manual_pairing">手動ペアリング</string>
|
||||
<string name="active_section_paired">ペアリング済み</string>
|
||||
<string name="active_section_not_paired">未ペアリング</string>
|
||||
<string name="active_section_transport">トランスポート</string>
|
||||
<string name="active_section_hardware_backed">ハードウェア保護</string>
|
||||
<string name="active_section_relay_session">Relay セッション</string>
|
||||
<string name="active_section_access">アクセス</string>
|
||||
<string name="active_section_paired_devices">ペアリング済み端末</string>
|
||||
<string name="active_section_device_count">%1$d 台の端末</string>
|
||||
<string name="active_section_session_activity">セッションのアクティビティ</string>
|
||||
<string name="active_section_last_checked_just_now">たった今確認</string>
|
||||
<string name="active_section_protected">保護済み</string>
|
||||
<string name="active_section_not_encrypted">暗号化なし</string>
|
||||
<string name="active_section_no_security_issues">セキュリティ上の問題は検出されませんでした</string>
|
||||
<string name="active_section_unencrypted_transport">この接続では暗号化されていないトランスポートを使用しています</string>
|
||||
<string name="active_section_actions">操作</string>
|
||||
<string name="active_section_revoke_relay">Relay のペアリングを解除</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">この端末から %1$s への接続方法</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · ホーム LAN</string>
|
||||
<string name="cw_preparing_connection">接続を準備しています…</string>
|
||||
<string name="cw_preparing_connection_hint">安全なローカルストレージを設定しています。まもなく完了します。</string>
|
||||
<string name="cw_relay_entry_title">Hermes Relay</string>
|
||||
<string name="cw_relay_entry_desc">Relay をペアリングすると、Terminal、Bridge、端末ツール、Relay セッション、権限を追加できます。</string>
|
||||
<string name="cw_relay_pair_qr">Hermes Relay をペアリング</string>
|
||||
<string name="cw_relay_pair_qr_desc">Relay セットアップ QR をスキャン</string>
|
||||
<string name="cw_relay_enter_code">ペアリングコードを入力</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes に接続しました</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard と Gateway の準備ができました。チャットを開始するか Manage を開けます。</string>
|
||||
<string name="cw_continue">続行</string>
|
||||
<string name="cw_timeline_discovered">Hermes を検出</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard の識別情報とステータスエンドポイントを確認済み</string>
|
||||
<string name="cw_timeline_access">Dashboard アクセス</string>
|
||||
<string name="cw_timeline_access_ready">追加のサインインは不要です</string>
|
||||
<string name="cw_timeline_authenticated">認証を確認済み</string>
|
||||
<string name="cw_timeline_ready">接続準備完了</string>
|
||||
<string name="cw_timeline_ready_detail">Chat、Manage、Voice でこの Dashboard を使用できます</string>
|
||||
<string name="active_section_optional_api_fallback">任意の直接 API フォールバック</string>
|
||||
<string name="active_section_api_not_required">この接続が Hermes Dashboard を使用する場合は必要ありません。</string>
|
||||
<string name="active_section_where_api_key">このキーはどこで入手しますか?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY は Hermes サーバー上で作成します。このアプリからは発行されません。任意の API サーバーを有効にする場合のみ設定します。このサーバーには使用可能なキーが必要です。同じ値をここに入力してください。</string>
|
||||
<string name="active_section_scan_relay_qr">Relay QR をスキャン</string>
|
||||
<string name="active_section_other_relay_methods">その他のペアリング方法</string>
|
||||
<string name="endpoints_tailscale_setup_hint">両方のデバイスで Tailscale に接続し、Hermes Dashboard がポート 9119 で到達可能である必要があります。</string>
|
||||
<string name="endpoints_setup_help">Tailscale セットアップヘルプ</string>
|
||||
<string name="voice_settings_provider_desc">音声を生成する場所です。利用可能な選択肢は、この Hermes ホストとインストール済みプロバイダーから取得されます。</string>
|
||||
<string name="voice_settings_model_desc">Latest はプロバイダーの更新に追従します。音声の動作を固定するには、バージョン付きモデルを選択します。</string>
|
||||
<string name="voice_settings_voice_desc">返信の声を選択します。音声をプレビューしても選択は保存されません。</string>
|
||||
<string name="voice_settings_language_desc">自動では、プロバイダーが話し言葉を検出します。検出が不安定な場合だけ言語を選択します。</string>
|
||||
<string name="voice_settings_language_auto">自動</string>
|
||||
<string name="voice_settings_provider_options_title">音声オプション</string>
|
||||
<string name="voice_settings_provider_options_desc">選択したプロバイダーが対応する設定だけを表示します。</string>
|
||||
<string name="voice_settings_auto_speak">返信を読み上げる</string>
|
||||
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
|
||||
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</string>
|
||||
</resources>
|
||||
|
||||
@@ -13,15 +13,17 @@
|
||||
<string name="onboarding_back">Back</string>
|
||||
<string name="onboarding_next">Next</string>
|
||||
<string name="onboarding_connect">Connect</string>
|
||||
<string name="onboarding_get_started">Get started</string>
|
||||
<string name="onboarding_step_count">Step %1$d of %2$d</string>
|
||||
|
||||
<string name="onboarding_welcome_title">Hermes-Relay for Android</string>
|
||||
<string name="onboarding_welcome_description">Chat with Hermes and manage your dashboard from your phone.</string>
|
||||
<string name="onboarding_welcome_title">Hermes,\nin your pocket</string>
|
||||
<string name="onboarding_welcome_description">Chat, manage, and use voice\nwith your own Hermes.</string>
|
||||
<string name="onboarding_welcome_badge">Welcome</string>
|
||||
<string name="onboarding_hermes_logo">Hermes logo</string>
|
||||
<string name="onboarding_chat_manage_label">Chat & Manage</string>
|
||||
<string name="onboarding_chat_manage_description">Connect to your running Hermes dashboard and API. No Relay install or pairing required.</string>
|
||||
<string name="onboarding_power_tools_label">Power tools</string>
|
||||
<string name="onboarding_power_tools_description">Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.</string>
|
||||
<string name="onboarding_chat_manage_label">Dashboard first</string>
|
||||
<string name="onboarding_chat_manage_description">No Relay required</string>
|
||||
<string name="onboarding_power_tools_label">Power tools later</string>
|
||||
<string name="onboarding_power_tools_description">Pair Relay when you need\nTerminal or Bridge</string>
|
||||
<string name="onboarding_setup_guide_hint">The setup guide has copy/paste commands when you need to start Hermes on a computer or server.</string>
|
||||
<string name="onboarding_setup_guide">Setup Guide</string>
|
||||
<string name="onboarding_hermes_docs">Hermes Docs</string>
|
||||
@@ -54,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">Realtime</string>
|
||||
<string name="onboarding_realtime_description">Provider-native realtime voice agent and profile-aware voice providers.</string>
|
||||
<string name="onboarding_review_permissions">Review permissions</string>
|
||||
<string name="onboarding_finish_setup_title">Finish setup</string>
|
||||
<string name="onboarding_finish_setup_description">Your Hermes connection is ready. Choose what this phone can do now, or change it later in Settings.</string>
|
||||
<string name="onboarding_chat_manage_ready">Ready — no phone permission needed.</string>
|
||||
<string name="onboarding_chat_alerts">Chat alerts</string>
|
||||
<string name="onboarding_chat_alerts_description">Allow Android notifications so enabled alerts can reach you in the background.</string>
|
||||
<string name="onboarding_chat_alerts_ready">Notifications are enabled for this app.</string>
|
||||
<string name="onboarding_optional_features">Optional features</string>
|
||||
<string name="onboarding_optional_features_description">Camera, microphone, notification companion, and available device tools stay off until you choose them.</string>
|
||||
<string name="onboarding_review_optional_permissions">Review optional permissions</string>
|
||||
<string name="onboarding_enable_chat_alerts">Enable chat alerts</string>
|
||||
<string name="onboarding_not_now">Not now</string>
|
||||
<string name="onboarding_finish">Finish setup</string>
|
||||
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">Live voice conversation</string>
|
||||
@@ -68,7 +82,7 @@
|
||||
<string name="chat_input_start_voice">Start voice conversation</string>
|
||||
<string name="chat_input_voice_setup_needed">Voice conversation — setup needed</string>
|
||||
<string name="chat_input_stop_streaming">Stop streaming</string>
|
||||
<string name="chat_input_steer_response">Steer the response</string>
|
||||
<string name="chat_input_steer_response">Correct the response</string>
|
||||
<string name="chat_input_queue_message">Queue message</string>
|
||||
|
||||
<!-- Bridge return labels -->
|
||||
@@ -99,7 +113,7 @@
|
||||
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">Edit your message…</string>
|
||||
<string name="chat_placeholder_steer">Steer the response…</string>
|
||||
<string name="chat_placeholder_steer">Correct the response…</string>
|
||||
<string name="chat_placeholder_queue">Queue a message…</string>
|
||||
<string name="chat_placeholder_message">Message…</string>
|
||||
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
|
||||
@@ -274,12 +288,62 @@
|
||||
|
||||
<!-- Connection wizard — Connect page essentials -->
|
||||
<string name="cw_connect_to_hermes">Connect to Hermes</string>
|
||||
<string name="cw_connect_description">Start the Hermes API/dashboard on your host, then connect this app. Relay pairing is optional and only needed for Terminal, Bridge, relay sessions, and channel grants.</string>
|
||||
<string name="cw_nearby_description">We’ll look for Hermes on this network. Dashboard and Gateway provide the standard Chat, Manage, and Voice connection.</string>
|
||||
<string name="cw_before_connecting">Before you connect</string>
|
||||
<string name="cw_connect_step_server_title">Start Hermes on your computer</string>
|
||||
<string name="cw_connect_step_server_body">Start the Dashboard on the Hermes computer. For first-time LAN access, use Setup Guide below to make it phone-reachable and enable sign-in.</string>
|
||||
<string name="cw_connect_step_network_title">Make the server reachable</string>
|
||||
<string name="cw_connect_step_network_body">Use the same Wi-Fi, or connect both devices to Tailscale.</string>
|
||||
<string name="cw_connect_step_phone_title">Connect from this phone</string>
|
||||
<string name="cw_connect_step_phone_body">Search below or enter the Dashboard address, such as 192.168.1.10:9119. Sign in if prompted—no API key is needed.</string>
|
||||
<string name="cw_nearby_searching">Searching nearby…</string>
|
||||
<string name="cw_nearby_searching_hint">Keep your phone on the same network as your Hermes server.</string>
|
||||
<string name="cw_nearby_heading">Hermes nearby</string>
|
||||
<string name="cw_nearby_empty">No Hermes servers found</string>
|
||||
<string name="cw_nearby_failed">Couldn’t search this network</string>
|
||||
<string name="cw_nearby_empty_hint">Check that Hermes is running, then search again or enter its address.</string>
|
||||
<string name="cw_nearby_search_again">Search again</string>
|
||||
<string name="cw_nearby_enter_address">Enter address or custom port</string>
|
||||
<string name="cw_preparing_connection">Preparing connection…</string>
|
||||
<string name="cw_preparing_connection_hint">Setting up secure local storage. This should only take a moment.</string>
|
||||
<string name="cw_other_connection_methods">Advanced connection options</string>
|
||||
<string name="cw_relay_entry_title">Hermes Relay</string>
|
||||
<string name="cw_relay_entry_desc">Pair Relay to add Terminal, Bridge, device tools, Relay sessions, and grants.</string>
|
||||
<string name="cw_relay_pair_qr">Pair Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">Scan a Relay setup QR</string>
|
||||
<string name="cw_relay_enter_code">Enter a Relay pairing code</string>
|
||||
<string name="cw_manual_hermes_title">Enter your Hermes address</string>
|
||||
<string name="cw_manual_hermes_description">Enter the Dashboard address you open in a browser. If it does not open on this phone, start hermes dashboard and check Wi-Fi or Tailscale.</string>
|
||||
<string name="cw_hermes_address">Hermes address</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 or hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">No API key is needed. Dashboard port 9119 is used when no port is included.</string>
|
||||
<string name="cw_find_hermes">Find Hermes</string>
|
||||
<string name="cw_hermes_found">Hermes found</string>
|
||||
<string name="cw_ready_to_connect">Ready to connect</string>
|
||||
<string name="cw_ready">Ready</string>
|
||||
<string name="cw_available_after_signin">Available after sign-in</string>
|
||||
<string name="cw_unavailable_server">Unavailable on this server</string>
|
||||
<string name="cw_could_not_verify">Couldn’t verify</string>
|
||||
<string name="cw_choose_another">Choose another</string>
|
||||
<string name="cw_sign_in_to_hermes">Sign in to Hermes</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes is connected</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard and Gateway are ready. You can start chatting or open Manage.</string>
|
||||
<string name="cw_continue">Continue</string>
|
||||
<string name="cw_timeline_discovered">Hermes discovered</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard identity and status endpoint verified</string>
|
||||
<string name="cw_timeline_access">Dashboard access</string>
|
||||
<string name="cw_timeline_access_ready">No additional sign-in required</string>
|
||||
<string name="cw_timeline_authenticated">Authentication verified</string>
|
||||
<string name="cw_timeline_ready">Connection ready</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage, and Voice can use this Dashboard</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes available</string>
|
||||
<string name="cw_semantics_capability">%1$s: %2$s</string>
|
||||
<string name="cw_connect_description">Use these options for API-only compatibility, custom setup, or alternate Relay pairing.</string>
|
||||
<string name="cw_try_demo">Try the demo</string>
|
||||
<string name="cw_try_demo_subtitle">Explore offline — no server needed.</string>
|
||||
<string name="cw_setup_guide">Setup Guide</string>
|
||||
<string name="cw_hermes_api">Hermes API</string>
|
||||
<string name="cw_advanced_relay_pairing">Advanced: Relay pairing</string>
|
||||
<string name="cw_advanced_relay_pairing">Relay pairing options</string>
|
||||
<string name="cw_advanced_relay_pairing_desc">Terminal, Bridge, Relay sessions, and grants require the Relay plugin.</string>
|
||||
<string name="cw_relay_docs">Relay docs</string>
|
||||
<string name="cw_skip_for_now">Skip for now — set up later in Settings</string>
|
||||
@@ -305,12 +369,12 @@
|
||||
<string name="cw_cancel">Cancel</string>
|
||||
|
||||
<!-- Method tiles -->
|
||||
<string name="cw_method_hermes_title">Hermes</string>
|
||||
<string name="cw_method_hermes_subtitle">API/dashboard setup for Chat, Manage, Skills, Cron, MCP, Profiles, Models, and Settings</string>
|
||||
<string name="cw_method_hermes_title">API-only connection</string>
|
||||
<string name="cw_method_hermes_subtitle">Compatibility and fallback when Dashboard/Gateway is unavailable</string>
|
||||
<string name="cw_method_scan_title">Scan setup QR</string>
|
||||
<string name="cw_method_scan_subtitle">Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin</string>
|
||||
<string name="cw_method_pair_code_title">Pair Relay by code</string>
|
||||
<string name="cw_method_pair_code_subtitle">Power-user path for Terminal, Bridge, Relay sessions, and grants</string>
|
||||
<string name="cw_method_pair_code_subtitle">Pair with a code created by your Relay server</string>
|
||||
<string name="cw_method_show_code_title">Show Relay code</string>
|
||||
<string name="cw_method_show_code_subtitle">No camera or QR? Register this phone\'s code on the host</string>
|
||||
|
||||
@@ -318,8 +382,13 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 or http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API used by Chat and sessions — API port 8642 and http:// assumed for bare hosts (the dashboard\'s 9119 is derived separately)</string>
|
||||
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is optional for Chat.</string>
|
||||
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is only for the optional direct API fallback.</string>
|
||||
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
|
||||
<string name="cw_pair_relay_for">Pair Relay with %1$s</string>
|
||||
<string name="cw_pair_relay_scoped_desc">Add the optional Relay extension to this saved Hermes connection. This does not add or replace a server.</string>
|
||||
<string name="cw_current_connection">Current connection</string>
|
||||
<string name="cw_current_hermes_connection">CURRENT HERMES CONNECTION</string>
|
||||
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice, and your saved routes stay unchanged.</string>
|
||||
<string name="cw_pair_relay_section_desc">Already running the Relay plugin? Pair here to enable Terminal, Bridge, and channel grants.</string>
|
||||
<string name="cw_pair_relay_url_label">Relay URL</string>
|
||||
<string name="cw_pair_relay_code_label">Pairing code</string>
|
||||
@@ -441,7 +510,7 @@
|
||||
<string name="endpoints_public">Public</string>
|
||||
<string name="endpoints_custom">Custom</string>
|
||||
<string name="endpoints_route_name">Route name</string>
|
||||
<string name="endpoints_api_url_host">API server URL or host</string>
|
||||
<string name="endpoints_api_url_host">Dashboard/Gateway URL or host</string>
|
||||
<string name="endpoints_saving">Saving…</string>
|
||||
<string name="endpoints_save">Save</string>
|
||||
<string name="endpoints_close">Close</string>
|
||||
@@ -580,7 +649,7 @@
|
||||
<string name="settings_hermes_management">Hermes management</string>
|
||||
<string name="settings_hermes_management_desc">Dashboard features: skills, cron, MCP, profiles, models</string>
|
||||
<string name="settings_chat">Chat</string>
|
||||
<string name="settings_chat_desc">API chat behavior, endpoints, tool display, message length</string>
|
||||
<string name="settings_chat_desc">Chat behavior, Gateway, API fallback, tool display, message length</string>
|
||||
<string name="settings_voice_mode">Voice mode</string>
|
||||
<string name="settings_voice_mode_desc">Dashboard voice, realtime relay options, providers</string>
|
||||
<string name="settings_threads">Threads</string>
|
||||
@@ -622,9 +691,9 @@
|
||||
<string name="settings_persistent_connection">Persistent connection</string>
|
||||
<string name="settings_persistent_connection_desc">Keeping your connection to Hermes open in the background</string>
|
||||
<string name="settings_connect_on_demand">Connect on demand only · saves battery</string>
|
||||
<string name="settings_turn_complete_alerts">Turn-complete alerts</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notify when a reply finishes while the app is in the background</string>
|
||||
<string name="settings_turn_complete_alerts_off">No alert when a backgrounded reply finishes</string>
|
||||
<string name="settings_turn_complete_alerts">Chat alerts</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Keep it connected in deep sleep</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android can still pause the connection once the screen\'s been off a while (Doze). Allow unrestricted battery so Persistent connection keeps working in the background.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Allow unrestricted battery</string>
|
||||
@@ -654,8 +723,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Show your recent messages as tappable chips above the composer to send them again. Off by default.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Keep keyboard open on send</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Notify when Hermes finishes</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Post a notification when a reply completes while the app is in the background</string>
|
||||
<string name="chat_settings_notify_when_finishes">Background chat alerts</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="chat_settings_share_phone_status">Share phone status with agent</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Include a short system message about the app and phone on every chat turn</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
|
||||
@@ -737,6 +806,8 @@
|
||||
<string name="conn_voice_relay">Relay</string>
|
||||
<string name="conn_voice_optional">Optional</string>
|
||||
<string name="conn_api_label">API</string>
|
||||
<string name="conn_chat_label">Chat</string>
|
||||
<string name="conn_manage_label">Manage</string>
|
||||
<string name="conn_dashboard_label">Dashboard</string>
|
||||
<string name="conn_voice_label">Voice</string>
|
||||
<string name="conn_relay_label">Relay</string>
|
||||
@@ -761,6 +832,10 @@
|
||||
<string name="detail_more_actions">More actions</string>
|
||||
<string name="detail_rename">Rename</string>
|
||||
<string name="detail_pair_relay">Pair Relay</string>
|
||||
<string name="detail_dashboard_primary">Dashboard primary</string>
|
||||
<string name="detail_core_ready">Core Hermes ready</string>
|
||||
<string name="detail_core_configured">Core Hermes configured</string>
|
||||
<string name="detail_overview_summary">Chat, Manage, and Voice use standard Hermes. Relay is an optional extension for advanced device features.</string>
|
||||
<string name="detail_repair">Re-pair</string>
|
||||
<string name="detail_revoke">Revoke</string>
|
||||
<string name="detail_remove">Remove</string>
|
||||
@@ -783,6 +858,83 @@
|
||||
<string name="detail_tab_routes">Routes</string>
|
||||
<string name="detail_tab_advanced">Advanced</string>
|
||||
<string name="detail_tab_security">Security</string>
|
||||
<string name="conn_startup_title">On app start</string>
|
||||
<string name="conn_startup_last_used">Last used</string>
|
||||
<string name="conn_startup_recommended">Recommended</string>
|
||||
<string name="conn_startup_choose">Choose startup connection</string>
|
||||
<plurals name="conn_server_count">
|
||||
<item quantity="one">%1$d server</item>
|
||||
<item quantity="other">%1$d servers</item>
|
||||
</plurals>
|
||||
<string name="conn_switch">Switch</string>
|
||||
<string name="conn_switching">Switching…</string>
|
||||
<string name="conn_switched">Active</string>
|
||||
<string name="conn_connecting_to">Connecting to %1$s…</string>
|
||||
<string name="conn_last_used_now">Last used just now</string>
|
||||
<string name="conn_last_used_format">Last used %1$s</string>
|
||||
<string name="conn_just_now">just now</string>
|
||||
<string name="conn_dashboard_only_route">Dashboard only</string>
|
||||
<string name="conn_no_routes">No routes configured</string>
|
||||
<string name="active_section_primary_dashboard">Primary Dashboard</string>
|
||||
<string name="active_section_dashboard_reachable_signed_in">Reachable · Signed in</string>
|
||||
<string name="active_section_dashboard_reachable">Reachable</string>
|
||||
<string name="active_section_dashboard_not_checked">Not checked yet</string>
|
||||
<string name="active_section_dashboard_unreachable">Not reachable</string>
|
||||
<string name="active_section_no_fallback_routes">No fallback routes yet</string>
|
||||
<string name="active_section_no_fallback_routes_desc">Add a LAN, Tailscale, or public address to keep this connection available when networks change.</string>
|
||||
<string name="active_section_add_api_fallback">Add fallback route</string>
|
||||
<string name="active_section_security_authentication">Authentication</string>
|
||||
<string name="active_section_dashboard_session">Dashboard session</string>
|
||||
<string name="active_section_credential_storage">Credential storage</string>
|
||||
<string name="active_section_encrypted_storage">Encrypted storage</string>
|
||||
<string name="active_section_no_relay_credential">No Relay credential</string>
|
||||
<string name="active_section_sign_out_dashboard">Sign out of Dashboard</string>
|
||||
<string name="active_section_credentials_encrypted">Credentials remain encrypted on this device.</string>
|
||||
<string name="active_section_reachable_badge">REACHABLE</string>
|
||||
<string name="active_section_unchecked_badge">UNCHECKED</string>
|
||||
<string name="active_section_edit">Edit</string>
|
||||
<string name="active_section_fallback_routes">Fallback routes</string>
|
||||
<string name="active_section_route_selection">Route selection</string>
|
||||
<string name="active_section_automatic">Automatic</string>
|
||||
<string name="active_section_api_access">API access</string>
|
||||
<string name="active_section_optional_api_fallback">Optional direct API fallback</string>
|
||||
<string name="active_section_api_not_required">Not required when this connection uses the Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">Where do I get this?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, which requires a usable key, then enter the same value here.</string>
|
||||
<string name="active_section_core_hermes">Core Hermes</string>
|
||||
<string name="active_section_optional_relay">Optional Relay</string>
|
||||
<string name="active_section_extend_connection">Extend this connection</string>
|
||||
<string name="active_section_relay_connected_features">Relay extensions</string>
|
||||
<string name="active_section_relay_optional_summary">Add Terminal, Bridge, device tools, Relay sessions, and secure remote routes.</string>
|
||||
<string name="active_section_view_relay_details">View Relay details</string>
|
||||
<string name="active_section_core_unchanged">Your current Hermes connection stays unchanged.</string>
|
||||
<string name="active_section_api_optional_direct">Optional for direct chat and integrations</string>
|
||||
<string name="active_section_configure_test">Configure & test</string>
|
||||
<string name="active_section_relay_optional_bridge">Optional bridge features and remote access</string>
|
||||
<string name="active_section_configure_relay">Configure Relay</string>
|
||||
<string name="active_section_scan_relay_qr">Scan Relay QR</string>
|
||||
<string name="active_section_other_relay_methods">Other pairing methods</string>
|
||||
<string name="active_section_pair_device_using_code">Pair this device using a code from the server.</string>
|
||||
<string name="active_section_enter_pairing_code">Enter pairing code</string>
|
||||
<string name="active_section_done">Done</string>
|
||||
<string name="active_section_connection_behavior">Connection behavior</string>
|
||||
<string name="active_section_manual_pairing">Manual pairing</string>
|
||||
<string name="active_section_paired">Paired</string>
|
||||
<string name="active_section_not_paired">Not paired</string>
|
||||
<string name="active_section_transport">Transport</string>
|
||||
<string name="active_section_hardware_backed">Hardware-backed</string>
|
||||
<string name="active_section_relay_session">Relay session</string>
|
||||
<string name="active_section_access">Access</string>
|
||||
<string name="active_section_paired_devices">Paired devices</string>
|
||||
<string name="active_section_device_count">%1$d devices</string>
|
||||
<string name="active_section_session_activity">Session activity</string>
|
||||
<string name="active_section_last_checked_just_now">Last checked just now</string>
|
||||
<string name="active_section_protected">Protected</string>
|
||||
<string name="active_section_not_encrypted">Not encrypted</string>
|
||||
<string name="active_section_no_security_issues">No security issues detected</string>
|
||||
<string name="active_section_unencrypted_transport">This connection uses an unencrypted transport</string>
|
||||
<string name="active_section_actions">Actions</string>
|
||||
<string name="active_section_revoke_relay">Revoke Relay pairing</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="drawer_filter_by_source">Filter by source</string>
|
||||
@@ -1400,17 +1552,17 @@
|
||||
<string name="voice_settings_back">Back</string>
|
||||
<string name="voice_settings_scope_label">Voice scope</string>
|
||||
<string name="voice_settings_global_voice">Global voice</string>
|
||||
<string name="voice_settings_standard_scope_body">This connection speaks through your Hermes server\'s configured TTS and STT (config.yaml on the server, or the dashboard\'s Audio settings) — it\'s host-wide, not per profile. Pair Relay to pick providers, models, and voices from the phone and carry them per profile.</string>
|
||||
<string name="voice_settings_standard_scope_body">Standard voice follows this Hermes host\'s existing TTS, STT, and voice configuration. These settings are shared across profiles; Relay can add profile-specific voice choices without replacing the host setup.</string>
|
||||
<string name="voice_settings_scope_resolving">Resolving the active profile\'s voice scope…</string>
|
||||
<string name="voice_settings_label_profile">Profile</string>
|
||||
<string name="voice_settings_label_scope">Scope</string>
|
||||
<string name="voice_settings_relay_scope_footer">Engine, route, and voice picks below are saved for this profile.</string>
|
||||
<string name="voice_settings_relay_scope_footer">Relay voice choices below override the host defaults for this profile only. Clear an override to follow the Hermes host again.</string>
|
||||
<string name="voice_settings_for_profile_title">Voice for this profile</string>
|
||||
<string name="voice_settings_engine_label">Voice engine</string>
|
||||
<string name="voice_settings_engine_hermes">Hermes Chat + Voice Output</string>
|
||||
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory; speech runs over the standard Hermes dashboard or Relay — whichever the STT/TTS route below picks.</string>
|
||||
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory. Speech uses the host\'s Standard configuration unless this profile selects a Relay voice override.</string>
|
||||
<string name="voice_settings_engine_realtime">Realtime Agent</string>
|
||||
<string name="voice_settings_engine_realtime_desc">Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.</string>
|
||||
<string name="voice_settings_engine_realtime_desc">A live provider session handles listening and speaking with its own voice model and voice. Hermes still handles tools, memory, and confirmations. Requires a paired Relay.</string>
|
||||
<string name="voice_settings_experimental">Experimental</string>
|
||||
<string name="voice_settings_realtime_no_relay">No Relay is configured for this connection, so the Realtime Agent can\'t start. Pair Relay in Settings → Connections, or switch back to Hermes Chat + Voice Output.</string>
|
||||
<string name="voice_settings_route_label">STT/TTS route</string>
|
||||
@@ -1424,19 +1576,19 @@
|
||||
<string name="voice_settings_status_auto_hermes">Ready — using Hermes</string>
|
||||
<string name="voice_settings_status_no_route">No route available yet</string>
|
||||
<string name="voice_settings_route_auto">Auto</string>
|
||||
<string name="voice_settings_route_auto_desc">Relay when paired; otherwise the Hermes dashboard. Recommended.</string>
|
||||
<string name="voice_settings_route_auto_desc">Use this profile\'s Relay voice when available, then fall back to the Hermes host. Recommended.</string>
|
||||
<string name="voice_settings_route_hermes">Hermes</string>
|
||||
<string name="voice_settings_route_hermes_desc">The dashboard audio path Hermes Desktop uses — works on a Hermes install, no Relay plugin required.</string>
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Relay plugin voice — profile-aware providers and streaming voice output.</string>
|
||||
<string name="voice_settings_route_relay_desc">Use the provider, model, and voice saved for this profile through Relay.</string>
|
||||
<string name="voice_settings_optional">Optional</string>
|
||||
<string name="voice_settings_signin_route_hint">You\'re connected over the %1$s route, and dashboard sign-ins are per-host — a sign-in from your home network doesn\'t carry over. Sign in once in Manage while on this route to unlock voice here too.</string>
|
||||
<string name="voice_settings_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Open Manage to sign in again and unlock voice.</string>
|
||||
<string name="voice_settings_signin_default_hint">Your Hermes dashboard requires sign-in before standard voice can transcribe or speak. Signing in once in Manage unlocks it for this connection.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Sign in via Manage</string>
|
||||
<string name="voice_settings_unsupported_build_body">This Hermes server build doesn\'t expose the dashboard audio routes yet. Update hermes-agent on the server, or pair Relay to use Relay voice.</string>
|
||||
<string name="voice_settings_tts_title">Text-to-Speech</string>
|
||||
<string name="voice_settings_streaming_output_label">Streaming output (/voice/output)</string>
|
||||
<string name="voice_settings_streaming_output_desc">The provider, model, and voice the agent speaks with on the Relay path.</string>
|
||||
<string name="voice_settings_streaming_output_desc">Streams spoken replies with the provider, model, and voice saved for this profile. It does not change the Hermes chat model.</string>
|
||||
<string name="voice_settings_basic_synthesize_label">Basic synthesize fallback (/voice/synthesize)</string>
|
||||
<string name="voice_settings_basic_synthesize_desc">Always available as the stable speech safety net when provider-native voice is unavailable.</string>
|
||||
<string name="voice_settings_provider_unavailable">unavailable</string>
|
||||
@@ -1444,14 +1596,17 @@
|
||||
<string name="voice_settings_yes">yes</string>
|
||||
<string name="voice_settings_no">no</string>
|
||||
<string name="voice_settings_label_provider">Provider</string>
|
||||
<string name="voice_settings_provider_desc">Where speech is generated. Available choices come from this Hermes host and its installed providers.</string>
|
||||
<string name="voice_settings_label_enabled">Enabled</string>
|
||||
<string name="voice_settings_label_model">Model</string>
|
||||
<string name="voice_settings_model_desc">Latest follows provider upgrades. Choose a versioned model to keep voice behavior pinned.</string>
|
||||
<string name="voice_settings_label_voice">Voice</string>
|
||||
<string name="voice_settings_voice_desc">Choose how replies sound. Previewing a voice does not save the selection.</string>
|
||||
<string name="voice_settings_provider_gemini">Gemini</string>
|
||||
<string name="voice_settings_provider_xai">xAI</string>
|
||||
<string name="voice_settings_hide_enhanced">Hide enhanced voice (%1$s)</string>
|
||||
<string name="voice_settings_advanced_enhanced">Advanced: enhanced voice (%1$s)</string>
|
||||
<string name="voice_settings_enhanced_body">Pick a voice and tone for the %1$s TTS provider. Leave a field on \"Server default\" to use the relay\'s saved config.</string>
|
||||
<string name="voice_settings_enhanced_body">Choose the options advertised by %1$s for this profile. Leave a field on \"Server default\" to follow the Relay host configuration.</string>
|
||||
<string name="voice_settings_server_default">Server default</string>
|
||||
<string name="voice_settings_voice_blank_label">Voice (blank = server default)</string>
|
||||
<string name="voice_settings_supports_tone_tags">supports tone tags</string>
|
||||
@@ -1459,7 +1614,9 @@
|
||||
<string name="voice_settings_audio_tags_unsupported">Requires a Gemini 3.1 TTS model — pick one above to enable.</string>
|
||||
<string name="voice_settings_voice_direction_label">Voice direction (optional)</string>
|
||||
<string name="voice_settings_voice_direction_placeholder">e.g. Warm, calm narrator; unhurried pace.</string>
|
||||
<string name="voice_settings_language_label">Language (optional, e.g. en)</string>
|
||||
<string name="voice_settings_language_label">Language</string>
|
||||
<string name="voice_settings_language_desc">Automatic lets the provider detect the spoken language. Choose a language only when detection is unreliable.</string>
|
||||
<string name="voice_settings_language_auto">Automatic</string>
|
||||
<string name="voice_settings_status_active">active</string>
|
||||
<string name="voice_settings_status_unavailable">unavailable</string>
|
||||
<string name="voice_settings_status_disabled">disabled</string>
|
||||
@@ -1480,7 +1637,11 @@
|
||||
<string name="voice_settings_model_id">Model ID</string>
|
||||
<string name="voice_settings_voice_id">Voice ID</string>
|
||||
<string name="voice_settings_streaming_latency">Streaming latency: %1$d</string>
|
||||
<string name="voice_settings_fallback_desc">Use legacy Hermes TTS if streaming output fails before audio starts</string>
|
||||
<string name="voice_settings_fallback_desc">If Relay streaming cannot start, speak through the Hermes host\'s Standard voice.</string>
|
||||
<string name="voice_settings_provider_options_title">Voice options</string>
|
||||
<string name="voice_settings_provider_options_desc">Only settings supported by the selected provider are shown.</string>
|
||||
<string name="voice_settings_auto_speak">Read replies aloud</string>
|
||||
<string name="voice_settings_auto_speak_desc">Automatically speak assistant replies on Hermes surfaces that honor this host setting.</string>
|
||||
<string name="voice_settings_expressive_tags">Expressive speech tags</string>
|
||||
<string name="voice_settings_expressive_tags_desc">Let xAI add tone cues (whisper, laugh, sigh, pitch shifts) to streamed speech.</string>
|
||||
<string name="voice_settings_saving">Saving...</string>
|
||||
@@ -1488,7 +1649,9 @@
|
||||
<string name="voice_settings_save_and_test">Save & test</string>
|
||||
<string name="voice_settings_sample_rate_must_be_number">Sample rate must be a number</string>
|
||||
<string name="voice_settings_realtime_agent_title">Realtime Agent</string>
|
||||
<string name="voice_settings_realtime_agent_desc">Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.</string>
|
||||
<string name="voice_settings_realtime_agent_desc">Realtime uses its own provider model and voice for a live speech session. It does not replace the Hermes chat model; Hermes still owns tools and confirmations.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controls the live speech session, not the Hermes chat model. Latest follows provider upgrades; a versioned model stays pinned.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">The voice used inside the live session. Built-in and custom voices appear when the provider advertises them.</string>
|
||||
<string name="voice_settings_detailed_trace">Detailed trace</string>
|
||||
<string name="voice_settings_detailed_trace_desc">Show compact Hermes status and result provenance in the timeline</string>
|
||||
<string name="voice_settings_persistent_session">Persistent session</string>
|
||||
@@ -1503,7 +1666,7 @@
|
||||
<string name="voice_settings_delivery_speak">Speak</string>
|
||||
<string name="voice_settings_delivery_notify">Notify</string>
|
||||
<string name="voice_settings_delivery_show_only">Show only</string>
|
||||
<string name="voice_settings_realtime_defaults_desc">Server-side realtime voice agent defaults for this profile</string>
|
||||
<string name="voice_settings_realtime_defaults_desc">Realtime provider, model, and voice saved for this profile. These choices are separate from Standard Hermes voice.</string>
|
||||
<string name="voice_settings_save_realtime_agent">Save realtime agent</string>
|
||||
<string name="voice_settings_global_controls_title">Global Voice Controls</string>
|
||||
<string name="voice_settings_global_controls_desc">These settings apply to both voice engines, on every profile.</string>
|
||||
@@ -1551,7 +1714,7 @@
|
||||
<string name="voice_settings_signin_required">Sign in required.</string>
|
||||
<string name="voice_settings_server_config_title">Server voice config</string>
|
||||
<string name="voice_settings_standard_badge">Standard</string>
|
||||
<string name="voice_settings_server_config_desc">Edit the host\'s text-to-speech and speech-to-text settings (config.yaml tts.* / stt.*) — the same values the dashboard\'s Audio settings write. Applies to new voice turns.</string>
|
||||
<string name="voice_settings_server_config_desc">Edit this host\'s Standard Hermes voice settings (config.yaml tts.*, stt.*, and voice.*), matching the dashboard\'s Audio configuration. Changes are host-wide and apply to new voice turns.</string>
|
||||
<string name="voice_settings_no_elevenlabs_key">No ElevenLabs API key on the server — set ELEVENLABS_API_KEY in Manage → Keys to pick a voice from a list.</string>
|
||||
<string name="voice_settings_saving_ellipsis">Saving…</string>
|
||||
<string name="voice_settings_discard">Discard</string>
|
||||
@@ -1598,6 +1761,7 @@
|
||||
<string name="dashboard_tab_cron">Cron</string>
|
||||
<string name="dashboard_tab_mcp">MCP</string>
|
||||
<string name="dashboard_tab_catalog">Catalog</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
|
||||
<string name="dashboard_tab_profiles">Profiles</string>
|
||||
<string name="dashboard_tab_models">Models</string>
|
||||
<string name="dashboard_tab_keys">Keys</string>
|
||||
@@ -1607,6 +1771,7 @@
|
||||
<string name="dashboard_tab_cron_lower">cron</string>
|
||||
<string name="dashboard_tab_mcp_lower">mcp</string>
|
||||
<string name="dashboard_tab_catalog_lower">catalog</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
|
||||
<string name="dashboard_tab_profiles_lower">profiles</string>
|
||||
<string name="dashboard_tab_models_lower">models</string>
|
||||
<string name="dashboard_tab_keys_lower">keys</string>
|
||||
@@ -1623,6 +1788,8 @@
|
||||
<string name="dashboard_tile_mcp_sub">Servers, status, tools</string>
|
||||
<string name="dashboard_tile_catalog_title">Catalog</string>
|
||||
<string name="dashboard_tile_catalog_sub">Discover upstream servers</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Custom Endpoints</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-compatible providers</string>
|
||||
<string name="dashboard_tile_models_title">Models</string>
|
||||
<string name="dashboard_tile_models_sub">Pick provider + default model</string>
|
||||
<string name="dashboard_tile_keys_title">Keys</string>
|
||||
@@ -1669,7 +1836,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">Dashboard sign-in required</string>
|
||||
<string name="dashboard_signin_required_body">Manage uses the Hermes dashboard session at %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">You\'re on the %1$s route. Dashboard sign-ins are per host, so your sign-in from the other route doesn\'t carry over — sign in once here and the app keeps both sessions.</string>
|
||||
<string name="dashboard_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Sign in again to refresh it across this connection\'s trusted routes.</string>
|
||||
<string name="dashboard_signin_with_provider">Sign in with %1$s</string>
|
||||
<string name="dashboard_username_password">Username & Password</string>
|
||||
<string name="dashboard_username">Username</string>
|
||||
@@ -1714,12 +1881,33 @@
|
||||
<string name="dashboard_action_enable">Enable</string>
|
||||
<string name="dashboard_action_disable">Disable</string>
|
||||
<string name="dashboard_action_test">Test</string>
|
||||
<string name="dashboard_action_authenticate">Authenticate</string>
|
||||
<string name="dashboard_action_validate">Validate</string>
|
||||
<string name="dashboard_action_edit">Edit</string>
|
||||
<string name="dashboard_action_remove">Remove</string>
|
||||
<string name="dashboard_action_soul">SOUL</string>
|
||||
<string name="dashboard_action_edit_soul">Edit SOUL</string>
|
||||
<string name="dashboard_action_use">Use</string>
|
||||
<string name="dashboard_action_describe">Describe</string>
|
||||
<string name="dashboard_action_model">Model</string>
|
||||
<string name="dashboard_mcp_oauth_title">Authenticate %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes will open the provider in your browser. Return here after approving access; credentials stay on the Hermes server.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP authentication approved</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP authentication failed</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">No browser is available to complete MCP authentication.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Add endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Edit endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_name">Name</string>
|
||||
<string name="dashboard_custom_endpoint_url">Base URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">Default model</string>
|
||||
<string name="dashboard_custom_endpoint_key">API key (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Leave blank to preserve an existing key when saving. Validation uses only a key entered here; Hermes does not expose or clear saved keys.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Context length (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Discover models from /models</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Endpoint reachable · %1$d model(s)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Endpoint validation failed</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Endpoint save failed</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Custom endpoint saved</string>
|
||||
<string name="dashboard_action_completed">%1$s completed</string>
|
||||
<string name="dashboard_action_failed">%1$s failed</string>
|
||||
<string name="dashboard_more">More</string>
|
||||
@@ -2032,9 +2220,9 @@
|
||||
<string name="active_section_allow_plain_connections">Allow plain connections</string>
|
||||
<string name="active_section_api_hermes_voice_reachable">API reachable — Hermes voice is configured</string>
|
||||
<string name="active_section_api_key_already_set">API key is already set</string>
|
||||
<string name="active_section_api_key_needed_hint">Enter a key if the API server requires one.</string>
|
||||
<string name="active_section_api_key_needed_hint">Use the API_SERVER_KEY created on your Hermes server. The app does not issue this key.</string>
|
||||
<string name="active_section_api_key_not_configured">API key not configured</string>
|
||||
<string name="active_section_api_key_optional">API key (optional)</string>
|
||||
<string name="active_section_api_key_optional">API key for direct API</string>
|
||||
<string name="active_section_api_key_stored_hint">Key is stored securely.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API reachable — review voice config</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API reachable — Relay voice is configured</string>
|
||||
@@ -2048,6 +2236,8 @@
|
||||
<string name="active_section_checking">Checking…</string>
|
||||
<string name="active_section_checking_routes">Checking routes…</string>
|
||||
<string name="active_section_choose_how_phone_reaches">Choose how this phone reaches the server</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">How this phone reaches %1$s</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · Home LAN</string>
|
||||
<string name="active_section_command_copied">Command copied</string>
|
||||
<string name="active_section_configured">Configured</string>
|
||||
<string name="active_section_connect">Connect</string>
|
||||
@@ -2142,6 +2332,7 @@
|
||||
<string name="conn_info_api_server_title">API Server</string>
|
||||
<string name="conn_info_approvals_off">Approvals are OFF</string>
|
||||
<string name="conn_info_auth">Auth</string>
|
||||
<string name="conn_info_relay_auth">Relay auth</string>
|
||||
<string name="conn_info_avg_ttft">Avg. TTFT</string>
|
||||
<string name="conn_info_channel_grants">Channel grants</string>
|
||||
<string name="conn_info_checking">Checking…</string>
|
||||
@@ -2181,6 +2372,7 @@
|
||||
<string name="conn_info_hermes">Hermes</string>
|
||||
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
|
||||
<string name="conn_info_hostname_paired">%1$s (paired)</string>
|
||||
<string name="conn_info_hostname_relay_paired">%1$s · Relay paired</string>
|
||||
<string name="conn_info_idle_suffix"> · Idle</string>
|
||||
<string name="conn_info_insecure_mode_allowed">Insecure mode allowed</string>
|
||||
<string name="conn_info_inspect_profile">Inspect %1$s</string>
|
||||
@@ -2405,6 +2597,7 @@
|
||||
<string name="tool_progress_status_completed">completed</string>
|
||||
<string name="tool_progress_status_failed">failed</string>
|
||||
<string name="tool_progress_status_running">running</string>
|
||||
<string name="image_generation_rendering">Rendering image</string>
|
||||
<string name="tool_progress_cd_collapse">Collapse</string>
|
||||
<string name="tool_progress_cd_expand">Expand</string>
|
||||
|
||||
@@ -2511,7 +2704,10 @@
|
||||
<!-- QrPairingScanner -->
|
||||
<string name="qr_scanner_title">Scan Hermes QR</string>
|
||||
<string name="qr_scanner_instruction">Scan a Hermes setup QR</string>
|
||||
<string name="qr_scanner_subtext">Ask Hermes: "Generate a QR code with my API URL and API key."</string>
|
||||
<string name="qr_scanner_subtext">Scan a Hermes setup or Relay pairing QR. Standard Dashboard connections do not require an API key.</string>
|
||||
<string name="qr_scanner_relay_title">Scan Relay QR</string>
|
||||
<string name="qr_scanner_relay_instruction">Scan a Relay pairing QR</string>
|
||||
<string name="qr_scanner_relay_subtext">Open the Relay pairing QR on your Hermes server. Your existing Dashboard connection and routes will stay unchanged.</string>
|
||||
<string name="qr_scanner_camera_error">Cannot start the camera on this device.</string>
|
||||
<string name="qr_scanner_fallback_message">You can pair without the camera.</string>
|
||||
<string name="qr_scanner_pair_manual">Pair manually</string>
|
||||
@@ -2978,12 +3174,14 @@
|
||||
<string name="diag_check_voice_relay">Voice (Relay)</string>
|
||||
<string name="diag_check_voice_standard">Voice (Standard)</string>
|
||||
<string name="endpoints_pin_title">Pinned endpoints</string>
|
||||
<string name="endpoints_route_editor_desc">Edit route</string>
|
||||
<string name="endpoints_route_editor_desc">Add the Dashboard/Gateway address this phone should use on that network.</string>
|
||||
<string name="endpoints_route_name_placeholder">Route name</string>
|
||||
<string name="endpoints_url_host_placeholder">Host</string>
|
||||
<string name="endpoints_url_supporting_blank">No supporting URLs</string>
|
||||
<string name="endpoints_url_supporting_enter">Enter a URL</string>
|
||||
<string name="endpoints_url_supporting_preview">Preview</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z or host.ts.net</string>
|
||||
<string name="endpoints_url_supporting_blank">Enter the server’s Dashboard address</string>
|
||||
<string name="endpoints_url_supporting_enter">Use an http:// or https:// Dashboard address</string>
|
||||
<string name="endpoints_url_supporting_preview">Will test: %1$s</string>
|
||||
<string name="endpoints_tailscale_setup_hint">Tailscale must be connected on both devices, and Hermes Dashboard must be reachable on port 9119.</string>
|
||||
<string name="endpoints_setup_help">Tailscale setup help</string>
|
||||
<string name="image_viewer_error">Error saving image</string>
|
||||
<string name="image_viewer_failed">Failed to save image</string>
|
||||
<string name="image_viewer_failed_template">Failed to save image: %s</string>
|
||||
@@ -3139,4 +3337,10 @@
|
||||
<string name="tool_output_risk_a11y">, %1$s output risk</string>
|
||||
<string name="tool_output_risk_findings">Output risk findings</string>
|
||||
<string name="tool_output_risk_redacted">Sensitive spans were redacted upstream.</string>
|
||||
<string name="diag_gateway_heartbeat" translatable="false">Gateway loop: %1$s%2$s</string>
|
||||
<string name="diag_toolsets_inventory" translatable="false">API toolsets: %1$d of %2$d enabled · Relay tools visible: %3$s</string>
|
||||
<string name="diag_yes" translatable="false">yes</string>
|
||||
<string name="diag_no" translatable="false">no</string>
|
||||
<string name="dashboard_nous_terminal_warning" translatable="false">The Nous provider login needs attention on the Hermes host. This is separate from Manage sign-in; chat can continue through another configured provider.</string>
|
||||
<string name="dashboard_gateway_topology" translatable="false">Gateway: %1$s · Profiles: %2$s · Ports: %3$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
- Visible warning badge when connected over http:// or ws://
|
||||
- Users are encouraged to set up TLS for production deployments
|
||||
|
||||
The app makes NO connections to external services — only user-configured endpoints.
|
||||
System CAs and CAs deliberately installed in Android's user credential store
|
||||
are accepted. This expands the available trust anchors without bypassing
|
||||
certificate-chain, hostname, or Relay certificate-pin verification.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="system" overridePins="false" />
|
||||
<certificates src="user" overridePins="false" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -52,6 +52,36 @@ class AgentDisplayTest {
|
||||
assertEquals(defaultProfile, effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveDisplayProfile_resolvesPinnedServerDefaultToNamedProfile() {
|
||||
val pinned = Profile(
|
||||
name = "pinned",
|
||||
model = "gpt-pinned",
|
||||
description = "Pinned profile",
|
||||
)
|
||||
|
||||
val effective = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = null,
|
||||
profiles = listOf(defaultProfile, pinned, mizu),
|
||||
serverDefaultProfileName = "pinned",
|
||||
)
|
||||
|
||||
assertEquals(pinned, effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveDisplayProfile_keepsExplicitSelectionAheadOfPinnedDefault() {
|
||||
val pinned = Profile(name = "pinned", model = "gpt-pinned")
|
||||
|
||||
val effective = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = mizu,
|
||||
profiles = listOf(defaultProfile, pinned, mizu),
|
||||
serverDefaultProfileName = "pinned",
|
||||
)
|
||||
|
||||
assertEquals(mizu, effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun agentName_usesProfileNameNotVerboseDescription() {
|
||||
// The name slot shows the NAME, even when a (verbose) description exists.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionCapabilitiesTest {
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyConnection_exposesStandardHermesCapabilities() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "https://hermes.example.com",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
)
|
||||
|
||||
assertEquals("https://hermes.example.com", connection.primaryEndpointUrl)
|
||||
assertEquals("hermes.example.com", connection.primaryHost)
|
||||
assertTrue(connection.capabilities.dashboardGatewayConfigured)
|
||||
assertTrue(connection.capabilities.gatewayChatAvailable)
|
||||
assertTrue(connection.capabilities.manageAvailable)
|
||||
assertTrue(connection.capabilities.standardVoiceAvailable)
|
||||
assertTrue(connection.capabilities.chatConfigured)
|
||||
assertFalse(connection.capabilities.apiChatFallbackAvailable)
|
||||
assertFalse(connection.capabilities.relayFeaturesAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyApiRecord_retainsDerivedDashboardAndAllConfiguredSurfaces() {
|
||||
val connection = connection(
|
||||
dashboardUrl = null,
|
||||
apiServerUrl = "http://192.168.1.25:8642",
|
||||
relayUrl = "ws://192.168.1.25:8767",
|
||||
)
|
||||
|
||||
assertEquals("http://192.168.1.25:9119", connection.primaryEndpointUrl)
|
||||
assertTrue(connection.capabilities.dashboardGatewayConfigured)
|
||||
assertTrue(connection.capabilities.apiServerConfigured)
|
||||
assertTrue(connection.capabilities.relayConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiOnlyConnection_canChatWithoutRelay() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "",
|
||||
apiServerUrl = "https://api.example.com",
|
||||
relayUrl = "",
|
||||
)
|
||||
|
||||
// A blank dashboard retains the legacy conventional derivation when
|
||||
// an API endpoint is present.
|
||||
assertTrue(connection.capabilities.dashboardGatewayConfigured)
|
||||
assertTrue(connection.capabilities.apiChatFallbackAvailable)
|
||||
assertFalse(connection.capabilities.relayFeaturesAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardFirstLabel_usesDashboardWhenApiIsAbsent() {
|
||||
assertEquals(
|
||||
"hermes.example.com",
|
||||
Connection.extractDefaultLabel(
|
||||
dashboardUrl = "https://hermes.example.com:9119",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun connection(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
) = Connection(
|
||||
id = "id-a",
|
||||
label = "Hermes",
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = "hermes_auth_id-a",
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,22 @@ class ConnectionDashboardFieldsTest {
|
||||
assertNull(Connection.deriveDefaultDashboardUrl("ws://localhost:8767"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultApiUrl_usesSameHostAndApiPort() {
|
||||
assertEquals(
|
||||
"http://100.75.1.2:8642",
|
||||
Connection.deriveDefaultApiUrl("http://100.75.1.2:9119"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultApiUrl_preservesHttpsAndIpv6Host() {
|
||||
assertEquals(
|
||||
"https://[fd7a:115c:a1e0::1]:8642",
|
||||
Connection.deriveDefaultApiUrl("https://[fd7a:115c:a1e0::1]:9119"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvedDashboardUrl_usesExplicitOverride() {
|
||||
val connection = sampleConnection(
|
||||
@@ -88,11 +104,39 @@ class ConnectionDashboardFieldsTest {
|
||||
|
||||
assertEquals(2, routes.size)
|
||||
assertEquals("lan", routes[0].role)
|
||||
assertEquals("192.168.1.25", routes[0].api.host)
|
||||
assertEquals("ws://192.168.1.25:8767", routes[0].relay.url)
|
||||
assertEquals("192.168.1.25", routes[0].api?.host)
|
||||
assertEquals("ws://192.168.1.25:8767", routes[0].relay?.url)
|
||||
assertEquals("tailscale", routes[1].role)
|
||||
assertEquals("hermes.tail1234.ts.net", routes[1].api.host)
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay.url)
|
||||
assertEquals("hermes.tail1234.ts.net", routes[1].api?.host)
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
|
||||
val route = Connection.endpointCandidateFromDashboardUrl(
|
||||
role = "",
|
||||
priority = 1,
|
||||
dashboardUrl = "100.75.1.2",
|
||||
)
|
||||
|
||||
assertEquals("tailscale", route?.role)
|
||||
assertEquals("http://100.75.1.2:9119", route?.dashboard?.url)
|
||||
assertNull(route?.api)
|
||||
assertNull(route?.relay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardRouteBuilder_preservesOptionalApiAndRelayWhenConfigured() {
|
||||
val route = Connection.endpointCandidateFromDashboardUrl(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
dashboardUrl = "hermes.tail1234.ts.net",
|
||||
apiServerUrl = "https://hermes.tail1234.ts.net:8642",
|
||||
relayUrl = "wss://hermes.tail1234.ts.net:8767",
|
||||
)
|
||||
|
||||
assertEquals("https://hermes.tail1234.ts.net:8642", route?.api?.url)
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", route?.relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -102,6 +146,30 @@ class ConnectionDashboardFieldsTest {
|
||||
assertEquals("public", Connection.inferRouteRole("https://hermes.example.com:8642"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discoveredLabel_prefersHostnameForAnUncustomizedIpLabel() {
|
||||
assertEquals(
|
||||
"hermes-box.local",
|
||||
Connection.chooseDiscoveredLabel(
|
||||
currentLabel = "192.168.1.25",
|
||||
primaryHost = "192.168.1.25",
|
||||
discoveredHostname = "hermes-box.local",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discoveredLabel_preservesAUserLabel() {
|
||||
assertEquals(
|
||||
"Home Hermes",
|
||||
Connection.chooseDiscoveredLabel(
|
||||
currentLabel = "Home Hermes",
|
||||
primaryHost = "192.168.1.25",
|
||||
discoveredHostname = "hermes-box.local",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleConnection(
|
||||
dashboardUrl: String? = null,
|
||||
): Connection = Connection(
|
||||
|
||||
+7
-7
@@ -43,7 +43,7 @@ class ConnectionRouteCandidateMergeTest {
|
||||
assertEquals(2, merged.size)
|
||||
assertEquals("lan", merged[0].role)
|
||||
assertEquals("tailscale", merged[1].role)
|
||||
assertEquals("100.64.0.7", merged[1].api.host)
|
||||
assertEquals("100.64.0.7", merged[1].api?.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -66,7 +66,7 @@ class ConnectionRouteCandidateMergeTest {
|
||||
|
||||
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
|
||||
|
||||
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay.url)
|
||||
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,9 +90,9 @@ class ConnectionRouteCandidateMergeTest {
|
||||
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
val tailscale = merged.first { it.api.host == "100.64.0.7" }
|
||||
val tailscale = merged.first { it.api?.host == "100.64.0.7" }
|
||||
assertEquals("tailscale", tailscale.role)
|
||||
assertEquals("ws://100.64.0.7:8767", tailscale.relay.url)
|
||||
assertEquals("ws://100.64.0.7:8767", tailscale.relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,10 +111,10 @@ class ConnectionRouteCandidateMergeTest {
|
||||
assertEquals(2, merged.size)
|
||||
assertTrue(
|
||||
"old priority-0 host must be replaced by the edited URL",
|
||||
merged.none { it.api.host == "192.168.1.50" },
|
||||
merged.none { it.api?.host == "192.168.1.50" },
|
||||
)
|
||||
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api.host)
|
||||
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api.host)
|
||||
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api?.host)
|
||||
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api?.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionStoreStartupTest {
|
||||
|
||||
@Test
|
||||
fun pinnedStartupOverridesLastUsedOnlyDuringHydration() = runTest {
|
||||
val dataStore = InMemoryPreferencesDataStore()
|
||||
val store = ConnectionStore(dataStore, backgroundScope)
|
||||
store.isHydrated.first { it }
|
||||
|
||||
val a = sampleConnection("connection-a", "A")
|
||||
val b = sampleConnection("connection-b", "B")
|
||||
store.addConnection(a)
|
||||
store.addConnection(b)
|
||||
store.setActiveConnection(b.id)
|
||||
store.setStartupConnection(a.id)
|
||||
|
||||
assertEquals(b.id, store.activeConnectionId.value)
|
||||
assertEquals(a.id, store.startupConnectionId.value)
|
||||
|
||||
val reloaded = ConnectionStore(dataStore, backgroundScope)
|
||||
reloaded.isHydrated.first { it }
|
||||
assertEquals(a.id, reloaded.activeConnectionId.value)
|
||||
assertEquals(a.id, reloaded.startupConnectionId.value)
|
||||
|
||||
reloaded.setStartupConnection(null)
|
||||
assertNull(reloaded.startupConnectionId.value)
|
||||
}
|
||||
|
||||
private fun sampleConnection(id: String, label: String) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
relayUrl = "ws://localhost:8767",
|
||||
tokenStoreKey = Connection.buildTokenStoreKey(id),
|
||||
)
|
||||
|
||||
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow<Preferences>(emptyPreferences())
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
val next = transform(state.value)
|
||||
state.value = next
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user