Compare commits
172
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77e34c2c02 | ||
|
|
f4ee409106 | ||
|
|
bfb608bea6 | ||
|
|
95a95fe7d2 | ||
|
|
1bdf2ae71b | ||
|
|
f9e7a2f320 | ||
|
|
988fac8522 | ||
|
|
d4832a6a38 | ||
|
|
f9c8736e5b | ||
|
|
a815dd33fa | ||
|
|
cc9c75a636 | ||
|
|
43179e03c0 | ||
|
|
693ac4ed64 | ||
|
|
f94d663ac4 | ||
|
|
d1a21bd42e | ||
|
|
7ee2d73010 | ||
|
|
682bde84fe | ||
|
|
16bdbe5f44 | ||
|
|
f43fba9fed | ||
|
|
d1745413fd | ||
|
|
1b7a8025c3 | ||
|
|
d92a87483e | ||
|
|
0bea626ed8 | ||
|
|
f453dd27f3 | ||
|
|
36546c2712 | ||
|
|
fbe3fc3e05 | ||
|
|
a77cebec43 | ||
|
|
8167f93705 | ||
|
|
52dc46072e | ||
|
|
c18ab4cce8 | ||
|
|
52b28e5b48 | ||
|
|
575fd82c59 | ||
|
|
ead3f5fd4f | ||
|
|
36a922be64 | ||
|
|
530a1c9591 | ||
|
|
353faa9da5 | ||
|
|
76fdbcfd70 | ||
|
|
5365e22fff | ||
|
|
1e3974fd88 | ||
|
|
72854d58b1 | ||
|
|
b63e0e726d | ||
|
|
fff3ba8d91 | ||
|
|
7c155e3693 | ||
|
|
d15d3b594c | ||
|
|
915b2ebe54 | ||
|
|
cf3634ee2b | ||
|
|
88b11856d3 | ||
|
|
aede6c8cb3 | ||
|
|
c1187f0f2f | ||
|
|
6ff473820c | ||
|
|
847a21cc0c | ||
|
|
8ebd97643d | ||
|
|
8aded16da9 | ||
|
|
a35c0b34f8 | ||
|
|
877cfad88a | ||
|
|
fb0b8deef7 | ||
|
|
1a710f071c | ||
|
|
89b1461431 | ||
|
|
ce72f7790e | ||
|
|
51f4d29ebb | ||
|
|
97a2dac96d | ||
|
|
a569361d43 | ||
|
|
35dfc8c051 | ||
|
|
4a1ece7ad0 | ||
|
|
41b6fb4f84 | ||
|
|
a15b247d1a | ||
|
|
2d486dd395 | ||
|
|
0fcb833c83 | ||
|
|
4507b2270a | ||
|
|
1cd3da5ac6 | ||
|
|
afe2be9304 | ||
|
|
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 | ||
|
|
2bfe21eaff | ||
|
|
d4cdb96bab | ||
|
|
481ad48c57 | ||
|
|
c5f35145b4 | ||
|
|
3283c9b601 | ||
|
|
74be630e05 | ||
|
|
44f030f93b | ||
|
|
abce00f49e | ||
|
|
123f1d1263 | ||
|
|
61c4337807 | ||
|
|
6e1338004c | ||
|
|
348152a7cb | ||
|
|
3ec34502ec | ||
|
|
d30de8656d | ||
|
|
9b9b8c7c06 | ||
|
|
93b82aa538 | ||
|
|
7f2049fa0a | ||
|
|
92444a7039 | ||
|
|
a02d7e10df | ||
|
|
8a3935ffa1 | ||
|
|
e48935929a | ||
|
|
1e82347e7d | ||
|
|
75ed3c4226 | ||
|
|
bade61ac34 | ||
|
|
f88559f856 |
@@ -89,7 +89,7 @@ jobs:
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-android.yml \
|
||||
--ref="android-v${VERSION}" \
|
||||
--ref=main \
|
||||
-f version="$VERSION"
|
||||
|
||||
- name: Approval summary
|
||||
@@ -97,4 +97,4 @@ jobs:
|
||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -38,10 +38,11 @@ on:
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||
# commit must finish its independent validation.
|
||||
concurrency:
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
@@ -12,8 +12,9 @@ on:
|
||||
tags:
|
||||
- "android-v*"
|
||||
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
||||
# does not recursively start workflows. It explicitly dispatches this file
|
||||
# at that tag instead. Manual tag pushes continue to use the push trigger.
|
||||
# does not recursively start workflows. It dispatches the current workflow
|
||||
# definition from main, while every job checks out the immutable tag. Manual
|
||||
# tag pushes continue to use the push trigger.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -37,19 +38,22 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
env:
|
||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
|
||||
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||
REF_VERSION="$DISPATCHED_VERSION"
|
||||
fi
|
||||
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
|
||||
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
|
||||
exit 1
|
||||
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
fi
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
||||
@@ -67,8 +71,8 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -111,6 +115,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -147,6 +153,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
|
||||
+70
-2
@@ -6,16 +6,84 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Android 1.5.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
|
||||
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
|
||||
|
||||
## [Android 1.5.1] - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
|
||||
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
|
||||
|
||||
### Changed
|
||||
|
||||
- **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 is more compact and explicit.** Multi-server installs can restore the last-used connection or pin a startup connection, Dashboard-only routes remain visible, and Advanced and Security present their settings and posture directly.
|
||||
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
|
||||
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
|
||||
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
|
||||
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **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-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.
|
||||
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
### 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
|
||||
|
||||
@@ -168,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -1,5 +1,200 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-28 — Android 1.5.2 production release
|
||||
|
||||
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
|
||||
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
|
||||
the advertised native PKCE system-browser flow, while compatible self-hosted
|
||||
providers retain cookie-backed full-page Dashboard authentication. Callback
|
||||
origin discovery remains server-driven, private-network HTTP compatibility is
|
||||
preserved, and arbitrary public HTTP redirects remain rejected.
|
||||
|
||||
The private Play preflight validated the exact application tree before release
|
||||
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
|
||||
`main` tip, the production workflow promoted versionCode 35 to the completed
|
||||
Google Play production track, and the public GitHub release contains the
|
||||
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
|
||||
checksum was independently verified; replacing the debug-signed phone build
|
||||
with the release-signed artifact requires an uninstall because Android
|
||||
correctly rejects cross-signature in-place updates.
|
||||
|
||||
## 2026-07-27 — Android replayed-message identity reconciliation
|
||||
|
||||
Android history reconciliation now collapses reconnect/rejoin replays of the
|
||||
same persisted message ID before publishing the transcript to Compose. The
|
||||
latest repeated snapshot replaces the value at the message's first transcript
|
||||
position, preserving stable ordering, distinct messages, and the LazyColumn
|
||||
identity contract without index- or random-key fallbacks.
|
||||
|
||||
Focused coverage reproduces the duplicate UUID condition and verifies that the
|
||||
authoritative final content wins while every rendered message keeps a unique
|
||||
stable UI key.
|
||||
|
||||
## 2026-07-26 — Android 1.5.1 patch reconciliation
|
||||
|
||||
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
|
||||
34. Voice now offers compact Focus and full Conversation presentation,
|
||||
Standard narration preserves valid completed replies, and promoted Realtime
|
||||
tasks release foreground voice controls while retaining progress and results.
|
||||
|
||||
Completed streamed answers promote from the stable live text node to full
|
||||
Markdown only after completion. The measured Markdown row is then positioned
|
||||
by its trailing edge until deferred code and attachment measurement settles,
|
||||
preventing the LazyColumn from restoring the start of a tall response.
|
||||
|
||||
The release also targets Android API level 36. Release notes, in-app What's
|
||||
New assets, localized Play notes, and the Play listing reference were updated
|
||||
for Android 1.5.1.
|
||||
|
||||
## 2026-07-25 — Immutable Android release dispatch repair
|
||||
|
||||
Android approval now dispatches the current release workflow definition from
|
||||
`main`, while every release job explicitly checks out the immutable
|
||||
`android-v*` tag. Validation confirms the dispatched version resolves to that
|
||||
checked-out commit and accepts the repository's surface-qualified
|
||||
`[Android x.y.z]` changelog heading. Existing tags remain unchanged, and a
|
||||
workflow-only correction can resume a failed publication without rebuilding
|
||||
from a different application tree.
|
||||
|
||||
Audited `.github/workflows/approve-release-android.yml`,
|
||||
`.github/workflows/release-android.yml`, `RELEASE.md`, and `DEVLOG.md`.
|
||||
|
||||
## 2026-07-25 — Android 1.5.0 final release reconciliation
|
||||
|
||||
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
|
||||
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
|
||||
voice, attachment, image-generation, security, localization, and Developer
|
||||
Options work into one public release narrative. Android remains version 1.5.0
|
||||
with monotonic versionCode 33 because that prepared version was not previously
|
||||
tagged or uploaded to production.
|
||||
|
||||
Release notes, the in-app What's New assets, localized Play release notes, and
|
||||
the Play listing reference now describe the final tree rather than the earlier
|
||||
voice-focused candidate. The release train is gated by the exact-tree private
|
||||
Play preflight before the `dev` to `main` release merge and public tag.
|
||||
|
||||
## 2026-07-25 — Active-turn retention and actionable interaction alerts
|
||||
|
||||
Android now promotes user-started chat work to foreground execution until every
|
||||
connection/profile/session-scoped turn settles. Independent leases preserve
|
||||
concurrent detached Gateway sessions, track sessions paused for input, and
|
||||
prevent one completion from stopping protection for siblings. The existing
|
||||
Persistent connection switch now extends retention only to idle periods.
|
||||
|
||||
The foreground notification reports active and waiting session counts.
|
||||
Interaction alerts add privacy-safe expanded profile/session context and an
|
||||
explicit review, answer, or secure-response action that deep-links to the exact
|
||||
conversation; commands, questions, passwords, secrets, and environment-variable
|
||||
names remain confined to the authenticated chat surface.
|
||||
|
||||
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
|
||||
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
|
||||
manifest, Android settings copy, chat user documentation, and Play foreground
|
||||
service declaration guidance.
|
||||
|
||||
## 2026-07-24 — Post-connect permission setup
|
||||
|
||||
Android onboarding now finishes with a layered permission step after a
|
||||
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-20 — Faster Android validation feedback
|
||||
|
||||
Android contributors now have one cross-platform pre-push command for locale,
|
||||
documentation, collection-API, and version checks plus primary Play-variant
|
||||
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
|
||||
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
|
||||
Android SDK without writing worktree-local configuration. Hosted CI retains
|
||||
the exhaustive all-variant lint gate.
|
||||
|
||||
Android CI now cancels a superseded run on `dev` or a pull-request ref while
|
||||
preserving every `main` run. A newer integration commit therefore stops paying
|
||||
for an older release smoke that can no longer become the tested release tip.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+6
-2
@@ -610,8 +610,12 @@ git push origin dev
|
||||
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
||||
`main`, and enter the version. Starting the workflow is the release approval. It
|
||||
verifies that `main` has the exact preflighted tree and creates the
|
||||
`android-v<version>` tag. Manual stable tags are still guarded by the same
|
||||
preflight proof in the tag workflow.
|
||||
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
|
||||
another workflow, approval dispatches the current release workflow definition
|
||||
from `main`; every release job explicitly checks out and verifies the immutable
|
||||
`android-v<version>` tag. This lets release-workflow fixes apply without moving
|
||||
an existing tag or changing its artifact tree. Manual stable tags are still
|
||||
guarded by the same preflight proof in the tag workflow.
|
||||
|
||||
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
||||
|
||||
+10
-8
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.4.8
|
||||
# Hermes-Relay-Android v1.5.2
|
||||
|
||||
**Release Date:** July 18, 2026
|
||||
**Release Date:** July 28, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.4.8-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.2-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,15 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch restores the public privacy-policy URL required by Google Play and moves the canonical policy to hermes-relay.dev.
|
||||
This patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list.
|
||||
|
||||
## Fixed
|
||||
|
||||
- The historical GitHub Pages privacy URL now serves the complete policy instead of returning 404.
|
||||
- The About screen opens the canonical policy on hermes-relay.dev.
|
||||
- Android release automation verifies both public policy URLs before uploading or publishing to Google Play.
|
||||
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
|
||||
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
|
||||
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
|
||||
- Full-screen in-app sign-in remains available for compatible dashboard providers.
|
||||
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.4.8** (versionCode **31**).
|
||||
- App version: **1.5.2** (versionCode **35**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -6,6 +6,24 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Verify Android native dashboard sign-in on device
|
||||
|
||||
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
|
||||
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
|
||||
untrusted callback noise, and closes on completion, cancellation, navigation,
|
||||
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
|
||||
prewarm, and standard voice; sign-out clears both cookie and native sessions.
|
||||
Older gateways retain the identified WebView cookie fallback.
|
||||
|
||||
Before release, device-test the real Custom Tab → provider → loopback return,
|
||||
configuration/background transitions, Manage reload, Gateway chat ticket,
|
||||
standard voice, sign-out, and process relaunch. Native bearer exchange remains
|
||||
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
|
||||
using the native flow.
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
@@ -36,6 +54,68 @@ 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.
|
||||
- Keep persistent approval-mode writes for multiplexed non-launch profiles
|
||||
read-only until upstream `config.get` / `config.set` bind an explicit
|
||||
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
|
||||
accepts `approvals.mode` but resolves it against the gateway process home;
|
||||
Android may reconcile a selected profile's `session.info.approval_mode`, but
|
||||
must not claim a profile-scoped write that upstream ignores.
|
||||
- Keep gateway `model.options` profile scoping blocked until the supported
|
||||
upstream RPC accepts an explicit `profile` and documents that the returned
|
||||
provider inventory was built inside that profile's runtime scope. Android
|
||||
now keys picker results to its active profile context and rejects late
|
||||
responses after a profile switch, but it deliberately does not send an
|
||||
invented `profile` parameter. API-server fallback can use the separate,
|
||||
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
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
|
||||
@@ -637,9 +717,10 @@ Deferred:
|
||||
|
||||
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
||||
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
|
||||
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
|
||||
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
||||
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
@@ -651,10 +732,6 @@ gated to pre-first-token. Deferred:
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
|
||||
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
||||
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||
@@ -1070,7 +1147,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").
|
||||
@@ -1121,6 +1198,7 @@ Follow-ups:
|
||||
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
|
||||
@@ -37,7 +37,7 @@ android {
|
||||
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||
applicationId = "com.axiomlabs.hermesrelay"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
@@ -245,6 +245,7 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.browser)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
@@ -326,8 +327,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,13 @@
|
||||
<?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" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
* Debug-build-only live host for fast image-generation motion tuning.
|
||||
*
|
||||
* Launch directly:
|
||||
* adb shell am start -n <applicationId>/
|
||||
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||
*/
|
||||
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent {
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||
var restartKey by remember { mutableIntStateOf(0) }
|
||||
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Image generation lab") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Live debug preview · no generation request",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||
).forEach { (style, label) ->
|
||||
FilterChip(
|
||||
selected = visualStyle == style,
|
||||
onClick = { visualStyle = style },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
key(restartKey, durationMillis, visualStyle) {
|
||||
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||
ImageGenerationResultTransition(
|
||||
generating = !showResult,
|
||||
startedAtMillis = startedAtMillis,
|
||||
animationDurationMillis = durationMillis,
|
||||
visualStyle = visualStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||
contentDescription = "Generated landscape preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Generated image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "12.4s",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Cycle speed",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
7_200 to "Slow",
|
||||
4_800 to "Normal",
|
||||
3_200 to "Fast",
|
||||
).forEach { (duration, label) ->
|
||||
FilterChip(
|
||||
selected = durationMillis == duration,
|
||||
onClick = { durationMillis = duration },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = true
|
||||
},
|
||||
enabled = !showResult,
|
||||
) {
|
||||
Text("Reveal result")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = false
|
||||
restartKey++
|
||||
},
|
||||
) {
|
||||
Text("Restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1 +1 @@
|
||||
The privacy policy now lives at hermes-relay.dev, the About screen opens it directly, and release automation verifies the public policy before publishing.
|
||||
Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows.
|
||||
|
||||
@@ -1 +1 @@
|
||||
隐私政策现已迁移到 hermes-relay.dev,“关于”页面可直接打开该政策;发布流程会在上线前验证政策页面是否可公开访问。
|
||||
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
|
||||
|
||||
@@ -90,12 +90,10 @@
|
||||
android:name=".notifications.ProactiveReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Opt-in "Persistent connection" — holds the user's connection to
|
||||
Hermes open while backgrounded so messages and live features stay
|
||||
responsive (relay-paired setups also keep device control +
|
||||
notification mirroring reachable). In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
<!-- Protects user-started active turns automatically; the optional
|
||||
"Persistent connection" setting extends the same foreground
|
||||
protection to idle/background connectivity (and relay-paired
|
||||
device features). In main so BOTH flavors ship it. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
@@ -103,7 +101,7 @@
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
|
||||
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -1,5 +1,109 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.2",
|
||||
"title": "Sign in without detours",
|
||||
"date": "2026-07-28",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Provider-compatible sign-in",
|
||||
"bullets": [
|
||||
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Stable conversation updates",
|
||||
"bullets": [
|
||||
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"title": "Voice and chat stay in place",
|
||||
"date": "2026-07-26",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice at the right depth",
|
||||
"bullets": [
|
||||
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
|
||||
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Reliable narration and background work",
|
||||
"bullets": [
|
||||
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Formatted answers stay readable",
|
||||
"bullets": [
|
||||
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
|
||||
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
v1.4.8 - Privacy policy link restored
|
||||
v1.5.2 - Sign in without detours
|
||||
|
||||
* The privacy policy now lives at hermes-relay.dev.
|
||||
* The About screen opens the hosted policy directly.
|
||||
* Release automation verifies the policy is publicly available before publishing.
|
||||
* Complete self-hosted OIDC sign-in through the dashboard callback.
|
||||
* Open Nous Portal securely in the system browser.
|
||||
* Sign in over private-LAN and Tailscale dashboard routes.
|
||||
* Keep replayed chat updates from duplicating conversation rows.
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -142,6 +142,21 @@ data class ChatMessage(
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
@@ -392,6 +407,8 @@ data class ChatSession(
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Server reports a persisted session runtime/model binding. */
|
||||
val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnMoaReferenceCheckpoint(
|
||||
val index: Int,
|
||||
val count: Int? = null,
|
||||
val label: String,
|
||||
val text: String = "",
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -13,6 +13,8 @@ data class DashboardConnectionStatus(
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val gatewayMode: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -112,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
|
||||
@@ -187,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
|
||||
@@ -196,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
|
||||
@@ -220,6 +247,7 @@ data class Connection(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
dashboardUrl: String? = null,
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
@@ -228,6 +256,7 @@ data class Connection(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
@@ -239,6 +268,7 @@ data class Connection(
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
@@ -313,6 +343,7 @@ data class Connection(
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
dashboardUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
@@ -336,12 +367,62 @@ data class Connection(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
||||
dashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
|
||||
?.let { DashboardEndpoint(url = it) }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl)
|
||||
?.let { DashboardEndpoint(url = it) },
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile stored API-derived routes with the Dashboard origin that
|
||||
* was actually verified during setup. Older app versions synthesized
|
||||
* `:9119` for every API route, even when the same host was reached
|
||||
* through an HTTPS reverse proxy on 443. Replace only that conventional
|
||||
* synthesized value (or a missing value); preserve explicit and
|
||||
* different-host LAN/Tailscale routes.
|
||||
*/
|
||||
fun reconcileDashboardRoutes(
|
||||
dashboardUrl: String?,
|
||||
candidates: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val explicitDashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return candidates
|
||||
return candidates.map { candidate ->
|
||||
val apiUrl = candidate.api?.url ?: return@map candidate
|
||||
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
|
||||
|
||||
val currentDashboard = candidate.dashboard?.url
|
||||
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
|
||||
val canReplace = currentDashboard.isNullOrBlank() ||
|
||||
(
|
||||
derivedDashboard != null &&
|
||||
currentDashboard.trim().trimEnd('/')
|
||||
.equals(derivedDashboard, ignoreCase = true)
|
||||
)
|
||||
if (canReplace) {
|
||||
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
|
||||
} else {
|
||||
candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
|
||||
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
|
||||
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
|
||||
return !leftHost.isNullOrBlank() &&
|
||||
!rightHost.isNullOrBlank() &&
|
||||
leftHost.equals(rightHost, ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
|
||||
@@ -543,29 +543,6 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
@@ -585,3 +562,40 @@ class ConnectionStore private constructor(
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore route defaults after loading a serialized connection. This remains
|
||||
* internal so focused persistence tests can exercise the same normalization
|
||||
* path used by [ConnectionStore].
|
||||
*/
|
||||
internal fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
|
||||
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
}
|
||||
val normalizedRoutes = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
candidates = storedOrDefaultRoutes,
|
||||
)
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
dashboardUrl != effectiveDashboardUrl ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +255,11 @@ class DataManager(
|
||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
||||
outputStream.flush()
|
||||
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||
?: return@withContext false
|
||||
outputStream.use {
|
||||
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -288,9 +290,10 @@ class DataManager(
|
||||
* - Clear DataStore preferences
|
||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||
* - Clear any cached data
|
||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
||||
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||
* should show onboarding again.
|
||||
*/
|
||||
suspend fun resetAppData() {
|
||||
suspend fun resetAppData(): Boolean =
|
||||
try {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
@@ -320,10 +323,11 @@ class DataManager(
|
||||
}
|
||||
|
||||
Log.d(TAG, "App data reset complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset app data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -384,16 +388,17 @@ class DataManager(
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
suspend fun resetOnboarding(): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed.
|
||||
@@ -412,13 +417,14 @@ class DataManager(
|
||||
/**
|
||||
* Mark onboarding as completed.
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In debug builds, Developer Options are unlocked by default until the user
|
||||
* explicitly locks them.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
||||
|
||||
/** Whether the app is running a debug build. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
}
|
||||
|
||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/** Unlock Developer Options. */
|
||||
@@ -45,18 +39,10 @@ object FeatureFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
/** Lock Developer Options, including in debug builds. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_RELAY_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ data class VoiceSettings(
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 1250L,
|
||||
/**
|
||||
* When true, voice keeps progress visual and waits for the settled Hermes
|
||||
* answer before speaking. Tool status, service updates, and intermediate
|
||||
* assistant commentary are not narrated.
|
||||
*/
|
||||
val finalAnswerOnly: Boolean = false,
|
||||
/** Presentation only; changing this never restarts or interrupts voice. */
|
||||
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
@@ -122,6 +130,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoicePresentationMode(val storageValue: String) {
|
||||
Focus("focus"),
|
||||
Conversation("conversation");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoicePresentationMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: Focus
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active scope for per-profile voice prefs.
|
||||
*
|
||||
@@ -182,6 +200,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// un-namespaced means switching profiles never churns these.
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
|
||||
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
@@ -191,6 +211,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
||||
const val DEFAULT_FINAL_ANSWER_ONLY = false
|
||||
const val DEFAULT_PRESENTATION_MODE = "focus"
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
|
||||
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// --- global (shared across profiles) ---
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
|
||||
presentationMode = VoicePresentationMode.fromStorage(
|
||||
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
|
||||
).storageValue,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
@@ -367,6 +393,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setFinalAnswerOnly(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setPresentationMode(mode: VoicePresentationMode) {
|
||||
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
@@ -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,11 @@ 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,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1290,8 +1306,11 @@ class RelayVoiceClient(
|
||||
token = token,
|
||||
chatSessionId = chatSessionId,
|
||||
conversationContext = conversationContext,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
finalAnswerOnly = finalAnswerOnly,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -1805,6 +1824,28 @@ class RelayVoiceClient(
|
||||
if (event.type == "hermes.run.promoted") {
|
||||
longRunningTurn.set(true)
|
||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||
if (persistent &&
|
||||
event.spokenHandoff == false &&
|
||||
activeTurn.compareAndSet(true, false)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime agent foreground turn ended at silent background promotion",
|
||||
)
|
||||
onTurnComplete(
|
||||
RealtimeVoiceSummary(
|
||||
provider = event.provider ?: session.provider,
|
||||
model = event.model ?: session.model,
|
||||
voice = event.voice ?: session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = event.firstAudioMs,
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (event.isAudioDelta) {
|
||||
audioChunks += 1
|
||||
@@ -1830,13 +1871,12 @@ class RelayVoiceClient(
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
if (persistent) {
|
||||
if (persistent && activeTurn.compareAndSet(true, false)) {
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
longRunningTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
} else if (!persistent) {
|
||||
if (claimTerminalSocket(
|
||||
webSocket,
|
||||
generation,
|
||||
@@ -2658,17 +2698,29 @@ class RelayVoiceClient(
|
||||
token: String,
|
||||
chatSessionId: String?,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
): 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))
|
||||
}
|
||||
if (finalAnswerOnly) {
|
||||
put("final_answer_only", JsonPrimitive(true))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2726,8 +2778,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))
|
||||
@@ -2940,6 +3007,9 @@ class RelayVoiceClient(
|
||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.toBooleanStrictOrNull(),
|
||||
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
||||
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||
@@ -2970,6 +3040,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
|
||||
@@ -3318,6 +3404,7 @@ data class RealtimeVoiceEvent(
|
||||
// ADR 33: background-run promotion fields.
|
||||
val tier: String? = null,
|
||||
val floor: String? = null,
|
||||
val spokenHandoff: Boolean? = null,
|
||||
// hermes.run.progress extras — drive the live background-run chip.
|
||||
val activeToolName: String? = null,
|
||||
val completedToolCount: Int? = null,
|
||||
|
||||
@@ -59,8 +59,8 @@ data class RouteProbeOutcome(
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** Dashboard-first routes use `GET
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `HEAD
|
||||
* ${api.url}/health`. Relay-only routes use `HEAD ${relay.httpUrl}/health`.
|
||||
* ${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
|
||||
@@ -110,7 +110,6 @@ class EndpointResolver(
|
||||
val baseUrl: String,
|
||||
val requestUrl: String,
|
||||
val path: String,
|
||||
val useHead: Boolean,
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
@@ -326,7 +325,11 @@ class EndpointResolver(
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "*/*")
|
||||
val request = if (target.useHead) requestBuilder.head().build() else requestBuilder.get().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) {
|
||||
@@ -407,7 +410,6 @@ class EndpointResolver(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/api/status",
|
||||
path = "/api/status",
|
||||
useHead = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -416,7 +418,6 @@ class EndpointResolver(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/health",
|
||||
path = "/health",
|
||||
useHead = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -436,7 +437,6 @@ class EndpointResolver(
|
||||
baseUrl = relayUrl,
|
||||
requestUrl = "$httpBase/health",
|
||||
path = "/health",
|
||||
useHead = 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> {
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Process-local ownership of background protection for work the user already
|
||||
* started. Keys are connection/profile/session scoped, so detached sibling
|
||||
* sessions retain independent leases and one completion cannot release
|
||||
* another session's protection.
|
||||
*/
|
||||
object ActiveTurnKeepAliveRegistry {
|
||||
data class Snapshot(
|
||||
val activeTurnCount: Int = 0,
|
||||
val waitingSessionCount: Int = 0,
|
||||
) {
|
||||
val required: Boolean get() = activeTurnCount > 0
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
private val leases = linkedMapOf<String, Boolean>()
|
||||
private val _snapshot = MutableStateFlow(Snapshot())
|
||||
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
|
||||
|
||||
fun acquire(key: String) {
|
||||
synchronized(lock) {
|
||||
leases[key] = leases[key] ?: false
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWaiting(key: String, waiting: Boolean) {
|
||||
synchronized(lock) {
|
||||
if (key in leases) {
|
||||
leases[key] = waiting
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(oldKey: String, newKey: String) {
|
||||
if (oldKey == newKey) return
|
||||
synchronized(lock) {
|
||||
val waiting = leases.remove(oldKey) ?: return
|
||||
leases[newKey] = waiting
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun release(key: String) {
|
||||
synchronized(lock) {
|
||||
if (leases.remove(key) != null) publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
synchronized(lock) {
|
||||
if (leases.isNotEmpty()) {
|
||||
leases.clear()
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resetForTest() {
|
||||
releaseAll()
|
||||
}
|
||||
|
||||
private fun publishLocked() {
|
||||
_snapshot.value = Snapshot(
|
||||
activeTurnCount = leases.size,
|
||||
waitingSessionCount = leases.count { it.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.MoaReference
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -43,6 +43,9 @@ class ChatHandler {
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
internal const val MAX_MESSAGES = 500
|
||||
private const val MAX_MOA_REFERENCES = 32
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
|
||||
private fun timestampToMillis(timestamp: Double?): Long {
|
||||
val value = timestamp ?: return 0L
|
||||
@@ -155,6 +158,15 @@ class ChatHandler {
|
||||
*/
|
||||
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
||||
|
||||
/**
|
||||
* Fired for a canonical `@image:<path>` directive found on a persisted
|
||||
* USER history row. This is intentionally separate from free-form
|
||||
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
|
||||
* can reach this callback.
|
||||
*/
|
||||
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
|
||||
{ _, _ -> }
|
||||
|
||||
/**
|
||||
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
||||
@@ -512,6 +524,42 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a provisional post-interim segment back into its sealed
|
||||
* assistant bubble when the terminal text proves they are one response.
|
||||
* Tool/card state accumulated after the interim remains attached.
|
||||
*/
|
||||
fun reconcileInterimMessage(
|
||||
interimMessageId: String,
|
||||
currentMessageId: String,
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
content = content,
|
||||
isStreaming = true,
|
||||
toolCalls = mergedTools,
|
||||
thinkingContent = current?.thinkingContent
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: interim.thinkingContent,
|
||||
isThinkingStreaming = current?.isThinkingStreaming
|
||||
?: interim.isThinkingStreaming,
|
||||
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
|
||||
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
|
||||
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
|
||||
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == interimMessageId) merged else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
@@ -973,6 +1021,27 @@ class ChatHandler {
|
||||
startedAt = task.startedAt,
|
||||
)
|
||||
}
|
||||
val checkpointMoaReferences = assistant.moaReferences
|
||||
.filter { it.index in 1..MAX_MOA_REFERENCES }
|
||||
.distinctBy { it.index }
|
||||
.sortedBy { it.index }
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
.map { reference ->
|
||||
MoaReference(
|
||||
index = reference.index,
|
||||
count = reference.count,
|
||||
label = reference.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (reference.available) {
|
||||
reference.text.take(MAX_MOA_REFERENCE_CHARS)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
available = reference.available,
|
||||
)
|
||||
}
|
||||
val restoredMoaReferences = currentAssistant?.moaReferences
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -996,6 +1065,7 @@ class ChatHandler {
|
||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||
?: assistant.cardDispatches,
|
||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||
moaReferences = restoredMoaReferences,
|
||||
)
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
@@ -1153,11 +1223,20 @@ class ChatHandler {
|
||||
// so we can attach results back to the originating assistant message's ToolCall
|
||||
val toolResults = items.filter { it.role == "tool" }
|
||||
.associateBy { it.toolCallId }
|
||||
// A reconnect/rejoin history response can repeat a persisted message row.
|
||||
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
|
||||
// so allowing both copies through would crash Compose before either copy
|
||||
// could be reconciled. A domain id identifies one persisted message: retain
|
||||
// its first transcript position while adopting the latest repeated snapshot.
|
||||
// Rows without ids remain independent, and tool/hidden rows keep their
|
||||
// separate handling above/below.
|
||||
val renderedItems = coalesceRenderedHistoryItems(items)
|
||||
|
||||
// Accumulator for media markers we find in loaded content — fired AFTER
|
||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||
// mutateMessage lookups find the newly-loaded messages.
|
||||
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
|
||||
|
||||
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
||||
// building the carry map, so the id-keyed delta-merge updates rows in
|
||||
@@ -1169,8 +1248,8 @@ class ChatHandler {
|
||||
// silently misses those rows, so a gateway turn's tokens/badges survived
|
||||
// only if a content match happened to cover them. See
|
||||
// [reconcileLiveIdsToServer].
|
||||
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
|
||||
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
|
||||
|
||||
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
||||
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
||||
@@ -1207,11 +1286,16 @@ class ChatHandler {
|
||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" ||
|
||||
displayKind == "auto_continue" -> 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 +1311,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,15 +1327,21 @@ 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
|
||||
|
||||
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
|
||||
PersistedImageReferenceParser.parse(rawContent)
|
||||
} else {
|
||||
PersistedImageReferences(rawContent, emptyList())
|
||||
}
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
persistedImages.cleanedText
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
@@ -1287,7 +1379,14 @@ class ChatHandler {
|
||||
// content-keyed queue. Inbound attachments are intentionally
|
||||
// excluded — they come back via the marker re-dispatch.
|
||||
val carriedAttachments = run {
|
||||
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
|
||||
val persistedImagePaths = persistedImages.paths.toHashSet()
|
||||
val byId = prior?.attachments.orEmpty().filter { attachment ->
|
||||
attachment.relayToken == null ||
|
||||
(
|
||||
role == MessageRole.USER &&
|
||||
attachment.relayToken in persistedImagePaths
|
||||
)
|
||||
}
|
||||
when {
|
||||
byId.isNotEmpty() -> byId
|
||||
role == MessageRole.USER ->
|
||||
@@ -1295,6 +1394,15 @@ class ChatHandler {
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (
|
||||
role == MessageRole.USER &&
|
||||
carriedAttachments.isEmpty() &&
|
||||
persistedImages.paths.isNotEmpty()
|
||||
) {
|
||||
persistedImages.paths.forEach { path ->
|
||||
pendingPersistedUserImages += messageId to path
|
||||
}
|
||||
}
|
||||
// Server reasoning is authoritative when present; absent, keep the
|
||||
// live-streamed thinking rather than blanking it on reload.
|
||||
val serverThinking =
|
||||
@@ -1339,6 +1447,10 @@ class ChatHandler {
|
||||
} else {
|
||||
prior.badges
|
||||
},
|
||||
// Keep sanitized advisor state while reconciling a still-live
|
||||
// row, but clear it once completion made history authoritative.
|
||||
// The server transcript never becomes the source of these blocks.
|
||||
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
|
||||
)
|
||||
} else {
|
||||
// INSERT — a server message with no local row yet. Built from
|
||||
@@ -1431,6 +1543,37 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse replayed visible history rows by their authoritative message id.
|
||||
*
|
||||
* Replacing the value at its first-seen slot preserves transcript ordering;
|
||||
* the last repeated value wins so a later, more complete snapshot is not lost.
|
||||
* Null ids cannot be proven identical and therefore remain separate rows.
|
||||
*/
|
||||
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
|
||||
val firstSlotById = HashMap<String, Int>()
|
||||
val coalesced = ArrayList<MessageItem>(items.size)
|
||||
for (item in items) {
|
||||
if (renderedRoleOf(item) == null) continue
|
||||
val id = item.id
|
||||
if (id == null) {
|
||||
coalesced += item
|
||||
continue
|
||||
}
|
||||
val existingSlot = firstSlotById[id]
|
||||
if (existingSlot == null) {
|
||||
firstSlotById[id] = coalesced.size
|
||||
coalesced += item
|
||||
} else {
|
||||
coalesced[existingSlot] = item
|
||||
}
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
@@ -1496,19 +1639,54 @@ 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", "auto_continue" -> 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"
|
||||
}
|
||||
}
|
||||
"auto_continue" -> "Continued after an interrupted turn"
|
||||
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
|
||||
@@ -1525,6 +1703,7 @@ class ChatHandler {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(t)
|
||||
@@ -1682,7 +1861,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)
|
||||
@@ -1710,6 +1892,7 @@ class ChatHandler {
|
||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||
source = item.source,
|
||||
hasModelConfig = item.hasModelConfig,
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
@@ -1760,7 +1943,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 ---
|
||||
@@ -2542,6 +2733,44 @@ class ChatHandler {
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
|
||||
val message = messages[targetIndex]
|
||||
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
|
||||
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
|
||||
val reference = MoaReference(
|
||||
index = nextIndex,
|
||||
count = event.count,
|
||||
label = event.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = event.available,
|
||||
)
|
||||
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
|
||||
val exactReplay = existingAtIndex == reference
|
||||
val base = if (nextIndex == 1 && !exactReplay) {
|
||||
emptyList()
|
||||
} else {
|
||||
message.moaReferences
|
||||
}
|
||||
if (exactReplay) {
|
||||
messages
|
||||
} else {
|
||||
val upserted = (base.filterNot { it.index == nextIndex } + reference)
|
||||
.sortedBy(MoaReference::index)
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
messages.toMutableList().also {
|
||||
it[targetIndex] = message.copy(moaReferences = upserted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
@@ -2867,13 +3096,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 +3311,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
|
||||
}
|
||||
|
||||
+395
-16
@@ -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
|
||||
@@ -49,8 +50,40 @@ data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
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 +125,54 @@ 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 models: List<String> = emptyList(),
|
||||
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 +336,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 +555,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,17 +1093,87 @@ 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.models
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(MAX_CUSTOM_ENDPOINT_MODELS)
|
||||
.map(::JsonPrimitive)
|
||||
.toList()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("models", JsonArray(it)) }
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
bearerAuth: DashboardBearerAuth? = null,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
bearerAuth?.let {
|
||||
builder.addInterceptor(it)
|
||||
builder.authenticator(it)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
@@ -947,14 +1181,70 @@ 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")
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
},
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1072,6 +1362,35 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer credentials are scoped to the exact saved dashboard base, including
|
||||
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
|
||||
* not sufficient authority to receive the active connection's token.
|
||||
*/
|
||||
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return candidateUrl.scheme == trustedUrl.scheme &&
|
||||
candidateUrl.host == trustedUrl.host &&
|
||||
candidateUrl.port == trustedUrl.port &&
|
||||
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
|
||||
candidateUrl.query == null &&
|
||||
trustedUrl.query == null
|
||||
}
|
||||
|
||||
fun trustedDashboardBearerAuthOrNull(
|
||||
candidate: String,
|
||||
trusted: String,
|
||||
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||
): DashboardBearerAuth? =
|
||||
if (isNativeDashboardTransportEligible(candidate) &&
|
||||
sameDashboardBase(candidate, trusted)
|
||||
) {
|
||||
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
@@ -1203,6 +1522,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 +1727,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? {
|
||||
|
||||
+649
-49
File diff suppressed because it is too large
Load Diff
+242
-84
@@ -31,13 +31,25 @@ 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 moaStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
@@ -56,6 +68,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 +131,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 +167,7 @@ class GatewayEventMapper(
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
previewedText = null
|
||||
callbacks.onStart()
|
||||
}
|
||||
|
||||
@@ -163,8 +217,21 @@ class GatewayEventMapper(
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
|
||||
val error = payload.string("error")
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview ->
|
||||
preview.isNotEmpty() &&
|
||||
(text.startsWith(preview) || preview.startsWith(text))
|
||||
} == true
|
||||
if (reconcilesInterim) {
|
||||
callbacks.onInterimReconciled(text)
|
||||
} else if (!text.isNullOrEmpty() &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
@@ -172,6 +239,12 @@ class GatewayEventMapper(
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
if (failed) {
|
||||
callbacks.onStatusUpdate(
|
||||
ERROR_STATUS_KIND,
|
||||
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
|
||||
)
|
||||
}
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
@@ -209,36 +282,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
|
||||
@@ -259,52 +302,57 @@ class GatewayEventMapper(
|
||||
}
|
||||
}
|
||||
|
||||
// MoA activity proves auto-compaction has resumed even though
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
"moa.reference" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val text = payload.string("text")?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
val available = !isFailedMoaReference(text)
|
||||
callbacks.onMoaReference(
|
||||
GatewayMoaReference(
|
||||
index = payload.int("index")?.takeIf { it > 0 },
|
||||
count = payload.int("count")
|
||||
?.takeIf { it > 0 },
|
||||
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
|
||||
.ifBlank { "Advisor" },
|
||||
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = available,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"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,
|
||||
),
|
||||
)
|
||||
"moa.progress" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
|
||||
"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,
|
||||
),
|
||||
)
|
||||
"moa.phase" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
when (payload.string("phase")?.lowercase()) {
|
||||
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
|
||||
"reference", "references" -> {
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"sudo.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
// Legacy phase marker retained by upstream for older consumers.
|
||||
"moa.aggregating" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
setMoaStatus("MoA: aggregating…")
|
||||
}
|
||||
|
||||
"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,
|
||||
),
|
||||
)
|
||||
"tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -336,27 +384,120 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
private fun clearActivityStatuses() {
|
||||
clearProviderWaitAndCompaction()
|
||||
if (!moaStatusActive) return
|
||||
moaStatusActive = false
|
||||
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun clearProviderWaitAndCompaction() {
|
||||
clearProviderWaitStatus()
|
||||
if (!compactionStatusActive) return
|
||||
compactionStatusActive = false
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun 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
|
||||
private fun setMoaStatus(text: String) {
|
||||
moaStatusActive = true
|
||||
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
const val MOA_STATUS_KIND = "moa"
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
"thinking.delta",
|
||||
"reasoning.available",
|
||||
"message.delta",
|
||||
"message.interim",
|
||||
"message.start",
|
||||
"tool.generating",
|
||||
"tool.start",
|
||||
"tool.complete",
|
||||
"message.complete",
|
||||
"error",
|
||||
)
|
||||
|
||||
internal fun isFailedMoaReference(text: String): Boolean {
|
||||
val normalized = text.trimStart().lowercase()
|
||||
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
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 +563,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)
|
||||
|
||||
+93
-16
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Opt-in foreground service that holds the app process up so the app's
|
||||
* connection to Hermes survives Android's background-freeze / Doze — i.e.
|
||||
* Foreground service that holds the app process up so work the user already
|
||||
* started survives Android's background-freeze / Doze. It runs automatically
|
||||
* while one or more turns are active, or continuously when the user enables
|
||||
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
||||
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
||||
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
|
||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||
* at submission. Off by default; only runs while the user enables the toggle.
|
||||
* at submission. Continuous idle retention is off by default; active work is
|
||||
* protected automatically and releases its lease on terminal settlement.
|
||||
*
|
||||
* # It does NOT own the socket
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
||||
* idle-close timer while retention is required. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
*
|
||||
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
|
||||
private const val CHANNEL_NAME = "Persistent connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
|
||||
private const val EXTRA_PERSISTENT = "persistent"
|
||||
private const val EXTRA_ACTIVE_TURNS = "active_turns"
|
||||
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
|
||||
@Volatile private var runningInstance: GatewayKeepAliveService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
fun update(
|
||||
context: Context,
|
||||
persistent: Boolean,
|
||||
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
) {
|
||||
if (!persistent && !activeTurns.required) {
|
||||
stop(context)
|
||||
return
|
||||
}
|
||||
runningInstance?.let { service ->
|
||||
service.applyState(persistent, activeTurns)
|
||||
service.startForegroundNotification()
|
||||
return
|
||||
}
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
.setAction(ACTION_REFRESH)
|
||||
.putExtra(EXTRA_PERSISTENT, persistent)
|
||||
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
|
||||
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var persistent = false
|
||||
private var activeTurns = 0
|
||||
private var waitingSessions = 0
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningInstance = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_REFRESH) {
|
||||
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
|
||||
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
|
||||
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
|
||||
.coerceIn(0, activeTurns)
|
||||
}
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
||||
// Flip the pref off so ConnectionViewModel's collector won't
|
||||
// restart us on the next foreground.
|
||||
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
persistent = false
|
||||
if (activeTurns == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
startForegroundNotification()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
persistent: Boolean,
|
||||
turns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
) {
|
||||
this.persistent = persistent
|
||||
activeTurns = turns.activeTurnCount
|
||||
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
|
||||
}
|
||||
|
||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||
// are all declared in the main manifest (both flavors), so the type is
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
val (title, body) = when {
|
||||
waitingSessions > 0 -> {
|
||||
val title = if (waitingSessions == 1) {
|
||||
"Hermes is waiting for input"
|
||||
} else {
|
||||
"$waitingSessions Hermes sessions need input"
|
||||
}
|
||||
title to if (activeTurns > waitingSessions) {
|
||||
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
|
||||
} else {
|
||||
"Open the requested session to review and continue."
|
||||
}
|
||||
}
|
||||
activeTurns > 0 -> {
|
||||
val title = if (activeTurns == 1) {
|
||||
"Hermes is finishing a turn"
|
||||
} else {
|
||||
"Hermes is finishing $activeTurns turns"
|
||||
}
|
||||
title to "The connection stays active until this work completes."
|
||||
}
|
||||
else -> getString(R.string.gateway_keepalive_title) to
|
||||
getString(R.string.gateway_keepalive_body)
|
||||
}
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.gateway_keepalive_title))
|
||||
.setContentText(getString(R.string.gateway_keepalive_body))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Turn off", stopPending)
|
||||
.build()
|
||||
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
|
||||
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
|
||||
Ready,
|
||||
}
|
||||
|
||||
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
|
||||
enum class GatewayApprovalMode(val wireValue: String) {
|
||||
Manual("manual"),
|
||||
Smart("smart"),
|
||||
Off("off");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
|
||||
"manual" -> Manual
|
||||
"smart" -> Smart
|
||||
"off" -> Off
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
|
||||
enum class GatewayApprovalModeCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
* gateway while the dashboard probe is unresolved or ready. A capability-
|
||||
* preferred SSE fallback is selected only after a definitive unavailable,
|
||||
* unsupported, or sign-in-required verdict.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
else -> if (
|
||||
gateway == GatewayAvailability.Ready ||
|
||||
gateway == GatewayAvailability.Unknown
|
||||
) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
@@ -95,6 +122,28 @@ data class GatewayInflightTurn(
|
||||
val user: String,
|
||||
val assistant: String,
|
||||
val streaming: Boolean,
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val recoverable: Boolean = false,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
|
||||
data class GatewayAutoContinue(
|
||||
val attempt: Int,
|
||||
val interruptedAt: Double?,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
val slug: String?,
|
||||
val name: String,
|
||||
val primaryPath: String?,
|
||||
)
|
||||
|
||||
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||
@@ -104,9 +153,15 @@ 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?,
|
||||
)
|
||||
val autoContinue: GatewayAutoContinue? = null,
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null || autoContinue != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
data class GatewayBackgroundTurnCompletion(
|
||||
@@ -115,6 +170,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 ""
|
||||
@@ -273,6 +347,14 @@ data class GatewayModelProvider(
|
||||
val totalModels: Int = 0,
|
||||
)
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
@@ -280,6 +362,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
requestProfileKey: String,
|
||||
currentProfileKey: String,
|
||||
): Boolean =
|
||||
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||
|
||||
/**
|
||||
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
||||
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||
@@ -296,7 +387,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 +421,19 @@ 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 = { _, _ -> },
|
||||
/**
|
||||
* The terminal text is equal/prefix-related to the sealed interim, so the
|
||||
* existing segment should be replaced in place instead of opening a second
|
||||
* assistant bubble.
|
||||
*/
|
||||
val onInterimReconciled: (text: String) -> Unit = { _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -352,6 +458,8 @@ class GatewayTurnCallbacks(
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/** Successful MoA advisor output for a transient labelled reference block. */
|
||||
val onMoaReference: (GatewayMoaReference) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
@@ -360,6 +468,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
|
||||
@@ -28,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -76,6 +78,10 @@ data class ServerCapabilities(
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
/** Authenticated provider/model inventory at `/api/model/options`. */
|
||||
val modelOptions: Boolean = false,
|
||||
/** Backend-acknowledged per-session model lock. */
|
||||
val sessionModelLock: Boolean = false,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
@@ -99,6 +105,8 @@ data class ServerCapabilities(
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
modelOptions = false,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -138,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
modelOptions = feature("model_options") || endpoint("model_options"),
|
||||
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,6 +169,269 @@ 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" }
|
||||
}
|
||||
|
||||
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
|
||||
data class ApiProviderModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
internal fun parseApiProviderModelOptionsBody(
|
||||
json: Json,
|
||||
body: String,
|
||||
): ApiProviderModelOptions? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val rows = root["providers"] as? JsonArray ?: return null
|
||||
val providers = rows.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
enum class ApiModelRoutingErrorCode {
|
||||
INVENTORY_UNSUPPORTED,
|
||||
INVENTORY_UNAVAILABLE,
|
||||
PROVIDER_NOT_AUTHENTICATED,
|
||||
MODEL_NOT_AVAILABLE,
|
||||
MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
LOCK_CAPABILITY_INCOMPLETE,
|
||||
LOCK_REJECTED,
|
||||
LOCK_ACK_MISMATCH,
|
||||
LEGACY_PROVIDER_UNSUPPORTED,
|
||||
}
|
||||
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
data class Locked(
|
||||
val sessionId: String,
|
||||
val model: String,
|
||||
val provider: String?,
|
||||
val effectiveModel: String = model,
|
||||
val effectiveProvider: String? = provider,
|
||||
) : ApiModelSelectionAck
|
||||
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
|
||||
}
|
||||
|
||||
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
|
||||
|
||||
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
|
||||
when {
|
||||
capabilities.sessionModelLock && capabilities.modelOptions ->
|
||||
ApiModelRoutingStrategy.LOCKED
|
||||
capabilities.sessionModelLock ->
|
||||
ApiModelRoutingStrategy.INCOMPLETE
|
||||
else ->
|
||||
ApiModelRoutingStrategy.LEGACY_HINT
|
||||
}
|
||||
|
||||
internal fun sessionTurnModelHint(
|
||||
acknowledgement: ApiModelSelectionAck,
|
||||
requestedModel: String?,
|
||||
): String? =
|
||||
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
|
||||
|
||||
internal data class ParsedApiModelLockAck(
|
||||
val sessionId: String?,
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val state: String?,
|
||||
val effectiveModel: String?,
|
||||
val effectiveProvider: String?,
|
||||
)
|
||||
|
||||
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val runtime = root["runtime"] as? JsonObject ?: return null
|
||||
val requested = runtime["requested"] as? JsonObject
|
||||
val effective = runtime["effective"] as? JsonObject
|
||||
return ParsedApiModelLockAck(
|
||||
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun confirmedRuntimeMatches(
|
||||
runtime: JsonObject?,
|
||||
expected: ApiModelSelectionAck.Locked,
|
||||
): Boolean {
|
||||
runtime ?: return false
|
||||
val effective = runtime["effective"] as? JsonObject ?: return false
|
||||
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
|
||||
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
|
||||
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
} 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.
|
||||
*
|
||||
@@ -174,6 +447,8 @@ class HermesApiClient(
|
||||
isLenient = true
|
||||
}
|
||||
) {
|
||||
@Volatile
|
||||
private var lastCapabilities: ServerCapabilities? = null
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
@@ -199,8 +474,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 +725,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 +751,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 +765,208 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
|
||||
suspend fun getProviderModelOptions(
|
||||
refresh: Boolean = false,
|
||||
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val suffix = if (refresh) "?refresh=true" else ""
|
||||
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
if (response.code == 404) {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||
} else {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
|
||||
},
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
"Model inventory authorization failed (HTTP ${response.code})."
|
||||
} else {
|
||||
"Model inventory unavailable (HTTP ${response.code})."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
|
||||
?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory returned an invalid response.",
|
||||
),
|
||||
)
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and, on capable servers, persist a model/provider lock before a
|
||||
* session turn is submitted. This never writes global config.
|
||||
*/
|
||||
suspend fun acknowledgeSessionModelSelection(
|
||||
sessionId: String,
|
||||
model: String?,
|
||||
provider: String?,
|
||||
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
|
||||
val selectedModel = AgentDisplay.requestModelName(model)
|
||||
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
|
||||
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
|
||||
// Capability snapshots can be populated by a disconnected startup
|
||||
// probe. Re-probe at the lock boundary instead of trusting a stale
|
||||
// false forever after the connection recovers.
|
||||
val capabilities = probeCapabilities()
|
||||
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
|
||||
val inventory = getProviderModelOptions().getOrElse {
|
||||
return@withContext Result.failure(it)
|
||||
}
|
||||
val aliases = getModelOptions()
|
||||
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val providerModel = selectedRoot ?: selectedModel
|
||||
val providerRow = when {
|
||||
selectedProvider != null ->
|
||||
inventory.providers.firstOrNull { it.slug == selectedProvider }
|
||||
else -> inventory.providers.singleOrNull { providerModel in it.models }
|
||||
?: inventory.providers.firstOrNull {
|
||||
it.isCurrent && providerModel in it.models
|
||||
}
|
||||
} ?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not in the API server's authenticated inventory.",
|
||||
),
|
||||
)
|
||||
if (!providerRow.authenticated) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
|
||||
"The selected provider is not authenticated on this profile.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel !in providerRow.models) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not available from ${providerRow.name}.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel in providerRow.unavailableModels) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
"The selected model is not available on the authenticated account.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val body = kotlinx.serialization.json.buildJsonObject {
|
||||
put("model", selectedModel)
|
||||
put("provider", providerRow.slug)
|
||||
}
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
|
||||
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body.string()
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
streamHttpFailureMessage(
|
||||
response.code,
|
||||
response.message,
|
||||
response.header("Retry-After"),
|
||||
responseBody,
|
||||
json,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val ack = parseApiModelLockAck(json, responseBody)
|
||||
if (
|
||||
ack?.sessionId != sessionId ||
|
||||
ack?.model != selectedModel ||
|
||||
ack?.provider != providerRow.slug ||
|
||||
ack?.state != "accepted" ||
|
||||
ack?.effectiveModel.isNullOrBlank() ||
|
||||
ack?.effectiveProvider.isNullOrBlank()
|
||||
) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
|
||||
"Server did not acknowledge the requested model lock.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val confirmedAck = requireNotNull(ack)
|
||||
Result.success(
|
||||
ApiModelSelectionAck.Locked(
|
||||
sessionId = sessionId,
|
||||
model = selectedModel,
|
||||
provider = providerRow.slug,
|
||||
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
|
||||
effectiveProvider = confirmedAck.effectiveProvider,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
"Model lock request failed before the message was sent.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
|
||||
"Server advertises an incomplete model-routing contract.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (selectedProvider != null) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
|
||||
"This Hermes version cannot safely preserve a provider selection on API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val advertised = getModelOptions().map { it.id }
|
||||
if (selectedModel !in advertised) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"This Hermes version did not advertise the selected model for API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -593,6 +1089,7 @@ class HermesApiClient(
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
@@ -623,6 +1120,10 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||
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,10 +1137,17 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -715,9 +1223,17 @@ class HermesApiClient(
|
||||
}
|
||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||
"assistant.completed" -> {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
}
|
||||
} else if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
@@ -729,9 +1245,15 @@ class HermesApiClient(
|
||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
} else if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
@@ -741,7 +1263,13 @@ class HermesApiClient(
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
@@ -799,9 +1327,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) }
|
||||
}
|
||||
}
|
||||
@@ -809,12 +1348,19 @@ class HermesApiClient(
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server closed the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
@@ -876,6 +1422,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 +1435,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -941,9 +1491,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 +1517,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
@@ -1070,6 +1632,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 +1645,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -1246,9 +1812,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 +1838,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- Capability detection ---
|
||||
@@ -1319,7 +1897,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
if (!healthy) {
|
||||
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||
return@withContext ServerCapabilities.DISCONNECTED
|
||||
}
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
@@ -1333,7 +1914,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
if (advertisedCapabilities != null) {
|
||||
lastCapabilities = advertisedCapabilities
|
||||
return@withContext advertisedCapabilities
|
||||
}
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
@@ -1378,7 +1962,7 @@ class HermesApiClient(
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
).also { lastCapabilities = it }
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
||||
*
|
||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||
* consumes `message` (or `input`) and `system_message` (or
|
||||
* `instructions`, string only). `message` accepts either a plain string
|
||||
* or OpenAI-style content parts (text + `image_url`) via
|
||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
||||
* `model`, and `profile` are NOT parsed.
|
||||
* `instructions`, string only). Newer servers also parse per-request model
|
||||
* fields and reuse a backend-acknowledged session model lock when those
|
||||
* fields are omitted. Android therefore acknowledges a lock first and
|
||||
* omits `model` on that turn; the builder's model field remains only for
|
||||
* older-server compatibility. Top-level `messages`, `attachments`, and
|
||||
* `profile` are not parsed.
|
||||
*
|
||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||
* array), `instructions`, `conversation_history` (array of
|
||||
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
|
||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||
* Top-level `attachments` and `profile` are NOT parsed.
|
||||
*
|
||||
* Legacy hint fields we deliberately keep sending although current native
|
||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
||||
* `profile` on runs/completions, and `stream` on runs. They are
|
||||
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||
* configuration hints (never user content, so they cannot mask data
|
||||
* loss) honored by legacy fork builds — the runs path in particular only
|
||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
private const val NATIVE_PKCE_FLOW = "native_pkce"
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val TOKEN_KEY = "dashboard_native_tokens_json"
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
@Serializable
|
||||
data class NativeDashboardTokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String = "",
|
||||
@SerialName("expires_at") val expiresAt: Long = 0L,
|
||||
val provider: String = "",
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
)
|
||||
|
||||
interface NativeDashboardTokenStore {
|
||||
/** Stable, non-secret identity used to serialize refresh-token rotation. */
|
||||
val coordinationKey: String
|
||||
fun load(): NativeDashboardTokens?
|
||||
fun save(tokens: NativeDashboardTokens)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
|
||||
NativeTokenRefreshCoordinator.clear(store)
|
||||
}
|
||||
|
||||
class EncryptedNativeDashboardTokenStore(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = tokenStoreKey
|
||||
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
|
||||
buildRawTokenStore(context.applicationContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
override fun load(): NativeDashboardTokens? =
|
||||
store.getString(TOKEN_KEY)?.let { raw ->
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
|
||||
* its verifier and CSRF state must never be persisted, logged, or copied into
|
||||
* Compose/SavedState UI state.
|
||||
*/
|
||||
class NativeDashboardAuthorization internal constructor(
|
||||
val authorizationUrl: String,
|
||||
internal val verifier: String,
|
||||
internal val state: String,
|
||||
internal val generation: Long,
|
||||
)
|
||||
|
||||
class NativeDashboardAuthClient(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
) {
|
||||
private val baseUrl = baseUrl.trim().trimEnd('/')
|
||||
|
||||
fun supportsNativePkce(status: DashboardStatus): Boolean =
|
||||
NATIVE_PKCE_FLOW in status.authFlows
|
||||
|
||||
fun beginAuthorization(
|
||||
redirectUri: String,
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
|
||||
// trailing "=", which makes Hermes' standards-compliant S256
|
||||
// comparison fail even though both sides hashed the same bytes.
|
||||
val verifier = randomBytes(32).base64Url().trimEnd('=')
|
||||
val challenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val state = randomBytes(24).base64Url()
|
||||
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
|
||||
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val url = root.newBuilder()
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
.addQueryParameter("code_challenge_method", "S256")
|
||||
.addQueryParameter("redirect_uri", redirectUri)
|
||||
.addQueryParameter("state", state)
|
||||
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||
.build()
|
||||
.toString()
|
||||
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||
}
|
||||
|
||||
/**
|
||||
* A private-route dashboard may be configured with a canonical HTTPS
|
||||
* callback origin for its provider. Starting the browser on the private
|
||||
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
|
||||
* discover the provider's declared callback and start native auth there.
|
||||
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
|
||||
* to the active connection route.
|
||||
*/
|
||||
private fun resolveAuthorizationBaseUrl(provider: String?): String {
|
||||
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
|
||||
if (
|
||||
!provider.equals("nous", ignoreCase = true) ||
|
||||
configured.scheme != "http" ||
|
||||
!isPrivateNetworkLiteral(configured.host)
|
||||
) {
|
||||
return baseUrl
|
||||
}
|
||||
val loginUrl = configured.newBuilder()
|
||||
.addPathSegments("auth/login")
|
||||
.addQueryParameter("provider", provider)
|
||||
.addQueryParameter("next", "/")
|
||||
.build()
|
||||
val discoveryClient = client.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
val location = discoveryClient.newCall(
|
||||
Request.Builder().url(loginUrl).get().build(),
|
||||
).execute().use { response ->
|
||||
if (response.code !in 300..399) null else response.header("Location")
|
||||
}
|
||||
return canonicalDashboardBaseFromNousRedirect(location)
|
||||
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
|
||||
}
|
||||
|
||||
fun exchangeCallback(
|
||||
authorization: NativeDashboardAuthorization,
|
||||
callbackTarget: String,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val callback = callbackTarget.toHttpUrlOrNull()
|
||||
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
|
||||
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
|
||||
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
|
||||
throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not use the expected loopback path",
|
||||
)
|
||||
}
|
||||
if (callback.queryParameter("state") != authorization.state) {
|
||||
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
|
||||
}
|
||||
callback.queryParameter("error")?.let {
|
||||
throw NativeDashboardCallbackException(
|
||||
message = "Gateway rejected native sign-in",
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
val code = callback.queryParameter("code")
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not include an authorization code",
|
||||
)
|
||||
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
|
||||
return postTokens(
|
||||
path = "/auth/native/token",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = authorization.generation,
|
||||
commitAllowed = commitAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
|
||||
NativeTokenRefreshCoordinator.cancelAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
authorization.generation,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearStoredSession() {
|
||||
clearNativeDashboardTokens(tokenStore)
|
||||
}
|
||||
|
||||
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
|
||||
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load()
|
||||
?: tokens
|
||||
?: throw IOException("No native dashboard session is stored")
|
||||
// A sibling client may already have rotated the single-use refresh
|
||||
// token while this caller was waiting. Adopt that winner instead
|
||||
// of replaying the stale token.
|
||||
if (tokens != null && current != tokens) return@synchronized current
|
||||
if (current.refreshToken.isBlank()) {
|
||||
clearIfUnchanged(current)
|
||||
throw IOException("Native dashboard session cannot be refreshed")
|
||||
}
|
||||
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
|
||||
val generation = NativeTokenRefreshCoordinator.currentGeneration(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
try {
|
||||
postTokens(
|
||||
path = "/auth/native/refresh",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = generation,
|
||||
)
|
||||
} catch (error: NativeDashboardAuthHttpException) {
|
||||
if (error.statusCode == 400 || error.statusCode == 401) {
|
||||
clearIfUnchanged(current)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postTokens(
|
||||
path: String,
|
||||
payload: String,
|
||||
clearOnAuthFailure: Boolean,
|
||||
expectedGeneration: Long,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val url = "$baseUrl$path".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(payload.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val tokens = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
|
||||
tokenStore.clear()
|
||||
}
|
||||
throw NativeDashboardAuthHttpException(response.code)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
|
||||
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
|
||||
.also {
|
||||
if (it.accessToken.isBlank()) {
|
||||
throw IOException("Dashboard token response did not include an access token")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
if (!commitAllowed() ||
|
||||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
|
||||
expectedGeneration
|
||||
) {
|
||||
throw IOException("Dashboard sign-in is no longer active")
|
||||
}
|
||||
tokenStore.save(tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
|
||||
|
||||
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
|
||||
if (tokenStore.load() == expected) tokenStore.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requireStrictLoopbackRedirect(redirectUri: String) {
|
||||
val url = redirectUri.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
|
||||
require(url.scheme == "http" && url.host == "127.0.0.1") {
|
||||
"Native redirect must use the 127.0.0.1 loopback address"
|
||||
}
|
||||
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
|
||||
"Native redirect must use an ephemeral port and the exact /callback path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class NativeDashboardCallbackException(
|
||||
message: String,
|
||||
val retryable: Boolean = true,
|
||||
) : IOException(message)
|
||||
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(
|
||||
url.scheme == "http" &&
|
||||
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes already permits explicitly configured HTTP dashboard sessions on
|
||||
* local routes. The brokered flow is no less protected than that cookie flow,
|
||||
* but remains unavailable to arbitrary cleartext Internet hosts.
|
||||
*/
|
||||
private fun isPrivateNetworkLiteral(host: String): Boolean {
|
||||
val octets = host.split('.').mapNotNull(String::toIntOrNull)
|
||||
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
return first == 10 ||
|
||||
(first == 172 && second in 16..31) ||
|
||||
(first == 192 && second == 168) ||
|
||||
(first == 100 && second in 64..127)
|
||||
}
|
||||
|
||||
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
|
||||
val providerUrl = location?.toHttpUrlOrNull() ?: return null
|
||||
if (
|
||||
providerUrl.scheme != "https" ||
|
||||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val callback = providerUrl.queryParameter("redirect_uri")
|
||||
?.toHttpUrlOrNull()
|
||||
?: return null
|
||||
if (callback.scheme != "https") return null
|
||||
val callbackSuffix = "/auth/callback"
|
||||
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
|
||||
val basePath = callback.encodedPath
|
||||
.removeSuffix(callbackSuffix)
|
||||
.ifBlank { "/" }
|
||||
return callback.newBuilder()
|
||||
.encodedPath(basePath)
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the native bearer to dashboard REST calls and rotates it before expiry
|
||||
* or after one 401. Refresh requests use a separate bare client, so neither a
|
||||
* stale bearer nor the authenticator can recurse into token rotation.
|
||||
*/
|
||||
class DashboardBearerAuth(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||
) : Interceptor, Authenticator {
|
||||
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||
val request = tokens?.let {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${it.accessToken}")
|
||||
.build()
|
||||
} ?: chain.request()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
val previous = response.request.header("Authorization") ?: return null
|
||||
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
|
||||
val tokens = usableTokens(
|
||||
forceRefresh = true,
|
||||
failedAccessToken = failedAccessToken,
|
||||
) ?: return null
|
||||
val next = "Bearer ${tokens.accessToken}"
|
||||
if (next == previous) return null
|
||||
return response.request.newBuilder().header("Authorization", next).build()
|
||||
}
|
||||
|
||||
private fun usableTokens(
|
||||
forceRefresh: Boolean,
|
||||
failedAccessToken: String?,
|
||||
): NativeDashboardTokens? =
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load() ?: return@synchronized null
|
||||
// A request can receive its 401 after another client already
|
||||
// rotated the token. Retry with the winner; do not rotate again.
|
||||
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
|
||||
return@synchronized current
|
||||
}
|
||||
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
|
||||
if (!forceRefresh && !nearExpiry) return@synchronized current
|
||||
runCatching { authClient.refresh(current) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count += 1
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private class NativeDashboardAuthHttpException(
|
||||
val statusCode: Int,
|
||||
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||
|
||||
private object NativeTokenRefreshCoordinator {
|
||||
private val locks = ConcurrentHashMap<String, Any>()
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
|
||||
|
||||
fun currentGeneration(key: String): Long =
|
||||
synchronized(lockFor(key)) { generations[key] ?: 0L }
|
||||
|
||||
fun beginAuthorization(key: String): Long =
|
||||
synchronized(lockFor(key)) {
|
||||
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
|
||||
}
|
||||
|
||||
fun cancelAuthorization(key: String, expectedGeneration: Long) {
|
||||
synchronized(lockFor(key)) {
|
||||
if ((generations[key] ?: 0L) == expectedGeneration) {
|
||||
generations[key] = expectedGeneration + 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(store: NativeDashboardTokenStore) {
|
||||
synchronized(lockFor(store.coordinationKey)) {
|
||||
generations[store.coordinationKey] =
|
||||
(generations[store.coordinationKey] ?: 0L) + 1L
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenExchange(
|
||||
val code: String,
|
||||
@SerialName("code_verifier") val codeVerifier: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenRefresh(
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
val provider: String,
|
||||
)
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
private const val ACCEPT_POLL_MILLIS = 500
|
||||
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
|
||||
|
||||
internal enum class DashboardRedirectAuthMode {
|
||||
NativePkce,
|
||||
WebView,
|
||||
}
|
||||
|
||||
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
|
||||
if ("native_pkce" in authFlows) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
|
||||
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
|
||||
* gateway's brokered system-browser flow for Nous when it is advertised.
|
||||
*/
|
||||
internal fun androidDashboardRedirectAuthMode(
|
||||
providerName: String,
|
||||
authFlows: List<String>,
|
||||
): DashboardRedirectAuthMode =
|
||||
if (
|
||||
providerName.equals("nous", ignoreCase = true) &&
|
||||
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
|
||||
) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one native dashboard sign-in attempt.
|
||||
*
|
||||
* The listener and PKCE authorization are both local to [signIn], so leaving
|
||||
* the screen, cancellation, timeout, or callback completion closes the port
|
||||
* and discards verifier/state. Nothing secret enters Compose or saved state.
|
||||
*/
|
||||
class NativeDashboardSignInCoordinator(
|
||||
private val authClient: NativeDashboardAuthClient,
|
||||
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
|
||||
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
|
||||
) {
|
||||
suspend fun signIn(
|
||||
provider: String?,
|
||||
launchAuthorization: suspend (String) -> Unit,
|
||||
): NativeDashboardTokens =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(timeoutMillis) {
|
||||
serverSocketFactory().use { server ->
|
||||
server.reuseAddress = false
|
||||
server.bind(
|
||||
InetSocketAddress(
|
||||
InetAddress.getByName("127.0.0.1"),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
)
|
||||
server.soTimeout = ACCEPT_POLL_MILLIS
|
||||
check(server.inetAddress.hostAddress == "127.0.0.1") {
|
||||
"Native sign-in listener did not bind to IPv4 loopback"
|
||||
}
|
||||
|
||||
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
|
||||
val authorization = authClient.beginAuthorization(redirectUri, provider)
|
||||
val attemptContext = currentCoroutineContext()
|
||||
var completed = false
|
||||
try {
|
||||
launchAuthorization(authorization.authorizationUrl)
|
||||
awaitValidCallback(
|
||||
server = server,
|
||||
authorization = authorization,
|
||||
commitAllowed = { attemptContext.isActive },
|
||||
).also { completed = true }
|
||||
} finally {
|
||||
if (!completed) {
|
||||
authClient.cancelAuthorization(authorization)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw IOException("Dashboard sign-in timed out")
|
||||
}
|
||||
|
||||
private suspend fun awaitValidCallback(
|
||||
server: ServerSocket,
|
||||
authorization: NativeDashboardAuthorization,
|
||||
commitAllowed: () -> Boolean,
|
||||
): NativeDashboardTokens {
|
||||
while (true) {
|
||||
val callback = acceptCallback(server)
|
||||
val tokens = callback.use { socket ->
|
||||
if (socket.inetAddress.hostAddress != "127.0.0.1") {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "403 Forbidden",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
val target = try {
|
||||
readCallbackTarget(
|
||||
input = socket.getInputStream(),
|
||||
expectedPort = server.localPort,
|
||||
)
|
||||
} catch (_: IOException) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
try {
|
||||
authClient.exchangeCallback(
|
||||
authorization,
|
||||
target,
|
||||
commitAllowed = commitAllowed,
|
||||
).also {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "200 OK",
|
||||
body = "Sign-in complete. You can return to Hermes Relay.",
|
||||
)
|
||||
}
|
||||
} catch (error: NativeDashboardCallbackException) {
|
||||
if (error.retryable) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (tokens != null) return tokens
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acceptCallback(server: ServerSocket): Socket {
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
try {
|
||||
return server.accept().apply { soTimeout = 5_000 }
|
||||
} catch (_: SocketTimeoutException) {
|
||||
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
|
||||
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
|
||||
?: throw IOException("Native sign-in callback was empty")
|
||||
val requestParts = requestLine.split(' ')
|
||||
if (requestParts.size != 3 || requestParts[0] != "GET" ||
|
||||
!requestParts[1].startsWith("/") ||
|
||||
!requestParts[2].startsWith("HTTP/1.")
|
||||
) {
|
||||
throw IOException("Native sign-in callback request was malformed")
|
||||
}
|
||||
|
||||
var headerBytes = 0
|
||||
var host: String? = null
|
||||
while (true) {
|
||||
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
|
||||
?: throw IOException("Native sign-in callback headers were incomplete")
|
||||
headerBytes += line.length + 2
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Host:", ignoreCase = true)) {
|
||||
host = line.substringAfter(':').trim()
|
||||
}
|
||||
if (headerBytes >= MAX_HEADER_BYTES) {
|
||||
throw IOException("Native sign-in callback headers were too large")
|
||||
}
|
||||
}
|
||||
if (host != "127.0.0.1:$expectedPort") {
|
||||
throw IOException("Native sign-in callback host was not accepted")
|
||||
}
|
||||
return requestParts[1]
|
||||
}
|
||||
|
||||
private fun readAsciiLine(input: InputStream, limit: Int): String? {
|
||||
if (limit <= 0) throw IOException("Native sign-in callback was too large")
|
||||
val bytes = ArrayList<Byte>(minOf(limit, 128))
|
||||
var previous = -1
|
||||
while (bytes.size < limit) {
|
||||
val current = input.read()
|
||||
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
|
||||
"Native sign-in callback ended unexpectedly",
|
||||
)
|
||||
if (previous == '\r'.code && current == '\n'.code) {
|
||||
bytes.removeAt(bytes.lastIndex)
|
||||
return bytes.toByteArray().toString(Charsets.US_ASCII)
|
||||
}
|
||||
bytes += current.toByte()
|
||||
previous = current
|
||||
}
|
||||
throw IOException("Native sign-in callback line was too large")
|
||||
}
|
||||
|
||||
private fun writeResponse(socket: Socket, status: String, body: String) {
|
||||
val html = """
|
||||
<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body><p>${escapeHtml(body)}</p></body></html>
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||
val headers = buildString {
|
||||
append("HTTP/1.1 ").append(status).append("\r\n")
|
||||
append("Content-Type: text/html; charset=utf-8\r\n")
|
||||
append("Content-Length: ").append(html.size).append("\r\n")
|
||||
append("Cache-Control: no-store\r\n")
|
||||
append("Connection: close\r\n\r\n")
|
||||
}.toByteArray(Charsets.US_ASCII)
|
||||
runCatching {
|
||||
socket.getOutputStream().apply {
|
||||
write(headers)
|
||||
write(html)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeHtml(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/**
|
||||
* Parsed form of upstream's persisted user-image directives.
|
||||
*
|
||||
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
|
||||
* Unknown or malformed directives stay visible as text. Valid directives are
|
||||
* removed from the bubble so a host-local path is never exposed in the UI.
|
||||
*/
|
||||
internal data class PersistedImageReferences(
|
||||
val cleanedText: String,
|
||||
val paths: List<String>,
|
||||
)
|
||||
|
||||
internal object PersistedImageReferenceParser {
|
||||
private const val MAX_INPUT_CHARS = 256 * 1024
|
||||
private const val MAX_PATH_CHARS = 2_048
|
||||
private const val MAX_ATTACHMENTS = 8
|
||||
|
||||
private val imageExtensions = setOf(
|
||||
"avif",
|
||||
"bmp",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
)
|
||||
|
||||
fun parse(content: String): PersistedImageReferences {
|
||||
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
|
||||
return PersistedImageReferences(content, emptyList())
|
||||
}
|
||||
|
||||
val keptLines = ArrayList<String>()
|
||||
val paths = ArrayList<String>()
|
||||
|
||||
for (line in content.lines()) {
|
||||
val path = parseDirectiveLine(line)
|
||||
if (path == null) {
|
||||
keptLines += line
|
||||
} else if (paths.size < MAX_ATTACHMENTS) {
|
||||
paths += path
|
||||
}
|
||||
// Recognized refs beyond the attachment cap are still removed:
|
||||
// exposing a server-local path is worse than omitting an excessive
|
||||
// attachment from a deliberately bounded gallery.
|
||||
}
|
||||
|
||||
return PersistedImageReferences(
|
||||
cleanedText = keptLines.joinToString("\n").trim(),
|
||||
paths = paths,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDirectiveLine(line: String): String? {
|
||||
if (!line.startsWith("@image:")) return null
|
||||
val rawValue = line.removePrefix("@image:")
|
||||
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
|
||||
|
||||
val path = unwrapCanonicalValue(rawValue) ?: return null
|
||||
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
|
||||
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun unwrapCanonicalValue(value: String): String? {
|
||||
val first = value.first()
|
||||
if (first !in charArrayOf('`', '"', '\'')) {
|
||||
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
|
||||
}
|
||||
if (value.length < 3 || value.last() != first) return null
|
||||
val inner = value.substring(1, value.lastIndex)
|
||||
return inner.takeIf { first !in it }
|
||||
}
|
||||
|
||||
private fun isAbsolutePath(path: String): Boolean =
|
||||
path.startsWith("/") ||
|
||||
(
|
||||
path.length >= 3 &&
|
||||
path[0].isLetter() &&
|
||||
path[1] == ':' &&
|
||||
(path[2] == '\\' || path[2] == '/')
|
||||
)
|
||||
|
||||
private fun hasImageExtension(path: String): Boolean {
|
||||
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
|
||||
return extension in imageExtensions
|
||||
}
|
||||
}
|
||||
+269
-12
@@ -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
|
||||
@@ -32,15 +41,15 @@ import java.util.concurrent.TimeUnit
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||
* must carry the same exact-origin cookie or native bearer session used by
|
||||
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||
@@ -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
|
||||
@@ -56,14 +66,10 @@ class StandardHermesVoiceClient(
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
@@ -94,7 +100,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
@@ -108,6 +114,7 @@ class StandardHermesVoiceClient(
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
@@ -130,7 +137,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
@@ -148,10 +155,54 @@ 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 callClient = callClient(baseUrl)
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
|
||||
.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() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
private fun callClient(baseUrl: String): OkHttpClient =
|
||||
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
|
||||
|
||||
private fun executeJson(
|
||||
request: Request,
|
||||
operation: String,
|
||||
callClient: OkHttpClient,
|
||||
): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
@@ -242,3 +293,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()
|
||||
}
|
||||
|
||||
+7
-2
@@ -163,7 +163,8 @@ data class SessionItem(
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val resolvedLastActivity: Double?
|
||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||
@@ -259,6 +260,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
|
||||
@@ -389,7 +392,9 @@ data class HermesSseEvent(
|
||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||
val text: String? = null, // /v1/runs reasoning.available text
|
||||
// Usage/token fields (on assistant.completed / run.completed)
|
||||
val usage: UsageInfo? = null
|
||||
val usage: UsageInfo? = null,
|
||||
// Native session routing proof on run.started and terminal events.
|
||||
val runtime: JsonObject? = null,
|
||||
) {
|
||||
/** Resolve the event type from whichever field is populated. */
|
||||
val resolvedType: String?
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
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"
|
||||
private const val GROUP_KEY = "gateway-interactions"
|
||||
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."
|
||||
}
|
||||
|
||||
internal fun safeExpandedBody(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val action = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL ->
|
||||
"Review the requested action. Nothing is approved from the notification."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
|
||||
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
|
||||
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
|
||||
}
|
||||
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
|
||||
val sessionLabel = sessionId.takeLast(12)
|
||||
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
|
||||
}
|
||||
|
||||
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Review approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Answer"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Respond securely"
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
context: Context,
|
||||
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 expandedBody = safeExpandedBody(sessionId, ask, profile)
|
||||
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)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.setGroup(GROUP_KEY)
|
||||
.addAction(0, actionLabel(ask), tapPending)
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -108,6 +109,7 @@ import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -154,7 +156,6 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
@@ -183,8 +184,8 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
|
||||
|
||||
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||
// time to tap the action before it auto-dismisses.
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
||||
showSnackbar(
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||
return showSnackbar(
|
||||
message = err.body,
|
||||
actionLabel = err.actionLabel,
|
||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
@@ -274,13 +275,29 @@ 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)
|
||||
@@ -583,15 +600,9 @@ fun RelayApp() {
|
||||
val standardVoiceClient = remember {
|
||||
StandardHermesVoiceClient(
|
||||
context = mediaContext,
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.cookieJar(
|
||||
DynamicDashboardCookieJar {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
},
|
||||
)
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
dashboardHttpClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
|
||||
},
|
||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||
// Live read (null for the default profile) — sent defensively on
|
||||
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
||||
@@ -769,6 +780,9 @@ fun RelayApp() {
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
chatViewModel.setIsolatedProfileApiProvider {
|
||||
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
|
||||
}
|
||||
chatViewModel.setSessionProfileNameProvider {
|
||||
connectionViewModel.effectiveSessionProfileName.value
|
||||
}
|
||||
@@ -1445,7 +1459,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
|
||||
@@ -1456,12 +1471,13 @@ fun RelayApp() {
|
||||
// The VM's cached per-connection store — the prewarm must NOT
|
||||
// construct its own (each instance lazily pays a multi-second
|
||||
// Keystore keyset build under a process-global Tink lock).
|
||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
||||
?: return@LaunchedEffect
|
||||
prewarmDashboardManage(
|
||||
cookieStore = cookieStore,
|
||||
clientFactory = {
|
||||
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
|
||||
},
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
cacheDir = hydrateContext.cacheDir,
|
||||
context = hydrateContext,
|
||||
)
|
||||
@@ -1820,6 +1836,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"
|
||||
@@ -1844,6 +1870,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)
|
||||
|
||||
@@ -1853,6 +1921,14 @@ fun RelayApp() {
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
voicePresentationMode = VoicePresentationMode.fromStorage(
|
||||
voiceSettings.presentationMode,
|
||||
),
|
||||
onVoicePresentationModeChange = { mode ->
|
||||
connectionSwitchScope.launch {
|
||||
voicePreferences.setPresentationMode(mode)
|
||||
}
|
||||
},
|
||||
openAgentSheetOnEntry = openAgentSheetArg,
|
||||
onAgentSheetArgConsumed = {
|
||||
backStackEntry.arguments?.putBoolean(
|
||||
@@ -1870,6 +1946,16 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onRepairConnection = {
|
||||
navController.navigate(
|
||||
Screen.Pair.route(
|
||||
connectionId = activeConnectionId,
|
||||
autoStart = "relay",
|
||||
),
|
||||
) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Empty-chat "needs connection" card also offers the offline
|
||||
// demo, so a skipped / never-connected first run can explore
|
||||
// without leaving Chat. Safe here — this state only shows when
|
||||
@@ -2228,8 +2314,8 @@ fun RelayApp() {
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
dashboardUrl = voiceDashboardUrl,
|
||||
dashboardCookieStoreProvider = {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
dashboardClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardClientForActive(dashboardUrl)
|
||||
},
|
||||
onOpenManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
@@ -2567,6 +2653,17 @@ fun RelayApp() {
|
||||
onNavigateToRealtimeVoice = {
|
||||
navController.navigate(Screen.RealtimeVoiceTest.route)
|
||||
},
|
||||
onNavigateToImageGenerationLab = {
|
||||
terminalAppContext.startActivity(
|
||||
android.content.Intent().apply {
|
||||
setClassName(
|
||||
terminalAppContext,
|
||||
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.RealtimeVoiceTest.route) {
|
||||
|
||||
+12
-12
@@ -268,7 +268,6 @@ fun ActiveCardRelayStatusSection(
|
||||
@Composable
|
||||
fun ActiveCardFeaturesSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
@@ -349,21 +348,19 @@ fun ActiveCardFeaturesSection(
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
@@ -614,7 +611,6 @@ private fun CapabilityRow(
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
||||
onPairRelay: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -654,7 +650,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -731,7 +727,7 @@ fun ActiveCardAdvancedSection(
|
||||
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
||||
Text(stringResource(R.string.active_section_other_relay_methods))
|
||||
}
|
||||
if (relayEnabled && relayEditorOpen) {
|
||||
if (relayEditorOpen) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -746,7 +742,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled = true, showApi = false, showRelay = true)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = false, showRelay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,7 +794,6 @@ fun ActiveCardAdvancedSection(
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
showApi: Boolean,
|
||||
showRelay: Boolean,
|
||||
) {
|
||||
@@ -960,7 +955,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled && showRelay) {
|
||||
if (showRelay) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -2098,6 +2093,9 @@ fun ActiveCardRoutesSection(
|
||||
outcomeFor = { candidate ->
|
||||
routeProbeOutcomes[connectionViewModel.routeOutcomeKey(candidate)]
|
||||
},
|
||||
dashboardAuthenticated = connection.dashboardLastStatus?.authenticated,
|
||||
dashboardSignInRequired = connection.dashboardLastStatus?.authRequired == true &&
|
||||
connection.dashboardLastStatus.authenticated != true,
|
||||
preferredRole = preferredRole,
|
||||
manualOverrideRole = manualOverrideRole,
|
||||
onUseNow = { candidate -> connectionViewModel.useRouteNow(candidate.role) },
|
||||
@@ -2127,10 +2125,12 @@ fun ActiveCardRoutesSection(
|
||||
if (routeEditorOpen) {
|
||||
RouteEditorDialog(
|
||||
original = routeEditorOriginal,
|
||||
onSave = { role, apiUrl, onResult ->
|
||||
relayEnabled = connection.relayUrl.isNotBlank() ||
|
||||
endpoints.any { it.relay != null },
|
||||
onSave = { role, dashboardUrl, onResult ->
|
||||
connectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl,
|
||||
original = routeEditorOriginal,
|
||||
onResult = onResult,
|
||||
)
|
||||
|
||||
@@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -261,8 +262,12 @@ fun AgentTextFlow(
|
||||
motionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||
// text on both paths so assistive tech hears the words.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
|
||||
/**
|
||||
* Stable, presentation-only summary for a message's attachment group.
|
||||
*
|
||||
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
|
||||
* by the existing attachment pipeline; this helper only chooses the compact
|
||||
* label shown while that pipeline is folded away.
|
||||
*/
|
||||
internal data class AttachmentGroupSummary(
|
||||
val count: Int,
|
||||
val firstName: String?,
|
||||
val firstType: AttachmentRenderMode,
|
||||
val remainingCount: Int,
|
||||
)
|
||||
|
||||
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
|
||||
val first = attachments.firstOrNull() ?: return null
|
||||
return AttachmentGroupSummary(
|
||||
count = attachments.size,
|
||||
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
|
||||
firstType = first.renderMode,
|
||||
remainingCount = (attachments.size - 1).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack-style disclosure for all attachments belonging to one message.
|
||||
*
|
||||
* The fold state is saveable and keyed by the message's stable Compose
|
||||
* identity, so attachment lifecycle updates do not unexpectedly reopen a
|
||||
* group the user collapsed. The compact header always remains available,
|
||||
* making preview, retry, download, and file actions recoverable with one tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CollapsibleAttachmentGroup(
|
||||
messageKey: String,
|
||||
attachments: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val summary = attachmentGroupSummary(attachments) ?: return
|
||||
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
|
||||
val stateLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
|
||||
)
|
||||
val actionLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
|
||||
)
|
||||
val typeLabel = stringResource(summary.firstType.labelResource())
|
||||
val detail = when {
|
||||
summary.firstName != null && summary.remainingCount > 0 ->
|
||||
stringResource(
|
||||
R.string.attachment_group_named_more,
|
||||
summary.firstName,
|
||||
typeLabel,
|
||||
summary.remainingCount,
|
||||
)
|
||||
summary.firstName != null ->
|
||||
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
|
||||
summary.remainingCount > 0 ->
|
||||
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
|
||||
else -> typeLabel
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-toggle-$messageKey")
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = actionLabel
|
||||
role = Role.Button
|
||||
stateDescription = stateLabel
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.attachment_group_count,
|
||||
summary.count,
|
||||
summary.count,
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-content-$messageKey"),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
|
||||
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
|
||||
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
|
||||
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
|
||||
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
|
||||
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
|
||||
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
|
||||
}
|
||||
+1290
-34
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,6 @@ import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
|
||||
@@ -197,8 +196,6 @@ fun ConnectionWizard(
|
||||
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
@@ -621,7 +618,6 @@ fun ConnectionWizard(
|
||||
WizardStep.RelayChoice -> RelayChoiceStep(
|
||||
connectionLabel = activeConnection?.label.orEmpty(),
|
||||
dashboardUrl = currentDashboardUrl,
|
||||
relayEnabled = relayEnabled,
|
||||
onPickScan = {
|
||||
chosenMethod = PairMethod.Scan
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
@@ -638,7 +634,6 @@ fun ConnectionWizard(
|
||||
)
|
||||
|
||||
WizardStep.Method -> MethodStep(
|
||||
relayEnabled = relayEnabled,
|
||||
onPickStandard = {
|
||||
chosenMethod = PairMethod.Standard
|
||||
standardError = null
|
||||
@@ -1701,7 +1696,6 @@ private fun FoundCapabilityLine(label: String, value: String, ready: Boolean) {
|
||||
private fun RelayChoiceStep(
|
||||
connectionLabel: String,
|
||||
dashboardUrl: String,
|
||||
relayEnabled: Boolean,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
onPickShowCode: () -> Unit,
|
||||
@@ -1769,14 +1763,12 @@ private fun RelayChoiceStep(
|
||||
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1794,7 +1786,6 @@ private fun RelayChoiceStep(
|
||||
|
||||
@Composable
|
||||
private fun MethodStep(
|
||||
relayEnabled: Boolean,
|
||||
onPickStandard: () -> Unit,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
@@ -1931,14 +1922,12 @@ private fun MethodStep(
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
|
||||
if (onSkip != null) {
|
||||
TextButton(
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +50,7 @@ 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
|
||||
@@ -61,6 +65,7 @@ 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
|
||||
@@ -75,9 +80,9 @@ import kotlinx.coroutines.launch
|
||||
* on app start, tried first on every resolve; cleared by
|
||||
* [onClearPreferred].
|
||||
*
|
||||
* Verbose by design: per-route visibility is retained instead of collapsing
|
||||
* 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
|
||||
@@ -109,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.
|
||||
@@ -177,6 +185,13 @@ fun EndpointsCard(
|
||||
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,
|
||||
@@ -221,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,
|
||||
@@ -232,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)
|
||||
|
||||
@@ -280,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.primaryRouteUrl().orEmpty() +
|
||||
(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),
|
||||
@@ -309,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
|
||||
@@ -394,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) {
|
||||
@@ -438,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(
|
||||
@@ -533,19 +690,21 @@ 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
|
||||
@@ -618,12 +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 {
|
||||
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
|
||||
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
dashboardUrl = Connection.normalizeDashboardUrlInput(it),
|
||||
dashboardUrl = dashboardUrl,
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = if (relayEnabled) {
|
||||
apiUrl?.let(Connection::deriveDefaultRelayUrl)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -654,6 +821,9 @@ 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),
|
||||
|
||||
+1107
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -69,6 +67,11 @@ fun MarkdownContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
color = textColor,
|
||||
)
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
@@ -89,7 +92,8 @@ fun MarkdownContent(
|
||||
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
||||
// live font-picker still applies) and is capped so the largest heading is
|
||||
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings.
|
||||
// proportionate to the 15sp body, matching Discord / GitHub-mobile
|
||||
// in-message headings.
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
@@ -110,16 +114,17 @@ fun MarkdownContent(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||
color = textColor.copy(alpha = 0.85f),
|
||||
),
|
||||
// Prose, list items, and quotes all sit at the 14sp body size so a
|
||||
// paragraph and the bullet list under it share one rhythm — the library
|
||||
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
|
||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
quote = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
|
||||
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
|
||||
// The library default 'text'/list role is bodyLarge (16sp), while
|
||||
// bodyMedium was previously 14sp and unnecessarily small for long chat.
|
||||
paragraph = chatBodyStyle,
|
||||
text = chatBodyStyle,
|
||||
bullet = chatBodyStyle,
|
||||
ordered = chatBodyStyle,
|
||||
list = chatBodyStyle,
|
||||
quote = chatBodyStyle.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = textColor.copy(alpha = 0.9f),
|
||||
),
|
||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||
// + the tinted chip already signal "code" without also shrinking it, and
|
||||
@@ -127,7 +132,7 @@ fun MarkdownContent(
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = textColor,
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
@@ -152,7 +157,7 @@ fun MarkdownContent(
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
SafeMarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
@@ -160,7 +165,7 @@ fun MarkdownContent(
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownHighlightedCodeFence(
|
||||
SafeMarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
@@ -304,7 +309,10 @@ fun StreamingMarkdownContent(
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -138,6 +138,8 @@ fun MessageBubble(
|
||||
* happening.
|
||||
*/
|
||||
recoveringAnswer: Boolean = false,
|
||||
imageGenerationStylePreference: String = "rotate",
|
||||
imageGenerationRotationIndex: Int = 0,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -172,7 +174,7 @@ fun MessageBubble(
|
||||
|
||||
val textColor = when (message.role) {
|
||||
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
|
||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
|
||||
@@ -205,6 +207,28 @@ fun MessageBubble(
|
||||
extractChatInlineImages(message.content)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val hasImageGenerationCall = remember(message.toolCalls) {
|
||||
message.toolCalls.any {
|
||||
it.name.trim().lowercase() == "image_generate"
|
||||
}
|
||||
}
|
||||
val imageGenerationStartMillis = remember(message.toolCalls) {
|
||||
imageGenerationStartedAt(message.toolCalls)
|
||||
}
|
||||
val imageGenerationVisualStyle = remember(
|
||||
imageGenerationStylePreference,
|
||||
imageGenerationRotationIndex,
|
||||
) {
|
||||
resolveImageGenerationVisualStyle(
|
||||
preference = imageGenerationStylePreference,
|
||||
rotationIndex = imageGenerationRotationIndex,
|
||||
)
|
||||
}
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -291,6 +315,30 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && showThinking) {
|
||||
message.moaReferences.forEach { reference ->
|
||||
ThinkingBlock(
|
||||
thinkingContent = if (reference.available) {
|
||||
reference.text
|
||||
} else {
|
||||
"Advisor unavailable."
|
||||
},
|
||||
isStreaming = false,
|
||||
headerText = buildString {
|
||||
append("Advisor ")
|
||||
append(reference.index)
|
||||
reference.count?.let { append("/").append(it) }
|
||||
append(" · ")
|
||||
append(reference.label)
|
||||
},
|
||||
accessibilityLabel = "Mixture of Agents advisor response",
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message bubble.
|
||||
//
|
||||
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
||||
@@ -309,6 +357,7 @@ fun MessageBubble(
|
||||
val showBubble = isUser || isSystem ||
|
||||
message.content.isNotBlank() ||
|
||||
message.isStreaming ||
|
||||
showImageGeneration ||
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
@@ -420,7 +469,10 @@ fun MessageBubble(
|
||||
// Plain text for user and system messages
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
@@ -478,35 +530,78 @@ 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()) {
|
||||
// Image generation owns the bubble's progress slot. Keep the
|
||||
// selected progress treatment mounted under the real result,
|
||||
// then reveal the same collapsible attachment surface without
|
||||
// rebuilding the surrounding message bubble.
|
||||
if (hasImageGenerationCall) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
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) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
ImageGenerationResultTransition(
|
||||
generating = showImageGeneration,
|
||||
startedAtMillis = imageGenerationStartMillis,
|
||||
visualStyle = imageGenerationVisualStyle,
|
||||
) {
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator — only while awaiting the first token. Once
|
||||
@@ -514,7 +609,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.
|
||||
|
||||
+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
|
||||
|
||||
@@ -39,6 +39,8 @@ fun ThinkingBlock(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
headerText: String? = null,
|
||||
accessibilityLabel: String = "Thinking",
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
@@ -65,13 +67,13 @@ fun ThinkingBlock(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Psychology,
|
||||
contentDescription = "Thinking",
|
||||
contentDescription = accessibilityLabel,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isStreaming) "Thinking..." else "Thought process",
|
||||
text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -73,8 +74,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
@@ -146,7 +149,8 @@ fun VoiceModeOverlay(
|
||||
voiceOutputEnabled: Boolean? = null,
|
||||
voiceOutputFallbackEnabled: Boolean? = null,
|
||||
onOverlayRequest: () -> Unit = {},
|
||||
onCompactModeChange: (Boolean) -> Unit = {},
|
||||
presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||
// === END PHASE3-voice-mode-transcript ===
|
||||
// === v0.4.1 JIT permission-denied chip ===
|
||||
// Tapped when the user clicks the permission-denied chip. Default no-op
|
||||
@@ -159,22 +163,21 @@ fun VoiceModeOverlay(
|
||||
onBackgroundRunCancel: () -> Unit = {},
|
||||
onBackgroundRunTap: () -> Unit = {},
|
||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit =
|
||||
{ _, _, _ -> },
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit =
|
||||
{ _, _, _ -> },
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
var controlsExpanded by remember { mutableStateOf(false) }
|
||||
var focusMode by remember { mutableStateOf(true) }
|
||||
val focusMode = presentationMode == VoicePresentationMode.Focus
|
||||
val setFocusMode: (Boolean) -> Unit = { focused ->
|
||||
focusMode = focused
|
||||
onCompactModeChange(!focused)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.voiceMode) {
|
||||
if (!uiState.voiceMode) {
|
||||
setFocusMode(true)
|
||||
}
|
||||
onPresentationModeChange(
|
||||
if (focused) VoicePresentationMode.Focus else VoicePresentationMode.Conversation,
|
||||
)
|
||||
}
|
||||
|
||||
// Voice errors surface ONLY on the overlay's own inline top banner
|
||||
@@ -193,7 +196,7 @@ fun VoiceModeOverlay(
|
||||
// chips, pill) didn't handle so stray taps/swipes don't fall
|
||||
// through to the chat + session drawer behind it. Children run on
|
||||
// the same Main pass leaf-first, so this only catches the gaps.
|
||||
// In compact mode the overlay is intentionally transparent and the
|
||||
// In Conversation the overlay is intentionally transparent and the
|
||||
// chat stays interactive, so no scrim is installed.
|
||||
.then(
|
||||
if (focusMode) {
|
||||
@@ -404,6 +407,11 @@ fun VoiceModeOverlay(
|
||||
message = msg,
|
||||
showThinking = showThinking,
|
||||
expanded = msg.id == latestId || msg.isStreaming,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
@@ -418,6 +426,11 @@ fun VoiceModeOverlay(
|
||||
),
|
||||
showThinking = showThinking,
|
||||
expanded = true,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -449,7 +462,7 @@ fun VoiceModeOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// Compact mode: the background-run chip must survive outside focus
|
||||
// Conversation: the background-run chip must survive outside focus
|
||||
// mode too — a running task with no visible presence reads as lost
|
||||
// (the chip previously existed ONLY in the focus layout).
|
||||
AnimatedVisibility(
|
||||
@@ -821,6 +834,15 @@ private fun VoiceSessionPill(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!focusMode) {
|
||||
ConversationVoiceMicButton(
|
||||
uiState = uiState,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
|
||||
@@ -855,10 +877,9 @@ private fun VoiceSessionPill(
|
||||
.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Moved out of the collapsed header (4a): the current
|
||||
// interaction-mode pill plus the inline mic control. The
|
||||
// compact mic only appears in compact mode, where the
|
||||
// full-size bottom mic button is hidden.
|
||||
// The expanded body keeps the current interaction mode
|
||||
// visible; Conversation's persistent mic stays in the
|
||||
// collapsed header so it never disappears.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -866,15 +887,6 @@ private fun VoiceSessionPill(
|
||||
) {
|
||||
StatusPill(uiState.interactionMode.label())
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (!focusMode) {
|
||||
CompactVoiceMicButton(
|
||||
uiState = uiState,
|
||||
onMicTap = onMicTap,
|
||||
onMicRelease = onMicRelease,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -915,7 +927,11 @@ private fun VoiceSessionPill(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
|
||||
if (focusMode) {
|
||||
stringResource(R.string.voice_overlay_conversation)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_focus)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -958,7 +974,7 @@ private fun VoiceSessionPill(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactVoiceMicButton(
|
||||
private fun ConversationVoiceMicButton(
|
||||
uiState: VoiceUiState,
|
||||
onMicTap: () -> Unit,
|
||||
onMicRelease: () -> Unit,
|
||||
@@ -1224,6 +1240,9 @@ private fun CompactTranscriptRow(
|
||||
message: ChatMessage,
|
||||
showThinking: Boolean,
|
||||
expanded: Boolean,
|
||||
onViewConversation: () -> Unit,
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
|
||||
) {
|
||||
if (message.role == MessageRole.SYSTEM) return
|
||||
|
||||
@@ -1248,6 +1267,13 @@ private fun CompactTranscriptRow(
|
||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.secondary
|
||||
}
|
||||
val (markdownBody, inlineImages) = remember(message.content, message.role) {
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
extractChatInlineImages(message.content)
|
||||
} else {
|
||||
message.content to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1285,7 +1311,7 @@ private fun CompactTranscriptRow(
|
||||
}
|
||||
when {
|
||||
isVoiceActionBubble && hasText -> MarkdownContent(
|
||||
content = message.content,
|
||||
content = markdownBody,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
message.role == MessageRole.USER && hasText -> Text(
|
||||
@@ -1295,12 +1321,107 @@ private fun CompactTranscriptRow(
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
hasText -> Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
hasText -> MarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
message.cards.forEachIndexed { index, card ->
|
||||
if (card.actions.isNotEmpty() || card.input != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val cardKey = card.id ?: "idx:$index"
|
||||
HermesCardBubble(
|
||||
card = card,
|
||||
cardKey = cardKey,
|
||||
dispatches = message.cardDispatches,
|
||||
onActionTap = { key, action ->
|
||||
onCardAction(message.id, key, action)
|
||||
},
|
||||
onInputSubmit = { key, value ->
|
||||
onCardInput(message.id, key, value)
|
||||
},
|
||||
maxWidth = 360.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.any { it.actions.isEmpty() && it.input == null } ||
|
||||
inlineImages.isNotEmpty()
|
||||
) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
VoiceRichResultAffordance(
|
||||
message = message,
|
||||
onViewConversation = onViewConversation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceRichResultAffordance(
|
||||
message: ChatMessage,
|
||||
onViewConversation: () -> Unit,
|
||||
) {
|
||||
val inlineImages = remember(message.content) {
|
||||
extractChatInlineImages(message.content).second
|
||||
}
|
||||
val previewModel = remember(message.attachments, inlineImages) {
|
||||
message.attachments.firstOrNull { it.isImage && !it.cachedUri.isNullOrBlank() }?.cachedUri
|
||||
?: inlineImages.firstOrNull {
|
||||
it.src.startsWith("https://") || it.src.startsWith("http://")
|
||||
}?.src
|
||||
}
|
||||
val label = when {
|
||||
message.attachments.size + inlineImages.size > 1 ->
|
||||
stringResource(
|
||||
R.string.voice_overlay_rich_results_count,
|
||||
message.attachments.size + inlineImages.size,
|
||||
)
|
||||
message.attachments.isNotEmpty() || inlineImages.isNotEmpty() ->
|
||||
stringResource(R.string.voice_overlay_image_ready)
|
||||
else -> stringResource(R.string.voice_overlay_rich_result_ready)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onViewConversation),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.48f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (previewModel != null) {
|
||||
AsyncImage(
|
||||
model = previewModel,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_view_conversation),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
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
|
||||
@@ -25,11 +29,13 @@ import androidx.compose.material.icons.Icons
|
||||
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
|
||||
@@ -59,6 +65,7 @@ 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
|
||||
@@ -71,13 +78,42 @@ 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.permissions.AppPermissionStatusProbe
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
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)
|
||||
|
||||
@@ -125,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(),
|
||||
@@ -174,7 +212,11 @@ fun OnboardingScreen(
|
||||
|
||||
// The selected welcome frame has no toolbar. Later information
|
||||
// pages keep quiet navigation without reserving space above it.
|
||||
if (currentPage in 1 until lastPage) {
|
||||
if (
|
||||
currentPageType == OnboardingPage.Chat ||
|
||||
currentPageType == OnboardingPage.Manage ||
|
||||
currentPageType == OnboardingPage.Power
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -201,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(),
|
||||
@@ -216,18 +264,42 @@ 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 (currentPageType != OnboardingPage.Connect) {
|
||||
if (
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions
|
||||
) {
|
||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -627,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,
|
||||
|
||||
@@ -206,10 +206,16 @@ fun AboutScreen(
|
||||
val remaining = 7 - versionTapCount
|
||||
when {
|
||||
remaining <= 0 -> {
|
||||
scope.launch { FeatureFlags.unlockDevOptions(context) }
|
||||
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
|
||||
versionTapCount = 0
|
||||
onUnlockDeveloperOptions()
|
||||
scope.launch {
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
devUnlockedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onUnlockDeveloperOptions()
|
||||
}
|
||||
}
|
||||
remaining <= 3 -> {
|
||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||
|
||||
@@ -460,6 +460,7 @@ fun AppearanceSettingsScreen(
|
||||
) {
|
||||
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -526,6 +527,48 @@ fun AppearanceSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_image_generation_style),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_image_generation_style_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val imageStyleOptions = listOf(
|
||||
"rotate" to stringResource(R.string.appearance_image_generation_rotate),
|
||||
"grid" to stringResource(R.string.appearance_image_generation_grid),
|
||||
"sphere" to stringResource(R.string.appearance_image_generation_sphere),
|
||||
"nodes" to stringResource(R.string.appearance_image_generation_nodes),
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
imageStyleOptions.forEach { (id, label) ->
|
||||
FilterChip(
|
||||
selected = imageGenerationStyle == id,
|
||||
onClick = { connectionViewModel.setImageGenerationStyle(id) },
|
||||
label = { Text(label) },
|
||||
leadingIcon = if (imageGenerationStyle == id) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -138,6 +139,7 @@ import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -155,7 +157,9 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
||||
@@ -195,11 +199,15 @@ 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
|
||||
import com.hermesandroid.relay.util.HumanErrorAction
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlin.math.abs
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
@@ -223,6 +231,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
|
||||
@@ -259,6 +284,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
|
||||
previous.messageCount == messageCount &&
|
||||
previous.lastMessageUiKey == lastMessageUiKey
|
||||
|
||||
internal fun releaseRetainedLiveTail(
|
||||
retainedUiKey: String?,
|
||||
completedUiKey: String?,
|
||||
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
|
||||
|
||||
internal fun tailEndScrollOffset(
|
||||
tailSizePx: Int,
|
||||
footerSizePx: Int,
|
||||
viewportSizePx: Int,
|
||||
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
|
||||
|
||||
private class ChatTailTransitionRef(
|
||||
var snapshot: ChatScrollSnapshot? = null,
|
||||
)
|
||||
@@ -418,6 +454,8 @@ fun ChatScreen(
|
||||
voiceViewModel: VoiceViewModel,
|
||||
voiceClient: RelayVoiceClient? = null,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
||||
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
||||
// fires so the host can clear the nav arg (prevents re-open on tab
|
||||
@@ -430,6 +468,7 @@ fun ChatScreen(
|
||||
// don't wire navigation.
|
||||
onNavigateToConnections: () -> Unit = {},
|
||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||
onRepairConnection: () -> Unit = onNavigateToConnect,
|
||||
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
|
||||
// skipped / never-connected first run can explore without a server. null hides it.
|
||||
onTryDemo: (() -> Unit)? = null,
|
||||
@@ -444,9 +483,10 @@ fun ChatScreen(
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
||||
val chatAlpha by animateFloatAsState(
|
||||
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f,
|
||||
targetValue = if (
|
||||
voiceUiState.voiceMode && voicePresentationMode == VoicePresentationMode.Focus
|
||||
) 0.4f else 1f,
|
||||
animationSpec = tween(300),
|
||||
label = "chatAlpha",
|
||||
)
|
||||
@@ -456,7 +496,13 @@ fun ChatScreen(
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
LaunchedEffect(chatViewModel) {
|
||||
chatViewModel.errorEvents.collect { err ->
|
||||
snackbarHost.showHumanError(err)
|
||||
val result = snackbarHost.showHumanError(err)
|
||||
if (
|
||||
result == SnackbarResult.ActionPerformed &&
|
||||
err.action == HumanErrorAction.Repair
|
||||
) {
|
||||
onRepairConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,6 +570,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()
|
||||
@@ -551,11 +598,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()
|
||||
@@ -587,7 +635,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()
|
||||
@@ -640,9 +688,24 @@ fun ChatScreen(
|
||||
// Animation settings
|
||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
||||
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
||||
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
|
||||
val imageGenerationOrdinals = remember(messages) {
|
||||
var nextOrdinal = 0
|
||||
buildMap {
|
||||
messages.forEach { message ->
|
||||
val generationCount = message.toolCalls.count {
|
||||
it.name.trim().equals("image_generate", ignoreCase = true)
|
||||
}
|
||||
if (generationCount > 0) {
|
||||
put(message.uiKey, nextOrdinal + generationCount - 1)
|
||||
nextOrdinal += generationCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
||||
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
||||
// empty / new-chat view (no messages) — it teaches the long-press entry
|
||||
@@ -750,6 +813,25 @@ fun ChatScreen(
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||
remember(chatViewModel, context) {
|
||||
{ messageId, cardKey, action ->
|
||||
if (action.mode == HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
val handleCardInput: (String, String, String) -> Unit = remember(chatViewModel) {
|
||||
{ messageId, cardKey, value ->
|
||||
chatViewModel.answerAsk(messageId, cardKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
|
||||
// transient snackbar, never a chat bubble.
|
||||
@@ -847,7 +929,6 @@ fun ChatScreen(
|
||||
if (!voiceUiState.voiceMode) {
|
||||
voiceOverlayHost.hide()
|
||||
pendingVoiceOverlayPermission = false
|
||||
voiceCompactMode = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,10 +1173,12 @@ fun ChatScreen(
|
||||
derivedStateOf {
|
||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||
val settlingVisibleTail = completionSettlingUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == completionSettlingUiKey
|
||||
messages.isNotEmpty() &&
|
||||
!isAtBottom &&
|
||||
!programmaticBottomScroll &&
|
||||
!((isStreaming || retainingVisibleTail) &&
|
||||
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
|
||||
smoothAutoScroll &&
|
||||
!userScrolledAway)
|
||||
}
|
||||
@@ -1293,34 +1376,99 @@ fun ChatScreen(
|
||||
) {
|
||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
||||
// Retention is only a completion-transition aid. Never leave the
|
||||
// finalized tail on the plain streaming renderer just because the
|
||||
// user disabled follow-scroll or is reading above the bottom.
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var settledFrames = 0
|
||||
repeat(6) {
|
||||
var previousMarkdownTailSize: Int? = null
|
||||
var previousMarkdownFooterSize: Int? = null
|
||||
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
|
||||
repeat(60) completionFrame@{
|
||||
withFrameNanos { }
|
||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
}
|
||||
}
|
||||
return@completionFrame
|
||||
}
|
||||
} else {
|
||||
|
||||
settledFrames += 1
|
||||
if (settledFrames >= 2) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (settledFrames < 2) return@completionFrame
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
settledFrames = 0
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
// Once Markdown owns the row, position its measured trailing edge
|
||||
// explicitly. `canScrollForward` is insufficient here: LazyColumn
|
||||
// may preserve the leading edge of a tall item while reporting an
|
||||
// otherwise valid item anchor. Repeating catches deferred parsing,
|
||||
// highlighted code, and attachment measurement without competing
|
||||
// with the ordinary streaming-growth coroutine.
|
||||
val layout = listState.layoutInfo
|
||||
val tailIndex = messages.size // header item + zero-based messages
|
||||
val footerIndex = tailIndex + 1
|
||||
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
|
||||
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
|
||||
if (tailInfo == null) {
|
||||
listState.scrollToItem(tailIndex)
|
||||
settledFrames = 0
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
val viewportHeight = layout.viewportSize.height
|
||||
if (viewportHeight <= 0) return@completionFrame
|
||||
val desiredOffset = tailEndScrollOffset(
|
||||
tailSizePx = tailInfo.size,
|
||||
footerSizePx = footerInfo?.size ?: 0,
|
||||
viewportSizePx = viewportHeight,
|
||||
)
|
||||
if (desiredOffset == 0) {
|
||||
listState.scrollToItem(footerIndex)
|
||||
} else {
|
||||
listState.scrollToItem(tailIndex, desiredOffset)
|
||||
}
|
||||
val footerSize = footerInfo?.size ?: 0
|
||||
settledFrames = if (
|
||||
previousMarkdownTailSize == tailInfo.size &&
|
||||
previousMarkdownFooterSize == footerSize
|
||||
) {
|
||||
settledFrames + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
previousMarkdownTailSize = tailInfo.size
|
||||
previousMarkdownFooterSize = footerSize
|
||||
if (settledFrames >= 12) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
completionSettlingUiKey = null
|
||||
}
|
||||
|
||||
@@ -1583,12 +1731,16 @@ 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 {
|
||||
!headerChatReady -> 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 (headerChatReady) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -1721,6 +1873,10 @@ fun ChatScreen(
|
||||
transitionSpec = { loadedContentTransform() },
|
||||
label = "chatHeaderSubtitle",
|
||||
) { line ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1728,6 +1884,13 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1929,6 +2092,9 @@ fun ChatScreen(
|
||||
chatReady = chatReady,
|
||||
isLoadingHistory = isLoadingHistory,
|
||||
isLoadingSessions = isLoadingSessions,
|
||||
gatewayAvailability = chatGatewayAvailability,
|
||||
dashboardRouteMovedHint = dashboardRouteMovedHint,
|
||||
onNavigateToManage = onNavigateToManage,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -2243,30 +2409,17 @@ fun ChatScreen(
|
||||
isLastInGroup = isLastInGroup,
|
||||
retainStreamingLayout = retainLiveLayout,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
imageGenerationStylePreference = imageGenerationStyle,
|
||||
imageGenerationRotationIndex =
|
||||
imageGenerationOrdinals[message.uiKey] ?: 0,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = { msgId, cardKey, action ->
|
||||
// OPEN_URL is resolved at the UI layer
|
||||
// because launching ACTION_VIEW needs a
|
||||
// Context. Record the dispatch first so
|
||||
// the card collapses even if launch fails.
|
||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
@@ -2315,7 +2468,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-")) {
|
||||
@@ -2332,12 +2485,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)
|
||||
@@ -2347,12 +2509,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2680,7 +2844,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()
|
||||
@@ -2707,13 +2871,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)
|
||||
@@ -2781,13 +2948,26 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
|
||||
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
|
||||
add(
|
||||
ChatInputPickerOption(
|
||||
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
value = model.id,
|
||||
group = "Routes",
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
add(
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2932,7 +3112,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 ->
|
||||
@@ -2949,7 +3133,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 },
|
||||
)
|
||||
@@ -3090,14 +3278,13 @@ fun ChatScreen(
|
||||
voiceConfigScope = activeVoiceScope,
|
||||
voiceOutputEnabled = activeVoiceEnabled,
|
||||
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
presentationMode = voicePresentationMode,
|
||||
onPresentationModeChange = onVoicePresentationModeChange,
|
||||
onOverlayRequest = showVoiceSystemOverlay,
|
||||
// Gear button in the overlay's expanded controls. The overlay
|
||||
// exits voice mode before invoking this, so navigation lands
|
||||
// on Voice Settings with no overlay left on top.
|
||||
onOpenSettings = onNavigateToVoiceSettings,
|
||||
onCompactModeChange = { compact ->
|
||||
voiceCompactMode = compact
|
||||
},
|
||||
// === v0.4.1 JIT permission-denied chip ===
|
||||
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
||||
// Permissions for the running package. Use BuildConfig
|
||||
@@ -3121,6 +3308,8 @@ fun ChatScreen(
|
||||
onHermesConfirmationAnswer = { answer ->
|
||||
voiceViewModel.answerHermesConfirmation(answer)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
// === END v0.4.1 ===
|
||||
)
|
||||
}
|
||||
@@ -3226,6 +3415,9 @@ private fun ChatColdStartLoadingState(
|
||||
chatReady: Boolean,
|
||||
isLoadingHistory: Boolean,
|
||||
isLoadingSessions: Boolean,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
dashboardRouteMovedHint: String?,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -3288,14 +3480,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.
|
||||
|
||||
@@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
||||
@@ -110,9 +109,6 @@ fun ConnectionDetailScreen(
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
val connection = connections.firstOrNull { it.id == connectionId }
|
||||
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
||||
LaunchedEffect(connection == null) {
|
||||
@@ -275,7 +271,6 @@ fun ConnectionDetailScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
relayUiState = relayUiState,
|
||||
relayEnabled = relayEnabled,
|
||||
onReconnect = onReconnect,
|
||||
onRepair = { onRepair(connectionId) },
|
||||
onOpenApiInfo = { showApiInfoSheet = true },
|
||||
@@ -303,7 +298,6 @@ fun ConnectionDetailScreen(
|
||||
|
||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onPairRelay = { onRepair(connectionId) },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
@@ -423,7 +417,6 @@ private fun ActiveOverview(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
relayUiState: RelayUiState,
|
||||
relayEnabled: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
@@ -498,7 +491,6 @@ private fun ActiveOverview(
|
||||
|
||||
ActiveCardFeaturesSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
onOpenApiInfo = onOpenApiInfo,
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+620
-43
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,19 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -21,12 +22,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,11 +40,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
|
||||
@@ -49,10 +52,19 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
@@ -66,7 +78,9 @@ fun DashboardSignInScreen(
|
||||
onBack: () -> Unit,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
@@ -78,22 +92,22 @@ fun DashboardSignInScreen(
|
||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||
var actionInFlight by remember { mutableStateOf(false) }
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var actionIsError by remember { mutableStateOf(false) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var authFlows by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf<List<String>>(emptyList())
|
||||
}
|
||||
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
|
||||
val cookieStoreFactory = remember(context, connectionId) {
|
||||
val cookieStoreFactory = remember(appContext, connectionId) {
|
||||
{
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
||||
?: EncryptedDashboardCookieStore(appContext, connectionId)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||
@@ -115,7 +129,7 @@ fun DashboardSignInScreen(
|
||||
|
||||
fun finishAuthentication() {
|
||||
scope.launch {
|
||||
invalidateDashboardManageCache(context.cacheDir)
|
||||
invalidateDashboardManageCache(appContext.cacheDir)
|
||||
connectionViewModel.refreshStandardVoice()
|
||||
connectionViewModel.refreshDashboardProfiles()
|
||||
authenticationComplete = true
|
||||
@@ -125,18 +139,20 @@ fun DashboardSignInScreen(
|
||||
LaunchedEffect(dashboardUrl, connectionId) {
|
||||
if (dashboardUrl.isBlank()) {
|
||||
loading = false
|
||||
actionMessage = context.getString(R.string.dashboard_no_url_configured)
|
||||
actionMessage = resources.getString(R.string.dashboard_no_url_configured)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
actionMessage = it.message ?: resources.getString(R.string.dashboard_request_failed)
|
||||
actionIsError = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providers = status.authProviderDetails.ifEmpty {
|
||||
client.getAuthProviders().getOrNull().orEmpty()
|
||||
}
|
||||
providers = client.getAuthProviders().getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
authFlows = status.authFlows
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
@@ -157,6 +173,7 @@ fun DashboardSignInScreen(
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
actionIsError = false
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
@@ -166,10 +183,12 @@ fun DashboardSignInScreen(
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
?: resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionMessage = e.message ?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
client.shutdown()
|
||||
@@ -177,11 +196,76 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
if (
|
||||
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
|
||||
DashboardRedirectAuthMode.WebView
|
||||
) {
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
|
||||
actionInFlight = true
|
||||
actionIsError = false
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
|
||||
nativeSignInJob = scope.launch {
|
||||
try {
|
||||
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
launchNativeDashboardAuthorization(context, authorizationUrl)
|
||||
}
|
||||
}
|
||||
val client = clientFactory()
|
||||
val session = try {
|
||||
verifyAndRecord(client)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
actionMessage = session.provider?.let {
|
||||
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||
actionIsError = false
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
actionMessage = error.message
|
||||
?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
nativeSignInJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(dashboardUrl, connectionId) {
|
||||
onDispose { nativeSignInJob?.cancel() }
|
||||
}
|
||||
|
||||
oauthProvider?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
DashboardOAuthScreen(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
clientFactory = clientFactory,
|
||||
onDismiss = { oauthProvider = null },
|
||||
onAuthenticated = { session ->
|
||||
oauthProvider = null
|
||||
@@ -193,13 +277,17 @@ fun DashboardSignInScreen(
|
||||
client.shutdown()
|
||||
}
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
onError = { actionMessage = it },
|
||||
onError = {
|
||||
actionMessage = it
|
||||
actionIsError = true
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -235,8 +323,15 @@ fun DashboardSignInScreen(
|
||||
providers = providers,
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
actionIsError = actionIsError,
|
||||
nativeSignInInFlight = nativeSignInJob != null,
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = { oauthProvider = it },
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionIsError = false
|
||||
nativeSignInJob?.cancel()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,8 +396,11 @@ private fun DashboardSignInForm(
|
||||
providers: List<DashboardAuthProvider>,
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
actionIsError: Boolean,
|
||||
nativeSignInInFlight: Boolean,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
onCancelNativeSignIn: () -> Unit,
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -334,6 +432,14 @@ private fun DashboardSignInForm(
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
@@ -360,15 +466,25 @@ private fun DashboardSignInForm(
|
||||
}
|
||||
}
|
||||
actionMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (actionIsError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DashboardOAuthDialog(
|
||||
private fun DashboardOAuthScreen(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -381,6 +497,8 @@ private fun DashboardOAuthDialog(
|
||||
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
||||
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var pageProgress by remember { mutableStateOf(0) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||
DashboardApiClient.authLoginUrl(
|
||||
baseUrl = dashboardUrl,
|
||||
@@ -389,14 +507,17 @@ private fun DashboardOAuthDialog(
|
||||
)
|
||||
}
|
||||
|
||||
fun maybeVerify(url: String?) {
|
||||
fun handleNavigation(url: String?) {
|
||||
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||
val root = dashboardUrl.trim().trimEnd('/')
|
||||
val relative = loadedUrl.trim().removePrefix(root)
|
||||
val stillAuthenticating = relative.startsWith("/login", true) ||
|
||||
relative.startsWith("/auth/login", true) ||
|
||||
relative.startsWith("/auth/callback", true)
|
||||
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
|
||||
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
|
||||
DashboardWebViewAuthNavigation.Continue -> return
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
|
||||
statusText = notAcceptedStatus
|
||||
onError(notAcceptedStatus)
|
||||
return
|
||||
}
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
|
||||
}
|
||||
val manager = CookieManager.getInstance()
|
||||
manager.flush()
|
||||
val imported = importDashboardCookieHeader(
|
||||
@@ -408,10 +529,7 @@ private fun DashboardOAuthDialog(
|
||||
checking = true
|
||||
statusText = verifyingStatus
|
||||
scope.launch {
|
||||
val client = DashboardApiClient(
|
||||
dashboardUrl,
|
||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val session = client.currentSession().getOrNull()
|
||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||
@@ -429,39 +547,162 @@ private fun DashboardOAuthDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
|
||||
}
|
||||
Text(statusText, style = MaterialTheme.typography.bodySmall)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean = false
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
maybeVerify(url)
|
||||
}
|
||||
}
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.stopLoading()
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.dashboard_signin_with_provider,
|
||||
provider.displayName ?: provider.name,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (pageProgress in 0..99) {
|
||||
LinearProgressIndicator(
|
||||
progress = { pageProgress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||
pageProgress = newProgress
|
||||
}
|
||||
}
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val target = request.url.toString()
|
||||
if (
|
||||
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||
) {
|
||||
handleNavigation(target)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
if (request.isForMainFrame) {
|
||||
val message = error.description?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: verifyFailedStatus
|
||||
statusText = message
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
handleNavigation(url)
|
||||
}
|
||||
}
|
||||
webView = this
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class DashboardWebViewAuthNavigation {
|
||||
Continue,
|
||||
ImportAndVerify,
|
||||
RejectLoopbackCallback,
|
||||
}
|
||||
|
||||
/**
|
||||
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
|
||||
* loopback callback belongs to the desktop native-PKCE contract and must never
|
||||
* be followed, imported, or treated as an authenticated Android return.
|
||||
*/
|
||||
internal fun dashboardWebViewAuthNavigation(
|
||||
dashboardUrl: String,
|
||||
loadedUrl: String,
|
||||
): DashboardWebViewAuthNavigation {
|
||||
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
|
||||
?: return DashboardWebViewAuthNavigation.Continue
|
||||
val loaded = loadedUrl.trim().toHttpUrlOrNull()
|
||||
?: return DashboardWebViewAuthNavigation.Continue
|
||||
val sameOrigin = dashboard.scheme == loaded.scheme &&
|
||||
dashboard.host.equals(loaded.host, ignoreCase = true) &&
|
||||
dashboard.port == loaded.port
|
||||
if (!sameOrigin) {
|
||||
val foreignLoopback = loaded.scheme == "http" &&
|
||||
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
|
||||
loaded.encodedPath == "/callback"
|
||||
return if (foreignLoopback) {
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||
} else {
|
||||
DashboardWebViewAuthNavigation.Continue
|
||||
}
|
||||
}
|
||||
|
||||
val basePath = dashboard.encodedPath.trimEnd('/')
|
||||
val relativePath = loaded.encodedPath
|
||||
.removePrefix(basePath)
|
||||
.ifBlank { "/" }
|
||||
return if (
|
||||
relativePath.equals("/login", ignoreCase = true) ||
|
||||
relativePath.equals("/auth/login", ignoreCase = true)
|
||||
) {
|
||||
DashboardWebViewAuthNavigation.Continue
|
||||
} else {
|
||||
// Includes the public /auth/callback response: import its cookies at
|
||||
// root scope, then verify the resulting session through /api/auth/me.
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
||||
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
|
||||
* (clear session / wipe caches), and any experimental toggles.
|
||||
* unlock. Hosts experimental labs, test tools, and data-management utilities.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToRealtimeVoice: () -> Unit = {},
|
||||
onNavigateToImageGenerationLab: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Data management local state — unfolded from the private
|
||||
// DataManagementSection helper in the old SettingsScreen.
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetOnboarding { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RestartAlt,
|
||||
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Relay features toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = relayEnabled,
|
||||
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNavigateToImageGenerationLab) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = stringResource(
|
||||
R.string.dev_settings_open_image_generation_lab_cd
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Lock developer options
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch { FeatureFlags.lockDevOptions(context) }
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
scope.launch {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_locked_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
|
||||
onClick = {
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
if (json == null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -577,8 +601,17 @@ fun DeveloperSettingsScreen(
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetAppData { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
|
||||
|
||||
@@ -81,6 +81,7 @@ fun DiagnosticsScreen(
|
||||
val relayInfo by connectionViewModel.relayInfo.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()
|
||||
@@ -182,6 +183,18 @@ fun DiagnosticsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
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)
|
||||
@@ -191,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,
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
|
||||
internal fun launchNativeDashboardAuthorization(
|
||||
context: Context,
|
||||
authorizationUrl: String,
|
||||
) {
|
||||
val uri = Uri.parse(authorizationUrl)
|
||||
val customTab = CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
||||
.build()
|
||||
.also {
|
||||
if (context !is Activity) {
|
||||
it.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
customTab.launchUrl(context, uri)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -812,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()
|
||||
@@ -844,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
|
||||
@@ -863,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
@@ -22,11 +22,16 @@ import javax.net.ssl.SSLPeerUnverifiedException
|
||||
* showHumanError in RelayApp.kt.
|
||||
*/
|
||||
|
||||
enum class HumanErrorAction {
|
||||
Repair,
|
||||
}
|
||||
|
||||
data class HumanError(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val retryable: Boolean = false,
|
||||
val actionLabel: String? = null,
|
||||
val action: HumanErrorAction? = null,
|
||||
)
|
||||
|
||||
private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c ->
|
||||
@@ -116,6 +121,7 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
|
||||
body = "Your session is no longer valid — re-pair this device",
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||
action = HumanErrorAction.Repair,
|
||||
)
|
||||
"403" in msg || "forbidden" in msg -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
|
||||
@@ -227,6 +233,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
|
||||
@@ -259,6 +277,7 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
body = "The server certificate changed since you paired — re-pair to trust it",
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||
action = HumanErrorAction.Repair,
|
||||
)
|
||||
is SecurityException -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,14 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.DataManager
|
||||
import com.hermesandroid.relay.data.DemoContent
|
||||
import com.hermesandroid.relay.data.DemoMode
|
||||
import com.hermesandroid.relay.data.DashboardEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
@@ -62,11 +64,15 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardChatDisplaySettings
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.ToolsetInfo
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
|
||||
@@ -92,6 +98,7 @@ import com.hermesandroid.relay.viewmodel.connection.PairingController
|
||||
import com.hermesandroid.relay.viewmodel.connection.ProfileController
|
||||
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -100,12 +107,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -188,6 +195,45 @@ internal fun isChatTransportReady(
|
||||
internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
|
||||
connection?.capabilities?.anySurfaceConfigured == true
|
||||
|
||||
/**
|
||||
* Resolve the Dashboard/Gateway surface for the route the resolver selected.
|
||||
*
|
||||
* A saved dashboard URL describes the primary route; it must not pin every
|
||||
* runtime route to that host. When a LAN connection has an explicit dashboard
|
||||
* URL and the resolver selects Tailscale, keeping the saved LAN URL makes
|
||||
* Gateway sessions, Manage, and standard voice all appear offline even though
|
||||
* the selected Tailscale candidate is reachable.
|
||||
*/
|
||||
internal fun resolveEffectiveDashboardUrl(
|
||||
connection: Connection?,
|
||||
endpoint: EndpointCandidate?,
|
||||
): String {
|
||||
if (connection == null) return ""
|
||||
endpoint?.dashboard?.url
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
endpoint?.api?.url?.let { apiUrl ->
|
||||
connection.dashboardUrl
|
||||
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
|
||||
?.let { return it }
|
||||
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
|
||||
}
|
||||
return connection.resolvedDashboardUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime API route only after the optional fallback was explicitly
|
||||
* configured. Discovery may attach a conventional same-host API candidate to a
|
||||
* Dashboard route, but that candidate alone must not enable API traffic.
|
||||
*/
|
||||
internal fun resolveEffectiveApiServerUrl(
|
||||
savedUrl: String,
|
||||
endpoint: EndpointCandidate?,
|
||||
): String {
|
||||
if (savedUrl.isBlank()) return ""
|
||||
return endpoint?.api?.url?.takeIf { it.isNotBlank() } ?: savedUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Relay transport metadata to the connection's existing standard routes
|
||||
* without adopting the Relay QR's API/Dashboard identity.
|
||||
@@ -195,12 +241,65 @@ internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
|
||||
internal fun mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes: List<EndpointCandidate>,
|
||||
relayRoutes: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> = standardRoutes.map { standard ->
|
||||
val relay = relayRoutes
|
||||
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
|
||||
?.relay
|
||||
?: return@map standard
|
||||
standard.copy(relay = relay)
|
||||
): List<EndpointCandidate> {
|
||||
if (standardRoutes.isEmpty()) {
|
||||
return relayRoutes.sortedWith(
|
||||
compareBy<EndpointCandidate> { it.priority }.thenBy { it.role },
|
||||
)
|
||||
}
|
||||
|
||||
val standardRoles = standardRoutes.map { it.role.lowercase() }.toSet()
|
||||
val merged = standardRoutes.map { standard ->
|
||||
val paired = relayRoutes
|
||||
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
|
||||
?: return@map standard
|
||||
val sameHost = standard.routeHost()?.equals(paired.routeHost(), ignoreCase = true) == true
|
||||
// The Standard connection remains authoritative for any surface it
|
||||
// already configured. A Relay QR fills missing surfaces and refreshes
|
||||
// its own transport, so a manually-added dashboard-only Tailscale
|
||||
// route gains the QR's same-host API fallback instead of remaining
|
||||
// Gateway-only. Never graft a QR API from a different host.
|
||||
standard.copy(
|
||||
api = standard.api ?: paired.api.takeIf { sameHost },
|
||||
dashboard = standard.dashboard ?: paired.dashboard.takeIf { sameHost },
|
||||
relay = paired.relay ?: standard.relay,
|
||||
proxy = standard.proxy ?: paired.proxy,
|
||||
security = standard.security ?: paired.security,
|
||||
recommended = standard.recommended || paired.recommended,
|
||||
)
|
||||
}
|
||||
// Relay-only pairing used to map over Standard's existing roles and drop
|
||||
// every unmatched QR role. The common LAN-only Standard + LAN/Tailscale QR
|
||||
// therefore looked paired while it had no remote route. Preserve the full
|
||||
// signed QR route set, appending roles Standard did not know yet.
|
||||
val missingQrRoles = relayRoutes.filterNot {
|
||||
it.role.lowercase() in standardRoles
|
||||
}
|
||||
return (merged + missingQrRoles)
|
||||
.map(EndpointCandidate::withDerivedDashboard)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
private fun EndpointCandidate.withDerivedDashboard(): EndpointCandidate {
|
||||
if (dashboard != null) return this
|
||||
val derived = api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.let(::DashboardEndpoint)
|
||||
?: return this
|
||||
return copy(dashboard = derived)
|
||||
}
|
||||
|
||||
private fun EndpointCandidate.routeHost(): String? {
|
||||
val raw = primaryRouteUrl() ?: return null
|
||||
val normalized = when {
|
||||
raw.startsWith("ws://", ignoreCase = true) ->
|
||||
"http://${raw.substringAfter("://")}"
|
||||
raw.startsWith("wss://", ignoreCase = true) ->
|
||||
"https://${raw.substringAfter("://")}"
|
||||
else -> raw
|
||||
}
|
||||
return runCatching { URI(normalized).host }.getOrNull()
|
||||
}
|
||||
|
||||
/** Pure persistence policy used by Relay-only QR apply and its regression tests. */
|
||||
@@ -309,6 +408,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// Animation
|
||||
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
||||
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
|
||||
private val KEY_IMAGE_GENERATION_STYLE = stringPreferencesKey("image_generation_style")
|
||||
private val KEY_CHAT_RECENT_PROMPTS = booleanPreferencesKey("chat_recent_prompts")
|
||||
|
||||
// Chat scroll behavior
|
||||
@@ -542,7 +642,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
context = application,
|
||||
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
|
||||
dashboardUrlProvider = { activeDashboardUrl() },
|
||||
gatewayKeepAliveProvider = { gatewayKeepAlive.value },
|
||||
gatewayKeepAliveProvider = {
|
||||
gatewayKeepAlive.value || ActiveTurnKeepAliveRegistry.snapshot.value.required
|
||||
},
|
||||
// Lets the dashboard cookie store ride the connection's token keyset
|
||||
// (one keyset build instead of two on cold start).
|
||||
tokenStoreKeyProvider = { cid ->
|
||||
@@ -631,12 +733,25 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
/** Currently-active endpoint, for the Endpoints card. */
|
||||
val activeEndpoint = connectionManager.activeEndpoint
|
||||
|
||||
private fun activeRouteCandidatesSnapshot(): List<EndpointCandidate> {
|
||||
private suspend fun activeRouteCandidatesSnapshot(): List<EndpointCandidate> {
|
||||
val activeId = connectionStore.activeConnectionId.value ?: return emptyList()
|
||||
return connectionStore.connections.value
|
||||
val current = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeId }
|
||||
?.routeCandidates
|
||||
.orEmpty()
|
||||
?: return emptyList()
|
||||
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
|
||||
val pairedRoutes = deviceId?.let { id ->
|
||||
runCatching {
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), id).first()
|
||||
}.getOrDefault(emptyList())
|
||||
}.orEmpty()
|
||||
val recovered = mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes = current.routeCandidates,
|
||||
relayRoutes = pairedRoutes,
|
||||
)
|
||||
if (recovered != current.routeCandidates) {
|
||||
connectionStore.updateConnection(current.copy(routeCandidates = recovered))
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
private fun normalizeStandardRouteCandidates(
|
||||
@@ -676,12 +791,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
extraApiUrls = extraApiUrls,
|
||||
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
|
||||
),
|
||||
existing = activeConnection.value?.routeCandidates.orEmpty(),
|
||||
)
|
||||
|
||||
private fun effectiveApiServerUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.api?.url ?: _apiServerUrl.value
|
||||
resolveEffectiveApiServerUrl(
|
||||
savedUrl = _apiServerUrl.value,
|
||||
endpoint = connectionManager.activeEndpoint.value,
|
||||
)
|
||||
|
||||
private fun effectiveRelayUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
|
||||
@@ -757,7 +876,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||
|
||||
// --- API Server state ---
|
||||
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
|
||||
// Blank is the unhydrated sentinel. Seeding this with the legacy localhost
|
||||
// default made a discovered remote API candidate look explicitly configured
|
||||
// during the first DataStore frame, briefly building an unauthenticated
|
||||
// Sessions client before a Dashboard-only connection restored its saved
|
||||
// blank URL.
|
||||
private val _apiServerUrl = MutableStateFlow("")
|
||||
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
|
||||
|
||||
private val _apiServerReachable = MutableStateFlow(false)
|
||||
@@ -864,6 +988,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun activeDashboardCookieStore(): DashboardCookieStore? =
|
||||
upstreamTransport.activeDashboardCookieStore()
|
||||
|
||||
/** Trusted active-connection clients used by the shared dashboard sign-in route. */
|
||||
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient =
|
||||
upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
|
||||
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient =
|
||||
upstreamTransport.dashboardHttpClientForActive(dashboardUrl)
|
||||
|
||||
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
|
||||
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
@@ -923,17 +1057,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
||||
|
||||
/**
|
||||
* Runtime route for chat/API traffic. The persisted API URL remains the
|
||||
* connection's base config; a resolver-selected endpoint temporarily wins
|
||||
* so paired devices can roam between LAN, Tailscale, and operator VPN
|
||||
* routes without rewriting stored settings.
|
||||
* Runtime route for chat/API traffic. Once API fallback is explicitly
|
||||
* configured, a resolver-selected endpoint temporarily wins so paired
|
||||
* devices can roam without rewriting stored settings. Discovery alone
|
||||
* never enables the optional API surface.
|
||||
*/
|
||||
val effectiveApiServerUrl: StateFlow<String> = combine(
|
||||
_apiServerUrl,
|
||||
connectionManager.activeEndpoint,
|
||||
) { savedUrl, endpoint ->
|
||||
endpoint?.api?.url ?: savedUrl
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
|
||||
resolveEffectiveApiServerUrl(savedUrl, endpoint)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
/**
|
||||
* Whether a chat turn is currently streaming — mirrored from
|
||||
@@ -986,22 +1120,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
/**
|
||||
* Runtime dashboard route. Explicit dashboard overrides stay on the
|
||||
* persisted connection, but auto-managed dashboard URLs follow the active
|
||||
* API route so Manage can move from LAN to Tailscale with Chat.
|
||||
* Runtime dashboard route. A selected endpoint's Dashboard/Gateway URL
|
||||
* wins because it is part of that route just like API and Relay. When an
|
||||
* older candidate has only API metadata, auto-managed dashboards can still
|
||||
* derive the conventional Dashboard URL; otherwise the saved URL remains
|
||||
* the fallback.
|
||||
*/
|
||||
val effectiveDashboardUrl: StateFlow<String> = combine(
|
||||
activeConnection,
|
||||
connectionManager.activeEndpoint,
|
||||
) { connection, endpoint ->
|
||||
when {
|
||||
connection == null -> ""
|
||||
endpoint != null &&
|
||||
Connection.isAutoManagedDashboardUrl(connection.dashboardUrl, connection.apiServerUrl) ->
|
||||
endpoint.dashboard?.url
|
||||
?: endpoint.api?.url?.let(Connection::deriveDefaultDashboardUrl).orEmpty()
|
||||
else -> connection.resolvedDashboardUrl
|
||||
}
|
||||
resolveEffectiveDashboardUrl(connection, endpoint)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
/**
|
||||
@@ -1056,7 +1185,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* persisted URL. Dashboard session cookies are host-scoped, so a sign-in
|
||||
* performed on the LAN host does not authenticate the Tailscale host —
|
||||
* every dashboard-riding surface (Manage, standard voice) uses this to
|
||||
* say *which* route it's on and why sign-in state didn't follow.
|
||||
* say *which* route rejected the saved shared session.
|
||||
*/
|
||||
val dashboardRouteMovedHint: StateFlow<String?> = combine(
|
||||
effectiveDashboardUrl,
|
||||
@@ -1582,17 +1711,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
// Drive the keep-alive: flip the active client's no-background-close
|
||||
// flag and start/stop the foreground service. Both flavors — the
|
||||
// Drive the keep-alive from either the user's always-on preference or
|
||||
// work the user already started. Active-turn leases are session scoped,
|
||||
// so sibling turns release independently. Both flavors — the
|
||||
// GatewayKeepAliveService is declared in the main manifest (Play permits
|
||||
// this Home-Assistant-class persistent-connection use case). Mirrors
|
||||
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
||||
viewModelScope.launch {
|
||||
gatewayKeepAlive.collect { enabled ->
|
||||
upstreamTransport.applyGatewayKeepAlive(enabled)
|
||||
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
|
||||
persistent to turns
|
||||
}.distinctUntilChanged().collect { (persistent, turns) ->
|
||||
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
|
||||
val ctx = getApplication<Application>()
|
||||
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
|
||||
else runCatching { GatewayKeepAliveService.stop(ctx) }
|
||||
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1651,6 +1782,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val imageGenerationStyle: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_IMAGE_GENERATION_STYLE] ?: "rotate" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "rotate")
|
||||
|
||||
fun setAnimationEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
@@ -1683,6 +1818,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageGenerationStyle(value: String) {
|
||||
val normalized = value.takeIf {
|
||||
it in setOf("rotate", "grid", "sphere", "nodes")
|
||||
} ?: "rotate"
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_IMAGE_GENERATION_STYLE] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth auto-scroll during chat streaming.
|
||||
// When enabled, the chat list smoothly follows new tokens, tool cards, and
|
||||
// reasoning deltas as they stream in — but only while the user is at the
|
||||
@@ -1993,6 +2139,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private val _relayInfo = MutableStateFlow<RelayHttpClient.RelayInfo?>(null)
|
||||
val relayInfo: StateFlow<RelayHttpClient.RelayInfo?> = _relayInfo.asStateFlow()
|
||||
private val _toolsetInventory = MutableStateFlow<List<ToolsetInfo>?>(null)
|
||||
val toolsetInventory: StateFlow<List<ToolsetInfo>?> = _toolsetInventory.asStateFlow()
|
||||
private val _diagnosticsCheckedAt = MutableStateFlow<Long?>(null)
|
||||
val diagnosticsCheckedAt: StateFlow<Long?> = _diagnosticsCheckedAt.asStateFlow()
|
||||
private val _diagnosticsRefreshing = MutableStateFlow(false)
|
||||
@@ -2013,6 +2161,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
viewModelScope.launch {
|
||||
val info = relayHttpClient.fetchRelayInfo().getOrNull()
|
||||
_relayInfo.value = info
|
||||
// Toolsets are profile-scoped upstream. Use the same routed client
|
||||
// as Chat so Diagnostics cannot pair a selected profile's Relay
|
||||
// state with the base/default profile's tool inventory.
|
||||
_toolsetInventory.value = _chatApiClient.value?.getToolsets()?.getOrNull()
|
||||
relayHttpClient.fetchUpdateCheck().onSuccess { _relayUpdateInfo.value = it }
|
||||
_diagnosticsCheckedAt.value = System.currentTimeMillis()
|
||||
_diagnosticsRefreshing.value = false
|
||||
@@ -3799,6 +3951,24 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard/Gateway can move independently of the optional API
|
||||
// fallback (for example, a custom dashboard-only route). Do not carry
|
||||
// the previous host's availability or cookie-backed status into the
|
||||
// new route while its probe is in flight. collectLatest cancels an
|
||||
// obsolete probe if the resolver changes routes again.
|
||||
viewModelScope.launch {
|
||||
effectiveDashboardUrl
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
|
||||
_standardAudioApiReachable.value = false
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
probeStandardVoice()
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a route change that was deferred because a turn was streaming.
|
||||
// (StateFlow already conflates/dedups, so no distinctUntilChanged.)
|
||||
viewModelScope.launch {
|
||||
@@ -4093,10 +4263,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
return
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
mirrorDashboardSessionCookies(
|
||||
store = dashboardCookieStoreFor(connectionId),
|
||||
targetUrl = dashboardUrl.orEmpty(),
|
||||
trustedHosts = trustedDashboardHosts(connectionId),
|
||||
)
|
||||
}
|
||||
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
try {
|
||||
val status = client.getStatus().getOrNull()
|
||||
if (status == null) {
|
||||
updateDashboardTopology(connectionId, null)
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
|
||||
_standardAudioApiReachable.value = false
|
||||
_serverChatDisplaySettings.value = null
|
||||
@@ -4104,6 +4282,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
|
||||
return
|
||||
}
|
||||
updateDashboardTopology(connectionId, status)
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
val authed = !status.authRequired || session?.authenticated == true
|
||||
recordDashboardStatusIfChanged(connectionId, status, session)
|
||||
@@ -4125,8 +4304,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = ctx.getString(R.string.conn_status_voice_signin),
|
||||
detail = "Dashboard sessions are per-host — a sign-in from another " +
|
||||
"route does not carry over; sign in once via Manage on this one",
|
||||
detail = "The encrypted dashboard session could not be reused on this " +
|
||||
"trusted route; open Manage to refresh the session",
|
||||
url = dashboardUrl,
|
||||
)
|
||||
}
|
||||
@@ -4147,6 +4326,60 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
private fun trustedDashboardHosts(connectionId: String): Set<String> {
|
||||
val connection = connectionStore.connections.value
|
||||
.firstOrNull { it.id == connectionId }
|
||||
?: return emptySet()
|
||||
val urls = buildList {
|
||||
connection.dashboardUrl?.let(::add)
|
||||
Connection.deriveDefaultDashboardUrl(connection.apiServerUrl)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::add)
|
||||
connection.routeCandidates.forEach { candidate ->
|
||||
candidate.dashboard?.url?.let(::add)
|
||||
candidate.api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::add)
|
||||
}
|
||||
}
|
||||
return urls.mapNotNullTo(mutableSetOf()) { url ->
|
||||
runCatching { URI(url).host?.lowercase() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private var topologyConnectionId: String? = null
|
||||
private var topologyGatewayMode: String? = null
|
||||
private var topologyProfiles: List<String> = emptyList()
|
||||
|
||||
fun selectedProfileUsesIsolatedApiRoute(): Boolean {
|
||||
val profile = profileController.selectedProfile.value ?: return false
|
||||
if (profile.hasIsolatedApi) return true
|
||||
val activeConnectionId = connectionStore.activeConnectionId.value
|
||||
val persisted = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeConnectionId }
|
||||
?.dashboardLastStatus
|
||||
val liveTopology = topologyConnectionId == activeConnectionId
|
||||
val mode = if (liveTopology) topologyGatewayMode else persisted?.gatewayMode
|
||||
val profiles = if (liveTopology) topologyProfiles else persisted?.profiles.orEmpty()
|
||||
return mode.equals("multiplex", ignoreCase = true) && profile.name in profiles
|
||||
}
|
||||
|
||||
/** Keep chat routing synchronized with the latest public dashboard topology. */
|
||||
private suspend fun updateDashboardTopology(connectionId: String, status: DashboardStatus?) {
|
||||
val nextMode = status?.gatewayMode
|
||||
val nextProfiles = status?.profiles.orEmpty()
|
||||
val changed = topologyConnectionId != connectionId ||
|
||||
topologyGatewayMode != nextMode ||
|
||||
topologyProfiles != nextProfiles
|
||||
topologyConnectionId = connectionId
|
||||
topologyGatewayMode = nextMode
|
||||
topologyProfiles = nextProfiles
|
||||
if (changed && connectionStore.activeConnectionId.value == connectionId) {
|
||||
rebuildChatApiClient()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshChatDisplaySettings(
|
||||
client: DashboardApiClient,
|
||||
authenticated: Boolean,
|
||||
@@ -4178,7 +4411,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val materiallySame = previous != null &&
|
||||
previous.reachable == reachable &&
|
||||
previous.authRequired == status?.authRequired &&
|
||||
previous.authenticated == session?.authenticated
|
||||
previous.authenticated == session?.authenticated &&
|
||||
previous.gatewayMode == status?.gatewayMode &&
|
||||
previous.profiles == status?.profiles.orEmpty()
|
||||
if (!materiallySame) {
|
||||
recordDashboardStatus(status = status, session = session, reachable = reachable)
|
||||
}
|
||||
@@ -4455,17 +4690,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
} else {
|
||||
current.dashboardUrl
|
||||
}
|
||||
val newRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = newDashboardUrl,
|
||||
candidates = payload.endpoints.orEmpty(),
|
||||
)
|
||||
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
|
||||
current.relayUrl != newRelayUrl ||
|
||||
current.dashboardUrl != newDashboardUrl ||
|
||||
current.routeCandidates != payload.endpoints.orEmpty()
|
||||
current.routeCandidates != newRouteCandidates
|
||||
if (needsUpdate) {
|
||||
connectionStore.updateConnection(
|
||||
current.copy(
|
||||
apiServerUrl = payload.serverUrl,
|
||||
relayUrl = newRelayUrl,
|
||||
dashboardUrl = newDashboardUrl,
|
||||
routeCandidates = payload.endpoints.orEmpty(),
|
||||
routeCandidates = newRouteCandidates,
|
||||
preferredRouteRole = current.preferredRouteRole
|
||||
?.takeIf { preferred ->
|
||||
payload.endpoints.orEmpty().any {
|
||||
@@ -4696,6 +4935,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
current.copy(
|
||||
label = nextLabel,
|
||||
dashboardUrl = normalized,
|
||||
routeCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = normalized,
|
||||
candidates = current.routeCandidates.ifEmpty {
|
||||
listOfNotNull(
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = Connection.inferRouteRole(normalized),
|
||||
priority = 0,
|
||||
dashboardUrl = normalized,
|
||||
apiServerUrl = current.apiServerUrl
|
||||
.takeIf { it.isNotBlank() },
|
||||
relayUrl = current.relayUrl
|
||||
.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
probeStandardVoice()
|
||||
@@ -4723,6 +4978,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
authProvider = session?.provider,
|
||||
gatewayTicketAvailable = gatewayTicketAvailable,
|
||||
message = message,
|
||||
gatewayMode = status?.gatewayMode,
|
||||
profiles = status?.profiles.orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -4742,7 +4999,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
upstreamTransport.clearDashboardAuthentication(connectionId)
|
||||
}
|
||||
connectionStore.setDashboardStatus(
|
||||
connectionId = connectionId,
|
||||
@@ -4755,6 +5012,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
authProvider = null,
|
||||
gatewayTicketAvailable = false,
|
||||
message = "Dashboard session cleared",
|
||||
gatewayMode = active?.dashboardLastStatus?.gatewayMode,
|
||||
profiles = active?.dashboardLastStatus?.profiles.orEmpty(),
|
||||
),
|
||||
)
|
||||
probeStandardVoice()
|
||||
@@ -5255,9 +5514,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private suspend fun rebuildChatApiClient() {
|
||||
val baseApiUrl = ProfileApiUrlResolver.normalize(effectiveApiServerUrlSnapshot())
|
||||
val profileApiUrl = ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = profileController.selectedProfile.value?.apiServerUrl,
|
||||
val selectedProfile = profileController.selectedProfile.value
|
||||
val activeConnectionId = connectionStore.activeConnectionId.value
|
||||
val topology = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeConnectionId }
|
||||
?.dashboardLastStatus
|
||||
val liveTopology = topologyConnectionId == activeConnectionId
|
||||
val profileApiUrl = ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = selectedProfile?.apiServerUrl,
|
||||
baseApiUrl = baseApiUrl,
|
||||
selectedProfileName = selectedProfile?.name,
|
||||
gatewayMode = if (liveTopology) topologyGatewayMode else topology?.gatewayMode,
|
||||
servedProfiles = if (liveTopology) topologyProfiles else topology?.profiles.orEmpty(),
|
||||
)
|
||||
val baseClient = _apiClient.value
|
||||
val key = apiKeyForClientBuild()
|
||||
@@ -5401,19 +5669,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun observeDeviceEndpoints(): kotlinx.coroutines.flow.Flow<List<EndpointCandidate>> {
|
||||
return activeConnection.flatMapLatest { connection ->
|
||||
val savedRoutes = connection?.routeCandidates.orEmpty()
|
||||
if (savedRoutes.isNotEmpty()) {
|
||||
kotlinx.coroutines.flow.flowOf(savedRoutes)
|
||||
} else {
|
||||
kotlinx.coroutines.flow.flow {
|
||||
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
|
||||
if (deviceId == null) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
emitAll(
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId)
|
||||
)
|
||||
}
|
||||
kotlinx.coroutines.flow.flow {
|
||||
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
|
||||
val pairedRoutes = deviceId?.let { id ->
|
||||
runCatching {
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), id).first()
|
||||
}.getOrDefault(emptyList())
|
||||
}.orEmpty()
|
||||
emit(
|
||||
mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes = savedRoutes,
|
||||
relayRoutes = pairedRoutes,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5422,7 +5690,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* Add or replace an extra fallback route on the active connection — the
|
||||
* standard path's manual equivalent of a v3 pairing QR's `endpoints`
|
||||
* array. The primary route (priority 0) mirrors the connection's main
|
||||
* Dashboard/Gateway route and is edited through the connection detail,
|
||||
* Dashboard/Gateway address and is edited through the connection detail,
|
||||
* never here.
|
||||
*
|
||||
* Legacy candidate sources (per-device PairingPreferences from old QR
|
||||
@@ -5436,12 +5704,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
*/
|
||||
fun saveExtraRoute(
|
||||
role: String,
|
||||
apiUrl: String,
|
||||
dashboardUrl: String,
|
||||
original: EndpointCandidate? = null,
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
if (original?.priority == 0) {
|
||||
onResult("The primary route mirrors the connection's API URL — edit that instead")
|
||||
onResult("The primary route mirrors the connection's Dashboard/Gateway address — edit that instead")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -5452,7 +5720,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
// Accept bare hosts/IPs — http:// is assumed and the standard
|
||||
// Dashboard/Gateway port defaults to 9119.
|
||||
val trimmedUrl = Connection.normalizeDashboardUrlInput(apiUrl)
|
||||
val trimmedUrl = Connection.normalizeDashboardUrlInput(dashboardUrl)
|
||||
val existing = seedRouteCandidates(current)
|
||||
if (existing.isEmpty()) {
|
||||
onResult("Set the connection's Dashboard/Gateway URL first")
|
||||
@@ -5468,6 +5736,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
priority = original?.priority
|
||||
?: ((withoutOriginal.maxOfOrNull { it.priority } ?: 0) + 1),
|
||||
dashboardUrl = trimmedUrl,
|
||||
apiServerUrl = Connection.deriveDefaultApiUrl(trimmedUrl),
|
||||
relayUrl = if (
|
||||
current.relayUrl.isNotBlank() ||
|
||||
original?.relay != null ||
|
||||
existing.any { it.relay != null }
|
||||
) {
|
||||
Connection.deriveDefaultApiUrl(trimmedUrl)
|
||||
?.let(Connection::deriveDefaultRelayUrl)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
if (candidate == null) {
|
||||
onResult(
|
||||
@@ -5501,13 +5780,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/**
|
||||
* Remove an extra fallback route. The primary route (priority 0) is
|
||||
* protected — it mirrors the connection's API URL. Clears a preferred-
|
||||
* protected — it mirrors the connection's Dashboard/Gateway address. Clears a preferred-
|
||||
* route override that pointed at the removed route, mirroring
|
||||
* [persistActiveConnectionUrls]' stale-preference handling.
|
||||
*/
|
||||
fun removeExtraRoute(candidate: EndpointCandidate, onResult: (String?) -> Unit = {}) {
|
||||
if (candidate.priority == 0) {
|
||||
onResult("The primary route can't be removed — edit the connection's API URL instead")
|
||||
onResult("The primary route can't be removed — edit the connection's Dashboard/Gateway address instead")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -5553,12 +5832,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* Routes card is currently displaying is what an edit starts from.
|
||||
*/
|
||||
private suspend fun seedRouteCandidates(current: Connection): List<EndpointCandidate> {
|
||||
current.routeCandidates.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
val fromPairing = runCatching {
|
||||
val deviceId = authManager.getOrCreateDeviceId()
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId).first()
|
||||
}.getOrDefault(emptyList())
|
||||
if (fromPairing.isNotEmpty()) return fromPairing
|
||||
val recovered = mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes = current.routeCandidates,
|
||||
relayRoutes = fromPairing,
|
||||
)
|
||||
if (recovered.isNotEmpty()) return recovered
|
||||
val dashboardUrl = current.resolvedDashboardUrl.takeIf { it.isNotBlank() }
|
||||
?: return emptyList()
|
||||
return listOfNotNull(
|
||||
@@ -5858,16 +6140,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
) {
|
||||
val activeId = connectionStore.activeConnectionId.value ?: return
|
||||
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return
|
||||
val nextRouteCandidates = routeCandidates ?: current.routeCandidates
|
||||
val nextPreferredRouteRole = when {
|
||||
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
|
||||
routeCandidates != null &&
|
||||
current.preferredRouteRole != null &&
|
||||
nextRouteCandidates.none {
|
||||
it.role.equals(current.preferredRouteRole, ignoreCase = true)
|
||||
} -> null
|
||||
else -> current.preferredRouteRole
|
||||
}
|
||||
val nextDashboardUrl = when {
|
||||
dashboardUrlOverride != null -> {
|
||||
dashboardUrlOverride
|
||||
@@ -5881,6 +6153,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
else -> current.dashboardUrl
|
||||
}
|
||||
val nextRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = nextDashboardUrl,
|
||||
candidates = routeCandidates ?: current.routeCandidates,
|
||||
)
|
||||
val nextPreferredRouteRole = when {
|
||||
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
|
||||
routeCandidates != null &&
|
||||
current.preferredRouteRole != null &&
|
||||
nextRouteCandidates.none {
|
||||
it.role.equals(current.preferredRouteRole, ignoreCase = true)
|
||||
} -> null
|
||||
else -> current.preferredRouteRole
|
||||
}
|
||||
if (
|
||||
current.apiServerUrl == apiServerUrl &&
|
||||
current.relayUrl == relayUrl &&
|
||||
@@ -6113,50 +6398,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
fun resetOnboarding(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
dataManager.resetOnboarding()
|
||||
_onboardingCompleted.value = false
|
||||
val success = dataManager.resetOnboarding()
|
||||
if (success) {
|
||||
_onboardingCompleted.value = false
|
||||
}
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAppData() {
|
||||
fun resetAppData(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
val success = runCatching {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to reset app data", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(onResult: (String) -> Unit) {
|
||||
fun exportSettings(onResult: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val json = dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
val json = runCatching {
|
||||
dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to prepare settings backup", it)
|
||||
}.getOrNull()
|
||||
onResult(json)
|
||||
}
|
||||
}
|
||||
@@ -6178,24 +6475,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
// Apply imported settings
|
||||
if (backup.connections.isNotEmpty()) {
|
||||
val success = runCatching {
|
||||
val importedRelayUrl = if (backup.connections.isEmpty()) {
|
||||
backup.relayUrl ?: backup.serverUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A backup is a replacement snapshot, including when it
|
||||
// intentionally contains zero connections.
|
||||
dataManager.restoreConnectionBackup(backup)
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_THEME] = backup.theme
|
||||
importedRelayUrl?.let { preferences[KEY_RELAY_URL] = it }
|
||||
backup.apiServerUrl
|
||||
?.takeIf { backup.connections.isEmpty() }
|
||||
?.let { preferences[KEY_API_SERVER_URL] = it }
|
||||
}
|
||||
connectionStore.activeConnection.value?.let { restored ->
|
||||
restorePersistedActiveConnectionContext(restored)
|
||||
}
|
||||
} else {
|
||||
// Prefer v2 fields, fall back to v1 serverUrl for relay
|
||||
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
setTheme(backup.theme)
|
||||
if (backup.onboardingCompleted) {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
_onboardingCompleted.value = true
|
||||
}
|
||||
onResult(true)
|
||||
if (backup.connections.isEmpty()) {
|
||||
// Preserve v1/v2 compatibility after clearing the current
|
||||
// multi-connection snapshot.
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
check(dataManager.setOnboardingCompleted(backup.onboardingCompleted)) {
|
||||
"Failed to restore onboarding state"
|
||||
}
|
||||
_onboardingCompleted.value = backup.onboardingCompleted
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to import settings backup", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+106
@@ -4,12 +4,18 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardBearerAuth
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.clearNativeDashboardTokens
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -78,6 +84,10 @@ class UpstreamTransportController(
|
||||
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
|
||||
private val dashboardCookieStores =
|
||||
ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
|
||||
private val dashboardTokenStores =
|
||||
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
|
||||
private var dashboardHttpClientCache:
|
||||
Triple<String, String, okhttp3.OkHttpClient>? = null
|
||||
|
||||
/**
|
||||
* Cookie store for [connectionId] — ONE instance per connection,
|
||||
@@ -107,6 +117,28 @@ class UpstreamTransportController(
|
||||
return dashboardCookieStoreFor(connectionId)
|
||||
}
|
||||
|
||||
private fun dashboardTokenStoreFor(connectionId: String): EncryptedNativeDashboardTokenStore {
|
||||
val key = tokenStoreKeyProvider(connectionId)
|
||||
?: com.hermesandroid.relay.data.Connection.buildTokenStoreKey(connectionId)
|
||||
return dashboardTokenStores.getOrPut(connectionId) {
|
||||
EncryptedNativeDashboardTokenStore(context, key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bearerAuthForTrustedDashboard(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
): DashboardBearerAuth? {
|
||||
if (activeConnectionIdProvider() != connectionId) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
return trustedDashboardBearerAuthOrNull(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
tokenStoreProvider = { dashboardTokenStoreFor(connectionId) },
|
||||
)
|
||||
}
|
||||
|
||||
// --- DashboardApiClient factory ----------------------------------------
|
||||
|
||||
/**
|
||||
@@ -120,6 +152,7 @@ class UpstreamTransportController(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -133,9 +166,80 @@ class UpstreamTransportController(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let {
|
||||
bearerAuthForTrustedDashboard(it, dashboardUrl)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Native PKCE client for the active connection's exact trusted dashboard
|
||||
* base. Setup probes and stale routes never receive the encrypted bearer
|
||||
* store.
|
||||
*/
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? {
|
||||
val connectionId = activeConnectionIdProvider() ?: return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
return null
|
||||
}
|
||||
if (!com.hermesandroid.relay.network.upstream.sameDashboardBase(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return NativeDashboardAuthClient(
|
||||
baseUrl = dashboardUrl,
|
||||
tokenStore = dashboardTokenStoreFor(connectionId),
|
||||
)
|
||||
}
|
||||
|
||||
/** Exact-origin authenticated HTTP client for non-REST dashboard consumers such as voice. */
|
||||
@Synchronized
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient {
|
||||
val connectionId = activeConnectionIdProvider() ?: "unassociated"
|
||||
dashboardHttpClientCache?.let { (cachedConnection, cachedUrl, client) ->
|
||||
if (cachedConnection == connectionId && cachedUrl == dashboardUrl) return client
|
||||
disposeDashboardHttpClient(client)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
return DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
|
||||
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
|
||||
},
|
||||
).also { dashboardHttpClientCache = Triple(connectionId, dashboardUrl, it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearDashboardAuthentication(connectionId: String) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
clearNativeDashboardTokens(dashboardTokenStoreFor(connectionId))
|
||||
dashboardHttpClientCache
|
||||
?.takeIf { it.first == connectionId }
|
||||
?.third
|
||||
?.let(::disposeDashboardHttpClient)
|
||||
if (dashboardHttpClientCache?.first == connectionId) {
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
gatewayClientCache
|
||||
?.takeIf { it.first == connectionId }
|
||||
?.third
|
||||
?.shutdown()
|
||||
if (gatewayClientCache?.first == connectionId) {
|
||||
gatewayClientCache = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
runCatching { client.cache?.close() }
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
|
||||
// --- Gateway availability ----------------------------------------------
|
||||
|
||||
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||
@@ -226,6 +330,8 @@ class UpstreamTransportController(
|
||||
synchronized(this) {
|
||||
gatewayClientCache?.third?.shutdown()
|
||||
gatewayClientCache = null
|
||||
dashboardHttpClientCache?.third?.let(::disposeDashboardHttpClient)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,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>
|
||||
@@ -64,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>
|
||||
@@ -90,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>
|
||||
@@ -330,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>
|
||||
@@ -619,11 +631,11 @@
|
||||
<string name="settings_profile_lock_desc">Fixe o app em um perfil de agente</string>
|
||||
<string name="settings_quick_controls">Controles rápidos</string>
|
||||
<string name="settings_persistent_connection">Conexão persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantém sua conexão com o Hermes aberta em segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</string>
|
||||
<string name="settings_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_persistent_connection_desc">Manter a conexão mesmo sem nenhum chat em andamento</string>
|
||||
<string name="settings_connect_on_demand">Chats ativos permanecem conectados automaticamente · a conexão ociosa é fechada para economizar bateria</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
|
||||
<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>
|
||||
@@ -652,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>
|
||||
@@ -1118,7 +1130,7 @@
|
||||
<string name="dev_settings_relay_features">Recursos do Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar configurações do Servidor Relay e de pareamento para desenvolvimento do Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratório de voz em tempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abrir a bancada de testes do WebSocket do provedor em versões de desenvolvimento</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Verifique a configuração de voz em tempo real e execute um teste de microfone</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratório de voz em tempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opções do desenvolvedor</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta seção e desativar recursos experimentais</string>
|
||||
@@ -1149,6 +1161,7 @@
|
||||
<string name="dev_settings_export_failed">Falha na exportação</string>
|
||||
<string name="dev_settings_imported">Configurações importadas</string>
|
||||
<string name="dev_settings_import_failed">Falha na importação — arquivo inválido</string>
|
||||
<string name="dev_settings_reset_failed">Falha ao redefinir</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Exportar backup sensível?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Este backup inclui conexões salvas, chaves da API, tokens de sessão do relay, IDs de dispositivos e cookies do painel. Qualquer pessoa com o arquivo poderá acessar seu servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -1412,7 +1425,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>
|
||||
@@ -1489,6 +1502,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Salvar agente em tempo real</string>
|
||||
<string name="voice_settings_global_controls_title">Controles globais de voz</string>
|
||||
<string name="voice_settings_global_controls_desc">Estas configurações se aplicam aos dois mecanismos de voz em todos os perfis.</string>
|
||||
<string name="voice_settings_final_answer_only">Somente a resposta final</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Fala apenas a resposta concluída. O progresso das ferramentas, as atualizações de serviço e os comentários intermediários permanecem visuais.</string>
|
||||
<string name="voice_settings_interaction_mode">Modo de interação</string>
|
||||
<string name="voice_settings_interaction_tap">Tocar para falar</string>
|
||||
<string name="voice_settings_interaction_hold">Manter pressionado para falar</string>
|
||||
@@ -1642,7 +1657,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>
|
||||
@@ -1688,6 +1703,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>
|
||||
@@ -1958,8 +1998,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Recolher controles de voz</string>
|
||||
<string name="voice_overlay_expand_cd">Expandir controles de voz</string>
|
||||
<string name="voice_overlay_exit_cd">Sair do modo de voz</string>
|
||||
<string name="voice_overlay_compact">Compacto</string>
|
||||
<string name="voice_overlay_focus">Foco</string>
|
||||
<string name="voice_overlay_conversation">Conversa</string>
|
||||
<string name="voice_overlay_image_ready">Imagem pronta</string>
|
||||
<string name="voice_overlay_rich_result_ready">Resultado avançado pronto</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d resultados prontos</string>
|
||||
<string name="voice_overlay_view_conversation">Ver conversa</string>
|
||||
<string name="voice_overlay_overlay">Sobreposição</string>
|
||||
<string name="voice_overlay_exit">Sair</string>
|
||||
<string name="voice_overlay_settings_cd">Configurações de voz</string>
|
||||
@@ -1984,7 +2028,7 @@
|
||||
<string name="active_section_api_key_already_set">A chave da API já está definida</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>
|
||||
@@ -2101,6 +2145,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma na próxima mensagem</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuração ativa</string>
|
||||
<string name="conn_info_inherited">Herdado</string>
|
||||
<string name="conn_info_bypassed">Ignorado</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidade</string>
|
||||
<string name="conn_info_agent_passport">Passaporte do agente</string>
|
||||
<string name="conn_info_chat_override">Substituição do chat</string>
|
||||
<string name="conn_info_fast_tier">Nível rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">As aprovações já são ignoradas pelo perfil.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar novo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Escolha como as aprovações são aplicadas.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprovação</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexão</string>
|
||||
<string name="conn_info_connection_state">Estado da conexão</string>
|
||||
@@ -2343,6 +2400,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 -->
|
||||
@@ -2427,6 +2485,23 @@
|
||||
<string name="attachment_unmute">Ativar som</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Anexo</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d anexo</item>
|
||||
<item quantity="other">%1$d anexos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Recolher anexos</string>
|
||||
<string name="attachment_group_expand">Expandir anexos</string>
|
||||
<string name="attachment_group_collapsed">Recolhido</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagem</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Áudio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Arquivo</string>
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
|
||||
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
|
||||
@@ -3060,7 +3135,7 @@
|
||||
<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 e insira aqui o mesmo valor.</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>
|
||||
@@ -3128,4 +3203,42 @@
|
||||
<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>
|
||||
<string name="dashboard_component_connected">conectado</string>
|
||||
<string name="dashboard_component_server_errors_5m">erros do servidor / 5 min</string>
|
||||
<string name="appearance_image_generation_style">Geração de imagens</string>
|
||||
<string name="appearance_image_generation_style_desc">Alterne entre as três animações de progresso ou mantenha um estilo em todas as gerações.</string>
|
||||
<string name="appearance_image_generation_rotate">Alternar</string>
|
||||
<string name="appearance_image_generation_grid">Grade</string>
|
||||
<string name="appearance_image_generation_sphere">Esfera</string>
|
||||
<string name="appearance_image_generation_nodes">Nós</string>
|
||||
<string name="dev_settings_image_generation_lab">Laboratório de geração de imagens</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Visualize os ciclos de geração e a revelação final da imagem</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Abrir o laboratório de geração de imagens</string>
|
||||
<string name="dashboard_native_signin_opening">Conclua o login no navegador e volte aqui.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Login pelo navegador cancelado.</string>
|
||||
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
|
||||
<string name="conn_info_approval_mode_desc">Política persistente para este perfil do Hermes. Aplica-se a todos os chats e dispositivos.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Atualize o Hermes para escolher um modo de aprovação do perfil. O YOLO por chat continuará disponível.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Somente leitura para este perfil multiplexado. O modo atual aparece após o início da sessão do perfil; alterá-lo exige RPCs de configuração por perfil do upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Perguntar antes de cada chamada de ferramenta protegida.</string>
|
||||
<string name="conn_info_approval_mode_smart">Inteligente</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Perguntar apenas quando o Hermes detectar risco elevado.</string>
|
||||
<string name="conn_info_approval_mode_off">Desativado</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Ignorar permanentemente as aprovações deste perfil.</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,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>
|
||||
@@ -70,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 返回标签 -->
|
||||
@@ -101,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>
|
||||
@@ -353,7 +365,7 @@
|
||||
<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>
|
||||
@@ -660,11 +672,11 @@
|
||||
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
|
||||
<string name="settings_quick_controls">快捷控制</string>
|
||||
<string name="settings_persistent_connection">持久连接</string>
|
||||
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
|
||||
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
|
||||
<string name="settings_turn_complete_alerts">回合完成提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">应用在后台时回复完成则通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">后台回复完成时不提醒</string>
|
||||
<string name="settings_persistent_connection_desc">即使没有聊天运行也保持连接</string>
|
||||
<string name="settings_connect_on_demand">活跃聊天会自动保持连接 · 空闲时关闭连接以节省电量</string>
|
||||
<string name="settings_turn_complete_alerts">聊天提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</string>
|
||||
<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>
|
||||
@@ -694,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>
|
||||
@@ -1173,7 +1185,7 @@
|
||||
<string name="dev_settings_relay_features">Relay 功能</string>
|
||||
<string name="dev_settings_relay_features_desc">显示 Relay 服务器和配对设置,用于 Bridge/Terminal 开发</string>
|
||||
<string name="dev_settings_realtime_voice_lab">实时语音实验室</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">为开发构建打开 provider websocket 测试台</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">检查实时语音设置并运行专项麦克风测试</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">打开实时语音实验室</string>
|
||||
<string name="dev_settings_lock_dev_options">锁定开发者选项</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">隐藏此分区并禁用实验性功能</string>
|
||||
@@ -1204,6 +1216,7 @@
|
||||
<string name="dev_settings_export_failed">导出失败</string>
|
||||
<string name="dev_settings_imported">设置已导入</string>
|
||||
<string name="dev_settings_import_failed">导入失败——文件无效</string>
|
||||
<string name="dev_settings_reset_failed">重置失败</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">导出敏感备份?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">此备份包含已保存的连接、API 密钥、Relay 会话令牌、设备 ID 和仪表盘 Cookie。任何持有该文件的人都可能访问您的 Hermes 服务器。</string>
|
||||
<string name="dev_settings_export_action">导出</string>
|
||||
@@ -1473,7 +1486,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>
|
||||
@@ -1550,6 +1563,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">保存实时 Agent</string>
|
||||
<string name="voice_settings_global_controls_title">全局语音控制</string>
|
||||
<string name="voice_settings_global_controls_desc">这些设置适用于所有个人资料上的两个语音引擎。</string>
|
||||
<string name="voice_settings_final_answer_only">仅朗读最终答案</string>
|
||||
<string name="voice_settings_final_answer_only_desc">只朗读最终确定的答案。工具进度、服务更新和中间评论仍仅以视觉方式显示。</string>
|
||||
<string name="voice_settings_interaction_mode">交互模式</string>
|
||||
<string name="voice_settings_interaction_tap">点击说话</string>
|
||||
<string name="voice_settings_interaction_hold">按住说话</string>
|
||||
@@ -1712,7 +1727,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>
|
||||
@@ -1763,6 +1778,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>
|
||||
@@ -2050,8 +2090,12 @@
|
||||
<string name="voice_overlay_collapse_cd">收起语音控制</string>
|
||||
<string name="voice_overlay_expand_cd">展开语音控制</string>
|
||||
<string name="voice_overlay_exit_cd">退出语音模式</string>
|
||||
<string name="voice_overlay_compact">紧凑</string>
|
||||
<string name="voice_overlay_focus">专注</string>
|
||||
<string name="voice_overlay_conversation">对话</string>
|
||||
<string name="voice_overlay_image_ready">图片已就绪</string>
|
||||
<string name="voice_overlay_rich_result_ready">丰富结果已就绪</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d 个结果已就绪</string>
|
||||
<string name="voice_overlay_view_conversation">查看对话</string>
|
||||
<string name="voice_overlay_overlay">浮窗</string>
|
||||
<string name="voice_overlay_exit">退出</string>
|
||||
<string name="voice_overlay_settings_cd">语音设置</string>
|
||||
@@ -2077,7 +2121,7 @@
|
||||
<string name="active_section_api_key_already_set">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>
|
||||
@@ -2195,6 +2239,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">下条消息确认</string>
|
||||
<string name="conn_info_connect">连接</string>
|
||||
<string name="conn_info_connected">已连接</string>
|
||||
<string name="conn_info_active_configuration">当前配置</string>
|
||||
<string name="conn_info_inherited">继承</string>
|
||||
<string name="conn_info_bypassed">已绕过</string>
|
||||
<string name="conn_info_customize_identity">自定义身份</string>
|
||||
<string name="conn_info_agent_passport">智能体档案</string>
|
||||
<string name="conn_info_chat_override">对话覆盖</string>
|
||||
<string name="conn_info_fast_tier">快速层级</string>
|
||||
<string name="conn_info_fast">快速</string>
|
||||
<string name="conn_info_context">上下文</string>
|
||||
<string name="conn_info_profile_already_bypasses">此配置已绕过审批。</string>
|
||||
<string name="conn_info_start_new_chat">开始新对话</string>
|
||||
<string name="conn_info_approval_mode_short_desc">选择如何执行审批。</string>
|
||||
<string name="conn_info_approval_policy">审批策略</string>
|
||||
<string name="conn_info_connecting">连接中…</string>
|
||||
<string name="conn_info_connection">连接</string>
|
||||
<string name="conn_info_connection_state">连接状态</string>
|
||||
@@ -2452,6 +2509,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>
|
||||
|
||||
@@ -2545,6 +2603,22 @@
|
||||
<string name="attachment_unmute">取消静音</string>
|
||||
<string name="attachment_mute">静音</string>
|
||||
<string name="attachment_title">附件</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="other">%1$d 个附件</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">收起附件</string>
|
||||
<string name="attachment_group_expand">展开附件</string>
|
||||
<string name="attachment_group_collapsed">已收起</string>
|
||||
<string name="attachment_group_expanded">已展开</string>
|
||||
<string name="attachment_type_image">图片</string>
|
||||
<string name="attachment_type_video">视频</string>
|
||||
<string name="attachment_type_audio">音频</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">文本</string>
|
||||
<string name="attachment_type_file">文件</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
||||
@@ -3217,9 +3291,47 @@
|
||||
<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_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>
|
||||
<string name="dashboard_component_connected">已连接</string>
|
||||
<string name="dashboard_component_server_errors_5m">服务器错误 / 5 分钟</string>
|
||||
<string name="appearance_image_generation_style">图像生成</string>
|
||||
<string name="appearance_image_generation_style_desc">轮换使用三种进度动画,或让每次生成都使用同一种样式。</string>
|
||||
<string name="appearance_image_generation_rotate">轮换</string>
|
||||
<string name="appearance_image_generation_grid">网格</string>
|
||||
<string name="appearance_image_generation_sphere">球体</string>
|
||||
<string name="appearance_image_generation_nodes">节点</string>
|
||||
<string name="dev_settings_image_generation_lab">图像生成实验室</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">预览生成循环和最终图像显现效果</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">打开图像生成实验室</string>
|
||||
<string name="dashboard_native_signin_opening">请在浏览器中完成登录,然后返回此处。</string>
|
||||
<string name="dashboard_native_signin_cancelled">已取消浏览器登录。</string>
|
||||
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
|
||||
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
|
||||
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
|
||||
<string name="conn_info_approval_mode_desc">此 Hermes 配置文件的持久策略。适用于所有聊天和设备。</string>
|
||||
<string name="conn_info_approval_mode_unsupported">请更新 Hermes 以选择配置文件批准模式。单聊天 YOLO 仍可使用。</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">此多路复用配置文件为只读。配置文件会话启动后会显示当前模式;更改模式需要 upstream 提供按配置文件划分的配置 RPC。</string>
|
||||
<string name="conn_info_approval_mode_manual">手动</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">每次调用受保护工具前都询问。</string>
|
||||
<string name="conn_info_approval_mode_smart">智能</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">仅在 Hermes 检测到较高风险时询问。</string>
|
||||
<string name="conn_info_approval_mode_off">关闭</string>
|
||||
<string name="conn_info_approval_mode_off_desc">始终跳过此配置文件的批准。</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,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>
|
||||
@@ -70,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 -->
|
||||
@@ -101,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>
|
||||
@@ -353,7 +365,7 @@
|
||||
<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>
|
||||
@@ -660,11 +672,11 @@
|
||||
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
|
||||
<string name="settings_quick_controls">Schnellsteuerung</string>
|
||||
<string name="settings_persistent_connection">Dauerhafte Verbindung</string>
|
||||
<string name="settings_persistent_connection_desc">Verbindung zu Hermes im Hintergrund offen halten</string>
|
||||
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</string>
|
||||
<string name="settings_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_persistent_connection_desc">Auch ohne laufenden Chat verbunden bleiben</string>
|
||||
<string name="settings_connect_on_demand">Aktive Chats bleiben automatisch verbunden · Leerlaufverbindung wird geschlossen, um Akku zu sparen</string>
|
||||
<string name="settings_turn_complete_alerts">Chat-Benachrichtigungen</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
|
||||
<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>
|
||||
@@ -694,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>
|
||||
@@ -1176,7 +1188,7 @@
|
||||
<string name="dev_settings_relay_features">Relay-Funktionen</string>
|
||||
<string name="dev_settings_relay_features_desc">Relay-Server- und Kopplungseinstellungen für Bridge-/Terminal-Entwicklung anzeigen</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Echtzeit-Sprachlabor</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">WebSocket-Testumgebung des Anbieters für Entwicklungs-Builds öffnen</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Echtzeit-Sprachkonfiguration prüfen und einen gezielten Mikrofontest ausführen</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Echtzeit-Sprachlabor öffnen</string>
|
||||
<string name="dev_settings_lock_dev_options">Entwickleroptionen sperren</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Diesen Abschnitt ausblenden und experimentelle Funktionen deaktivieren</string>
|
||||
@@ -1207,6 +1219,7 @@
|
||||
<string name="dev_settings_export_failed">Export fehlgeschlagen</string>
|
||||
<string name="dev_settings_imported">Einstellungen importiert</string>
|
||||
<string name="dev_settings_import_failed">Import fehlgeschlagen — ungültige Datei</string>
|
||||
<string name="dev_settings_reset_failed">Zurücksetzen fehlgeschlagen</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Vertrauliche Sicherung exportieren?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Diese Sicherung enthält gespeicherte Verbindungen, API-Schlüssel, Relay-Sitzungstoken, Geräte-IDs und Dashboard-Cookies. Jede Person mit dieser Datei kann möglicherweise auf deinen Hermes-Server zugreifen.</string>
|
||||
<string name="dev_settings_export_action">Exportieren</string>
|
||||
@@ -1476,7 +1489,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>
|
||||
@@ -1553,6 +1566,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Echtzeit-Agent speichern</string>
|
||||
<string name="voice_settings_global_controls_title">Globale Sprachsteuerung</string>
|
||||
<string name="voice_settings_global_controls_desc">Diese Einstellungen gelten für beide Sprach-Engines in jedem Profil.</string>
|
||||
<string name="voice_settings_final_answer_only">Nur endgültige Antwort</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Spricht nur die abgeschlossene Antwort. Werkzeugfortschritt, Dienstmeldungen und Zwischenkommentare bleiben visuell.</string>
|
||||
<string name="voice_settings_interaction_mode">Interaktionsmodus</string>
|
||||
<string name="voice_settings_interaction_tap">Tippen zum Sprechen</string>
|
||||
<string name="voice_settings_interaction_hold">Halten zum Sprechen</string>
|
||||
@@ -1715,7 +1730,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>
|
||||
@@ -1766,6 +1781,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>
|
||||
@@ -2053,8 +2093,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Sprachsteuerung einklappen</string>
|
||||
<string name="voice_overlay_expand_cd">Sprachsteuerung ausklappen</string>
|
||||
<string name="voice_overlay_exit_cd">Sprachmodus beenden</string>
|
||||
<string name="voice_overlay_compact">Kompakt</string>
|
||||
<string name="voice_overlay_focus">Fokus</string>
|
||||
<string name="voice_overlay_conversation">Gespräch</string>
|
||||
<string name="voice_overlay_image_ready">Bild bereit</string>
|
||||
<string name="voice_overlay_rich_result_ready">Rich-Ergebnis bereit</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d Ergebnisse bereit</string>
|
||||
<string name="voice_overlay_view_conversation">Gespräch anzeigen</string>
|
||||
<string name="voice_overlay_overlay">Overlay</string>
|
||||
<string name="voice_overlay_exit">Beenden</string>
|
||||
<string name="voice_overlay_settings_cd">Spracheinstellungen</string>
|
||||
@@ -2080,7 +2124,7 @@
|
||||
<string name="active_section_api_key_already_set">API-Schlüssel ist bereits festgelegt</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>
|
||||
@@ -2200,6 +2244,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Bei nächster Nachricht bestätigen</string>
|
||||
<string name="conn_info_connect">Verbinden</string>
|
||||
<string name="conn_info_connected">Verbunden</string>
|
||||
<string name="conn_info_active_configuration">Aktive Konfiguration</string>
|
||||
<string name="conn_info_inherited">Geerbt</string>
|
||||
<string name="conn_info_bypassed">Umgangen</string>
|
||||
<string name="conn_info_customize_identity">Identität anpassen</string>
|
||||
<string name="conn_info_agent_passport">Agentenpass</string>
|
||||
<string name="conn_info_chat_override">Chat-Überschreibung</string>
|
||||
<string name="conn_info_fast_tier">Schnellstufe</string>
|
||||
<string name="conn_info_fast">Schnell</string>
|
||||
<string name="conn_info_context">Kontext</string>
|
||||
<string name="conn_info_profile_already_bypasses">Genehmigungen werden bereits vom Profil umgangen.</string>
|
||||
<string name="conn_info_start_new_chat">Neuen Chat starten</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Wähle, wie Genehmigungen erzwungen werden.</string>
|
||||
<string name="conn_info_approval_policy">Genehmigungsrichtlinie</string>
|
||||
<string name="conn_info_connecting">Verbindung wird hergestellt…</string>
|
||||
<string name="conn_info_connection">Verbindung</string>
|
||||
<string name="conn_info_connection_state">Verbindungsstatus</string>
|
||||
@@ -2455,6 +2512,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>
|
||||
|
||||
@@ -2548,6 +2606,23 @@
|
||||
<string name="attachment_unmute">Ton an</string>
|
||||
<string name="attachment_mute">Stummschalten</string>
|
||||
<string name="attachment_title">Anhang</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d Anhang</item>
|
||||
<item quantity="other">%1$d Anhänge</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Anhänge einklappen</string>
|
||||
<string name="attachment_group_expand">Anhänge ausklappen</string>
|
||||
<string name="attachment_group_collapsed">Eingeklappt</string>
|
||||
<string name="attachment_group_expanded">Ausgeklappt</string>
|
||||
<string name="attachment_type_image">Bild</string>
|
||||
<string name="attachment_type_video">Video</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Text</string>
|
||||
<string name="attachment_type_file">Datei</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
|
||||
@@ -3283,9 +3358,47 @@
|
||||
<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 und gib hier denselben Wert ein.</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>
|
||||
<string name="dashboard_component_connected">verbunden</string>
|
||||
<string name="dashboard_component_server_errors_5m">Serverfehler / 5 Min.</string>
|
||||
<string name="appearance_image_generation_style">Bildgenerierung</string>
|
||||
<string name="appearance_image_generation_style_desc">Wechsle zwischen allen drei Fortschrittsanimationen oder verwende bei jeder Generierung denselben Stil.</string>
|
||||
<string name="appearance_image_generation_rotate">Wechseln</string>
|
||||
<string name="appearance_image_generation_grid">Raster</string>
|
||||
<string name="appearance_image_generation_sphere">Kugel</string>
|
||||
<string name="appearance_image_generation_nodes">Knoten</string>
|
||||
<string name="dev_settings_image_generation_lab">Bildgenerierungs-Labor</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Generierungsschleifen und die abschließende Bildenthüllung als Vorschau anzeigen</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Bildgenerierungs-Labor öffnen</string>
|
||||
<string name="dashboard_native_signin_opening">Schließe die Anmeldung im Browser ab und kehre dann hierher zurück.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Browser-Anmeldung abgebrochen.</string>
|
||||
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
|
||||
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
|
||||
<string name="conn_info_approval_mode_desc">Dauerhafte Richtlinie für dieses Hermes-Profil. Gilt für alle Chats und Geräte.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Aktualisiere Hermes, um einen Profil-Bestätigungsmodus auszuwählen. Chatbezogenes YOLO bleibt verfügbar.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Schreibgeschützt für dieses multiplexte Profil. Der aktuelle Modus erscheint nach dem Start der Profilsitzung; Änderungen erfordern profilbezogene Konfigurations-RPCs von Upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manuell</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Vor jedem geschützten Werkzeugaufruf nachfragen.</string>
|
||||
<string name="conn_info_approval_mode_smart">Smart</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Nur nachfragen, wenn Hermes ein erhöhtes Risiko erkennt.</string>
|
||||
<string name="conn_info_approval_mode_off">Aus</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Bestätigungen für dieses Profil dauerhaft umgehen.</string>
|
||||
</resources>
|
||||
|
||||
@@ -51,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>
|
||||
@@ -63,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>
|
||||
@@ -84,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>
|
||||
@@ -311,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>
|
||||
@@ -587,11 +599,11 @@
|
||||
<string name="settings_profile_lock_desc">Anclar la aplicación a un perfil de agente</string>
|
||||
<string name="settings_quick_controls">Controles rápidos</string>
|
||||
<string name="settings_persistent_connection">Conexión persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantener abierta su conexión a Hermes en segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</string>
|
||||
<string name="settings_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_persistent_connection_desc">Mantener la conexión aunque no haya ningún chat en curso</string>
|
||||
<string name="settings_connect_on_demand">Los chats activos siguen conectados automáticamente · la conexión inactiva se cierra para ahorrar batería</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
|
||||
<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>
|
||||
@@ -619,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>
|
||||
@@ -1064,7 +1076,7 @@
|
||||
<string name="dev_settings_relay_features">Características de Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar el servidor Relay y la configuración de emparejamiento para el desarrollo de Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abra el banco de pruebas del proveedor websocket para compilaciones de desarrollo.</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Revisa la configuración de voz en tiempo real y ejecuta una prueba de micrófono</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opciones de desarrollador</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta sección y desactivar las funciones experimentales</string>
|
||||
@@ -1095,6 +1107,7 @@
|
||||
<string name="dev_settings_export_failed">Exportación fallida</string>
|
||||
<string name="dev_settings_imported">Configuraciones importadas</string>
|
||||
<string name="dev_settings_import_failed">Error al importar: archivo no válido</string>
|
||||
<string name="dev_settings_reset_failed">Error al restablecer</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">¿Exportar copia de seguridad confidencial?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Esta copia de seguridad incluye conexiones guardadas, claves API, tokens de sesión relay, ID de dispositivos y cookies del panel. Cualquier persona que tenga el archivo podrá acceder a su servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -1352,7 +1365,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>
|
||||
@@ -1429,6 +1442,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Guardar agente en tiempo real</string>
|
||||
<string name="voice_settings_global_controls_title">Controles de voz globales</string>
|
||||
<string name="voice_settings_global_controls_desc">Estas configuraciones se aplican a ambos motores de voz, en todos los perfiles.</string>
|
||||
<string name="voice_settings_final_answer_only">Solo la respuesta final</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Reproduce únicamente la respuesta definitiva. El progreso de herramientas, las actualizaciones de servicio y los comentarios intermedios permanecen visuales.</string>
|
||||
<string name="voice_settings_interaction_mode">Modo de interacción</string>
|
||||
<string name="voice_settings_interaction_tap">Toca para hablar</string>
|
||||
<string name="voice_settings_interaction_hold">Espera para hablar</string>
|
||||
@@ -1571,7 +1586,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>
|
||||
@@ -1612,6 +1627,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>
|
||||
@@ -1865,8 +1905,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Contraer controles de voz</string>
|
||||
<string name="voice_overlay_expand_cd">Ampliar los controles de voz</string>
|
||||
<string name="voice_overlay_exit_cd">Salir del modo de voz</string>
|
||||
<string name="voice_overlay_compact">Compacto</string>
|
||||
<string name="voice_overlay_focus">Enfocar</string>
|
||||
<string name="voice_overlay_conversation">Conversación</string>
|
||||
<string name="voice_overlay_image_ready">Imagen lista</string>
|
||||
<string name="voice_overlay_rich_result_ready">Resultado enriquecido listo</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d resultados listos</string>
|
||||
<string name="voice_overlay_view_conversation">Ver conversación</string>
|
||||
<string name="voice_overlay_overlay">Cubrir</string>
|
||||
<string name="voice_overlay_exit">Salida</string>
|
||||
<string name="voice_overlay_settings_cd">Configuraciones de voz</string>
|
||||
@@ -1890,7 +1934,7 @@
|
||||
<string name="active_section_api_key_already_set">La clave API ya está configurada</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>
|
||||
@@ -2006,6 +2050,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma en el siguiente mensaje</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuración activa</string>
|
||||
<string name="conn_info_inherited">Heredado</string>
|
||||
<string name="conn_info_bypassed">Omitido</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidad</string>
|
||||
<string name="conn_info_agent_passport">Pasaporte del agente</string>
|
||||
<string name="conn_info_chat_override">Anulación del chat</string>
|
||||
<string name="conn_info_fast_tier">Nivel rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">El perfil ya omite las aprobaciones.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar nuevo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Elige cómo se aplican las aprobaciones.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprobación</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexión</string>
|
||||
<string name="conn_info_connection_state">Estado de conexión</string>
|
||||
@@ -2229,6 +2286,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>
|
||||
@@ -2304,6 +2362,23 @@
|
||||
<string name="attachment_unmute">Dejar de silenciar</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Adjunto</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d archivo adjunto</item>
|
||||
<item quantity="other">%1$d archivos adjuntos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Contraer archivos adjuntos</string>
|
||||
<string name="attachment_group_expand">Expandir archivos adjuntos</string>
|
||||
<string name="attachment_group_collapsed">Contraído</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagen</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Archivo</string>
|
||||
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
|
||||
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||
<string name="crash_dismiss">Descartar</string>
|
||||
@@ -2966,11 +3041,49 @@
|
||||
<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 e introduzca aquí el mismo valor.</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>
|
||||
<string name="dashboard_component_connected">conectado</string>
|
||||
<string name="dashboard_component_server_errors_5m">errores del servidor / 5 min</string>
|
||||
<string name="appearance_image_generation_style">Generación de imágenes</string>
|
||||
<string name="appearance_image_generation_style_desc">Alterna entre las tres animaciones de progreso o conserva un estilo para cada generación.</string>
|
||||
<string name="appearance_image_generation_rotate">Alternar</string>
|
||||
<string name="appearance_image_generation_grid">Cuadrícula</string>
|
||||
<string name="appearance_image_generation_sphere">Esfera</string>
|
||||
<string name="appearance_image_generation_nodes">Nodos</string>
|
||||
<string name="dev_settings_image_generation_lab">Laboratorio de generación de imágenes</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Previsualiza los ciclos de generación y la revelación final de la imagen</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Abrir el laboratorio de generación de imágenes</string>
|
||||
<string name="dashboard_native_signin_opening">Completa el inicio de sesión en el navegador y vuelve aquí.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Inicio de sesión en el navegador cancelado.</string>
|
||||
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
|
||||
<string name="conn_info_approval_mode_desc">Política persistente para este perfil de Hermes. Se aplica a todos los chats y dispositivos.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Actualiza Hermes para elegir un modo de aprobación del perfil. YOLO por chat seguirá disponible.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Solo lectura para este perfil multiplexado. El modo actual aparece al iniciar la sesión del perfil; cambiarlo requiere RPC de configuración por perfil de upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Preguntar antes de cada llamada a una herramienta protegida.</string>
|
||||
<string name="conn_info_approval_mode_smart">Inteligente</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Preguntar solo cuando Hermes detecte un riesgo elevado.</string>
|
||||
<string name="conn_info_approval_mode_off">Desactivado</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Omitir permanentemente las aprobaciones para este perfil.</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,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>
|
||||
@@ -70,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 -->
|
||||
@@ -101,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>
|
||||
@@ -353,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>
|
||||
@@ -660,11 +672,11 @@
|
||||
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
|
||||
<string name="settings_quick_controls">クイックコントロール</string>
|
||||
<string name="settings_persistent_connection">永続的な接続</string>
|
||||
<string name="settings_persistent_connection_desc">Hermes への接続をバックグラウンドで開いたままにします</string>
|
||||
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
|
||||
<string name="settings_turn_complete_alerts">ターン完了アラート</string>
|
||||
<string name="settings_turn_complete_alerts_desc">アプリがバックグラウンドで動作しているときに返信が完了したときに通知する</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドでの返信が終了してもアラートは表示されません</string>
|
||||
<string name="settings_persistent_connection_desc">チャットが実行されていないときも接続を維持</string>
|
||||
<string name="settings_connect_on_demand">アクティブなチャットは自動的に接続を維持 · アイドル時はバッテリー節約のため接続を閉じます</string>
|
||||
<string name="settings_turn_complete_alerts">チャット通知</string>
|
||||
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</string>
|
||||
<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>
|
||||
@@ -694,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>
|
||||
@@ -1189,7 +1201,7 @@
|
||||
<string name="dev_settings_relay_features">Relay の特徴</string>
|
||||
<string name="dev_settings_relay_features_desc">Relay サーバーと Bridge/ターミナル開発用のペアリング設定を表示</string>
|
||||
<string name="dev_settings_realtime_voice_lab">リアルタイム音声ラボ</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">開発ビルド用のプロバイダー WebSocket テストベンチを開きます</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">リアルタイム音声の設定を確認し、マイクの集中テストを実行します</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">リアルタイム音声ラボを開く</string>
|
||||
<string name="dev_settings_lock_dev_options">開発者向けオプションをロックする</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">このセクションを非表示にし、実験的な機能を無効にします</string>
|
||||
@@ -1220,6 +1232,7 @@
|
||||
<string name="dev_settings_export_failed">エクスポートに失敗しました</string>
|
||||
<string name="dev_settings_imported">インポートされた設定</string>
|
||||
<string name="dev_settings_import_failed">インポートに失敗しました - 無効なファイル</string>
|
||||
<string name="dev_settings_reset_failed">リセットに失敗しました</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">機密性の高いバックアップをエクスポートしますか?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">このバックアップには、保存された接続、API キー、Relay セッション トークン、デバイス ID、およびダッシュボード Cookie が含まれます。ファイルを持っている人は誰でも、Hermes サーバーにアクセスできる可能性があります。</string>
|
||||
<string name="dev_settings_export_action">輸出</string>
|
||||
@@ -1489,7 +1502,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>
|
||||
@@ -1566,6 +1579,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">リアルタイムエージェントを保存する</string>
|
||||
<string name="voice_settings_global_controls_title">グローバル音声コントロール</string>
|
||||
<string name="voice_settings_global_controls_desc">これらの設定は、すべてのプロファイルの両方の音声エンジンに適用されます。</string>
|
||||
<string name="voice_settings_final_answer_only">最終回答のみ</string>
|
||||
<string name="voice_settings_final_answer_only_desc">確定した回答だけを読み上げます。ツールの進行状況、サービス更新、中間コメントは画面表示のみになります。</string>
|
||||
<string name="voice_settings_interaction_mode">インタラクションモード</string>
|
||||
<string name="voice_settings_interaction_tap">タップして話す</string>
|
||||
<string name="voice_settings_interaction_hold">押し続けて話す</string>
|
||||
@@ -1728,7 +1743,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>
|
||||
@@ -1779,6 +1794,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>
|
||||
@@ -2066,8 +2106,12 @@
|
||||
<string name="voice_overlay_collapse_cd">音声コントロールを折りたたむ</string>
|
||||
<string name="voice_overlay_expand_cd">音声コントロールを拡張する</string>
|
||||
<string name="voice_overlay_exit_cd">音声モードを終了する</string>
|
||||
<string name="voice_overlay_compact">コンパクト</string>
|
||||
<string name="voice_overlay_focus">集中</string>
|
||||
<string name="voice_overlay_conversation">会話</string>
|
||||
<string name="voice_overlay_image_ready">画像の準備ができました</string>
|
||||
<string name="voice_overlay_rich_result_ready">リッチな結果の準備ができました</string>
|
||||
<string name="voice_overlay_rich_results_count">結果が%1$d件準備できました</string>
|
||||
<string name="voice_overlay_view_conversation">会話を表示</string>
|
||||
<string name="voice_overlay_overlay">かぶせる</string>
|
||||
<string name="voice_overlay_exit">出口</string>
|
||||
<string name="voice_overlay_settings_cd">音声設定</string>
|
||||
@@ -2093,7 +2137,7 @@
|
||||
<string name="active_section_api_key_already_set">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>
|
||||
@@ -2211,6 +2255,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">次のメッセージで確認します</string>
|
||||
<string name="conn_info_connect">接続する</string>
|
||||
<string name="conn_info_connected">接続済み</string>
|
||||
<string name="conn_info_active_configuration">有効な構成</string>
|
||||
<string name="conn_info_inherited">継承</string>
|
||||
<string name="conn_info_bypassed">バイパス</string>
|
||||
<string name="conn_info_customize_identity">アイデンティティをカスタマイズ</string>
|
||||
<string name="conn_info_agent_passport">エージェントパスポート</string>
|
||||
<string name="conn_info_chat_override">チャットの上書き</string>
|
||||
<string name="conn_info_fast_tier">高速ティア</string>
|
||||
<string name="conn_info_fast">高速</string>
|
||||
<string name="conn_info_context">コンテキスト</string>
|
||||
<string name="conn_info_profile_already_bypasses">承認はプロファイルですでにバイパスされています。</string>
|
||||
<string name="conn_info_start_new_chat">新しいチャットを開始</string>
|
||||
<string name="conn_info_approval_mode_short_desc">承認を適用する方法を選択します。</string>
|
||||
<string name="conn_info_approval_policy">承認ポリシー</string>
|
||||
<string name="conn_info_connecting">接続中…</string>
|
||||
<string name="conn_info_connection">繋がり</string>
|
||||
<string name="conn_info_connection_state">接続状態</string>
|
||||
@@ -2466,6 +2523,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>
|
||||
|
||||
@@ -2559,6 +2617,22 @@
|
||||
<string name="attachment_unmute">ミュートを解除する</string>
|
||||
<string name="attachment_mute">ミュート</string>
|
||||
<string name="attachment_title">アタッチメント</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="other">添付ファイル %1$d 件</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">添付ファイルを折りたたむ</string>
|
||||
<string name="attachment_group_expand">添付ファイルを展開する</string>
|
||||
<string name="attachment_group_collapsed">折りたたみ済み</string>
|
||||
<string name="attachment_group_expanded">展開済み</string>
|
||||
<string name="attachment_type_image">画像</string>
|
||||
<string name="attachment_type_video">動画</string>
|
||||
<string name="attachment_type_audio">音声</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">テキスト</string>
|
||||
<string name="attachment_type_file">ファイル</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
||||
@@ -3283,9 +3357,47 @@
|
||||
<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_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>
|
||||
<string name="dashboard_component_connected">接続済み</string>
|
||||
<string name="dashboard_component_server_errors_5m">サーバーエラー / 5分</string>
|
||||
<string name="appearance_image_generation_style">画像生成</string>
|
||||
<string name="appearance_image_generation_style_desc">3種類の進行アニメーションを順番に使うか、毎回同じスタイルを使用します。</string>
|
||||
<string name="appearance_image_generation_rotate">ローテーション</string>
|
||||
<string name="appearance_image_generation_grid">グリッド</string>
|
||||
<string name="appearance_image_generation_sphere">球体</string>
|
||||
<string name="appearance_image_generation_nodes">ノード</string>
|
||||
<string name="dev_settings_image_generation_lab">画像生成ラボ</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">生成ループと最終画像の表示をプレビューします</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">画像生成ラボを開く</string>
|
||||
<string name="dashboard_native_signin_opening">ブラウザでサインインを完了してから、ここに戻ってください。</string>
|
||||
<string name="dashboard_native_signin_cancelled">ブラウザでのサインインをキャンセルしました。</string>
|
||||
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
|
||||
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
|
||||
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
|
||||
<string name="conn_info_approval_mode_desc">このHermesプロフィールに対する永続的なポリシーです。すべてのチャットとデバイスに適用されます。</string>
|
||||
<string name="conn_info_approval_mode_unsupported">プロフィール承認モードを選択するにはHermesを更新してください。チャット単位のYOLOは引き続き利用できます。</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">この多重化プロフィールでは読み取り専用です。現在のモードはプロフィールセッション開始後に表示され、変更にはupstreamのプロフィール別設定RPCが必要です。</string>
|
||||
<string name="conn_info_approval_mode_manual">手動</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">保護されたツール呼び出しのたびに確認します。</string>
|
||||
<string name="conn_info_approval_mode_smart">スマート</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Hermesが高いリスクを検出した場合にのみ確認します。</string>
|
||||
<string name="conn_info_approval_mode_off">オフ</string>
|
||||
<string name="conn_info_approval_mode_off_desc">このプロフィールの承認を常に省略します。</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,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>
|
||||
@@ -70,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 -->
|
||||
@@ -101,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>
|
||||
@@ -190,6 +202,8 @@
|
||||
<string name="conn_label_session">Session</string>
|
||||
<string name="conn_label_route">Route</string>
|
||||
<string name="conn_label_status">Status</string>
|
||||
<string name="dashboard_component_connected">connected</string>
|
||||
<string name="dashboard_component_server_errors_5m">server errors / 5m</string>
|
||||
<string name="conn_label_connect">Connect</string>
|
||||
<string name="conn_label_connections">Connections</string>
|
||||
<string name="conn_detail_add_connection">Add a Vanilla Hermes API/dashboard connection</string>
|
||||
@@ -370,7 +384,7 @@
|
||||
<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>
|
||||
@@ -677,11 +691,11 @@
|
||||
<string name="settings_profile_lock_desc">Pin the app to one agent profile</string>
|
||||
<string name="settings_quick_controls">Quick Controls</string>
|
||||
<string name="settings_persistent_connection">Persistent connection</string>
|
||||
<string name="settings_persistent_connection_desc">Keeping your connection to Hermes open in the background</string>
|
||||
<string name="settings_connect_on_demand">Connect on demand only · saves battery</string>
|
||||
<string name="settings_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_persistent_connection_desc">Stay connected even when no chat is running</string>
|
||||
<string name="settings_connect_on_demand">Active chats stay connected automatically · idle connection closes to save battery</string>
|
||||
<string name="settings_turn_complete_alerts">Chat alerts</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
|
||||
<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>
|
||||
@@ -711,8 +725,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>
|
||||
@@ -888,7 +902,7 @@
|
||||
<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, then enter the same value here.</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>
|
||||
@@ -1211,6 +1225,12 @@
|
||||
<string name="appearance_font">Font</string>
|
||||
<string name="appearance_font_desc">Sets the typeface across the whole app. Code and timestamps stay monospaced.</string>
|
||||
<string name="appearance_animation">Animation</string>
|
||||
<string name="appearance_image_generation_style">Image generation</string>
|
||||
<string name="appearance_image_generation_style_desc">Rotate through all three progress animations, or keep one style for every generation.</string>
|
||||
<string name="appearance_image_generation_rotate">Rotate</string>
|
||||
<string name="appearance_image_generation_grid">Grid</string>
|
||||
<string name="appearance_image_generation_sphere">Sphere</string>
|
||||
<string name="appearance_image_generation_nodes">Nodes</string>
|
||||
<string name="appearance_ascii_sphere">ASCII sphere</string>
|
||||
<string name="appearance_ascii_sphere_desc">Show animated sphere on empty chat screen and ambient mode</string>
|
||||
<string name="appearance_behind_messages">Behind messages</string>
|
||||
@@ -1270,8 +1290,11 @@
|
||||
<string name="dev_settings_relay_features">Relay features</string>
|
||||
<string name="dev_settings_relay_features_desc">Show Relay Server and Pairing settings for Bridge/Terminal development</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Realtime voice lab</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Open the provider websocket testbench for dev builds</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Inspect realtime voice setup and run a focused microphone test</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Open realtime voice lab</string>
|
||||
<string name="dev_settings_image_generation_lab">Image generation lab</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Preview generation loops and the final image reveal</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Open image generation lab</string>
|
||||
<string name="dev_settings_lock_dev_options">Lock developer options</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Hide this section and disable experimental features</string>
|
||||
<string name="dev_settings_locked_toast">Developer options locked</string>
|
||||
@@ -1301,6 +1324,7 @@
|
||||
<string name="dev_settings_export_failed">Export failed</string>
|
||||
<string name="dev_settings_imported">Settings imported</string>
|
||||
<string name="dev_settings_import_failed">Import failed — invalid file</string>
|
||||
<string name="dev_settings_reset_failed">Reset failed</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Export sensitive backup?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server.</string>
|
||||
<string name="dev_settings_export_action">Export</string>
|
||||
@@ -1540,17 +1564,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>
|
||||
@@ -1564,19 +1588,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>
|
||||
@@ -1584,14 +1608,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>
|
||||
@@ -1599,7 +1626,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>
|
||||
@@ -1620,7 +1649,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>
|
||||
@@ -1628,7 +1661,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>
|
||||
@@ -1643,10 +1678,12 @@
|
||||
<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>
|
||||
<string name="voice_settings_final_answer_only">Final answer only</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Speak only the settled answer. Tool progress, service updates, and intermediate commentary stay visual.</string>
|
||||
<string name="voice_settings_interaction_mode">Interaction mode</string>
|
||||
<string name="voice_settings_interaction_tap">Tap to talk</string>
|
||||
<string name="voice_settings_interaction_hold">Hold to talk</string>
|
||||
@@ -1691,7 +1728,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>
|
||||
@@ -1738,6 +1775,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>
|
||||
@@ -1747,6 +1785,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>
|
||||
@@ -1763,6 +1802,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>
|
||||
@@ -1809,7 +1850,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>
|
||||
@@ -1823,6 +1864,10 @@
|
||||
<string name="dashboard_oauth_verifying">Verifying dashboard session…</string>
|
||||
<string name="dashboard_oauth_not_accepted">Sign-in was not accepted yet. Finish the dashboard flow to continue.</string>
|
||||
<string name="dashboard_oauth_verify_failed">Dashboard sign-in verification failed</string>
|
||||
<string name="dashboard_native_signin_opening">Complete sign-in in your browser, then return here.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Browser sign-in cancelled.</string>
|
||||
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
|
||||
<string name="dashboard_close_signin">Close sign-in</string>
|
||||
|
||||
<!-- Error body -->
|
||||
@@ -1854,12 +1899,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>
|
||||
@@ -2147,8 +2213,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Collapse voice controls</string>
|
||||
<string name="voice_overlay_expand_cd">Expand voice controls</string>
|
||||
<string name="voice_overlay_exit_cd">Exit voice mode</string>
|
||||
<string name="voice_overlay_compact">Compact</string>
|
||||
<string name="voice_overlay_focus">Focus</string>
|
||||
<string name="voice_overlay_conversation">Conversation</string>
|
||||
<string name="voice_overlay_focus">Voice focus</string>
|
||||
<string name="voice_overlay_image_ready">Image ready</string>
|
||||
<string name="voice_overlay_rich_result_ready">Rich result ready</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d results ready</string>
|
||||
<string name="voice_overlay_view_conversation">View conversation</string>
|
||||
<string name="voice_overlay_overlay">Overlay</string>
|
||||
<string name="voice_overlay_exit">Exit</string>
|
||||
<string name="voice_overlay_settings_cd">Voice settings</string>
|
||||
@@ -2174,7 +2244,7 @@
|
||||
<string name="active_section_api_key_already_set">API key is already set</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>
|
||||
@@ -2294,6 +2364,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirms on next message</string>
|
||||
<string name="conn_info_connect">Connect</string>
|
||||
<string name="conn_info_connected">Connected</string>
|
||||
<string name="conn_info_active_configuration">Active configuration</string>
|
||||
<string name="conn_info_inherited">Inherited</string>
|
||||
<string name="conn_info_bypassed">Bypassed</string>
|
||||
<string name="conn_info_customize_identity">Customize identity</string>
|
||||
<string name="conn_info_agent_passport">Agent Passport</string>
|
||||
<string name="conn_info_chat_override">Chat override</string>
|
||||
<string name="conn_info_fast_tier">Fast tier</string>
|
||||
<string name="conn_info_fast">Fast</string>
|
||||
<string name="conn_info_context">Context</string>
|
||||
<string name="conn_info_profile_already_bypasses">Approvals already bypassed by profile.</string>
|
||||
<string name="conn_info_start_new_chat">Start new chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Choose how approvals are enforced.</string>
|
||||
<string name="conn_info_approval_policy">Approval policy</string>
|
||||
<string name="conn_info_connecting">Connecting…</string>
|
||||
<string name="conn_info_connection">Connection</string>
|
||||
<string name="conn_info_connection_state">Connection state</string>
|
||||
@@ -2414,7 +2497,19 @@
|
||||
<string name="conn_info_yes">Yes</string>
|
||||
<string name="conn_info_yes_hidden">Yes (hidden)</string>
|
||||
<string name="conn_info_yolo_mode_desc">Bypass approval prompts for tool calls.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Bypass approval prompts for this chat only. Resets when the session changes.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profile approvals are Off, so this chat already bypasses prompts. Choose Manual or Smart before using the per-chat override.</string>
|
||||
<string name="conn_info_yolo_mode_title">YOLO mode</string>
|
||||
<string name="conn_info_approval_mode_title">Profile approval mode</string>
|
||||
<string name="conn_info_approval_mode_desc">Persistent policy for this Hermes profile. Applies across chats and devices.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Update Hermes to choose a profile approval mode. Per-chat YOLO remains available.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Read-only for this multiplexed profile. Its current mode appears after the profile session starts; changing it requires upstream profile-scoped config RPCs.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Ask before every protected tool call.</string>
|
||||
<string name="conn_info_approval_mode_smart">Smart</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Ask only when Hermes detects elevated risk.</string>
|
||||
<string name="conn_info_approval_mode_off">Off</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Persistently bypass approvals for this profile.</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
|
||||
@@ -2549,6 +2644,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>
|
||||
|
||||
@@ -2642,6 +2738,23 @@
|
||||
<string name="attachment_unmute">Unmute</string>
|
||||
<string name="attachment_mute">Mute</string>
|
||||
<string name="attachment_title">Attachment</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d attachment</item>
|
||||
<item quantity="other">%1$d attachments</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Collapse attachments</string>
|
||||
<string name="attachment_group_expand">Expand attachments</string>
|
||||
<string name="attachment_group_collapsed">Collapsed</string>
|
||||
<string name="attachment_group_expanded">Expanded</string>
|
||||
<string name="attachment_type_image">Image</string>
|
||||
<string name="attachment_type_video">Video</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Text</string>
|
||||
<string name="attachment_type_file">File</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
||||
@@ -3288,4 +3401,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>
|
||||
|
||||
@@ -126,6 +126,20 @@ class ChatTurnCheckpointStoreTest {
|
||||
startedAt = 1_002L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Safe advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
|
||||
id = "run-1",
|
||||
title = "Research",
|
||||
|
||||
@@ -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(
|
||||
@@ -95,6 +111,75 @@ class ConnectionDashboardFieldsTest {
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildRouteCandidates_preservesExplicitSameHostHttpsDashboard() {
|
||||
val routes = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
)
|
||||
|
||||
assertEquals(1, routes.size)
|
||||
assertEquals("https://hermes.example.com:443", routes.single().dashboard?.url)
|
||||
assertEquals("https://hermes.example.com:8643", routes.single().api?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileDashboardRoutes_repairsStoredSameHostDerivedPort() {
|
||||
val stored = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
)
|
||||
|
||||
val repaired = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
candidates = stored,
|
||||
)
|
||||
|
||||
assertEquals("https://hermes.example.com:443", repaired.single().dashboard?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileDashboardRoutes_keepsDifferentHostRoamingDashboard() {
|
||||
val stored = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://100.71.8.56:8642",
|
||||
relayUrl = "ws://100.71.8.56:8767",
|
||||
)
|
||||
|
||||
val repaired = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
candidates = stored,
|
||||
)
|
||||
|
||||
assertEquals("http://100.71.8.56:9119", repaired.single().dashboard?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedSecureDashboard_repairsDerivedGatewayRouteOnReload() {
|
||||
val stored = Connection(
|
||||
id = "conn-https",
|
||||
label = "Secure Hermes",
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
tokenStoreKey = "hermes_auth_https",
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
routeCandidates = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
),
|
||||
)
|
||||
|
||||
val reloaded = json.decodeFromString<Connection>(
|
||||
json.encodeToString(Connection.serializer(), stored),
|
||||
).withDashboardDefaults()
|
||||
|
||||
assertEquals("https://hermes.example.com:443", reloaded.dashboardUrl)
|
||||
assertEquals(
|
||||
"https://hermes.example.com:443",
|
||||
reloaded.routeCandidates.single().dashboard?.url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
|
||||
val route = Connection.endpointCandidateFromDashboardUrl(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.coVerify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class DataManagerIoTest {
|
||||
@Test
|
||||
fun writeBackupFailsWhenProviderReturnsNoOutputStream() = runTest {
|
||||
val uri = mockk<Uri>()
|
||||
val resolver = mockk<ContentResolver>()
|
||||
val context = mockk<Context>()
|
||||
every { context.contentResolver } returns resolver
|
||||
every { resolver.openOutputStream(uri) } returns null
|
||||
|
||||
val success = DataManager(context).writeBackupToUri(uri, "{}")
|
||||
|
||||
assertFalse(success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreTreatsEmptyConnectionsAsReplacementSnapshot() = runTest {
|
||||
val context = mockk<Context>()
|
||||
val store = mockk<ConnectionStore>()
|
||||
every { context.filesDir } returns File("build/tmp/data-manager-test/files")
|
||||
coJustRun {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
|
||||
DataManager(context, store).restoreConnectionBackup(DataManager.AppBackup())
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user