Compare 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 | ||
|
|
2bfe21eaff |
@@ -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:
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-10
@@ -6,18 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
### Fixed
|
||||
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **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
|
||||
|
||||
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
|
||||
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
|
||||
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
|
||||
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
|
||||
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
|
||||
|
||||
## [Android 1.5.1] - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
|
||||
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
|
||||
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
|
||||
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
|
||||
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
@@ -31,14 +47,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-22
|
||||
## [Android 1.5.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -47,6 +67,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
|
||||
@@ -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,97 @@
|
||||
# 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
|
||||
@@ -81,6 +173,19 @@ instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-20 — Faster Android validation feedback
|
||||
|
||||
Android contributors now have one cross-platform pre-push command for locale,
|
||||
documentation, collection-API, and version checks plus primary Play-variant
|
||||
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
|
||||
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
|
||||
Android SDK without writing worktree-local configuration. Hosted CI retains
|
||||
the exhaustive all-variant lint gate.
|
||||
|
||||
Android CI now cancels a superseded run on `dev` or a pull-request ref while
|
||||
preserving every `main` run. A newer integration commit therefore stops paying
|
||||
for an older release smoke that can no longer become the tested release tip.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
|
||||
+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
-17
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
# Hermes-Relay-Android v1.5.2
|
||||
|
||||
**Release Date:** July 22, 2026
|
||||
**Release Date:** July 28, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.5.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,24 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
||||
|
||||
## Added
|
||||
|
||||
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
|
||||
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
|
||||
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
|
||||
This patch 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
|
||||
|
||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
||||
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
|
||||
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
|
||||
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
|
||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
||||
- 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.5.0** (versionCode **33**).
|
||||
- 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
|
||||
@@ -78,6 +96,19 @@ intentionally remain outside that code batch:
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Keep persistent approval-mode writes for multiplexed non-launch profiles
|
||||
read-only until upstream `config.get` / `config.set` bind an explicit
|
||||
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
|
||||
accepts `approvals.mode` but resolves it against the gateway process home;
|
||||
Android may reconcile a selected profile's `session.info.approval_mode`, but
|
||||
must not claim a profile-scoped write that upstream ignores.
|
||||
- Keep gateway `model.options` profile scoping blocked until the supported
|
||||
upstream RPC accepts an explicit `profile` and documents that the returned
|
||||
provider inventory was built inside that profile's runtime scope. Android
|
||||
now keys picker results to its active profile context and rejects late
|
||||
responses after a profile switch, but it deliberately does not send an
|
||||
invented `profile` parameter. API-server fallback can use the separate,
|
||||
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
@@ -686,9 +717,10 @@ Deferred:
|
||||
|
||||
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
||||
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
|
||||
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
|
||||
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
||||
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
@@ -700,10 +732,6 @@ gated to pre-first-token. Deferred:
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
|
||||
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
||||
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||
@@ -1170,6 +1198,7 @@ Follow-ups:
|
||||
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1 +1 @@
|
||||
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
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 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
|
||||
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,29 +1,84 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Voice that keeps pace",
|
||||
"date": "2026-07-22",
|
||||
"version": "1.5.2",
|
||||
"title": "Sign in without detours",
|
||||
"date": "2026-07-28",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A clearer voice studio",
|
||||
"header": "Provider-compatible sign-in",
|
||||
"bullets": [
|
||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
||||
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Natural streaming speech",
|
||||
"header": "Stable conversation updates",
|
||||
"bullets": [
|
||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
||||
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"title": "Voice and chat stay in place",
|
||||
"date": "2026-07-26",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice at the right depth",
|
||||
"bullets": [
|
||||
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
|
||||
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More upstream-aware controls",
|
||||
"header": "Reliable narration and background work",
|
||||
"bullets": [
|
||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
||||
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Formatted answers stay readable",
|
||||
"bullets": [
|
||||
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
|
||||
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
v1.5.0 - Voice that keeps pace
|
||||
v1.5.2 - Sign in without detours
|
||||
|
||||
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
|
||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
* 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.
|
||||
|
||||
@@ -142,6 +142,21 @@ data class ChatMessage(
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
@@ -392,6 +407,8 @@ data class ChatSession(
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Server reports a persisted session runtime/model binding. */
|
||||
val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnMoaReferenceCheckpoint(
|
||||
val index: Int,
|
||||
val count: Int? = null,
|
||||
val label: String,
|
||||
val text: String = "",
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -247,6 +247,7 @@ data class Connection(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
dashboardUrl: String? = null,
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
@@ -255,6 +256,7 @@ data class Connection(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
@@ -266,6 +268,7 @@ data class Connection(
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
dashboardUrl = dashboardUrl,
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +343,7 @@ data class Connection(
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
dashboardUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
@@ -363,12 +367,62 @@ data class Connection(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
||||
dashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
|
||||
?.let { DashboardEndpoint(url = it) }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl)
|
||||
?.let { DashboardEndpoint(url = it) },
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile stored API-derived routes with the Dashboard origin that
|
||||
* was actually verified during setup. Older app versions synthesized
|
||||
* `:9119` for every API route, even when the same host was reached
|
||||
* through an HTTPS reverse proxy on 443. Replace only that conventional
|
||||
* synthesized value (or a missing value); preserve explicit and
|
||||
* different-host LAN/Tailscale routes.
|
||||
*/
|
||||
fun reconcileDashboardRoutes(
|
||||
dashboardUrl: String?,
|
||||
candidates: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val explicitDashboard = dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return candidates
|
||||
return candidates.map { candidate ->
|
||||
val apiUrl = candidate.api?.url ?: return@map candidate
|
||||
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
|
||||
|
||||
val currentDashboard = candidate.dashboard?.url
|
||||
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
|
||||
val canReplace = currentDashboard.isNullOrBlank() ||
|
||||
(
|
||||
derivedDashboard != null &&
|
||||
currentDashboard.trim().trimEnd('/')
|
||||
.equals(derivedDashboard, ignoreCase = true)
|
||||
)
|
||||
if (canReplace) {
|
||||
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
|
||||
} else {
|
||||
candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
|
||||
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
|
||||
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
|
||||
return !leftHost.isNullOrBlank() &&
|
||||
!rightHost.isNullOrBlank() &&
|
||||
leftHost.equals(rightHost, ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
|
||||
@@ -543,29 +543,6 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
@@ -585,3 +562,40 @@ class ConnectionStore private constructor(
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore route defaults after loading a serialized connection. This remains
|
||||
* internal so focused persistence tests can exercise the same normalization
|
||||
* path used by [ConnectionStore].
|
||||
*/
|
||||
internal fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
|
||||
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
}
|
||||
val normalizedRoutes = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
candidates = storedOrDefaultRoutes,
|
||||
)
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
dashboardUrl != effectiveDashboardUrl ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +255,11 @@ class DataManager(
|
||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
||||
outputStream.flush()
|
||||
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||
?: return@withContext false
|
||||
outputStream.use {
|
||||
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -288,9 +290,10 @@ class DataManager(
|
||||
* - Clear DataStore preferences
|
||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||
* - Clear any cached data
|
||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
||||
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||
* should show onboarding again.
|
||||
*/
|
||||
suspend fun resetAppData() {
|
||||
suspend fun resetAppData(): Boolean =
|
||||
try {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
@@ -320,10 +323,11 @@ class DataManager(
|
||||
}
|
||||
|
||||
Log.d(TAG, "App data reset complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset app data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -384,16 +388,17 @@ class DataManager(
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
suspend fun resetOnboarding(): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed.
|
||||
@@ -412,13 +417,14 @@ class DataManager(
|
||||
/**
|
||||
* Mark onboarding as completed.
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In debug builds, Developer Options are unlocked by default until the user
|
||||
* explicitly locks them.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
||||
|
||||
/** Whether the app is running a debug build. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
}
|
||||
|
||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/** Unlock Developer Options. */
|
||||
@@ -45,18 +39,10 @@ object FeatureFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
/** Lock Developer Options, including in debug builds. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_RELAY_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ data class VoiceSettings(
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 1250L,
|
||||
/**
|
||||
* When true, voice keeps progress visual and waits for the settled Hermes
|
||||
* answer before speaking. Tool status, service updates, and intermediate
|
||||
* assistant commentary are not narrated.
|
||||
*/
|
||||
val finalAnswerOnly: Boolean = false,
|
||||
/** Presentation only; changing this never restarts or interrupts voice. */
|
||||
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
@@ -122,6 +130,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoicePresentationMode(val storageValue: String) {
|
||||
Focus("focus"),
|
||||
Conversation("conversation");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoicePresentationMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: Focus
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active scope for per-profile voice prefs.
|
||||
*
|
||||
@@ -182,6 +200,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// un-namespaced means switching profiles never churns these.
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
|
||||
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
@@ -191,6 +211,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
||||
const val DEFAULT_FINAL_ANSWER_ONLY = false
|
||||
const val DEFAULT_PRESENTATION_MODE = "focus"
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
|
||||
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// --- global (shared across profiles) ---
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
|
||||
presentationMode = VoicePresentationMode.fromStorage(
|
||||
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
|
||||
).storageValue,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
@@ -367,6 +393,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setFinalAnswerOnly(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setPresentationMode(mode: VoicePresentationMode) {
|
||||
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
@@ -1278,6 +1278,7 @@ class RelayVoiceClient(
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1309,6 +1310,7 @@ class RelayVoiceClient(
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
finalAnswerOnly = finalAnswerOnly,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -1822,6 +1824,28 @@ class RelayVoiceClient(
|
||||
if (event.type == "hermes.run.promoted") {
|
||||
longRunningTurn.set(true)
|
||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||
if (persistent &&
|
||||
event.spokenHandoff == false &&
|
||||
activeTurn.compareAndSet(true, false)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime agent foreground turn ended at silent background promotion",
|
||||
)
|
||||
onTurnComplete(
|
||||
RealtimeVoiceSummary(
|
||||
provider = event.provider ?: session.provider,
|
||||
model = event.model ?: session.model,
|
||||
voice = event.voice ?: session.voice,
|
||||
sampleRate = session.sampleRate,
|
||||
audioChunks = audioChunks,
|
||||
audioBytes = audioBytes,
|
||||
firstAudioMs = event.firstAudioMs,
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (event.isAudioDelta) {
|
||||
audioChunks += 1
|
||||
@@ -1847,13 +1871,12 @@ class RelayVoiceClient(
|
||||
responseDoneMs = event.responseDoneMs,
|
||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||
)
|
||||
if (persistent) {
|
||||
if (persistent && activeTurn.compareAndSet(true, false)) {
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
longRunningTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
} else if (!persistent) {
|
||||
if (claimTerminalSocket(
|
||||
webSocket,
|
||||
generation,
|
||||
@@ -2679,6 +2702,7 @@ class RelayVoiceClient(
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
finalAnswerOnly: Boolean = false,
|
||||
): Result<RealtimeSessionResponse> {
|
||||
val body = buildJsonObject {
|
||||
putProfile()
|
||||
@@ -2694,6 +2718,9 @@ class RelayVoiceClient(
|
||||
sampleRate?.takeIf { it > 0 }?.let {
|
||||
put("sample_rate", JsonPrimitive(it))
|
||||
}
|
||||
if (finalAnswerOnly) {
|
||||
put("final_answer_only", JsonPrimitive(true))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2980,6 +3007,9 @@ class RelayVoiceClient(
|
||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.toBooleanStrictOrNull(),
|
||||
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
||||
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||
@@ -3374,6 +3404,7 @@ data class RealtimeVoiceEvent(
|
||||
// ADR 33: background-run promotion fields.
|
||||
val tier: String? = null,
|
||||
val floor: String? = null,
|
||||
val spokenHandoff: Boolean? = null,
|
||||
// hermes.run.progress extras — drive the live background-run chip.
|
||||
val activeToolName: String? = null,
|
||||
val completedToolCount: Int? = null,
|
||||
|
||||
+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,12 +1286,13 @@ class ChatHandler {
|
||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
displayKind == "async_delegation_complete" ||
|
||||
displayKind == "auto_continue" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
@@ -1250,12 +1330,18 @@ class ChatHandler {
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
|
||||
PersistedImageReferenceParser.parse(rawContent)
|
||||
} else {
|
||||
PersistedImageReferences(rawContent, emptyList())
|
||||
}
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
persistedImages.cleanedText
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
@@ -1293,7 +1379,14 @@ class ChatHandler {
|
||||
// content-keyed queue. Inbound attachments are intentionally
|
||||
// excluded — they come back via the marker re-dispatch.
|
||||
val carriedAttachments = run {
|
||||
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
|
||||
val persistedImagePaths = persistedImages.paths.toHashSet()
|
||||
val byId = prior?.attachments.orEmpty().filter { attachment ->
|
||||
attachment.relayToken == null ||
|
||||
(
|
||||
role == MessageRole.USER &&
|
||||
attachment.relayToken in persistedImagePaths
|
||||
)
|
||||
}
|
||||
when {
|
||||
byId.isNotEmpty() -> byId
|
||||
role == MessageRole.USER ->
|
||||
@@ -1301,6 +1394,15 @@ class ChatHandler {
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (
|
||||
role == MessageRole.USER &&
|
||||
carriedAttachments.isEmpty() &&
|
||||
persistedImages.paths.isNotEmpty()
|
||||
) {
|
||||
persistedImages.paths.forEach { path ->
|
||||
pendingPersistedUserImages += messageId to path
|
||||
}
|
||||
}
|
||||
// Server reasoning is authoritative when present; absent, keep the
|
||||
// live-streamed thinking rather than blanking it on reload.
|
||||
val serverThinking =
|
||||
@@ -1345,6 +1447,10 @@ class ChatHandler {
|
||||
} else {
|
||||
prior.badges
|
||||
},
|
||||
// Keep sanitized advisor state while reconciling a still-live
|
||||
// row, but clear it once completion made history authoritative.
|
||||
// The server transcript never becomes the source of these blocks.
|
||||
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
|
||||
)
|
||||
} else {
|
||||
// INSERT — a server message with no local row yet. Built from
|
||||
@@ -1437,6 +1543,37 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse replayed visible history rows by their authoritative message id.
|
||||
*
|
||||
* Replacing the value at its first-seen slot preserves transcript ordering;
|
||||
* the last repeated value wins so a later, more complete snapshot is not lost.
|
||||
* Null ids cannot be proven identical and therefore remain separate rows.
|
||||
*/
|
||||
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
|
||||
val firstSlotById = HashMap<String, Int>()
|
||||
val coalesced = ArrayList<MessageItem>(items.size)
|
||||
for (item in items) {
|
||||
if (renderedRoleOf(item) == null) continue
|
||||
val id = item.id
|
||||
if (id == null) {
|
||||
coalesced += item
|
||||
continue
|
||||
}
|
||||
val existingSlot = firstSlotById[id]
|
||||
if (existingSlot == null) {
|
||||
firstSlotById[id] = coalesced.size
|
||||
coalesced += item
|
||||
} else {
|
||||
coalesced[existingSlot] = item
|
||||
}
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
@@ -1505,7 +1642,7 @@ class ChatHandler {
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
@@ -1539,6 +1676,7 @@ class ChatHandler {
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
"auto_continue" -> "Continued after an interrupted turn"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -1565,6 +1703,7 @@ class ChatHandler {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(t)
|
||||
@@ -1753,6 +1892,7 @@ class ChatHandler {
|
||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||
source = item.source,
|
||||
hasModelConfig = item.hasModelConfig,
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
@@ -2593,6 +2733,44 @@ class ChatHandler {
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
|
||||
val message = messages[targetIndex]
|
||||
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
|
||||
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
|
||||
val reference = MoaReference(
|
||||
index = nextIndex,
|
||||
count = event.count,
|
||||
label = event.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = event.available,
|
||||
)
|
||||
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
|
||||
val exactReplay = existingAtIndex == reference
|
||||
val base = if (nextIndex == 1 && !exactReplay) {
|
||||
emptyList()
|
||||
} else {
|
||||
message.moaReferences
|
||||
}
|
||||
if (exactReplay) {
|
||||
messages
|
||||
} else {
|
||||
val upserted = (base.filterNot { it.index == nextIndex } + reference)
|
||||
.sortedBy(MoaReference::index)
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
messages.toMutableList().also {
|
||||
it[targetIndex] = message.copy(moaReferences = upserted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
|
||||
@@ -50,6 +50,7 @@ data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
@@ -158,6 +159,7 @@ data class DashboardCustomEndpointDraft(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
@@ -1136,23 +1138,42 @@ class DashboardApiClient(
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.models
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(MAX_CUSTOM_ENDPOINT_MODELS)
|
||||
.map(::JsonPrimitive)
|
||||
.toList()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("models", JsonArray(it)) }
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
bearerAuth: DashboardBearerAuth? = null,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
bearerAuth?.let {
|
||||
builder.addInterceptor(it)
|
||||
builder.authenticator(it)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
@@ -1180,6 +1201,9 @@ class DashboardApiClient(
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
},
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
@@ -1338,6 +1362,35 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer credentials are scoped to the exact saved dashboard base, including
|
||||
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
|
||||
* not sufficient authority to receive the active connection's token.
|
||||
*/
|
||||
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return candidateUrl.scheme == trustedUrl.scheme &&
|
||||
candidateUrl.host == trustedUrl.host &&
|
||||
candidateUrl.port == trustedUrl.port &&
|
||||
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
|
||||
candidateUrl.query == null &&
|
||||
trustedUrl.query == null
|
||||
}
|
||||
|
||||
fun trustedDashboardBearerAuthOrNull(
|
||||
candidate: String,
|
||||
trusted: String,
|
||||
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||
): DashboardBearerAuth? =
|
||||
if (isNativeDashboardTransportEligible(candidate) &&
|
||||
sameDashboardBase(candidate, trusted)
|
||||
) {
|
||||
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
|
||||
+200
-13
@@ -164,6 +164,7 @@ class GatewayChatClient(
|
||||
private const val CONNECT_ATTEMPTS = 2
|
||||
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
||||
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
|
||||
private const val MAX_RECOVERY_BUFFERED_EVENTS = 256
|
||||
|
||||
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
|
||||
private const val MAX_TURN_REJOINS = 4
|
||||
@@ -284,6 +285,14 @@ class GatewayChatClient(
|
||||
private val _serverYolo = MutableStateFlow<Boolean?>(null)
|
||||
val serverYolo: StateFlow<Boolean?> = _serverYolo.asStateFlow()
|
||||
|
||||
private val _serverApprovalMode = MutableStateFlow<GatewayApprovalMode?>(null)
|
||||
val serverApprovalMode: StateFlow<GatewayApprovalMode?> = _serverApprovalMode.asStateFlow()
|
||||
|
||||
private val _approvalModeCapability =
|
||||
MutableStateFlow(GatewayApprovalModeCapability.Unknown)
|
||||
val approvalModeCapability: StateFlow<GatewayApprovalModeCapability> =
|
||||
_approvalModeCapability.asStateFlow()
|
||||
|
||||
private val _serverFast = MutableStateFlow<Boolean?>(null)
|
||||
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
|
||||
|
||||
@@ -362,6 +371,15 @@ class GatewayChatClient(
|
||||
@Volatile
|
||||
private var activeTurn: GatewayTurn? = null
|
||||
|
||||
private data class RecoveryEvent(
|
||||
val sessionId: String,
|
||||
val type: String,
|
||||
val payload: JsonObject?,
|
||||
)
|
||||
|
||||
private val recoveryEventLock = Any()
|
||||
private var recoveryEvents: MutableList<RecoveryEvent>? = null
|
||||
|
||||
/**
|
||||
* Turns deliberately detached when the user switches profile/session.
|
||||
* Upstream continues them server-side; retain the live→durable binding so
|
||||
@@ -820,24 +838,33 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
response = rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
preferredLiveId?.let { liveId ->
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
||||
synchronized(recoveryEventLock) {
|
||||
recoveryEvents = mutableListOf()
|
||||
}
|
||||
response = try {
|
||||
rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
preferredLiveId?.let { liveId ->
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
val recoveredLiveId = response.stringField("session_id")
|
||||
?: run {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (!preferredLiveId.isNullOrBlank()) {
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
|
||||
}
|
||||
@@ -854,6 +881,9 @@ class GatewayChatClient(
|
||||
user = value.stringField("user").orEmpty(),
|
||||
assistant = value.stringField("assistant").orEmpty(),
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
status = value.stringField("status"),
|
||||
error = value.stringField("error"),
|
||||
recoverable = value.booleanField("recoverable") == true,
|
||||
)
|
||||
}
|
||||
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||
@@ -862,8 +892,20 @@ class GatewayChatClient(
|
||||
?.let(::GatewayQueuedTurn)
|
||||
}
|
||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
||||
val autoContinue = (response["auto_continue"] as? JsonObject)?.let { value ->
|
||||
val attempt = value.stringField("attempt")?.toIntOrNull()
|
||||
?: (value["attempt"] as? JsonPrimitive)?.intOrNull
|
||||
if (attempt != null && attempt > 0) {
|
||||
GatewayAutoContinue(
|
||||
attempt = attempt,
|
||||
interruptedAt = value.stringField("interrupted_at")?.toDoubleOrNull(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (running) {
|
||||
if (running || autoContinue != null) {
|
||||
if (boundTurn == null || boundTurn.ended) {
|
||||
boundTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(callbacks),
|
||||
@@ -873,6 +915,13 @@ class GatewayChatClient(
|
||||
activeTurn = turn
|
||||
}
|
||||
}
|
||||
val buffered = synchronized(recoveryEventLock) {
|
||||
recoveryEvents
|
||||
?.filter { it.sessionId == recoveredLiveId }
|
||||
.orEmpty()
|
||||
.also { recoveryEvents = null }
|
||||
}
|
||||
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
@@ -884,6 +933,7 @@ class GatewayChatClient(
|
||||
}
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||
// its events flow through the completed checkpoint's mapper.
|
||||
val priorBoundTurn = boundTurn
|
||||
@@ -914,6 +964,7 @@ class GatewayChatClient(
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (boundTurn != null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
@@ -929,6 +980,7 @@ class GatewayChatClient(
|
||||
status = response.stringField("status"),
|
||||
inflight = inflight,
|
||||
queued = queued,
|
||||
autoContinue = autoContinue,
|
||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||
?.takeUnless { it.ended },
|
||||
)
|
||||
@@ -1377,6 +1429,84 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the profile-persisted approval policy added in gateway contract v3.
|
||||
* Older gateways either reject the key or return no recognized value; both
|
||||
* downgrade this optional control without affecting chat or per-session YOLO.
|
||||
*/
|
||||
suspend fun getApprovalMode(): Result<GatewayApprovalMode> {
|
||||
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
if (currentSessionProfile() != null) {
|
||||
return Result.failure(approvalModeRequiresLaunchProfile())
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
val response = rpc(
|
||||
"config.get",
|
||||
buildJsonObject { put("key", "approvals.mode") },
|
||||
)
|
||||
response.exceptionOrNull()?.let { error ->
|
||||
if (error.isApprovalModeUnsupported()) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
return Result.failure(error)
|
||||
}
|
||||
val mode = GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
|
||||
?: run {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = mode
|
||||
return Result.success(mode)
|
||||
}
|
||||
|
||||
/** Persist the selected approval policy for the active gateway profile. */
|
||||
suspend fun setApprovalMode(mode: GatewayApprovalMode): Result<GatewayApprovalMode> {
|
||||
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
if (currentSessionProfile() != null) {
|
||||
return Result.failure(approvalModeRequiresLaunchProfile())
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
val response = rpc(
|
||||
"config.set",
|
||||
buildJsonObject {
|
||||
put("key", "approvals.mode")
|
||||
put("value", mode.wireValue)
|
||||
},
|
||||
)
|
||||
response.exceptionOrNull()?.let { error ->
|
||||
if (error.isApprovalModeUnsupported()) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
return Result.failure(error)
|
||||
}
|
||||
val authoritative =
|
||||
GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
|
||||
?: run {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = authoritative
|
||||
return Result.success(authoritative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fast mode (priority service tier) via `config.set {key:"fast"}` —
|
||||
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
|
||||
@@ -1452,6 +1582,7 @@ class GatewayChatClient(
|
||||
private suspend fun connectOnce() {
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
|
||||
@@ -1541,6 +1672,14 @@ class GatewayChatClient(
|
||||
* session can paint its real model up front rather than waiting for a turn.
|
||||
*/
|
||||
private fun applySessionInfo(info: JsonObject) {
|
||||
val contract = (info["desktop_contract"] as? JsonPrimitive)?.intOrNull
|
||||
if (contract != null && contract < 3) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
GatewayApprovalMode.fromWire(info.stringField("approval_mode"))?.let { mode ->
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = mode
|
||||
}
|
||||
if (info.containsKey("personality")) {
|
||||
_serverPersonality.value =
|
||||
(info.stringField("personality") ?: "").ifBlank { "none" }
|
||||
@@ -1845,6 +1984,25 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// A cold session.resume may schedule auto-continue before its RPC
|
||||
// response reaches Android. The recovery buffer is an ownership gate,
|
||||
// not an observational copy: an event is either claimed here for
|
||||
// replay or routed live below, never both. The resume response drains
|
||||
// and closes the gate under this same lock, so later frames route live.
|
||||
// Already-owned sibling sessions retain their background routing.
|
||||
val claimedByRecovery = !eventSessionId.isNullOrBlank() &&
|
||||
!backgroundTurns.containsKey(eventSessionId) &&
|
||||
synchronized(recoveryEventLock) {
|
||||
recoveryEvents?.let { buffered ->
|
||||
if (buffered.size >= MAX_RECOVERY_BUFFERED_EVENTS) {
|
||||
buffered.removeAt(0)
|
||||
}
|
||||
buffered += RecoveryEvent(eventSessionId, type, payload)
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
if (claimedByRecovery) return
|
||||
|
||||
// A profile/session switch may leave an upstream turn running while a
|
||||
// different profile becomes visible. Its events must never paint the
|
||||
// new transcript, but the terminal event still needs to reconcile the
|
||||
@@ -1916,7 +2074,7 @@ class GatewayChatClient(
|
||||
// `/personality`, desktop, or TUI change keeps the app in sync. Falls
|
||||
// through to the turn dispatch below so an in-flight turn still sees it.
|
||||
if (type == "session.info" &&
|
||||
(eventSessionId == null || liveSessionId == null || eventSessionId == liveSessionId)
|
||||
(eventSessionId == null || eventSessionId == liveSessionId)
|
||||
) {
|
||||
// Connection-level session info (model / provider / effort / persona /
|
||||
// yolo / fast / usage) — shared with the session.resume result via
|
||||
@@ -2046,6 +2204,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
pendingRpcs.values.forEach {
|
||||
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
||||
@@ -2224,6 +2383,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
}
|
||||
|
||||
@@ -2705,6 +2865,9 @@ class GatewayChatClient(
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onInterimReconciled = { text ->
|
||||
callbackDispatcher { callbacks.onInterimReconciled(text) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
@@ -2717,6 +2880,7 @@ class GatewayChatClient(
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
||||
@@ -2798,6 +2962,29 @@ private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
msg.contains("unknown method", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Throwable?.isApprovalModeUnsupported(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
val message = rpcError.message.orEmpty()
|
||||
return rpcError.code == JSONRPC_METHOD_NOT_FOUND ||
|
||||
rpcError.code == 4002 ||
|
||||
message.contains("approval mode", ignoreCase = true) &&
|
||||
(
|
||||
message.contains("unknown", ignoreCase = true) ||
|
||||
message.contains("unsupported", ignoreCase = true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun approvalModeUnsupported(): GatewayRpcException =
|
||||
GatewayRpcException(
|
||||
"profile approval modes are not supported by this gateway",
|
||||
JSONRPC_METHOD_NOT_FOUND,
|
||||
)
|
||||
|
||||
private fun approvalModeRequiresLaunchProfile(): GatewayRpcException =
|
||||
GatewayRpcException(
|
||||
"profile approval mode is read-only for multiplexed non-launch profiles",
|
||||
)
|
||||
|
||||
private fun JsonObject.stringField(key: String): String? =
|
||||
(get(key) as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class GatewayEventMapper(
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
private var moaStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
@@ -216,13 +217,18 @@ class GatewayEventMapper(
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
|
||||
val error = payload.string("error")
|
||||
val text = payload.string("text")
|
||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
||||
val duplicatesPreview = responsePreviewed &&
|
||||
!text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
||||
if (!text.isNullOrEmpty() &&
|
||||
!duplicatesPreview &&
|
||||
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview ->
|
||||
preview.isNotEmpty() &&
|
||||
(text.startsWith(preview) || preview.startsWith(text))
|
||||
} == true
|
||||
if (reconcilesInterim) {
|
||||
callbacks.onInterimReconciled(text)
|
||||
} else if (!text.isNullOrEmpty() &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
@@ -233,6 +239,12 @@ class GatewayEventMapper(
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
if (failed) {
|
||||
callbacks.onStatusUpdate(
|
||||
ERROR_STATUS_KIND,
|
||||
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
|
||||
)
|
||||
}
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
@@ -290,9 +302,57 @@ class GatewayEventMapper(
|
||||
}
|
||||
}
|
||||
|
||||
// MoA activity proves auto-compaction has resumed even though
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
"moa.reference" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val text = payload.string("text")?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
val available = !isFailedMoaReference(text)
|
||||
callbacks.onMoaReference(
|
||||
GatewayMoaReference(
|
||||
index = payload.int("index")?.takeIf { it > 0 },
|
||||
count = payload.int("count")
|
||||
?.takeIf { it > 0 },
|
||||
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
|
||||
.ifBlank { "Advisor" },
|
||||
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = available,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"moa.progress" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
|
||||
"moa.phase" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
when (payload.string("phase")?.lowercase()) {
|
||||
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
|
||||
"reference", "references" -> {
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy phase marker retained by upstream for older consumers.
|
||||
"moa.aggregating" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
setMoaStatus("MoA: aggregating…")
|
||||
}
|
||||
|
||||
"tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -324,15 +384,31 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
private fun clearActivityStatuses() {
|
||||
clearProviderWaitAndCompaction()
|
||||
if (!moaStatusActive) return
|
||||
moaStatusActive = false
|
||||
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun clearProviderWaitAndCompaction() {
|
||||
clearProviderWaitStatus()
|
||||
if (!compactionStatusActive) return
|
||||
compactionStatusActive = false
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun setMoaStatus(text: String) {
|
||||
moaStatusActive = true
|
||||
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
const val MOA_STATUS_KIND = "moa"
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
@@ -348,6 +424,11 @@ class GatewayEventMapper(
|
||||
"error",
|
||||
)
|
||||
|
||||
internal fun isFailedMoaReference(text: String): Boolean {
|
||||
val normalized = text.trimStart().lowercase()
|
||||
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
|
||||
+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,9 @@ data class GatewayInflightTurn(
|
||||
val user: String,
|
||||
val assistant: String,
|
||||
val streaming: Boolean,
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val recoverable: Boolean = false,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
@@ -102,6 +132,12 @@ data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
|
||||
data class GatewayAutoContinue(
|
||||
val attempt: Int,
|
||||
val interruptedAt: Double?,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
@@ -120,10 +156,11 @@ data class GatewaySessionRecovery(
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
val autoContinue: GatewayAutoContinue? = null,
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null
|
||||
get() = running || queued != null || autoContinue != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
@@ -310,6 +347,14 @@ data class GatewayModelProvider(
|
||||
val totalModels: Int = 0,
|
||||
)
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
@@ -317,6 +362,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
requestProfileKey: String,
|
||||
currentProfileKey: String,
|
||||
): Boolean =
|
||||
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||
|
||||
/**
|
||||
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
||||
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||
@@ -374,6 +428,12 @@ class GatewayTurnCallbacks(
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
/**
|
||||
* The terminal text is equal/prefix-related to the sealed interim, so the
|
||||
* existing segment should be replaced in place instead of opening a second
|
||||
* assistant bubble.
|
||||
*/
|
||||
val onInterimReconciled: (text: String) -> Unit = { _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -398,6 +458,8 @@ class GatewayTurnCallbacks(
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/** Successful MoA advisor output for a transient labelled reference block. */
|
||||
val onMoaReference: (GatewayMoaReference) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -77,6 +78,10 @@ data class ServerCapabilities(
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
/** Authenticated provider/model inventory at `/api/model/options`. */
|
||||
val modelOptions: Boolean = false,
|
||||
/** Backend-acknowledged per-session model lock. */
|
||||
val sessionModelLock: Boolean = false,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
@@ -100,6 +105,8 @@ data class ServerCapabilities(
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
modelOptions = false,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
modelOptions = feature("model_options") || endpoint("model_options"),
|
||||
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,6 +199,129 @@ data class ApiModelOption(
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
|
||||
data class ApiProviderModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
internal fun parseApiProviderModelOptionsBody(
|
||||
json: Json,
|
||||
body: String,
|
||||
): ApiProviderModelOptions? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val rows = root["providers"] as? JsonArray ?: return null
|
||||
val providers = rows.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
enum class ApiModelRoutingErrorCode {
|
||||
INVENTORY_UNSUPPORTED,
|
||||
INVENTORY_UNAVAILABLE,
|
||||
PROVIDER_NOT_AUTHENTICATED,
|
||||
MODEL_NOT_AVAILABLE,
|
||||
MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
LOCK_CAPABILITY_INCOMPLETE,
|
||||
LOCK_REJECTED,
|
||||
LOCK_ACK_MISMATCH,
|
||||
LEGACY_PROVIDER_UNSUPPORTED,
|
||||
}
|
||||
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
data class Locked(
|
||||
val sessionId: String,
|
||||
val model: String,
|
||||
val provider: String?,
|
||||
val effectiveModel: String = model,
|
||||
val effectiveProvider: String? = provider,
|
||||
) : ApiModelSelectionAck
|
||||
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
|
||||
}
|
||||
|
||||
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
|
||||
|
||||
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
|
||||
when {
|
||||
capabilities.sessionModelLock && capabilities.modelOptions ->
|
||||
ApiModelRoutingStrategy.LOCKED
|
||||
capabilities.sessionModelLock ->
|
||||
ApiModelRoutingStrategy.INCOMPLETE
|
||||
else ->
|
||||
ApiModelRoutingStrategy.LEGACY_HINT
|
||||
}
|
||||
|
||||
internal fun sessionTurnModelHint(
|
||||
acknowledgement: ApiModelSelectionAck,
|
||||
requestedModel: String?,
|
||||
): String? =
|
||||
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
|
||||
|
||||
internal data class ParsedApiModelLockAck(
|
||||
val sessionId: String?,
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val state: String?,
|
||||
val effectiveModel: String?,
|
||||
val effectiveProvider: String?,
|
||||
)
|
||||
|
||||
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val runtime = root["runtime"] as? JsonObject ?: return null
|
||||
val requested = runtime["requested"] as? JsonObject
|
||||
val effective = runtime["effective"] as? JsonObject
|
||||
return ParsedApiModelLockAck(
|
||||
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun confirmedRuntimeMatches(
|
||||
runtime: JsonObject?,
|
||||
expected: ApiModelSelectionAck.Locked,
|
||||
): Boolean {
|
||||
runtime ?: return false
|
||||
val effective = runtime["effective"] as? JsonObject ?: return false
|
||||
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
|
||||
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
|
||||
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
@@ -315,6 +447,8 @@ class HermesApiClient(
|
||||
isLenient = true
|
||||
}
|
||||
) {
|
||||
@Volatile
|
||||
private var lastCapabilities: ServerCapabilities? = null
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
@@ -634,6 +768,205 @@ class HermesApiClient(
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
|
||||
suspend fun getProviderModelOptions(
|
||||
refresh: Boolean = false,
|
||||
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val suffix = if (refresh) "?refresh=true" else ""
|
||||
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
if (response.code == 404) {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||
} else {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
|
||||
},
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
"Model inventory authorization failed (HTTP ${response.code})."
|
||||
} else {
|
||||
"Model inventory unavailable (HTTP ${response.code})."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
|
||||
?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory returned an invalid response.",
|
||||
),
|
||||
)
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and, on capable servers, persist a model/provider lock before a
|
||||
* session turn is submitted. This never writes global config.
|
||||
*/
|
||||
suspend fun acknowledgeSessionModelSelection(
|
||||
sessionId: String,
|
||||
model: String?,
|
||||
provider: String?,
|
||||
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
|
||||
val selectedModel = AgentDisplay.requestModelName(model)
|
||||
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
|
||||
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
|
||||
// Capability snapshots can be populated by a disconnected startup
|
||||
// probe. Re-probe at the lock boundary instead of trusting a stale
|
||||
// false forever after the connection recovers.
|
||||
val capabilities = probeCapabilities()
|
||||
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
|
||||
val inventory = getProviderModelOptions().getOrElse {
|
||||
return@withContext Result.failure(it)
|
||||
}
|
||||
val aliases = getModelOptions()
|
||||
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val providerModel = selectedRoot ?: selectedModel
|
||||
val providerRow = when {
|
||||
selectedProvider != null ->
|
||||
inventory.providers.firstOrNull { it.slug == selectedProvider }
|
||||
else -> inventory.providers.singleOrNull { providerModel in it.models }
|
||||
?: inventory.providers.firstOrNull {
|
||||
it.isCurrent && providerModel in it.models
|
||||
}
|
||||
} ?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not in the API server's authenticated inventory.",
|
||||
),
|
||||
)
|
||||
if (!providerRow.authenticated) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
|
||||
"The selected provider is not authenticated on this profile.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel !in providerRow.models) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not available from ${providerRow.name}.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel in providerRow.unavailableModels) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
"The selected model is not available on the authenticated account.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val body = kotlinx.serialization.json.buildJsonObject {
|
||||
put("model", selectedModel)
|
||||
put("provider", providerRow.slug)
|
||||
}
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
|
||||
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body.string()
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
streamHttpFailureMessage(
|
||||
response.code,
|
||||
response.message,
|
||||
response.header("Retry-After"),
|
||||
responseBody,
|
||||
json,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val ack = parseApiModelLockAck(json, responseBody)
|
||||
if (
|
||||
ack?.sessionId != sessionId ||
|
||||
ack?.model != selectedModel ||
|
||||
ack?.provider != providerRow.slug ||
|
||||
ack?.state != "accepted" ||
|
||||
ack?.effectiveModel.isNullOrBlank() ||
|
||||
ack?.effectiveProvider.isNullOrBlank()
|
||||
) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
|
||||
"Server did not acknowledge the requested model lock.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val confirmedAck = requireNotNull(ack)
|
||||
Result.success(
|
||||
ApiModelSelectionAck.Locked(
|
||||
sessionId = sessionId,
|
||||
model = selectedModel,
|
||||
provider = providerRow.slug,
|
||||
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
|
||||
effectiveProvider = confirmedAck.effectiveProvider,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
"Model lock request failed before the message was sent.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
|
||||
"Server advertises an incomplete model-routing contract.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (selectedProvider != null) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
|
||||
"This Hermes version cannot safely preserve a provider selection on API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val advertised = getModelOptions().map { it.id }
|
||||
if (selectedModel !in advertised) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"This Hermes version did not advertise the selected model for API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -756,6 +1089,7 @@ class HermesApiClient(
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
@@ -786,6 +1120,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
@@ -806,7 +1141,13 @@ class HermesApiClient(
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -882,9 +1223,17 @@ class HermesApiClient(
|
||||
}
|
||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||
"assistant.completed" -> {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
}
|
||||
} else if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
@@ -896,9 +1245,15 @@ class HermesApiClient(
|
||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
} else if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
@@ -908,7 +1263,13 @@ class HermesApiClient(
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
@@ -987,7 +1348,13 @@ class HermesApiClient(
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server closed the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1897,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
if (!healthy) {
|
||||
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||
return@withContext ServerCapabilities.DISCONNECTED
|
||||
}
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
@@ -1544,7 +1914,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
if (advertisedCapabilities != null) {
|
||||
lastCapabilities = advertisedCapabilities
|
||||
return@withContext advertisedCapabilities
|
||||
}
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
@@ -1589,7 +1962,7 @@ class HermesApiClient(
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
).also { lastCapabilities = it }
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
||||
*
|
||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||
* consumes `message` (or `input`) and `system_message` (or
|
||||
* `instructions`, string only). `message` accepts either a plain string
|
||||
* or OpenAI-style content parts (text + `image_url`) via
|
||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
||||
* `model`, and `profile` are NOT parsed.
|
||||
* `instructions`, string only). Newer servers also parse per-request model
|
||||
* fields and reuse a backend-acknowledged session model lock when those
|
||||
* fields are omitted. Android therefore acknowledges a lock first and
|
||||
* omits `model` on that turn; the builder's model field remains only for
|
||||
* older-server compatibility. Top-level `messages`, `attachments`, and
|
||||
* `profile` are not parsed.
|
||||
*
|
||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||
* array), `instructions`, `conversation_history` (array of
|
||||
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
|
||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||
* Top-level `attachments` and `profile` are NOT parsed.
|
||||
*
|
||||
* Legacy hint fields we deliberately keep sending although current native
|
||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
||||
* `profile` on runs/completions, and `stream` on runs. They are
|
||||
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||
* configuration hints (never user content, so they cannot mask data
|
||||
* loss) honored by legacy fork builds — the runs path in particular only
|
||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
private const val NATIVE_PKCE_FLOW = "native_pkce"
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val TOKEN_KEY = "dashboard_native_tokens_json"
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
@Serializable
|
||||
data class NativeDashboardTokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String = "",
|
||||
@SerialName("expires_at") val expiresAt: Long = 0L,
|
||||
val provider: String = "",
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
)
|
||||
|
||||
interface NativeDashboardTokenStore {
|
||||
/** Stable, non-secret identity used to serialize refresh-token rotation. */
|
||||
val coordinationKey: String
|
||||
fun load(): NativeDashboardTokens?
|
||||
fun save(tokens: NativeDashboardTokens)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
|
||||
NativeTokenRefreshCoordinator.clear(store)
|
||||
}
|
||||
|
||||
class EncryptedNativeDashboardTokenStore(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = tokenStoreKey
|
||||
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
|
||||
buildRawTokenStore(context.applicationContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
override fun load(): NativeDashboardTokens? =
|
||||
store.getString(TOKEN_KEY)?.let { raw ->
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
|
||||
* its verifier and CSRF state must never be persisted, logged, or copied into
|
||||
* Compose/SavedState UI state.
|
||||
*/
|
||||
class NativeDashboardAuthorization internal constructor(
|
||||
val authorizationUrl: String,
|
||||
internal val verifier: String,
|
||||
internal val state: String,
|
||||
internal val generation: Long,
|
||||
)
|
||||
|
||||
class NativeDashboardAuthClient(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
) {
|
||||
private val baseUrl = baseUrl.trim().trimEnd('/')
|
||||
|
||||
fun supportsNativePkce(status: DashboardStatus): Boolean =
|
||||
NATIVE_PKCE_FLOW in status.authFlows
|
||||
|
||||
fun beginAuthorization(
|
||||
redirectUri: String,
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
|
||||
// trailing "=", which makes Hermes' standards-compliant S256
|
||||
// comparison fail even though both sides hashed the same bytes.
|
||||
val verifier = randomBytes(32).base64Url().trimEnd('=')
|
||||
val challenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val state = randomBytes(24).base64Url()
|
||||
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
|
||||
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val url = root.newBuilder()
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
.addQueryParameter("code_challenge_method", "S256")
|
||||
.addQueryParameter("redirect_uri", redirectUri)
|
||||
.addQueryParameter("state", state)
|
||||
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||
.build()
|
||||
.toString()
|
||||
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||
}
|
||||
|
||||
/**
|
||||
* A private-route dashboard may be configured with a canonical HTTPS
|
||||
* callback origin for its provider. Starting the browser on the private
|
||||
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
|
||||
* discover the provider's declared callback and start native auth there.
|
||||
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
|
||||
* to the active connection route.
|
||||
*/
|
||||
private fun resolveAuthorizationBaseUrl(provider: String?): String {
|
||||
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
|
||||
if (
|
||||
!provider.equals("nous", ignoreCase = true) ||
|
||||
configured.scheme != "http" ||
|
||||
!isPrivateNetworkLiteral(configured.host)
|
||||
) {
|
||||
return baseUrl
|
||||
}
|
||||
val loginUrl = configured.newBuilder()
|
||||
.addPathSegments("auth/login")
|
||||
.addQueryParameter("provider", provider)
|
||||
.addQueryParameter("next", "/")
|
||||
.build()
|
||||
val discoveryClient = client.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
val location = discoveryClient.newCall(
|
||||
Request.Builder().url(loginUrl).get().build(),
|
||||
).execute().use { response ->
|
||||
if (response.code !in 300..399) null else response.header("Location")
|
||||
}
|
||||
return canonicalDashboardBaseFromNousRedirect(location)
|
||||
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
|
||||
}
|
||||
|
||||
fun exchangeCallback(
|
||||
authorization: NativeDashboardAuthorization,
|
||||
callbackTarget: String,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val callback = callbackTarget.toHttpUrlOrNull()
|
||||
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
|
||||
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
|
||||
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
|
||||
throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not use the expected loopback path",
|
||||
)
|
||||
}
|
||||
if (callback.queryParameter("state") != authorization.state) {
|
||||
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
|
||||
}
|
||||
callback.queryParameter("error")?.let {
|
||||
throw NativeDashboardCallbackException(
|
||||
message = "Gateway rejected native sign-in",
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
val code = callback.queryParameter("code")
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not include an authorization code",
|
||||
)
|
||||
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
|
||||
return postTokens(
|
||||
path = "/auth/native/token",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = authorization.generation,
|
||||
commitAllowed = commitAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
|
||||
NativeTokenRefreshCoordinator.cancelAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
authorization.generation,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearStoredSession() {
|
||||
clearNativeDashboardTokens(tokenStore)
|
||||
}
|
||||
|
||||
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
|
||||
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load()
|
||||
?: tokens
|
||||
?: throw IOException("No native dashboard session is stored")
|
||||
// A sibling client may already have rotated the single-use refresh
|
||||
// token while this caller was waiting. Adopt that winner instead
|
||||
// of replaying the stale token.
|
||||
if (tokens != null && current != tokens) return@synchronized current
|
||||
if (current.refreshToken.isBlank()) {
|
||||
clearIfUnchanged(current)
|
||||
throw IOException("Native dashboard session cannot be refreshed")
|
||||
}
|
||||
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
|
||||
val generation = NativeTokenRefreshCoordinator.currentGeneration(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
try {
|
||||
postTokens(
|
||||
path = "/auth/native/refresh",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = generation,
|
||||
)
|
||||
} catch (error: NativeDashboardAuthHttpException) {
|
||||
if (error.statusCode == 400 || error.statusCode == 401) {
|
||||
clearIfUnchanged(current)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postTokens(
|
||||
path: String,
|
||||
payload: String,
|
||||
clearOnAuthFailure: Boolean,
|
||||
expectedGeneration: Long,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val url = "$baseUrl$path".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(payload.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val tokens = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
|
||||
tokenStore.clear()
|
||||
}
|
||||
throw NativeDashboardAuthHttpException(response.code)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
|
||||
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
|
||||
.also {
|
||||
if (it.accessToken.isBlank()) {
|
||||
throw IOException("Dashboard token response did not include an access token")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
if (!commitAllowed() ||
|
||||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
|
||||
expectedGeneration
|
||||
) {
|
||||
throw IOException("Dashboard sign-in is no longer active")
|
||||
}
|
||||
tokenStore.save(tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
|
||||
|
||||
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
|
||||
if (tokenStore.load() == expected) tokenStore.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requireStrictLoopbackRedirect(redirectUri: String) {
|
||||
val url = redirectUri.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
|
||||
require(url.scheme == "http" && url.host == "127.0.0.1") {
|
||||
"Native redirect must use the 127.0.0.1 loopback address"
|
||||
}
|
||||
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
|
||||
"Native redirect must use an ephemeral port and the exact /callback path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class NativeDashboardCallbackException(
|
||||
message: String,
|
||||
val retryable: Boolean = true,
|
||||
) : IOException(message)
|
||||
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(
|
||||
url.scheme == "http" &&
|
||||
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes already permits explicitly configured HTTP dashboard sessions on
|
||||
* local routes. The brokered flow is no less protected than that cookie flow,
|
||||
* but remains unavailable to arbitrary cleartext Internet hosts.
|
||||
*/
|
||||
private fun isPrivateNetworkLiteral(host: String): Boolean {
|
||||
val octets = host.split('.').mapNotNull(String::toIntOrNull)
|
||||
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
return first == 10 ||
|
||||
(first == 172 && second in 16..31) ||
|
||||
(first == 192 && second == 168) ||
|
||||
(first == 100 && second in 64..127)
|
||||
}
|
||||
|
||||
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
|
||||
val providerUrl = location?.toHttpUrlOrNull() ?: return null
|
||||
if (
|
||||
providerUrl.scheme != "https" ||
|
||||
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val callback = providerUrl.queryParameter("redirect_uri")
|
||||
?.toHttpUrlOrNull()
|
||||
?: return null
|
||||
if (callback.scheme != "https") return null
|
||||
val callbackSuffix = "/auth/callback"
|
||||
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
|
||||
val basePath = callback.encodedPath
|
||||
.removeSuffix(callbackSuffix)
|
||||
.ifBlank { "/" }
|
||||
return callback.newBuilder()
|
||||
.encodedPath(basePath)
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the native bearer to dashboard REST calls and rotates it before expiry
|
||||
* or after one 401. Refresh requests use a separate bare client, so neither a
|
||||
* stale bearer nor the authenticator can recurse into token rotation.
|
||||
*/
|
||||
class DashboardBearerAuth(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||
) : Interceptor, Authenticator {
|
||||
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||
val request = tokens?.let {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${it.accessToken}")
|
||||
.build()
|
||||
} ?: chain.request()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
val previous = response.request.header("Authorization") ?: return null
|
||||
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
|
||||
val tokens = usableTokens(
|
||||
forceRefresh = true,
|
||||
failedAccessToken = failedAccessToken,
|
||||
) ?: return null
|
||||
val next = "Bearer ${tokens.accessToken}"
|
||||
if (next == previous) return null
|
||||
return response.request.newBuilder().header("Authorization", next).build()
|
||||
}
|
||||
|
||||
private fun usableTokens(
|
||||
forceRefresh: Boolean,
|
||||
failedAccessToken: String?,
|
||||
): NativeDashboardTokens? =
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load() ?: return@synchronized null
|
||||
// A request can receive its 401 after another client already
|
||||
// rotated the token. Retry with the winner; do not rotate again.
|
||||
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
|
||||
return@synchronized current
|
||||
}
|
||||
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
|
||||
if (!forceRefresh && !nearExpiry) return@synchronized current
|
||||
runCatching { authClient.refresh(current) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count += 1
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private class NativeDashboardAuthHttpException(
|
||||
val statusCode: Int,
|
||||
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||
|
||||
private object NativeTokenRefreshCoordinator {
|
||||
private val locks = ConcurrentHashMap<String, Any>()
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
|
||||
|
||||
fun currentGeneration(key: String): Long =
|
||||
synchronized(lockFor(key)) { generations[key] ?: 0L }
|
||||
|
||||
fun beginAuthorization(key: String): Long =
|
||||
synchronized(lockFor(key)) {
|
||||
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
|
||||
}
|
||||
|
||||
fun cancelAuthorization(key: String, expectedGeneration: Long) {
|
||||
synchronized(lockFor(key)) {
|
||||
if ((generations[key] ?: 0L) == expectedGeneration) {
|
||||
generations[key] = expectedGeneration + 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(store: NativeDashboardTokenStore) {
|
||||
synchronized(lockFor(store.coordinationKey)) {
|
||||
generations[store.coordinationKey] =
|
||||
(generations[store.coordinationKey] ?: 0L) + 1L
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenExchange(
|
||||
val code: String,
|
||||
@SerialName("code_verifier") val codeVerifier: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenRefresh(
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
val provider: String,
|
||||
)
|
||||
+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
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -41,15 +41,15 @@ import java.util.concurrent.TimeUnit
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||
* must carry the same exact-origin cookie or native bearer session used by
|
||||
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||
@@ -66,12 +66,10 @@ class StandardHermesVoiceClient(
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
standardHermesDashboardAudioClient(okHttpClient)
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
@@ -102,7 +100,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
@@ -116,6 +114,7 @@ class StandardHermesVoiceClient(
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
@@ -138,7 +137,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
@@ -162,13 +161,14 @@ class StandardHermesVoiceClient(
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val callClient = callClient(baseUrl)
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
@@ -195,7 +195,14 @@ class StandardHermesVoiceClient(
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
private fun callClient(baseUrl: String): OkHttpClient =
|
||||
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
|
||||
|
||||
private fun executeJson(
|
||||
request: Request,
|
||||
operation: String,
|
||||
callClient: OkHttpClient,
|
||||
): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
+5
-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
|
||||
@@ -391,7 +392,9 @@ data class HermesSseEvent(
|
||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||
val text: String? = null, // /v1/runs reasoning.available text
|
||||
// Usage/token fields (on assistant.completed / run.completed)
|
||||
val usage: UsageInfo? = null
|
||||
val usage: UsageInfo? = null,
|
||||
// Native session routing proof on run.started and terminal events.
|
||||
val runtime: JsonObject? = null,
|
||||
) {
|
||||
/** Resolve the event type from whichever field is populated. */
|
||||
val resolvedType: String?
|
||||
|
||||
+30
@@ -33,6 +33,7 @@ object InteractionRequestNotifier {
|
||||
private const val TAG = "InteractionNotifier"
|
||||
internal const val CHANNEL_ID = "chat_interactions"
|
||||
private const val CHANNEL_NAME = "Hermes needs input"
|
||||
private const val GROUP_KEY = "gateway-interactions"
|
||||
internal const val NOTIFICATION_ID = 3823
|
||||
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
||||
|
||||
@@ -72,6 +73,31 @@ object InteractionRequestNotifier {
|
||||
-> "Open Hermes to respond securely."
|
||||
}
|
||||
|
||||
internal fun safeExpandedBody(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val action = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL ->
|
||||
"Review the requested action. Nothing is approved from the notification."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
|
||||
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
|
||||
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
|
||||
}
|
||||
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
|
||||
val sessionLabel = sessionId.takeLast(12)
|
||||
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
|
||||
}
|
||||
|
||||
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Review approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Answer"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Respond securely"
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
context: Context,
|
||||
@@ -106,6 +132,7 @@ object InteractionRequestNotifier {
|
||||
)
|
||||
val title = safeTitle(ask)
|
||||
val body = safeBody(ask)
|
||||
val expandedBody = safeExpandedBody(sessionId, ask, profile)
|
||||
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes needs your input")
|
||||
@@ -117,6 +144,7 @@ object InteractionRequestNotifier {
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
@@ -124,6 +152,8 @@ object InteractionRequestNotifier {
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.setGroup(GROUP_KEY)
|
||||
.addAction(0, actionLabel(ask), tapPending)
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -108,6 +109,7 @@ import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -154,7 +156,6 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
@@ -183,8 +184,8 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
|
||||
|
||||
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||
// time to tap the action before it auto-dismisses.
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
||||
showSnackbar(
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||
return showSnackbar(
|
||||
message = err.body,
|
||||
actionLabel = err.actionLabel,
|
||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
@@ -599,15 +600,9 @@ fun RelayApp() {
|
||||
val standardVoiceClient = remember {
|
||||
StandardHermesVoiceClient(
|
||||
context = mediaContext,
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.cookieJar(
|
||||
DynamicDashboardCookieJar {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
},
|
||||
)
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
dashboardHttpClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
|
||||
},
|
||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||
// Live read (null for the default profile) — sent defensively on
|
||||
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
||||
@@ -1476,10 +1471,10 @@ fun RelayApp() {
|
||||
// The VM's cached per-connection store — the prewarm must NOT
|
||||
// construct its own (each instance lazily pays a multi-second
|
||||
// Keystore keyset build under a process-global Tink lock).
|
||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
||||
?: return@LaunchedEffect
|
||||
prewarmDashboardManage(
|
||||
cookieStore = cookieStore,
|
||||
clientFactory = {
|
||||
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
|
||||
},
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
@@ -1926,6 +1921,14 @@ fun RelayApp() {
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
voicePresentationMode = VoicePresentationMode.fromStorage(
|
||||
voiceSettings.presentationMode,
|
||||
),
|
||||
onVoicePresentationModeChange = { mode ->
|
||||
connectionSwitchScope.launch {
|
||||
voicePreferences.setPresentationMode(mode)
|
||||
}
|
||||
},
|
||||
openAgentSheetOnEntry = openAgentSheetArg,
|
||||
onAgentSheetArgConsumed = {
|
||||
backStackEntry.arguments?.putBoolean(
|
||||
@@ -1943,6 +1946,16 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onRepairConnection = {
|
||||
navController.navigate(
|
||||
Screen.Pair.route(
|
||||
connectionId = activeConnectionId,
|
||||
autoStart = "relay",
|
||||
),
|
||||
) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Empty-chat "needs connection" card also offers the offline
|
||||
// demo, so a skipped / never-connected first run can explore
|
||||
// without leaving Chat. Safe here — this state only shows when
|
||||
@@ -2301,8 +2314,8 @@ fun RelayApp() {
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
dashboardUrl = voiceDashboardUrl,
|
||||
dashboardCookieStoreProvider = {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
dashboardClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardClientForActive(dashboardUrl)
|
||||
},
|
||||
onOpenManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
@@ -2640,6 +2653,17 @@ fun RelayApp() {
|
||||
onNavigateToRealtimeVoice = {
|
||||
navController.navigate(Screen.RealtimeVoiceTest.route)
|
||||
},
|
||||
onNavigateToImageGenerationLab = {
|
||||
terminalAppContext.startActivity(
|
||||
android.content.Intent().apply {
|
||||
setClassName(
|
||||
terminalAppContext,
|
||||
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.RealtimeVoiceTest.route) {
|
||||
|
||||
+5
-10
@@ -268,7 +268,6 @@ fun ActiveCardRelayStatusSection(
|
||||
@Composable
|
||||
fun ActiveCardFeaturesSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
@@ -349,21 +348,19 @@ fun ActiveCardFeaturesSection(
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
@@ -614,7 +611,6 @@ private fun CapabilityRow(
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
||||
onPairRelay: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -654,7 +650,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -731,7 +727,7 @@ fun ActiveCardAdvancedSection(
|
||||
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
||||
Text(stringResource(R.string.active_section_other_relay_methods))
|
||||
}
|
||||
if (relayEnabled && relayEditorOpen) {
|
||||
if (relayEditorOpen) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -746,7 +742,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled = true, showApi = false, showRelay = true)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = false, showRelay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,7 +794,6 @@ fun ActiveCardAdvancedSection(
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
showApi: Boolean,
|
||||
showRelay: Boolean,
|
||||
) {
|
||||
@@ -960,7 +955,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled && showRelay) {
|
||||
if (showRelay) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
|
||||
@@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -261,8 +262,12 @@ fun AgentTextFlow(
|
||||
motionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||
// text on both paths so assistive tech hears the words.
|
||||
|
||||
+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
|
||||
}
|
||||
+1265
-16
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(
|
||||
|
||||
+918
-11
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,11 @@ fun MarkdownContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
color = textColor,
|
||||
)
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
@@ -87,7 +92,8 @@ fun MarkdownContent(
|
||||
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
||||
// live font-picker still applies) and is capped so the largest heading is
|
||||
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings.
|
||||
// proportionate to the 15sp body, matching Discord / GitHub-mobile
|
||||
// in-message headings.
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
@@ -108,16 +114,17 @@ fun MarkdownContent(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||
color = textColor.copy(alpha = 0.85f),
|
||||
),
|
||||
// Prose, list items, and quotes all sit at the 14sp body size so a
|
||||
// paragraph and the bullet list under it share one rhythm — the library
|
||||
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
|
||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
quote = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
|
||||
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
|
||||
// The library default 'text'/list role is bodyLarge (16sp), while
|
||||
// bodyMedium was previously 14sp and unnecessarily small for long chat.
|
||||
paragraph = chatBodyStyle,
|
||||
text = chatBodyStyle,
|
||||
bullet = chatBodyStyle,
|
||||
ordered = chatBodyStyle,
|
||||
list = chatBodyStyle,
|
||||
quote = chatBodyStyle.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = textColor.copy(alpha = 0.9f),
|
||||
),
|
||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||
// + the tinted chip already signal "code" without also shrinking it, and
|
||||
@@ -125,7 +132,7 @@ fun MarkdownContent(
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = textColor,
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
@@ -302,7 +309,10 @@ fun StreamingMarkdownContent(
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -139,6 +138,8 @@ fun MessageBubble(
|
||||
* happening.
|
||||
*/
|
||||
recoveringAnswer: Boolean = false,
|
||||
imageGenerationStylePreference: String = "rotate",
|
||||
imageGenerationRotationIndex: Int = 0,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -173,7 +174,7 @@ fun MessageBubble(
|
||||
|
||||
val textColor = when (message.role) {
|
||||
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
|
||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
|
||||
@@ -211,6 +212,23 @@ fun MessageBubble(
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val hasImageGenerationCall = remember(message.toolCalls) {
|
||||
message.toolCalls.any {
|
||||
it.name.trim().lowercase() == "image_generate"
|
||||
}
|
||||
}
|
||||
val imageGenerationStartMillis = remember(message.toolCalls) {
|
||||
imageGenerationStartedAt(message.toolCalls)
|
||||
}
|
||||
val imageGenerationVisualStyle = remember(
|
||||
imageGenerationStylePreference,
|
||||
imageGenerationRotationIndex,
|
||||
) {
|
||||
resolveImageGenerationVisualStyle(
|
||||
preference = imageGenerationStylePreference,
|
||||
rotationIndex = imageGenerationRotationIndex,
|
||||
)
|
||||
}
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -297,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
|
||||
@@ -427,7 +469,10 @@ fun MessageBubble(
|
||||
// Plain text for user and system messages
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
@@ -485,44 +530,78 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation owns the bubble's progress slot. It replaces
|
||||
// the generic first-token dots, remains mounted through the
|
||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
||||
// attachment renderer in this same Surface.
|
||||
Crossfade(
|
||||
targetState = showImageGeneration,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "imageGenerationToResult",
|
||||
) { generating ->
|
||||
if (generating) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationPlaceholder()
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
// Image generation owns the bubble's progress slot. Keep the
|
||||
// selected progress treatment mounted under the real result,
|
||||
// then reveal the same collapsible attachment surface without
|
||||
// rebuilding the surrounding message bubble.
|
||||
if (hasImageGenerationCall) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationResultTransition(
|
||||
generating = showImageGeneration,
|
||||
startedAtMillis = imageGenerationStartMillis,
|
||||
visualStyle = imageGenerationVisualStyle,
|
||||
) {
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator — only while awaiting the first token. Once
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -156,7 +157,9 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
||||
@@ -204,6 +207,7 @@ import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.HumanErrorAction
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlin.math.abs
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
@@ -280,6 +284,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
|
||||
previous.messageCount == messageCount &&
|
||||
previous.lastMessageUiKey == lastMessageUiKey
|
||||
|
||||
internal fun releaseRetainedLiveTail(
|
||||
retainedUiKey: String?,
|
||||
completedUiKey: String?,
|
||||
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
|
||||
|
||||
internal fun tailEndScrollOffset(
|
||||
tailSizePx: Int,
|
||||
footerSizePx: Int,
|
||||
viewportSizePx: Int,
|
||||
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
|
||||
|
||||
private class ChatTailTransitionRef(
|
||||
var snapshot: ChatScrollSnapshot? = null,
|
||||
)
|
||||
@@ -439,6 +454,8 @@ fun ChatScreen(
|
||||
voiceViewModel: VoiceViewModel,
|
||||
voiceClient: RelayVoiceClient? = null,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
||||
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
||||
// fires so the host can clear the nav arg (prevents re-open on tab
|
||||
@@ -451,6 +468,7 @@ fun ChatScreen(
|
||||
// don't wire navigation.
|
||||
onNavigateToConnections: () -> Unit = {},
|
||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||
onRepairConnection: () -> Unit = onNavigateToConnect,
|
||||
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
|
||||
// skipped / never-connected first run can explore without a server. null hides it.
|
||||
onTryDemo: (() -> Unit)? = null,
|
||||
@@ -465,9 +483,10 @@ fun ChatScreen(
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
||||
val chatAlpha by animateFloatAsState(
|
||||
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f,
|
||||
targetValue = if (
|
||||
voiceUiState.voiceMode && voicePresentationMode == VoicePresentationMode.Focus
|
||||
) 0.4f else 1f,
|
||||
animationSpec = tween(300),
|
||||
label = "chatAlpha",
|
||||
)
|
||||
@@ -477,7 +496,13 @@ fun ChatScreen(
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
LaunchedEffect(chatViewModel) {
|
||||
chatViewModel.errorEvents.collect { err ->
|
||||
snackbarHost.showHumanError(err)
|
||||
val result = snackbarHost.showHumanError(err)
|
||||
if (
|
||||
result == SnackbarResult.ActionPerformed &&
|
||||
err.action == HumanErrorAction.Repair
|
||||
) {
|
||||
onRepairConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,9 +688,24 @@ fun ChatScreen(
|
||||
// Animation settings
|
||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
||||
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
||||
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
|
||||
val imageGenerationOrdinals = remember(messages) {
|
||||
var nextOrdinal = 0
|
||||
buildMap {
|
||||
messages.forEach { message ->
|
||||
val generationCount = message.toolCalls.count {
|
||||
it.name.trim().equals("image_generate", ignoreCase = true)
|
||||
}
|
||||
if (generationCount > 0) {
|
||||
put(message.uiKey, nextOrdinal + generationCount - 1)
|
||||
nextOrdinal += generationCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
||||
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
||||
// empty / new-chat view (no messages) — it teaches the long-press entry
|
||||
@@ -773,6 +813,25 @@ fun ChatScreen(
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||
remember(chatViewModel, context) {
|
||||
{ messageId, cardKey, action ->
|
||||
if (action.mode == HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
val handleCardInput: (String, String, String) -> Unit = remember(chatViewModel) {
|
||||
{ messageId, cardKey, value ->
|
||||
chatViewModel.answerAsk(messageId, cardKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
|
||||
// transient snackbar, never a chat bubble.
|
||||
@@ -870,7 +929,6 @@ fun ChatScreen(
|
||||
if (!voiceUiState.voiceMode) {
|
||||
voiceOverlayHost.hide()
|
||||
pendingVoiceOverlayPermission = false
|
||||
voiceCompactMode = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,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)
|
||||
}
|
||||
@@ -1316,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
|
||||
}
|
||||
|
||||
@@ -2284,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 &&
|
||||
@@ -2836,6 +2948,18 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
|
||||
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
|
||||
add(
|
||||
ChatInputPickerOption(
|
||||
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
value = model.id,
|
||||
group = "Routes",
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
add(
|
||||
@@ -3154,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
|
||||
@@ -3185,6 +3308,8 @@ fun ChatScreen(
|
||||
onHermesConfirmationAnswer = { answer ->
|
||||
voiceViewModel.answerHermesConfirmation(answer)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
// === END v0.4.1 ===
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+84
-28
@@ -107,6 +107,7 @@ import com.hermesandroid.relay.network.upstream.McpOAuthFlowCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
@@ -464,15 +465,8 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = cookieStoreFactory(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun loadDashboardSection(
|
||||
@@ -1548,6 +1542,41 @@ private fun ManageOverviewBody(
|
||||
)
|
||||
}
|
||||
}
|
||||
status?.componentHealth
|
||||
?.takeIf { it.supported }
|
||||
?.let { health ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
RelaySectionCaption(
|
||||
title = stringResource(R.string.dashboard_metric_dashboard),
|
||||
meta = health.overall?.replaceFirstChar(Char::uppercase)
|
||||
?: stringResource(R.string.conn_label_status),
|
||||
)
|
||||
dashboardComponentHealthLines(
|
||||
health = health,
|
||||
connectedLabel = stringResource(R.string.dashboard_component_connected),
|
||||
serverErrorsLabel = stringResource(R.string.dashboard_component_server_errors_5m),
|
||||
).forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val signInStatus = status
|
||||
if (signInStatus?.authRequired == true && authenticated != true) {
|
||||
item {
|
||||
@@ -1916,7 +1945,7 @@ private suspend fun fetchDashboardSectionStateWith(
|
||||
* start lands on an already-populated Manage tab.
|
||||
*/
|
||||
internal suspend fun prewarmDashboardManage(
|
||||
cookieStore: DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
effectiveProfileName: String? = null,
|
||||
@@ -1945,12 +1974,7 @@ internal suspend fun prewarmDashboardManage(
|
||||
// encrypted cookie store per section: 8 Keystore keyset builds, each
|
||||
// holding Tink's process-global lock for seconds on StrongBox devices,
|
||||
// which starved main-thread keystore users and froze the UI at startup.
|
||||
val client = withContext(Dispatchers.IO) {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStore = cookieStore),
|
||||
)
|
||||
}
|
||||
val client = withContext(Dispatchers.IO) { clientFactory() }
|
||||
try {
|
||||
val preamble = try {
|
||||
fetchDashboardPreamble(client)
|
||||
@@ -2598,6 +2622,7 @@ private fun DashboardOAuthSignInDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -2634,16 +2659,7 @@ private fun DashboardOAuthSignInDialog(
|
||||
statusText = context.getString(R.string.dashboard_oauth_verifying)
|
||||
scope.launch {
|
||||
try {
|
||||
val session = withDashboardClient(
|
||||
clientFactory = {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = cookieStoreFactory(),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { client ->
|
||||
val session = withDashboardClient(clientFactory = clientFactory) { client ->
|
||||
client.currentSession().getOrNull()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
@@ -3466,6 +3482,7 @@ private fun CustomEndpointDialog(
|
||||
var discoverModels by remember(existing) {
|
||||
mutableStateOf(existing?.meta?.contains("discover=off") != true)
|
||||
}
|
||||
var validatedModels by remember(existing) { mutableStateOf(emptyList<String>()) }
|
||||
var busy by remember(existing) { mutableStateOf(false) }
|
||||
var message by remember(existing) { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -3474,6 +3491,7 @@ private fun CustomEndpointDialog(
|
||||
name = name.trim(),
|
||||
baseUrl = baseUrl.trim(),
|
||||
model = model.trim(),
|
||||
models = validatedModels,
|
||||
apiKey = apiKey.takeIf { it.isNotBlank() },
|
||||
contextLength = contextLength.toIntOrNull(),
|
||||
discoverModels = discoverModels,
|
||||
@@ -3488,8 +3506,24 @@ private fun CustomEndpointDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
|
||||
OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) }, enabled = !busy)
|
||||
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
|
||||
OutlinedTextField(
|
||||
baseUrl,
|
||||
{
|
||||
baseUrl = it
|
||||
validatedModels = emptyList()
|
||||
},
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) },
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(
|
||||
model,
|
||||
{
|
||||
model = it
|
||||
validatedModels = emptyList()
|
||||
},
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) },
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
@@ -3518,6 +3552,11 @@ private fun CustomEndpointDialog(
|
||||
busy = false
|
||||
message = result.fold(
|
||||
onSuccess = { validation ->
|
||||
validatedModels = validation.models
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(256)
|
||||
validation.message.ifBlank {
|
||||
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
|
||||
}
|
||||
@@ -3837,6 +3876,23 @@ internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryI
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
internal fun dashboardComponentHealthLines(
|
||||
health: DashboardComponentHealthRollup,
|
||||
connectedLabel: String = "connected",
|
||||
serverErrorsLabel: String = "server errors / 5m",
|
||||
): List<String> = health.components.map { component ->
|
||||
buildList {
|
||||
add("${component.name}: ${component.status}")
|
||||
component.message?.takeIf(String::isNotBlank)?.let(::add)
|
||||
if (component.configured != null || component.connected != null) {
|
||||
add("${component.connected ?: 0}/${component.configured ?: 0} $connectedLabel")
|
||||
}
|
||||
component.unhandled5xxCount5m
|
||||
?.takeIf { it > 0 }
|
||||
?.let { add("$it $serverErrorsLabel") }
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun summarizeRoot(root: JsonElement): String {
|
||||
return when (root) {
|
||||
is JsonObject -> {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -21,12 +22,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,11 +40,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
|
||||
@@ -49,10 +52,19 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
@@ -66,7 +78,9 @@ fun DashboardSignInScreen(
|
||||
onBack: () -> Unit,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
@@ -78,22 +92,22 @@ fun DashboardSignInScreen(
|
||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||
var actionInFlight by remember { mutableStateOf(false) }
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var actionIsError by remember { mutableStateOf(false) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var authFlows by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf<List<String>>(emptyList())
|
||||
}
|
||||
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
|
||||
val cookieStoreFactory = remember(context, connectionId) {
|
||||
val cookieStoreFactory = remember(appContext, connectionId) {
|
||||
{
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
||||
?: EncryptedDashboardCookieStore(appContext, connectionId)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||
@@ -115,7 +129,7 @@ fun DashboardSignInScreen(
|
||||
|
||||
fun finishAuthentication() {
|
||||
scope.launch {
|
||||
invalidateDashboardManageCache(context.cacheDir)
|
||||
invalidateDashboardManageCache(appContext.cacheDir)
|
||||
connectionViewModel.refreshStandardVoice()
|
||||
connectionViewModel.refreshDashboardProfiles()
|
||||
authenticationComplete = true
|
||||
@@ -125,18 +139,20 @@ fun DashboardSignInScreen(
|
||||
LaunchedEffect(dashboardUrl, connectionId) {
|
||||
if (dashboardUrl.isBlank()) {
|
||||
loading = false
|
||||
actionMessage = context.getString(R.string.dashboard_no_url_configured)
|
||||
actionMessage = resources.getString(R.string.dashboard_no_url_configured)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
actionMessage = it.message ?: resources.getString(R.string.dashboard_request_failed)
|
||||
actionIsError = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providers = status.authProviderDetails.ifEmpty {
|
||||
client.getAuthProviders().getOrNull().orEmpty()
|
||||
}
|
||||
providers = client.getAuthProviders().getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
authFlows = status.authFlows
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
@@ -157,6 +173,7 @@ fun DashboardSignInScreen(
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
actionIsError = false
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
@@ -166,10 +183,12 @@ fun DashboardSignInScreen(
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
?: resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionMessage = e.message ?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
client.shutdown()
|
||||
@@ -177,11 +196,76 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
if (
|
||||
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
|
||||
DashboardRedirectAuthMode.WebView
|
||||
) {
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
|
||||
actionInFlight = true
|
||||
actionIsError = false
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
|
||||
nativeSignInJob = scope.launch {
|
||||
try {
|
||||
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
launchNativeDashboardAuthorization(context, authorizationUrl)
|
||||
}
|
||||
}
|
||||
val client = clientFactory()
|
||||
val session = try {
|
||||
verifyAndRecord(client)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
actionMessage = session.provider?.let {
|
||||
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||
actionIsError = false
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
actionMessage = error.message
|
||||
?: resources.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
nativeSignInJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(dashboardUrl, connectionId) {
|
||||
onDispose { nativeSignInJob?.cancel() }
|
||||
}
|
||||
|
||||
oauthProvider?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
DashboardOAuthScreen(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
clientFactory = clientFactory,
|
||||
onDismiss = { oauthProvider = null },
|
||||
onAuthenticated = { session ->
|
||||
oauthProvider = null
|
||||
@@ -193,13 +277,17 @@ fun DashboardSignInScreen(
|
||||
client.shutdown()
|
||||
}
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
onError = { actionMessage = it },
|
||||
onError = {
|
||||
actionMessage = it
|
||||
actionIsError = true
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -235,8 +323,15 @@ fun DashboardSignInScreen(
|
||||
providers = providers,
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
actionIsError = actionIsError,
|
||||
nativeSignInInFlight = nativeSignInJob != null,
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = { oauthProvider = it },
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionIsError = false
|
||||
nativeSignInJob?.cancel()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,8 +396,11 @@ private fun DashboardSignInForm(
|
||||
providers: List<DashboardAuthProvider>,
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
actionIsError: Boolean,
|
||||
nativeSignInInFlight: Boolean,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
onCancelNativeSignIn: () -> Unit,
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -334,6 +432,14 @@ private fun DashboardSignInForm(
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
@@ -360,15 +466,25 @@ private fun DashboardSignInForm(
|
||||
}
|
||||
}
|
||||
actionMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (actionIsError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DashboardOAuthDialog(
|
||||
private fun DashboardOAuthScreen(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -381,6 +497,8 @@ private fun DashboardOAuthDialog(
|
||||
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
||||
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
var pageProgress by remember { mutableStateOf(0) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||
DashboardApiClient.authLoginUrl(
|
||||
baseUrl = dashboardUrl,
|
||||
@@ -389,14 +507,17 @@ private fun DashboardOAuthDialog(
|
||||
)
|
||||
}
|
||||
|
||||
fun maybeVerify(url: String?) {
|
||||
fun handleNavigation(url: String?) {
|
||||
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||
val root = dashboardUrl.trim().trimEnd('/')
|
||||
val relative = loadedUrl.trim().removePrefix(root)
|
||||
val stillAuthenticating = relative.startsWith("/login", true) ||
|
||||
relative.startsWith("/auth/login", true) ||
|
||||
relative.startsWith("/auth/callback", true)
|
||||
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
|
||||
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
|
||||
DashboardWebViewAuthNavigation.Continue -> return
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
|
||||
statusText = notAcceptedStatus
|
||||
onError(notAcceptedStatus)
|
||||
return
|
||||
}
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
|
||||
}
|
||||
val manager = CookieManager.getInstance()
|
||||
manager.flush()
|
||||
val imported = importDashboardCookieHeader(
|
||||
@@ -408,10 +529,7 @@ private fun DashboardOAuthDialog(
|
||||
checking = true
|
||||
statusText = verifyingStatus
|
||||
scope.launch {
|
||||
val client = DashboardApiClient(
|
||||
dashboardUrl,
|
||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val session = client.currentSession().getOrNull()
|
||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||
@@ -429,39 +547,162 @@ private fun DashboardOAuthDialog(
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
|
||||
}
|
||||
Text(statusText, style = MaterialTheme.typography.bodySmall)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean = false
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
maybeVerify(url)
|
||||
}
|
||||
}
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.stopLoading()
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.dashboard_signin_with_provider,
|
||||
provider.displayName ?: provider.name,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (pageProgress in 0..99) {
|
||||
LinearProgressIndicator(
|
||||
progress = { pageProgress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||
pageProgress = newProgress
|
||||
}
|
||||
}
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val target = request.url.toString()
|
||||
if (
|
||||
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||
) {
|
||||
handleNavigation(target)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
if (request.isForMainFrame) {
|
||||
val message = error.description?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: verifyFailedStatus
|
||||
statusText = message
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
handleNavigation(url)
|
||||
}
|
||||
}
|
||||
webView = this
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class DashboardWebViewAuthNavigation {
|
||||
Continue,
|
||||
ImportAndVerify,
|
||||
RejectLoopbackCallback,
|
||||
}
|
||||
|
||||
/**
|
||||
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
|
||||
* loopback callback belongs to the desktop native-PKCE contract and must never
|
||||
* be followed, imported, or treated as an authenticated Android return.
|
||||
*/
|
||||
internal fun dashboardWebViewAuthNavigation(
|
||||
dashboardUrl: String,
|
||||
loadedUrl: String,
|
||||
): DashboardWebViewAuthNavigation {
|
||||
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
|
||||
?: return DashboardWebViewAuthNavigation.Continue
|
||||
val loaded = loadedUrl.trim().toHttpUrlOrNull()
|
||||
?: return DashboardWebViewAuthNavigation.Continue
|
||||
val sameOrigin = dashboard.scheme == loaded.scheme &&
|
||||
dashboard.host.equals(loaded.host, ignoreCase = true) &&
|
||||
dashboard.port == loaded.port
|
||||
if (!sameOrigin) {
|
||||
val foreignLoopback = loaded.scheme == "http" &&
|
||||
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
|
||||
loaded.encodedPath == "/callback"
|
||||
return if (foreignLoopback) {
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||
} else {
|
||||
DashboardWebViewAuthNavigation.Continue
|
||||
}
|
||||
}
|
||||
|
||||
val basePath = dashboard.encodedPath.trimEnd('/')
|
||||
val relativePath = loaded.encodedPath
|
||||
.removePrefix(basePath)
|
||||
.ifBlank { "/" }
|
||||
return if (
|
||||
relativePath.equals("/login", ignoreCase = true) ||
|
||||
relativePath.equals("/auth/login", ignoreCase = true)
|
||||
) {
|
||||
DashboardWebViewAuthNavigation.Continue
|
||||
} else {
|
||||
// Includes the public /auth/callback response: import its cookies at
|
||||
// root scope, then verify the resulting session through /api/auth/me.
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
||||
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
|
||||
* (clear session / wipe caches), and any experimental toggles.
|
||||
* unlock. Hosts experimental labs, test tools, and data-management utilities.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToRealtimeVoice: () -> Unit = {},
|
||||
onNavigateToImageGenerationLab: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Data management local state — unfolded from the private
|
||||
// DataManagementSection helper in the old SettingsScreen.
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetOnboarding { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RestartAlt,
|
||||
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Relay features toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = relayEnabled,
|
||||
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNavigateToImageGenerationLab) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = stringResource(
|
||||
R.string.dev_settings_open_image_generation_lab_cd
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Lock developer options
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch { FeatureFlags.lockDevOptions(context) }
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
scope.launch {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_locked_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
|
||||
onClick = {
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
if (json == null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -577,8 +601,17 @@ fun DeveloperSettingsScreen(
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetAppData { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
|
||||
|
||||
+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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,9 +112,7 @@ import com.hermesandroid.relay.network.relay.VoiceProviderValidationResponse
|
||||
import com.hermesandroid.relay.network.upstream.ConfigFieldType
|
||||
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.ElevenLabsVoices
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.applyConfigEdits
|
||||
import com.hermesandroid.relay.network.upstream.configValueAt
|
||||
import com.hermesandroid.relay.network.upstream.parseConfigSchema
|
||||
@@ -184,12 +182,12 @@ fun VoiceSettingsScreen(
|
||||
*/
|
||||
connectionId: String? = null,
|
||||
/**
|
||||
* Dashboard base URL + per-connection cookie store provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, cookie auth).
|
||||
* Dashboard base URL + trusted per-connection client provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, session auth).
|
||||
* Null on connections with no dashboard — the editor card is then hidden.
|
||||
*/
|
||||
dashboardUrl: String? = null,
|
||||
dashboardCookieStoreProvider: (() -> DashboardCookieStore?)? = null,
|
||||
dashboardClientProvider: ((String) -> DashboardApiClient)? = null,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
@@ -208,17 +206,12 @@ fun VoiceSettingsScreen(
|
||||
// just observes it; the editor cards push saves back through the VM.
|
||||
val configState by settingsViewModel.configState.collectAsState()
|
||||
|
||||
// Standard-path server voice-config editor client (dashboard cookie auth).
|
||||
// Standard-path server voice-config editor client (cookie or native bearer).
|
||||
// Built once per (dashboardUrl, connection); shut down on dispose. Null when
|
||||
// the connection has no dashboard, which hides the card entirely.
|
||||
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
|
||||
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
|
||||
DashboardApiClient(
|
||||
baseUrl = url,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreProvider?.invoke() ?: InMemoryDashboardCookieStore(),
|
||||
),
|
||||
)
|
||||
dashboardClientProvider?.invoke(url)
|
||||
}
|
||||
DisposableEffect(dashboardConfigClient) {
|
||||
onDispose { dashboardConfigClient?.shutdown() }
|
||||
@@ -3231,6 +3224,17 @@ private fun GlobalVoiceControlsCard(
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
SettingSwitchRow(
|
||||
title = stringResource(R.string.voice_settings_final_answer_only),
|
||||
detail = stringResource(R.string.voice_settings_final_answer_only_desc),
|
||||
checked = voiceSettings.finalAnswerOnly,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { prefsRepo.setFinalAnswerOnly(enabled) }
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.voice_settings_interaction_mode),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
|
||||
@@ -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",
|
||||
@@ -271,6 +277,7 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
body = "The server certificate changed since you paired — re-pair to trust it",
|
||||
retryable = false,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||
action = HumanErrorAction.Repair,
|
||||
)
|
||||
is SecurityException -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,9 +68,11 @@ import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.ToolsetInfo
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
|
||||
@@ -210,12 +212,28 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
endpoint?.dashboard?.url
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
endpoint?.api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.let { return it }
|
||||
endpoint?.api?.url?.let { apiUrl ->
|
||||
connection.dashboardUrl
|
||||
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
|
||||
?.let { return it }
|
||||
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
|
||||
}
|
||||
return connection.resolvedDashboardUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime API route only after the optional fallback was explicitly
|
||||
* configured. Discovery may attach a conventional same-host API candidate to a
|
||||
* Dashboard route, but that candidate alone must not enable API traffic.
|
||||
*/
|
||||
internal fun resolveEffectiveApiServerUrl(
|
||||
savedUrl: String,
|
||||
endpoint: EndpointCandidate?,
|
||||
): String {
|
||||
if (savedUrl.isBlank()) return ""
|
||||
return endpoint?.api?.url?.takeIf { it.isNotBlank() } ?: savedUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Relay transport metadata to the connection's existing standard routes
|
||||
* without adopting the Relay QR's API/Dashboard identity.
|
||||
@@ -390,6 +408,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// Animation
|
||||
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
||||
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
|
||||
private val KEY_IMAGE_GENERATION_STYLE = stringPreferencesKey("image_generation_style")
|
||||
private val KEY_CHAT_RECENT_PROMPTS = booleanPreferencesKey("chat_recent_prompts")
|
||||
|
||||
// Chat scroll behavior
|
||||
@@ -623,7 +642,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
context = application,
|
||||
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
|
||||
dashboardUrlProvider = { activeDashboardUrl() },
|
||||
gatewayKeepAliveProvider = { gatewayKeepAlive.value },
|
||||
gatewayKeepAliveProvider = {
|
||||
gatewayKeepAlive.value || ActiveTurnKeepAliveRegistry.snapshot.value.required
|
||||
},
|
||||
// Lets the dashboard cookie store ride the connection's token keyset
|
||||
// (one keyset build instead of two on cold start).
|
||||
tokenStoreKeyProvider = { cid ->
|
||||
@@ -770,12 +791,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
extraApiUrls = extraApiUrls,
|
||||
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
|
||||
),
|
||||
existing = activeConnection.value?.routeCandidates.orEmpty(),
|
||||
)
|
||||
|
||||
private fun effectiveApiServerUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.api?.url ?: _apiServerUrl.value
|
||||
resolveEffectiveApiServerUrl(
|
||||
savedUrl = _apiServerUrl.value,
|
||||
endpoint = connectionManager.activeEndpoint.value,
|
||||
)
|
||||
|
||||
private fun effectiveRelayUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
|
||||
@@ -851,7 +876,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||
|
||||
// --- API Server state ---
|
||||
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
|
||||
// Blank is the unhydrated sentinel. Seeding this with the legacy localhost
|
||||
// default made a discovered remote API candidate look explicitly configured
|
||||
// during the first DataStore frame, briefly building an unauthenticated
|
||||
// Sessions client before a Dashboard-only connection restored its saved
|
||||
// blank URL.
|
||||
private val _apiServerUrl = MutableStateFlow("")
|
||||
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
|
||||
|
||||
private val _apiServerReachable = MutableStateFlow(false)
|
||||
@@ -958,6 +988,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun activeDashboardCookieStore(): DashboardCookieStore? =
|
||||
upstreamTransport.activeDashboardCookieStore()
|
||||
|
||||
/** Trusted active-connection clients used by the shared dashboard sign-in route. */
|
||||
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient =
|
||||
upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
|
||||
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient =
|
||||
upstreamTransport.dashboardHttpClientForActive(dashboardUrl)
|
||||
|
||||
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
|
||||
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
@@ -1017,17 +1057,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
||||
|
||||
/**
|
||||
* Runtime route for chat/API traffic. The persisted API URL remains the
|
||||
* connection's base config; a resolver-selected endpoint temporarily wins
|
||||
* so paired devices can roam between LAN, Tailscale, and operator VPN
|
||||
* routes without rewriting stored settings.
|
||||
* Runtime route for chat/API traffic. Once API fallback is explicitly
|
||||
* configured, a resolver-selected endpoint temporarily wins so paired
|
||||
* devices can roam without rewriting stored settings. Discovery alone
|
||||
* never enables the optional API surface.
|
||||
*/
|
||||
val effectiveApiServerUrl: StateFlow<String> = combine(
|
||||
_apiServerUrl,
|
||||
connectionManager.activeEndpoint,
|
||||
) { savedUrl, endpoint ->
|
||||
endpoint?.api?.url ?: savedUrl
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
|
||||
resolveEffectiveApiServerUrl(savedUrl, endpoint)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
/**
|
||||
* Whether a chat turn is currently streaming — mirrored from
|
||||
@@ -1671,17 +1711,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
// Drive the keep-alive: flip the active client's no-background-close
|
||||
// flag and start/stop the foreground service. Both flavors — the
|
||||
// Drive the keep-alive from either the user's always-on preference or
|
||||
// work the user already started. Active-turn leases are session scoped,
|
||||
// so sibling turns release independently. Both flavors — the
|
||||
// GatewayKeepAliveService is declared in the main manifest (Play permits
|
||||
// this Home-Assistant-class persistent-connection use case). Mirrors
|
||||
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
||||
viewModelScope.launch {
|
||||
gatewayKeepAlive.collect { enabled ->
|
||||
upstreamTransport.applyGatewayKeepAlive(enabled)
|
||||
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
|
||||
persistent to turns
|
||||
}.distinctUntilChanged().collect { (persistent, turns) ->
|
||||
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
|
||||
val ctx = getApplication<Application>()
|
||||
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
|
||||
else runCatching { GatewayKeepAliveService.stop(ctx) }
|
||||
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1740,6 +1782,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val imageGenerationStyle: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_IMAGE_GENERATION_STYLE] ?: "rotate" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "rotate")
|
||||
|
||||
fun setAnimationEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
@@ -1772,6 +1818,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageGenerationStyle(value: String) {
|
||||
val normalized = value.takeIf {
|
||||
it in setOf("rotate", "grid", "sphere", "nodes")
|
||||
} ?: "rotate"
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_IMAGE_GENERATION_STYLE] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth auto-scroll during chat streaming.
|
||||
// When enabled, the chat list smoothly follows new tokens, tool cards, and
|
||||
// reasoning deltas as they stream in — but only while the user is at the
|
||||
@@ -4633,17 +4690,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
} else {
|
||||
current.dashboardUrl
|
||||
}
|
||||
val newRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = newDashboardUrl,
|
||||
candidates = payload.endpoints.orEmpty(),
|
||||
)
|
||||
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
|
||||
current.relayUrl != newRelayUrl ||
|
||||
current.dashboardUrl != newDashboardUrl ||
|
||||
current.routeCandidates != payload.endpoints.orEmpty()
|
||||
current.routeCandidates != newRouteCandidates
|
||||
if (needsUpdate) {
|
||||
connectionStore.updateConnection(
|
||||
current.copy(
|
||||
apiServerUrl = payload.serverUrl,
|
||||
relayUrl = newRelayUrl,
|
||||
dashboardUrl = newDashboardUrl,
|
||||
routeCandidates = payload.endpoints.orEmpty(),
|
||||
routeCandidates = newRouteCandidates,
|
||||
preferredRouteRole = current.preferredRouteRole
|
||||
?.takeIf { preferred ->
|
||||
payload.endpoints.orEmpty().any {
|
||||
@@ -4874,6 +4935,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
current.copy(
|
||||
label = nextLabel,
|
||||
dashboardUrl = normalized,
|
||||
routeCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = normalized,
|
||||
candidates = current.routeCandidates.ifEmpty {
|
||||
listOfNotNull(
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = Connection.inferRouteRole(normalized),
|
||||
priority = 0,
|
||||
dashboardUrl = normalized,
|
||||
apiServerUrl = current.apiServerUrl
|
||||
.takeIf { it.isNotBlank() },
|
||||
relayUrl = current.relayUrl
|
||||
.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
probeStandardVoice()
|
||||
@@ -4922,7 +4999,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
upstreamTransport.clearDashboardAuthentication(connectionId)
|
||||
}
|
||||
connectionStore.setDashboardStatus(
|
||||
connectionId = connectionId,
|
||||
@@ -6063,16 +6140,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
) {
|
||||
val activeId = connectionStore.activeConnectionId.value ?: return
|
||||
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: return
|
||||
val nextRouteCandidates = routeCandidates ?: current.routeCandidates
|
||||
val nextPreferredRouteRole = when {
|
||||
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
|
||||
routeCandidates != null &&
|
||||
current.preferredRouteRole != null &&
|
||||
nextRouteCandidates.none {
|
||||
it.role.equals(current.preferredRouteRole, ignoreCase = true)
|
||||
} -> null
|
||||
else -> current.preferredRouteRole
|
||||
}
|
||||
val nextDashboardUrl = when {
|
||||
dashboardUrlOverride != null -> {
|
||||
dashboardUrlOverride
|
||||
@@ -6086,6 +6153,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
else -> current.dashboardUrl
|
||||
}
|
||||
val nextRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = nextDashboardUrl,
|
||||
candidates = routeCandidates ?: current.routeCandidates,
|
||||
)
|
||||
val nextPreferredRouteRole = when {
|
||||
preferredRouteRole != null -> preferredRouteRole.takeIf { it.isNotBlank() }
|
||||
routeCandidates != null &&
|
||||
current.preferredRouteRole != null &&
|
||||
nextRouteCandidates.none {
|
||||
it.role.equals(current.preferredRouteRole, ignoreCase = true)
|
||||
} -> null
|
||||
else -> current.preferredRouteRole
|
||||
}
|
||||
if (
|
||||
current.apiServerUrl == apiServerUrl &&
|
||||
current.relayUrl == relayUrl &&
|
||||
@@ -6318,50 +6398,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
fun resetOnboarding(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
dataManager.resetOnboarding()
|
||||
_onboardingCompleted.value = false
|
||||
val success = dataManager.resetOnboarding()
|
||||
if (success) {
|
||||
_onboardingCompleted.value = false
|
||||
}
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAppData() {
|
||||
fun resetAppData(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
val success = runCatching {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to reset app data", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(onResult: (String) -> Unit) {
|
||||
fun exportSettings(onResult: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val json = dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
val json = runCatching {
|
||||
dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to prepare settings backup", it)
|
||||
}.getOrNull()
|
||||
onResult(json)
|
||||
}
|
||||
}
|
||||
@@ -6383,24 +6475,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
// Apply imported settings
|
||||
if (backup.connections.isNotEmpty()) {
|
||||
val success = runCatching {
|
||||
val importedRelayUrl = if (backup.connections.isEmpty()) {
|
||||
backup.relayUrl ?: backup.serverUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A backup is a replacement snapshot, including when it
|
||||
// intentionally contains zero connections.
|
||||
dataManager.restoreConnectionBackup(backup)
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_THEME] = backup.theme
|
||||
importedRelayUrl?.let { preferences[KEY_RELAY_URL] = it }
|
||||
backup.apiServerUrl
|
||||
?.takeIf { backup.connections.isEmpty() }
|
||||
?.let { preferences[KEY_API_SERVER_URL] = it }
|
||||
}
|
||||
connectionStore.activeConnection.value?.let { restored ->
|
||||
restorePersistedActiveConnectionContext(restored)
|
||||
}
|
||||
} else {
|
||||
// Prefer v2 fields, fall back to v1 serverUrl for relay
|
||||
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
setTheme(backup.theme)
|
||||
if (backup.onboardingCompleted) {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
_onboardingCompleted.value = true
|
||||
}
|
||||
onResult(true)
|
||||
if (backup.connections.isEmpty()) {
|
||||
// Preserve v1/v2 compatibility after clearing the current
|
||||
// multi-connection snapshot.
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
check(dataManager.setOnboardingCompleted(backup.onboardingCompleted)) {
|
||||
"Failed to restore onboarding state"
|
||||
}
|
||||
_onboardingCompleted.value = backup.onboardingCompleted
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to import settings backup", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -99,6 +100,113 @@ private enum class StandardSpeechStreamState {
|
||||
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
||||
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
|
||||
|
||||
internal data class AssistantSpeechDelta(
|
||||
val message: ChatMessage,
|
||||
val text: String,
|
||||
val startsNewBubble: Boolean,
|
||||
)
|
||||
|
||||
internal data class AssistantSpeechBatch(
|
||||
val deltas: List<AssistantSpeechDelta>,
|
||||
val assistantMessages: List<ChatMessage>,
|
||||
val aggregateText: String,
|
||||
val hasTurnAssistant: Boolean,
|
||||
) {
|
||||
/** Last non-empty assistant bubble: the settled answer after any tool commentary. */
|
||||
val finalAnswerText: String
|
||||
get() = assistantMessages
|
||||
.asReversed()
|
||||
.firstOrNull { it.content.isNotBlank() }
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-voice-turn cursor over every assistant bubble created after the user
|
||||
* submits the turn. Tool-using Hermes runs may finalize an interim assistant
|
||||
* bubble and then append a second bubble with the actual answer; tracking only
|
||||
* the last bubble drops one of those segments.
|
||||
*
|
||||
* Existing stable UI keys are fenced at construction so StateFlow replay and
|
||||
* later history reconciliation cannot narrate an older session after adopting
|
||||
* a server message ID. Content rewrites are adopted silently unless they
|
||||
* preserve the exact prior prefix: only genuine suffix growth is speech.
|
||||
*/
|
||||
internal class AssistantSpeechCursor(
|
||||
baselineMessages: List<ChatMessage>,
|
||||
) {
|
||||
private val baselineAssistantKeys = baselineMessages.asSequence()
|
||||
.filter { it.role == MessageRole.ASSISTANT }
|
||||
.mapTo(mutableSetOf()) { it.uiKey }
|
||||
private val observedContent = mutableMapOf<String, String>()
|
||||
|
||||
fun poll(messages: List<ChatMessage>): AssistantSpeechBatch {
|
||||
val turnAssistants = messages.filter {
|
||||
it.role == MessageRole.ASSISTANT && it.uiKey !in baselineAssistantKeys
|
||||
}
|
||||
val deltas = buildList {
|
||||
turnAssistants.forEach { message ->
|
||||
val key = message.uiKey
|
||||
val firstObservation = key !in observedContent
|
||||
val hasPriorBubbleSpeech = observedContent.values.any { it.isNotEmpty() }
|
||||
val previous = observedContent[key].orEmpty()
|
||||
val current = message.content
|
||||
if (current.length > previous.length && current.startsWith(previous)) {
|
||||
add(
|
||||
AssistantSpeechDelta(
|
||||
message = message,
|
||||
text = current.substring(previous.length),
|
||||
startsNewBubble = firstObservation && hasPriorBubbleSpeech,
|
||||
),
|
||||
)
|
||||
}
|
||||
observedContent[key] = current
|
||||
}
|
||||
}
|
||||
return AssistantSpeechBatch(
|
||||
deltas = deltas,
|
||||
assistantMessages = turnAssistants,
|
||||
aggregateText = turnAssistants
|
||||
.map { it.content.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n"),
|
||||
hasTurnAssistant = turnAssistants.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds one voice request to its chat session. Existing-session turns are
|
||||
* fixed immediately. A brand-new chat starts with no session id, so the first
|
||||
* server id is accepted only while the locally submitted user row is still in
|
||||
* that session's message list; switching to another existing session while
|
||||
* creation is pending therefore fails closed.
|
||||
*/
|
||||
internal class VoiceTurnSessionFence(initialSessionId: String?) {
|
||||
private var boundSessionId: String? = initialSessionId
|
||||
private val startedWithoutSession = initialSessionId == null
|
||||
private var submittedUserUiKey: String? = null
|
||||
|
||||
fun bindSubmittedUser(uiKey: String?) {
|
||||
submittedUserUiKey = uiKey
|
||||
}
|
||||
|
||||
fun accepts(sessionId: String?, messages: List<ChatMessage>): Boolean {
|
||||
boundSessionId?.let { return sessionId == it }
|
||||
if (!startedWithoutSession) return false
|
||||
if (sessionId == null) return true
|
||||
|
||||
val userKey = submittedUserUiKey ?: return false
|
||||
val ownsSubmittedTurn = messages.any {
|
||||
it.role == MessageRole.USER && it.uiKey == userKey
|
||||
}
|
||||
if (!ownsSubmittedTurn) return false
|
||||
boundSessionId = sessionId
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
||||
|
||||
@@ -224,9 +332,8 @@ enum class BackgroundRunPhase {
|
||||
DONE,
|
||||
}
|
||||
|
||||
internal fun realtimeTurnActiveAfterResponseDone(backgroundPhase: BackgroundRunPhase?): Boolean =
|
||||
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
||||
backgroundPhase == BackgroundRunPhase.RECONNECTING
|
||||
internal fun realtimeTurnActiveAfterPromotion(spokenHandoff: Boolean?): Boolean =
|
||||
spokenHandoff != false
|
||||
|
||||
internal fun preserveRealtimeTurnOnStop(backgroundPhase: BackgroundRunPhase?): Boolean =
|
||||
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
||||
@@ -372,7 +479,7 @@ data class VoiceStats(
|
||||
* ### Sentence-boundary streaming TTS
|
||||
* The SSE stream emits text one token at a time, but TTS wants whole
|
||||
* sentences to sound natural. We observe [ChatViewModel.messages],
|
||||
* extract deltas from the currently-streaming assistant message, and
|
||||
* extract deltas from every assistant message created by the active run, and
|
||||
* feed each completed sentence into a bounded [ttsQueue]. A dedicated
|
||||
* consumer coroutine pulls from the queue, synthesizes each sentence,
|
||||
* and plays them back-to-back via [VoicePlayer.awaitCompletion].
|
||||
@@ -380,10 +487,9 @@ data class VoiceStats(
|
||||
* ### Integration note (V2a → V2b cleanup)
|
||||
* This first version uses the public [ChatViewModel.messages] StateFlow
|
||||
* to observe streaming deltas rather than adding a `// VOICE HOOK`
|
||||
* callback inside ChatViewModel. It's clean but depends on the
|
||||
* "last message with isStreaming=true" invariant — if ChatViewModel
|
||||
* ever streams multiple assistant messages concurrently this will need
|
||||
* a dedicated per-turn flow. See `DEVLOG.md` and V2b ticket.
|
||||
* callback inside ChatViewModel. A per-turn cursor fences pre-existing
|
||||
* history and follows all assistant bubbles until ChatViewModel reports
|
||||
* that the complete Hermes run has ended.
|
||||
*/
|
||||
class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
@@ -550,6 +656,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferences: VoicePreferencesRepository? = null
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var finalAnswerOnly: Boolean = false
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
private var realtimePersistentSession: Boolean = true
|
||||
private var realtimeModel: String = ""
|
||||
@@ -672,8 +779,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** Tracks which assistant-message IDs have already been consumed so
|
||||
* we don't re-process older turns when the history list updates. */
|
||||
private var lastObservedMessageId: String? = null
|
||||
private var lastObservedContentLength: Int = 0
|
||||
private var assistantSpeechCursor: AssistantSpeechCursor? = null
|
||||
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
|
||||
private var sentenceBuffer: StringBuilder = StringBuilder()
|
||||
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
|
||||
private val brokeredToolSpeechKeys = mutableSetOf<String>()
|
||||
@@ -776,15 +883,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var ttsChunksThisResponse: Int = 0
|
||||
private var lastTtsChunkFinishedAtMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Assistant-message-id that already existed BEFORE the current turn's
|
||||
* [chatVm.sendMessage] call. The stream observer ignores any emission
|
||||
* whose `lastAssistant.id` equals this, so StateFlow's initial replay
|
||||
* of the previous turn's response doesn't get spoken as a reply to
|
||||
* the current voice input.
|
||||
*/
|
||||
private var ignoreAssistantId: String? = null
|
||||
|
||||
/** MP3 files produced by synthesize — trimmed to [TTS_CACHE_CAP]. */
|
||||
private val ttsFileHistory = ArrayDeque<File>()
|
||||
|
||||
@@ -1279,10 +1377,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
|
||||
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
|
||||
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
|
||||
val realtimeSelectionChanged =
|
||||
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
|
||||
if (
|
||||
voiceEngineMode != nextEngineMode ||
|
||||
finalAnswerPolicyChanged ||
|
||||
realtimeTraceDetails != settings.realtimeTraceDetails ||
|
||||
realtimeSelectionChanged
|
||||
) {
|
||||
@@ -1290,6 +1390,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
TAG,
|
||||
"Voice prefs updated engine=${nextEngineMode.storageValue} " +
|
||||
"interaction=${settings.interactionMode} " +
|
||||
"finalAnswerOnly=${settings.finalAnswerOnly} " +
|
||||
"realtimeTraceDetails=${settings.realtimeTraceDetails} " +
|
||||
"realtimeModel=${settings.realtimeModel.ifBlank { "relay-default" }} " +
|
||||
"realtimeVoice=${settings.realtimeVoice.ifBlank { "relay-default" }}",
|
||||
@@ -1301,11 +1402,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
|
||||
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
|
||||
(realtimePersistentSession && !settings.realtimePersistentSession) ||
|
||||
finalAnswerPolicyChanged ||
|
||||
realtimeSelectionChanged
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
finalAnswerOnly = settings.finalAnswerOnly
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
realtimePersistentSession = settings.realtimePersistentSession
|
||||
realtimeModel = settings.realtimeModel
|
||||
@@ -1737,8 +1840,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sentenceBuffer = StringBuilder()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
currentTurnPcm = ByteArray(0)
|
||||
resetBrokeredToolSpeechState()
|
||||
@@ -2144,8 +2247,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sentenceBuffer = StringBuilder()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
currentTurnPcm = ByteArray(0)
|
||||
resetBrokeredToolSpeechState()
|
||||
@@ -3061,8 +3164,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Reset sentence buffering state for the new turn.
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = AssistantSpeechCursor(chatVm.messages.value)
|
||||
voiceTurnSessionFence = VoiceTurnSessionFence(chatVm.currentSessionId.value)
|
||||
streamComplete = false
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -3073,23 +3176,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
clearSpokenChunksState()
|
||||
|
||||
// Capture the id of the assistant message that currently sits at
|
||||
// the end of history. StateFlow.collect replays the current value
|
||||
// to new subscribers, so without this guard the observer would
|
||||
// treat the previous turn's full response as one giant delta for
|
||||
// the new turn and TTS the wrong answer.
|
||||
ignoreAssistantId = chatVm.messages.value
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
|
||||
// Kick off streaming observer BEFORE sending the message so we don't
|
||||
// miss early deltas that arrive synchronously from the callback.
|
||||
startStreamObserver(chatVm)
|
||||
if (!finalAnswerOnly) {
|
||||
prepareStandardSpeechStream()
|
||||
}
|
||||
|
||||
// Route the transcribed text through the normal chat pipeline.
|
||||
// This will create a user message + kick off the SSE stream.
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
// This creates the user row synchronously before kicking off the
|
||||
// transport, so bind the turn before observing the replaying StateFlows.
|
||||
// Starting the observer first leaves a small window where a legitimate
|
||||
// session adoption can be rejected before the submitted user key exists.
|
||||
// StateFlow replay preserves any assistant text that arrives before the
|
||||
// observer starts.
|
||||
val submittedUserUiKey =
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
|
||||
startStreamObserver(chatVm)
|
||||
}
|
||||
|
||||
private suspend fun runVoiceRelayPreflight(engineLabel: String): Boolean {
|
||||
@@ -3147,8 +3248,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -3216,7 +3317,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (speak && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
if (speak && !finalAnswerOnly && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
// W3: per-turn throttle independent of the per-key dedupe above.
|
||||
// Suppress the TTS enqueue (UI state + diagnostics already
|
||||
// applied) when spoken status is too frequent or has hit the
|
||||
@@ -3288,6 +3389,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
conversationContext = conversationContext,
|
||||
model = realtimeModel,
|
||||
voice = realtimeVoice,
|
||||
finalAnswerOnly = finalAnswerOnly,
|
||||
onHandoff = { event -> recordRealtimeVoiceHandoff(sessionGeneration, event) },
|
||||
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
|
||||
onTurnComplete = { summary ->
|
||||
@@ -3332,9 +3434,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
if (suppressCommandResponse) {
|
||||
if (event.type == "voice.response.done") {
|
||||
providerRealtimeAgentTurnActive.set(
|
||||
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase),
|
||||
)
|
||||
providerRealtimeAgentTurnActive.set(false)
|
||||
suppressLocalCommandResponse = false
|
||||
realtimeAudioSuppressed = false
|
||||
}
|
||||
@@ -3555,10 +3655,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
"hermes.run.promoted" -> {
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
// The run detached to the background; the provider speaks the
|
||||
// handoff. Surface a persistent chip so the user knows a long
|
||||
// task is still in flight (ADR 33 Tier B/C).
|
||||
providerRealtimeAgentTurnActive.set(
|
||||
realtimeTurnActiveAfterPromotion(event.spokenHandoff),
|
||||
)
|
||||
// The run detached to the background. A spoken handoff keeps
|
||||
// the foreground turn active until response.done; a silent
|
||||
// handoff ends it here. The task chip remains either way.
|
||||
val tier = event.tier ?: "promoted"
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -3762,9 +3864,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
hermesConfirmation = null,
|
||||
)
|
||||
}
|
||||
providerRealtimeAgentTurnActive.set(
|
||||
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase)
|
||||
)
|
||||
providerRealtimeAgentTurnActive.set(false)
|
||||
}
|
||||
"voice.error" -> {
|
||||
realtimeConfirmationControl = null
|
||||
@@ -4223,64 +4323,116 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe [ChatViewModel.messages]. When the last assistant message
|
||||
* grows (isStreaming=true), diff the content against our last snapshot,
|
||||
* push the new delta into [sentenceBuffer], and flush completed
|
||||
* sentences into [ttsQueue]. On isStreaming=false, flush the remaining
|
||||
* buffer and end the turn.
|
||||
* Observe every assistant bubble created by the active Hermes run. A tool
|
||||
* turn can finalize one bubble while the run is still active and later
|
||||
* append the final answer in another bubble, so completion is keyed to
|
||||
* [ChatViewModel.isStreaming], not an individual message flag.
|
||||
*/
|
||||
private fun startStreamObserver(chatVm: ChatViewModel) {
|
||||
streamObserverJob?.cancel()
|
||||
streamObserverJob = viewModelScope.launch {
|
||||
chatVm.messages.collect { messages ->
|
||||
val lastAssistant = messages.lastOrNull {
|
||||
it.role == MessageRole.ASSISTANT
|
||||
} ?: return@collect
|
||||
|
||||
// Skip the assistant message that existed BEFORE the current
|
||||
// turn's sendMessage. Without this, StateFlow's replay of the
|
||||
// current list (containing the PREVIOUS turn's response) gets
|
||||
// treated as a delta and the agent voices the old answer.
|
||||
if (lastAssistant.id == ignoreAssistantId) return@collect
|
||||
|
||||
val msgId = lastAssistant.id
|
||||
if (lastObservedMessageId == null) {
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
} else if (lastObservedMessageId != msgId) {
|
||||
// A new assistant turn appeared — flush whatever's left
|
||||
// from the previous one, then switch tracking.
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
resetBrokeredToolSpeechState()
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
}
|
||||
|
||||
observeHermesToolLoopForSpeech(lastAssistant)
|
||||
|
||||
val content = lastAssistant.content
|
||||
if (content.length > lastObservedContentLength) {
|
||||
val delta = content.substring(lastObservedContentLength)
|
||||
lastObservedContentLength = content.length
|
||||
onStreamDelta(delta, content)
|
||||
}
|
||||
|
||||
if (!lastAssistant.isStreaming && lastObservedContentLength > 0) {
|
||||
// Stream ended — mark complete so the chunker stops
|
||||
// holding short trailing sentences, cancel the idle
|
||||
// timer (we know exactly when the stream is done), and
|
||||
// flush any trailing buffer.
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
// Speaking state will naturally end when TTS queue drains.
|
||||
// We can't easily wait here without blocking the collector;
|
||||
// the TTS consumer transitions back to Idle.
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
val cursor = assistantSpeechCursor ?: AssistantSpeechCursor(chatVm.messages.value).also {
|
||||
assistantSpeechCursor = it
|
||||
}
|
||||
val sessionFence = voiceTurnSessionFence
|
||||
?: VoiceTurnSessionFence(chatVm.currentSessionId.value).also {
|
||||
voiceTurnSessionFence = it
|
||||
}
|
||||
streamObserverJob = viewModelScope.launch {
|
||||
combine(
|
||||
chatVm.messages,
|
||||
chatVm.isStreaming,
|
||||
chatVm.currentSessionId,
|
||||
) { messages, runActive, sessionId -> Triple(messages, runActive, sessionId) }
|
||||
.collect { (messages, runActive, sessionId) ->
|
||||
if (!sessionFence.accepts(sessionId, messages)) {
|
||||
// Session id and message history are independent flows.
|
||||
// During session creation/adoption, combine can briefly
|
||||
// pair the new id with the old history (or vice versa).
|
||||
// Skip that inconsistent snapshot without permanently
|
||||
// killing narration; the next coherent emission is still
|
||||
// fenced by the submitted user row/session identity.
|
||||
return@collect
|
||||
}
|
||||
|
||||
val batch = cursor.poll(messages)
|
||||
if (finalAnswerOnly) {
|
||||
if (batch.deltas.isNotEmpty()) {
|
||||
onVisualStreamDelta(batch.aggregateText)
|
||||
}
|
||||
} else {
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
if (!finalAnswerOnly) {
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
}
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (finalAnswerOnly) {
|
||||
speakSettledFinalAnswer(batch.finalAnswerText)
|
||||
} else if (!finishStandardSpeechStream()) {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVisualStreamDelta(fullContent: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
responseText = fullContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Final-only mode deliberately trades streaming latency for a clean spoken
|
||||
* result. The last non-empty assistant bubble is the settled answer; earlier
|
||||
* bubbles and tool states remain visible in Chat but never enter TTS.
|
||||
*/
|
||||
private fun speakSettledFinalAnswer(answer: String) {
|
||||
val spoken = sanitizeForTts(answer)
|
||||
if (spoken.isBlank()) return
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Speaking,
|
||||
outputAudioActive = false,
|
||||
responseText = answer,
|
||||
)
|
||||
}
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
if (offerStandardSpeechText(spoken)) {
|
||||
finishStandardSpeechStream()
|
||||
} else {
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
appendSanitizedDelta(spoken)
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginAssistantSpeechBubble() {
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (standardSpeechStreamOwnsReply()) {
|
||||
offerStandardSpeechText("\n\n")
|
||||
} else {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4312,7 +4464,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* device through the normal tool loop.
|
||||
*/
|
||||
private fun observeHermesToolLoopForSpeech(message: ChatMessage) {
|
||||
if (!_uiState.value.voiceMode || message.toolCalls.isEmpty()) return
|
||||
if (finalAnswerOnly || !_uiState.value.voiceMode || message.toolCalls.isEmpty()) return
|
||||
|
||||
var spokenForMessage = brokeredToolSpeechCounts[message.id] ?: 0
|
||||
message.toolCalls.forEach { tool ->
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -631,8 +631,8 @@
|
||||
<string name="settings_profile_lock_desc">Fixe o app em um perfil de agente</string>
|
||||
<string name="settings_quick_controls">Controles rápidos</string>
|
||||
<string name="settings_persistent_connection">Conexão persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantém sua conexão com o Hermes aberta em segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</string>
|
||||
<string name="settings_persistent_connection_desc">Manter a conexão mesmo sem nenhum chat em andamento</string>
|
||||
<string name="settings_connect_on_demand">Chats ativos permanecem conectados automaticamente · a conexão ociosa é fechada para economizar bateria</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
|
||||
@@ -1130,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>
|
||||
@@ -1161,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>
|
||||
@@ -1501,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>
|
||||
@@ -1995,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>
|
||||
@@ -2138,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>
|
||||
@@ -2465,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>
|
||||
@@ -3177,4 +3214,31 @@
|
||||
<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>
|
||||
|
||||
@@ -672,8 +672,8 @@
|
||||
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
|
||||
<string name="settings_quick_controls">快捷控制</string>
|
||||
<string name="settings_persistent_connection">持久连接</string>
|
||||
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
|
||||
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
|
||||
<string name="settings_persistent_connection_desc">即使没有聊天运行也保持连接</string>
|
||||
<string name="settings_connect_on_demand">活跃聊天会自动保持连接 · 空闲时关闭连接以节省电量</string>
|
||||
<string name="settings_turn_complete_alerts">聊天提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</string>
|
||||
@@ -1185,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>
|
||||
@@ -1216,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>
|
||||
@@ -1562,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>
|
||||
@@ -2087,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>
|
||||
@@ -2232,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>
|
||||
@@ -2583,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>
|
||||
@@ -3271,4 +3307,31 @@
|
||||
<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>
|
||||
|
||||
@@ -672,8 +672,8 @@
|
||||
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
|
||||
<string name="settings_quick_controls">Schnellsteuerung</string>
|
||||
<string name="settings_persistent_connection">Dauerhafte Verbindung</string>
|
||||
<string name="settings_persistent_connection_desc">Verbindung zu Hermes im Hintergrund offen halten</string>
|
||||
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</string>
|
||||
<string name="settings_persistent_connection_desc">Auch ohne laufenden Chat verbunden bleiben</string>
|
||||
<string name="settings_connect_on_demand">Aktive Chats bleiben automatisch verbunden · Leerlaufverbindung wird geschlossen, um Akku zu sparen</string>
|
||||
<string name="settings_turn_complete_alerts">Chat-Benachrichtigungen</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
|
||||
@@ -1188,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>
|
||||
@@ -1219,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>
|
||||
@@ -1565,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>
|
||||
@@ -2090,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>
|
||||
@@ -2237,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>
|
||||
@@ -2586,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>
|
||||
@@ -3337,4 +3374,31 @@
|
||||
<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>
|
||||
|
||||
@@ -599,8 +599,8 @@
|
||||
<string name="settings_profile_lock_desc">Anclar la aplicación a un perfil de agente</string>
|
||||
<string name="settings_quick_controls">Controles rápidos</string>
|
||||
<string name="settings_persistent_connection">Conexión persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantener abierta su conexión a Hermes en segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</string>
|
||||
<string name="settings_persistent_connection_desc">Mantener la conexión aunque no haya ningún chat en curso</string>
|
||||
<string name="settings_connect_on_demand">Los chats activos siguen conectados automáticamente · la conexión inactiva se cierra para ahorrar batería</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
|
||||
@@ -1076,7 +1076,7 @@
|
||||
<string name="dev_settings_relay_features">Características de Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar el servidor Relay y la configuración de emparejamiento para el desarrollo de Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abra el banco de pruebas del proveedor websocket para compilaciones de desarrollo.</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Revisa la configuración de voz en tiempo real y ejecuta una prueba de micrófono</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opciones de desarrollador</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta sección y desactivar las funciones experimentales</string>
|
||||
@@ -1107,6 +1107,7 @@
|
||||
<string name="dev_settings_export_failed">Exportación fallida</string>
|
||||
<string name="dev_settings_imported">Configuraciones importadas</string>
|
||||
<string name="dev_settings_import_failed">Error al importar: archivo no válido</string>
|
||||
<string name="dev_settings_reset_failed">Error al restablecer</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">¿Exportar copia de seguridad confidencial?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Esta copia de seguridad incluye conexiones guardadas, claves API, tokens de sesión relay, ID de dispositivos y cookies del panel. Cualquier persona que tenga el archivo podrá acceder a su servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -1441,6 +1442,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Guardar agente en tiempo real</string>
|
||||
<string name="voice_settings_global_controls_title">Controles de voz globales</string>
|
||||
<string name="voice_settings_global_controls_desc">Estas configuraciones se aplican a ambos motores de voz, en todos los perfiles.</string>
|
||||
<string name="voice_settings_final_answer_only">Solo la respuesta final</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Reproduce únicamente la respuesta definitiva. El progreso de herramientas, las actualizaciones de servicio y los comentarios intermedios permanecen visuales.</string>
|
||||
<string name="voice_settings_interaction_mode">Modo de interacción</string>
|
||||
<string name="voice_settings_interaction_tap">Toca para hablar</string>
|
||||
<string name="voice_settings_interaction_hold">Espera para hablar</string>
|
||||
@@ -1902,8 +1905,12 @@
|
||||
<string name="voice_overlay_collapse_cd">Contraer controles de voz</string>
|
||||
<string name="voice_overlay_expand_cd">Ampliar los controles de voz</string>
|
||||
<string name="voice_overlay_exit_cd">Salir del modo de voz</string>
|
||||
<string name="voice_overlay_compact">Compacto</string>
|
||||
<string name="voice_overlay_focus">Enfocar</string>
|
||||
<string name="voice_overlay_conversation">Conversación</string>
|
||||
<string name="voice_overlay_image_ready">Imagen lista</string>
|
||||
<string name="voice_overlay_rich_result_ready">Resultado enriquecido listo</string>
|
||||
<string name="voice_overlay_rich_results_count">%1$d resultados listos</string>
|
||||
<string name="voice_overlay_view_conversation">Ver conversación</string>
|
||||
<string name="voice_overlay_overlay">Cubrir</string>
|
||||
<string name="voice_overlay_exit">Salida</string>
|
||||
<string name="voice_overlay_settings_cd">Configuraciones de voz</string>
|
||||
@@ -2043,6 +2050,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma en el siguiente mensaje</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuración activa</string>
|
||||
<string name="conn_info_inherited">Heredado</string>
|
||||
<string name="conn_info_bypassed">Omitido</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidad</string>
|
||||
<string name="conn_info_agent_passport">Pasaporte del agente</string>
|
||||
<string name="conn_info_chat_override">Anulación del chat</string>
|
||||
<string name="conn_info_fast_tier">Nivel rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">El perfil ya omite las aprobaciones.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar nuevo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Elige cómo se aplican las aprobaciones.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprobación</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexión</string>
|
||||
<string name="conn_info_connection_state">Estado de conexión</string>
|
||||
@@ -2342,6 +2362,23 @@
|
||||
<string name="attachment_unmute">Dejar de silenciar</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Adjunto</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d archivo adjunto</item>
|
||||
<item quantity="other">%1$d archivos adjuntos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Contraer archivos adjuntos</string>
|
||||
<string name="attachment_group_expand">Expandir archivos adjuntos</string>
|
||||
<string name="attachment_group_collapsed">Contraído</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagen</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Archivo</string>
|
||||
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
|
||||
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||
<string name="crash_dismiss">Descartar</string>
|
||||
@@ -3022,4 +3059,31 @@
|
||||
<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>
|
||||
|
||||
@@ -672,8 +672,8 @@
|
||||
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
|
||||
<string name="settings_quick_controls">クイックコントロール</string>
|
||||
<string name="settings_persistent_connection">永続的な接続</string>
|
||||
<string name="settings_persistent_connection_desc">Hermes への接続をバックグラウンドで開いたままにします</string>
|
||||
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
|
||||
<string name="settings_persistent_connection_desc">チャットが実行されていないときも接続を維持</string>
|
||||
<string name="settings_connect_on_demand">アクティブなチャットは自動的に接続を維持 · アイドル時はバッテリー節約のため接続を閉じます</string>
|
||||
<string name="settings_turn_complete_alerts">チャット通知</string>
|
||||
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</string>
|
||||
@@ -1201,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>
|
||||
@@ -1232,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>
|
||||
@@ -1578,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>
|
||||
@@ -2103,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>
|
||||
@@ -2248,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>
|
||||
@@ -2597,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>
|
||||
@@ -3337,4 +3373,31 @@
|
||||
<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>
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
<string name="conn_label_session">Session</string>
|
||||
<string name="conn_label_route">Route</string>
|
||||
<string name="conn_label_status">Status</string>
|
||||
<string name="dashboard_component_connected">connected</string>
|
||||
<string name="dashboard_component_server_errors_5m">server errors / 5m</string>
|
||||
<string name="conn_label_connect">Connect</string>
|
||||
<string name="conn_label_connections">Connections</string>
|
||||
<string name="conn_detail_add_connection">Add a Vanilla Hermes API/dashboard connection</string>
|
||||
@@ -689,8 +691,8 @@
|
||||
<string name="settings_profile_lock_desc">Pin the app to one agent profile</string>
|
||||
<string name="settings_quick_controls">Quick Controls</string>
|
||||
<string name="settings_persistent_connection">Persistent connection</string>
|
||||
<string name="settings_persistent_connection_desc">Keeping your connection to Hermes open in the background</string>
|
||||
<string name="settings_connect_on_demand">Connect on demand only · saves battery</string>
|
||||
<string name="settings_persistent_connection_desc">Stay connected even when no chat is running</string>
|
||||
<string name="settings_connect_on_demand">Active chats stay connected automatically · idle connection closes to save battery</string>
|
||||
<string name="settings_turn_complete_alerts">Chat alerts</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
|
||||
@@ -1223,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>
|
||||
@@ -1282,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>
|
||||
@@ -1313,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>
|
||||
@@ -1670,6 +1682,8 @@
|
||||
<string name="voice_settings_save_realtime_agent">Save realtime agent</string>
|
||||
<string name="voice_settings_global_controls_title">Global Voice Controls</string>
|
||||
<string name="voice_settings_global_controls_desc">These settings apply to both voice engines, on every profile.</string>
|
||||
<string name="voice_settings_final_answer_only">Final answer only</string>
|
||||
<string name="voice_settings_final_answer_only_desc">Speak only the settled answer. Tool progress, service updates, and intermediate commentary stay visual.</string>
|
||||
<string name="voice_settings_interaction_mode">Interaction mode</string>
|
||||
<string name="voice_settings_interaction_tap">Tap to talk</string>
|
||||
<string name="voice_settings_interaction_hold">Hold to talk</string>
|
||||
@@ -1850,6 +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 -->
|
||||
@@ -2195,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>
|
||||
@@ -2342,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>
|
||||
@@ -2462,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 -->
|
||||
|
||||
@@ -2691,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>
|
||||
|
||||
@@ -126,6 +126,20 @@ class ChatTurnCheckpointStoreTest {
|
||||
startedAt = 1_002L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Safe advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
|
||||
id = "run-1",
|
||||
title = "Research",
|
||||
|
||||
@@ -111,6 +111,75 @@ class ConnectionDashboardFieldsTest {
|
||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildRouteCandidates_preservesExplicitSameHostHttpsDashboard() {
|
||||
val routes = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
)
|
||||
|
||||
assertEquals(1, routes.size)
|
||||
assertEquals("https://hermes.example.com:443", routes.single().dashboard?.url)
|
||||
assertEquals("https://hermes.example.com:8643", routes.single().api?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileDashboardRoutes_repairsStoredSameHostDerivedPort() {
|
||||
val stored = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
)
|
||||
|
||||
val repaired = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
candidates = stored,
|
||||
)
|
||||
|
||||
assertEquals("https://hermes.example.com:443", repaired.single().dashboard?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileDashboardRoutes_keepsDifferentHostRoamingDashboard() {
|
||||
val stored = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "http://100.71.8.56:8642",
|
||||
relayUrl = "ws://100.71.8.56:8767",
|
||||
)
|
||||
|
||||
val repaired = Connection.reconcileDashboardRoutes(
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
candidates = stored,
|
||||
)
|
||||
|
||||
assertEquals("http://100.71.8.56:9119", repaired.single().dashboard?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedSecureDashboard_repairsDerivedGatewayRouteOnReload() {
|
||||
val stored = Connection(
|
||||
id = "conn-https",
|
||||
label = "Secure Hermes",
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
tokenStoreKey = "hermes_auth_https",
|
||||
dashboardUrl = "https://hermes.example.com:443",
|
||||
routeCandidates = Connection.buildRouteCandidates(
|
||||
apiServerUrl = "https://hermes.example.com:8643",
|
||||
relayUrl = "wss://hermes.example.com:8767",
|
||||
),
|
||||
)
|
||||
|
||||
val reloaded = json.decodeFromString<Connection>(
|
||||
json.encodeToString(Connection.serializer(), stored),
|
||||
).withDashboardDefaults()
|
||||
|
||||
assertEquals("https://hermes.example.com:443", reloaded.dashboardUrl)
|
||||
assertEquals(
|
||||
"https://hermes.example.com:443",
|
||||
reloaded.routeCandidates.single().dashboard?.url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
|
||||
val route = Connection.endpointCandidateFromDashboardUrl(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.coVerify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class DataManagerIoTest {
|
||||
@Test
|
||||
fun writeBackupFailsWhenProviderReturnsNoOutputStream() = runTest {
|
||||
val uri = mockk<Uri>()
|
||||
val resolver = mockk<ContentResolver>()
|
||||
val context = mockk<Context>()
|
||||
every { context.contentResolver } returns resolver
|
||||
every { resolver.openOutputStream(uri) } returns null
|
||||
|
||||
val success = DataManager(context).writeBackupToUri(uri, "{}")
|
||||
|
||||
assertFalse(success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreTreatsEmptyConnectionsAsReplacementSnapshot() = runTest {
|
||||
val context = mockk<Context>()
|
||||
val store = mockk<ConnectionStore>()
|
||||
every { context.filesDir } returns File("build/tmp/data-manager-test/files")
|
||||
coJustRun {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
|
||||
DataManager(context, store).restoreConnectionBackup(DataManager.AppBackup())
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FeatureFlagsTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runTest {
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPreferenceUsesBuildDefault() = runTest {
|
||||
assertEquals(FeatureFlags.isDevBuild, FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitLockOverridesDebugBuildDefault() = runTest {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
|
||||
assertFalse(FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlockPersistsAfterExplicitLock() = runTest {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
|
||||
assertTrue(FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -65,4 +67,44 @@ class VoicePreferencesRepositoryTest {
|
||||
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
|
||||
assertEquals("leo", settings.realtimeVoice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finalAnswerOnlyPersistsGloballyAcrossProfileScopes() = runTest {
|
||||
assertFalse(repository.settings.first().finalAnswerOnly)
|
||||
|
||||
repository.setFinalAnswerOnly(true)
|
||||
assertTrue(repository.settings.first().finalAnswerOnly)
|
||||
|
||||
repository.setActiveScope("connection-a", "coder")
|
||||
assertTrue(repository.settings.first().finalAnswerOnly)
|
||||
|
||||
repository.setActiveScope("connection-b", "writer")
|
||||
assertTrue(repository.settings.first().finalAnswerOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun presentationModePersistsGloballyAcrossProfileScopes() = runTest {
|
||||
assertEquals(
|
||||
VoicePresentationMode.Focus.storageValue,
|
||||
repository.settings.first().presentationMode,
|
||||
)
|
||||
|
||||
repository.setPresentationMode(VoicePresentationMode.Conversation)
|
||||
assertEquals(
|
||||
VoicePresentationMode.Conversation.storageValue,
|
||||
repository.settings.first().presentationMode,
|
||||
)
|
||||
|
||||
repository.setActiveScope("connection-a", "coder")
|
||||
assertEquals(
|
||||
VoicePresentationMode.Conversation.storageValue,
|
||||
repository.settings.first().presentationMode,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownPresentationModeFallsBackToFocus() {
|
||||
assertEquals(VoicePresentationMode.Focus, VoicePresentationMode.fromStorage("unknown"))
|
||||
assertEquals(VoicePresentationMode.Focus, VoicePresentationMode.fromStorage(null))
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -147,6 +147,28 @@ class RelayVoiceClientRoutingTest {
|
||||
assertEquals("leo", payload["voice"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realtimeAgentSessionSendsFinalAnswerOnlyPolicy() = runTest {
|
||||
val client = RelayVoiceClient(
|
||||
context = context,
|
||||
okHttpClient = httpClient,
|
||||
relayUrlProvider = { relayUrl(lanServer) },
|
||||
sessionTokenProvider = { "session-token" },
|
||||
)
|
||||
|
||||
val result = client.runRealtimeAgent(
|
||||
prompt = "Check Hermes quietly",
|
||||
inputPcm = ByteArray(0),
|
||||
finalAnswerOnly = true,
|
||||
) { _, _ -> }
|
||||
|
||||
assertTrue(result.exceptionOrNull()?.message, result.isSuccess)
|
||||
val request = lanServer.takeRequest(2, TimeUnit.SECONDS)
|
||||
?: error("missing realtime session request")
|
||||
val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
|
||||
assertEquals("true", payload["final_answer_only"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceOutputSessionResponseParsesResumeMetadata() {
|
||||
val response = Json.decodeFromString(
|
||||
@@ -2102,6 +2124,81 @@ class RelayVoiceClientRoutingTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistentRealtimePromotionUsesSpokenHandoffAsForegroundBoundary() = runBlocking {
|
||||
val opened = CountDownLatch(1)
|
||||
val turns = Channel<RealtimeTurnInput>(Channel.UNLIMITED)
|
||||
val turnCompletions = Channel<Unit>(Channel.UNLIMITED)
|
||||
val followUpDelivered = CompletableDeferred<Result<Unit>>()
|
||||
lateinit var socket: ScriptedWebSocket
|
||||
lateinit var listener: WebSocketListener
|
||||
lanServer.dispatcher = sessionOnlyDispatcher(
|
||||
path = "/voice/realtime-agent/session",
|
||||
body = """
|
||||
{
|
||||
"success": true,
|
||||
"session_id": "realtime-agent-promotion-boundary-test",
|
||||
"websocket_path": "/voice/realtime-agent/session-test",
|
||||
"provider": "xai_realtime",
|
||||
"model": "grok-voice-latest",
|
||||
"voice": "leo",
|
||||
"sample_rate": 24000
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val client = RelayVoiceClient(
|
||||
context = context,
|
||||
okHttpClient = httpClient,
|
||||
relayUrlProvider = { relayUrl(lanServer) },
|
||||
sessionTokenProvider = { "session-token" },
|
||||
webSocketFactory = { request, callback ->
|
||||
listener = callback
|
||||
socket = ScriptedWebSocket(request, callback) { true }
|
||||
callback.onOpen(socket, mockk(relaxed = true))
|
||||
opened.countDown()
|
||||
socket
|
||||
},
|
||||
)
|
||||
val sessionJob = async(Dispatchers.IO) {
|
||||
client.runRealtimeAgent(
|
||||
prompt = "Start a long task",
|
||||
inputPcm = ByteArray(0),
|
||||
turnInputs = turns,
|
||||
onTurnComplete = { turnCompletions.trySend(Unit) },
|
||||
) { _, _ -> }
|
||||
}
|
||||
|
||||
try {
|
||||
assertTrue(opened.await(2, TimeUnit.SECONDS))
|
||||
listener.onMessage(
|
||||
socket,
|
||||
"""{"type":"hermes.run.promoted","run_id":"run-silent","spoken_handoff":false}""",
|
||||
)
|
||||
withTimeout(2_000) { turnCompletions.receive() }
|
||||
|
||||
turns.send(
|
||||
RealtimeTurnInput(
|
||||
inputPcm = ByteArray(6_400) { 4 },
|
||||
deliveryResult = followUpDelivered,
|
||||
)
|
||||
)
|
||||
assertTrue(withTimeout(2_000) { followUpDelivered.await() }.isSuccess)
|
||||
|
||||
listener.onMessage(
|
||||
socket,
|
||||
"""{"type":"hermes.run.promoted","run_id":"run-spoken","spoken_handoff":true}""",
|
||||
)
|
||||
delay(100)
|
||||
assertTrue(turnCompletions.tryReceive().isFailure)
|
||||
|
||||
listener.onMessage(socket, """{"type":"voice.response.done"}""")
|
||||
withTimeout(2_000) { turnCompletions.receive() }
|
||||
} finally {
|
||||
turns.close()
|
||||
withTimeout(2_000) { sessionJob.await() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun relayUrl(server: MockWebServer): String =
|
||||
"ws://${server.hostName}:${server.port}"
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ActiveTurnKeepAliveRegistryTest {
|
||||
@After
|
||||
fun tearDown() = ActiveTurnKeepAliveRegistry.resetForTest()
|
||||
|
||||
@Test
|
||||
fun siblingSessionsHoldIndependentLeases() {
|
||||
ActiveTurnKeepAliveRegistry.acquire("connection::victor::session-a")
|
||||
ActiveTurnKeepAliveRegistry.acquire("connection::default::session-b")
|
||||
ActiveTurnKeepAliveRegistry.setWaiting("connection::victor::session-a", true)
|
||||
|
||||
assertEquals(
|
||||
ActiveTurnKeepAliveRegistry.Snapshot(activeTurnCount = 2, waitingSessionCount = 1),
|
||||
ActiveTurnKeepAliveRegistry.snapshot.value,
|
||||
)
|
||||
|
||||
ActiveTurnKeepAliveRegistry.release("connection::victor::session-a")
|
||||
|
||||
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
|
||||
assertEquals(0, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
|
||||
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRenameMovesRatherThanDuplicatesLease() {
|
||||
ActiveTurnKeepAliveRegistry.acquire("temporary")
|
||||
ActiveTurnKeepAliveRegistry.setWaiting("temporary", true)
|
||||
ActiveTurnKeepAliveRegistry.rename("temporary", "durable")
|
||||
|
||||
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
|
||||
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
|
||||
|
||||
ActiveTurnKeepAliveRegistry.release("durable")
|
||||
assertFalse(ActiveTurnKeepAliveRegistry.snapshot.value.required)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnMoaReferenceCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -15,6 +17,8 @@ import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -800,6 +804,57 @@ class ChatHandlerTest {
|
||||
assertEquals("2 background tasks completed", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_rendersAutoContinueAsNeutralSystemTimelineRow() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "continue-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("private continuation prompt"),
|
||||
displayKind = "auto_continue",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals(MessageRole.SYSTEM, msg.role)
|
||||
assertEquals("Continued after an interrupted turn", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileInterimMessage_collapsesProvisionalFinalBubble() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "interim",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "candidate",
|
||||
timestamp = 1L,
|
||||
isStreaming = false,
|
||||
),
|
||||
)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "provisional",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.reconcileInterimMessage(
|
||||
interimMessageId = "interim",
|
||||
currentMessageId = "provisional",
|
||||
content = "candidate answer",
|
||||
)
|
||||
|
||||
val assistant = handler.messages.value.single()
|
||||
assertEquals("interim", assistant.id)
|
||||
assertEquals("candidate answer", assistant.content)
|
||||
assertTrue(assistant.isStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_skipsToolMessages() {
|
||||
val items = listOf(
|
||||
@@ -849,6 +904,231 @@ class ChatHandlerTest {
|
||||
assertEquals("", handler.messages.value[0].content)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: persisted user image references (HRUI-073) ---
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_liftsCaptionFirstPersistedImageRef() {
|
||||
val requested = mutableListOf<Pair<String, String>>()
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
requested += messageId to path
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("What is this?", message.content)
|
||||
assertEquals(listOf("image-user-1" to "/tmp/cat.png"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_keepsImageOnlyTurnAndParsesQuotedSpacedPath() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-2",
|
||||
role = "user",
|
||||
content = JsonPrimitive(
|
||||
"@image:`/tmp/Hermes composer images/holiday photo.webp`"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("", handler.messages.value.single().content)
|
||||
assertEquals(
|
||||
listOf("/tmp/Hermes composer images/holiday photo.webp"),
|
||||
requested,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_extractsMultipleRefsInOrder() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-3",
|
||||
role = "user",
|
||||
content = JsonPrimitive(
|
||||
"Compare these\n@image:/tmp/a.png\n@image:\"/tmp/two images/b.jpg\""
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("Compare these", handler.messages.value.single().content)
|
||||
assertEquals(listOf("/tmp/a.png", "/tmp/two images/b.jpg"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_extractsRefFromNativeVisionContentArray() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
val nativeVisionContent = buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Describe this\n@image:/tmp/native.png")
|
||||
}
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
put(
|
||||
"image_url",
|
||||
buildJsonObject { put("url", "data:image/png;base64,AAAA") },
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "image-user-4", role = "user", content = nativeVisionContent))
|
||||
)
|
||||
|
||||
assertEquals("Describe this", handler.messages.value.single().content)
|
||||
assertEquals(listOf("/tmp/native.png"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_keepsMalformedUnknownAndInlineImageDirectivesAsText() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
val content = listOf(
|
||||
"@image:relative.png",
|
||||
"@image:`/tmp/unclosed.png",
|
||||
"@image:/etc/passwd",
|
||||
"mention @image:/tmp/inline.png here",
|
||||
).joinToString("\n")
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "image-user-5", role = "user", content = JsonPrimitive(content)))
|
||||
)
|
||||
|
||||
assertEquals(content, handler.messages.value.single().content)
|
||||
assertTrue(requested.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_canRenderPathFreeUnavailableImageState() {
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
handler.mutateMessage(messageId) { message ->
|
||||
message.copy(
|
||||
attachments = message.attachments + Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = path.substringAfterLast('/'),
|
||||
state = AttachmentState.FAILED,
|
||||
errorMessage = "Image unavailable on this connection",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-6",
|
||||
role = "user",
|
||||
content = JsonPrimitive("@image:/tmp/deleted.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("", message.content)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
|
||||
assertEquals("deleted.png", message.attachments.single().fileName)
|
||||
assertFalse(message.attachments.single().errorMessage.orEmpty().contains("/tmp/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_coldResumeDispatchesPersistedImageOnlyOnceAcrossReloads() {
|
||||
var requestCount = 0
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
requestCount++
|
||||
handler.mutateMessage(messageId) { message ->
|
||||
message.copy(
|
||||
attachments = message.attachments + Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = "resume.png",
|
||||
state = AttachmentState.FAILED,
|
||||
errorMessage = "Image unavailable on this connection",
|
||||
relayToken = path,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val history = listOf(
|
||||
MessageItem(
|
||||
id = "image-user-cold",
|
||||
role = "user",
|
||||
content = JsonPrimitive("@image:/tmp/resume.png"),
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(history)
|
||||
handler.loadMessageHistory(history)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals(1, requestCount)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_immediateReloadCarriesLocalImageWithoutDuplicateFetch() {
|
||||
handler.addUserMessage(
|
||||
ChatMessage(
|
||||
id = "optimistic-image",
|
||||
role = MessageRole.USER,
|
||||
content = "What is this?",
|
||||
timestamp = 1L,
|
||||
attachments = listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = "base64-pixels",
|
||||
fileName = "cat.png",
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
var requestCount = 0
|
||||
handler.onPersistedUserImageRequested = { _, _ -> requestCount++ }
|
||||
val history = listOf(
|
||||
MessageItem(
|
||||
id = "server-image",
|
||||
role = "user",
|
||||
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(history)
|
||||
handler.loadMessageHistory(history)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("server-image", message.id)
|
||||
assertEquals("What is this?", message.content)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals("base64-pixels", message.attachments.single().content)
|
||||
assertEquals(0, requestCount)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: outbound attachment preservation (GAP 1) ---
|
||||
|
||||
@Test
|
||||
@@ -1237,6 +1517,49 @@ class ChatHandlerTest {
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_coalescesReplayedDomainIdWithoutLosingOrderOrContent() {
|
||||
val replayedId = "2c93af28-0b0b-436b-a112-7f164cac931d"
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "user-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("question"),
|
||||
timestamp = 1.0,
|
||||
),
|
||||
MessageItem(
|
||||
id = replayedId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("partial answer"),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
MessageItem(
|
||||
id = "system-1",
|
||||
role = "system",
|
||||
content = JsonPrimitive("distinct visible content"),
|
||||
timestamp = 3.0,
|
||||
),
|
||||
// Rejoin replay of the same persisted message. The latest
|
||||
// snapshot is authoritative, but its first transcript position
|
||||
// and Compose identity must remain stable.
|
||||
MessageItem(
|
||||
id = replayedId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("final answer"),
|
||||
timestamp = 4.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(listOf("user-1", replayedId, "system-1"), messages.map { it.id })
|
||||
assertEquals("final answer", messages[1].content)
|
||||
assertEquals("distinct visible content", messages[2].content)
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
||||
// Once the first reload adopts the server id, subsequent reloads match by
|
||||
@@ -1737,6 +2060,20 @@ class ChatHandlerTest {
|
||||
completedAt = 7L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Recovered advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
turnStatus = "Running terminal",
|
||||
priorUserMessageCount = 1,
|
||||
@@ -1755,10 +2092,107 @@ class ChatHandlerTest {
|
||||
assertFalse(restored.toolCalls[0].isComplete)
|
||||
assertTrue(restored.toolCalls[1].isComplete)
|
||||
assertEquals(true, restored.toolCalls[1].success)
|
||||
assertEquals(listOf(1, 2), restored.moaReferences.map { it.index })
|
||||
assertEquals("Recovered advice", restored.moaReferences.first().text)
|
||||
assertFalse(restored.moaReferences.last().available)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onMoaReference_upsertsByCanonicalIndexAndResetsOnNewSequence() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Second"),
|
||||
)
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "First"),
|
||||
)
|
||||
assertEquals(listOf(1), handler.messages.value.single().moaReferences.map { it.index })
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Second"),
|
||||
)
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "First"),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), handler.messages.value.single().moaReferences.map { it.index })
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Updated second"),
|
||||
)
|
||||
assertEquals(
|
||||
"Updated second",
|
||||
handler.messages.value.single().moaReferences.single { it.index == 2 }.text,
|
||||
)
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "New first"),
|
||||
)
|
||||
|
||||
val reset = handler.messages.value.single().moaReferences
|
||||
assertEquals(listOf(1), reset.map { it.index })
|
||||
assertEquals("New first", reset.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_doesNotPersistMoaReferenceBlocks() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer",
|
||||
timestamp = 1L,
|
||||
moaReferences = listOf(
|
||||
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Answer"))),
|
||||
)
|
||||
|
||||
assertTrue(handler.messages.value.single().moaReferences.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_preservesMoaReferencesWhileMatchingTurnIsStillLive() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Partial",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
moaReferences = listOf(
|
||||
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Partial"))),
|
||||
)
|
||||
|
||||
assertEquals("Transient", handler.messages.value.single().moaReferences.single().text)
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
||||
|
||||
+4
-1
@@ -686,6 +686,7 @@ class DashboardApiClientTest {
|
||||
name = "Local",
|
||||
baseUrl = "https://llm.example/v1",
|
||||
model = "qwen",
|
||||
models = listOf("qwen", "qwen-vl", " qwen ", ""),
|
||||
apiKey = "never-persist-this",
|
||||
)
|
||||
|
||||
@@ -701,7 +702,9 @@ class DashboardApiClientTest {
|
||||
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
||||
val save = server.takeRequest()
|
||||
assertEquals("/api/providers/custom-endpoints", save.path)
|
||||
assertTrue(save.body.readUtf8().contains("never-persist-this"))
|
||||
val saveBody = save.body.readUtf8()
|
||||
assertTrue(saveBody.contains("never-persist-this"))
|
||||
assertTrue(saveBody.contains(""""models":["qwen","qwen-vl"]"""))
|
||||
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
||||
|
||||
+324
-1
@@ -63,6 +63,9 @@ class GatewayClientHarness(
|
||||
|
||||
@Volatile
|
||||
var recoveryInflightStreaming: Boolean? = null
|
||||
var recoveryInflightError: String? = null
|
||||
var recoveryInflightRecoverable: Boolean = false
|
||||
var recoveryAutoContinueAttempt: Int? = null
|
||||
|
||||
@Volatile
|
||||
var recoveryQueuedUser: String? = null
|
||||
@@ -93,6 +96,12 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var reasoningDisplay = "hide"
|
||||
|
||||
@Volatile
|
||||
var approvalMode = "smart"
|
||||
|
||||
/** Config keys rejected with the older-gateway unknown-key response. */
|
||||
val unsupportedConfigKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
@Volatile
|
||||
var askResponseStatus = "ok"
|
||||
|
||||
@@ -141,6 +150,23 @@ class GatewayClientHarness(
|
||||
)
|
||||
return
|
||||
}
|
||||
val configKey = (params["key"] as? JsonPrimitive)?.contentOrNull
|
||||
if (
|
||||
(method == "config.get" || method == "config.set") &&
|
||||
configKey in unsupportedConfigKeys
|
||||
) {
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id.toLong())
|
||||
put("error", buildJsonObject {
|
||||
put("code", 4002)
|
||||
put("message", "unknown config key: $configKey")
|
||||
})
|
||||
}.toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
val result: JsonObject? = when (method) {
|
||||
"session.create" -> buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
@@ -250,6 +276,7 @@ class GatewayClientHarness(
|
||||
put("value", reasoningEffort)
|
||||
put("display", reasoningDisplay)
|
||||
}
|
||||
"approvals.mode" -> buildJsonObject { put("value", approvalMode) }
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
||||
@@ -268,6 +295,18 @@ class GatewayClientHarness(
|
||||
put("key", "fast")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
|
||||
}
|
||||
"yolo" -> buildJsonObject {
|
||||
put("key", "yolo")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "0")
|
||||
}
|
||||
"approvals.mode" -> {
|
||||
approvalMode =
|
||||
(params["value"] as? JsonPrimitive)?.contentOrNull ?: approvalMode
|
||||
buildJsonObject {
|
||||
put("key", "approvals.mode")
|
||||
put("value", approvalMode)
|
||||
}
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
@@ -299,16 +338,27 @@ class GatewayClientHarness(
|
||||
put("info", buildJsonObject { put("project", project) })
|
||||
}
|
||||
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
||||
if (recoveryRunning || recoveryInflightStreaming != null) {
|
||||
if (recoveryRunning || recoveryInflightStreaming != null || recoveryInflightError != null) {
|
||||
put("inflight", buildJsonObject {
|
||||
put("user", "research this")
|
||||
put("assistant", recoveryAssistant)
|
||||
put("streaming", inflightStreaming)
|
||||
recoveryInflightError?.let { error ->
|
||||
put("status", "error")
|
||||
put("error", error)
|
||||
put("recoverable", recoveryInflightRecoverable)
|
||||
}
|
||||
})
|
||||
}
|
||||
recoveryQueuedUser?.let { user ->
|
||||
put("queued", buildJsonObject { put("user", user) })
|
||||
}
|
||||
recoveryAutoContinueAttempt?.let { attempt ->
|
||||
put("auto_continue", buildJsonObject {
|
||||
put("attempt", attempt)
|
||||
put("interrupted_at", 1_700_000_000.0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun recoveryResult(sessionId: String): JsonObject = recoveryPayload(sessionId)
|
||||
@@ -427,6 +477,7 @@ class GatewayChatClientTest {
|
||||
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||
val moaReferences = ConcurrentLinkedQueue<GatewayMoaReference>()
|
||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||
val reconcileRequests = AtomicInteger(0)
|
||||
val completeLatch = CountDownLatch(1)
|
||||
@@ -447,6 +498,7 @@ class GatewayChatClientTest {
|
||||
onError = { errors += it; completeLatch.countDown() },
|
||||
onToolGenerating = { toolGenerating += it ?: "" },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onMoaReference = { moaReferences += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
onInteractionExpired = { },
|
||||
onInteractionResolved = { interactionResolutions += it },
|
||||
@@ -491,6 +543,14 @@ class GatewayChatClientTest {
|
||||
client = buildClient(rpcTimeoutMs, promptSubmitTimeoutMs, turnIdleTimeoutMs)
|
||||
}
|
||||
|
||||
private fun waitUntil(timeoutMs: Long = 2_000L, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition() && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue("condition did not settle within ${timeoutMs}ms", condition())
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
harness = GatewayClientHarness()
|
||||
@@ -1642,6 +1702,146 @@ class GatewayChatClientTest {
|
||||
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval mode get and set use profile config without session yolo scope`() {
|
||||
harness.approvalMode = "smart"
|
||||
|
||||
val fetched = runBlocking { client.getApprovalMode() }
|
||||
val updated = runBlocking { client.setApprovalMode(GatewayApprovalMode.Off) }
|
||||
|
||||
assertEquals(GatewayApprovalMode.Smart, fetched.getOrThrow())
|
||||
assertEquals(GatewayApprovalMode.Off, updated.getOrThrow())
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Supported,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
assertEquals(GatewayApprovalMode.Off, client.serverApprovalMode.value)
|
||||
val getRpc = harness.awaitRpc("config.get")
|
||||
assertEquals("approvals.mode", (getRpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
val setRpc = harness.awaitRpc("config.set")
|
||||
assertEquals("approvals.mode", (setRpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("off", (setRpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(setRpc.containsKey("scope"))
|
||||
assertFalse(setRpc.containsKey("session_id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session info reconciles known approval modes and ignores unknown values`() {
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject {
|
||||
put("approval_mode", "manual")
|
||||
put("desktop_contract", 3)
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
waitUntil { client.serverApprovalMode.value == GatewayApprovalMode.Manual }
|
||||
assertEquals(GatewayApprovalModeCapability.Supported, client.approvalModeCapability.value)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("approval_mode", "future-mode") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(30)
|
||||
assertEquals(GatewayApprovalMode.Manual, client.serverApprovalMode.value)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("desktop_contract", 2) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
waitUntil {
|
||||
client.approvalModeCapability.value ==
|
||||
GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
assertEquals(GatewayApprovalMode.Manual, client.serverApprovalMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older gateway rejection disables only approval mode capability`() {
|
||||
harness.unsupportedConfigKeys += "approvals.mode"
|
||||
|
||||
val first = runBlocking { client.getApprovalMode() }
|
||||
val configGetsAfterFirst = harness.rpcLog.count { (method, _) -> method == "config.get" }
|
||||
val second = runBlocking { client.getApprovalMode() }
|
||||
|
||||
assertTrue(first.isFailure)
|
||||
assertTrue(second.isFailure)
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Unsupported,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
assertEquals(
|
||||
configGetsAfterFirst,
|
||||
harness.rpcLog.count { (method, _) -> method == "config.get" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiplexed profile approval mode is read only until upstream scopes config rpc`() {
|
||||
client.sessionProfileProvider = { "work" }
|
||||
|
||||
val fetched = runBlocking { client.getApprovalMode() }
|
||||
val updated = runBlocking { client.setApprovalMode(GatewayApprovalMode.Manual) }
|
||||
|
||||
assertTrue(fetched.isFailure)
|
||||
assertTrue(updated.isFailure)
|
||||
assertTrue(
|
||||
fetched.exceptionOrNull()?.message.orEmpty().contains("read-only"),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
harness.rpcLog.count { (method, _) ->
|
||||
method == "config.get" || method == "config.set"
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Unknown,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale session info cannot overwrite approval mode after session clear`() {
|
||||
harness.approvalMode = "smart"
|
||||
assertEquals(GatewayApprovalMode.Smart, runBlocking { client.getApprovalMode() }.getOrThrow())
|
||||
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
client.clearSession()
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("approval_mode", "off") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(30)
|
||||
|
||||
assertEquals(GatewayApprovalMode.Smart, client.serverApprovalMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reasoning settings update targets live session when present`() {
|
||||
val r = Recorder()
|
||||
@@ -1696,6 +1896,25 @@ class GatewayChatClientTest {
|
||||
assertFalse(rpc.containsKey("scope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yolo update targets live session with ephemeral session scope`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.setYolo(true) }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(result.getOrThrow())
|
||||
val rpc = harness.awaitRpc("config.set")
|
||||
assertEquals("yolo", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("1", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("session", (rpc["scope"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
// --- Edit & regenerate ---
|
||||
|
||||
@Test
|
||||
@@ -1960,6 +2179,110 @@ class GatewayChatClientTest {
|
||||
recovery.handle!!.detach()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn exposes retained terminal failure without live handle`() {
|
||||
harness.recoveryInflightStreaming = false
|
||||
harness.recoveryAssistant = "partial answer"
|
||||
harness.recoveryInflightError = "provider failed"
|
||||
harness.recoveryInflightRecoverable = true
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertFalse(recovery.running)
|
||||
assertFalse(recovery.hasPendingWork)
|
||||
assertEquals("error", recovery.inflight?.status)
|
||||
assertEquals("provider failed", recovery.inflight?.error)
|
||||
assertTrue(recovery.inflight?.recoverable == true)
|
||||
assertNull(recovery.handle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto continue buffers message start racing resume acknowledgement`() {
|
||||
runBlocking {
|
||||
harness.recoveryAutoContinueAttempt = 1
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val recorder = Recorder()
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
assertEquals("session.resume", ack.method)
|
||||
val liveId = (harness.recoveryResult("stored-42")
|
||||
.getValue("session_id") as JsonPrimitive).content
|
||||
ack.ws.send(harness.eventFrame("message.start", null, liveId))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued answer") },
|
||||
liveId,
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult(liveId))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (recorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(1, recovery.autoContinue?.attempt)
|
||||
assertTrue(recovery.hasPendingWork)
|
||||
assertEquals(listOf("continued answer"), recorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume race delivers auto continue events through exactly one owner`() {
|
||||
runBlocking {
|
||||
// Keep the recovered live id warm so the early message.start could be
|
||||
// accepted by normal unsolicited routing while session.resume is also
|
||||
// buffering it. Recovery must exclusively claim the frame instead.
|
||||
assertTrue(client.prewarmAwait("stored-42"))
|
||||
harness.recoveryAutoContinueAttempt = 1
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val recorder = Recorder()
|
||||
client.setUnsolicitedTurnProvider {
|
||||
GatewayInboundTurnRegistration(recorder.callbacks) { true }
|
||||
}
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
assertEquals("session.resume", ack.method)
|
||||
val liveId = (harness.recoveryResult("stored-42")
|
||||
.getValue("session_id") as JsonPrimitive).content
|
||||
ack.ws.send(harness.eventFrame("message.start", null, liveId))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued once") },
|
||||
liveId,
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult(liveId))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (recorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(listOf("continued once"), recorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps inflight and queued resume live`() {
|
||||
harness.recoveryInflightStreaming = true
|
||||
|
||||
+12
-4
@@ -5,8 +5,9 @@ import org.junit.Test
|
||||
|
||||
/**
|
||||
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
|
||||
* tier sits above the capability-preferred SSE endpoint, but only for "auto"
|
||||
* and only when the dashboard probe reports Ready.
|
||||
* tier sits above the capability-preferred SSE endpoint for "auto". An
|
||||
* unresolved cold-start probe remains on Gateway until it produces a
|
||||
* definitive fallback verdict.
|
||||
*/
|
||||
class GatewayEndpointResolutionTest {
|
||||
|
||||
@@ -35,9 +36,16 @@ class GatewayEndpointResolutionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back to capability preference for every non-ready state`() {
|
||||
fun `auto stays on gateway while cold-start availability is unresolved`() {
|
||||
assertEquals(
|
||||
"gateway",
|
||||
resolveStreamingEndpointPreference("auto", GatewayAvailability.Unknown, fullCaps),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back after a definitive non-ready verdict`() {
|
||||
listOf(
|
||||
GatewayAvailability.Unknown,
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported,
|
||||
|
||||
+177
-1
@@ -19,6 +19,7 @@ class GatewayEventMapperTest {
|
||||
private class Recorder {
|
||||
val textDeltas = mutableListOf<String>()
|
||||
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
||||
val reconciledInterims = mutableListOf<String>()
|
||||
val thinkingDeltas = mutableListOf<String>()
|
||||
val toolStarts = mutableListOf<Pair<String, String>>()
|
||||
val toolDones = mutableListOf<Pair<String, String?>>()
|
||||
@@ -26,6 +27,7 @@ class GatewayEventMapperTest {
|
||||
val toolOutputRisks = mutableListOf<GatewayToolOutputRisk>()
|
||||
val toolGenerating = mutableListOf<String?>()
|
||||
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
||||
val moaReferences = mutableListOf<GatewayMoaReference>()
|
||||
val interactions = mutableListOf<GatewayAsk>()
|
||||
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
|
||||
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
|
||||
@@ -45,6 +47,7 @@ class GatewayEventMapperTest {
|
||||
onStart = { starts++ },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
|
||||
onInterimReconciled = { text -> reconciledInterims += text },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
onToolCallDone = { id, preview -> toolDones += id to preview },
|
||||
@@ -57,6 +60,7 @@ class GatewayEventMapperTest {
|
||||
onError = { errors += it },
|
||||
onToolGenerating = { toolGenerating += it },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onMoaReference = { moaReferences += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
onInteractionExpired = { interactionExpiries += it },
|
||||
onInteractionResolved = { interactionResolutions += it },
|
||||
@@ -231,6 +235,93 @@ class GatewayEventMapperTest {
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed complete equal to interim reconciles one bubble`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed complete extending interim replaces it with full final`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed truncated final replaces longer interim`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal error complete preserves partial and reports failed status`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj(
|
||||
"""{"text":"partial answer","status":"error","error":"provider failed","partial":true,"recoverable":true}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("partial answer"), r.textDeltas)
|
||||
assertEquals(listOf("error" to "provider failed"), r.statusUpdates)
|
||||
assertEquals(1, r.completes)
|
||||
assertTrue(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal error complete without text renders error fallback`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"status":"error","error":"agent build failed","recoverable":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Error: agent build failed"), r.textDeltas)
|
||||
assertEquals(listOf("error" to "agent build failed"), r.statusUpdates)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already streamed interim seals without replaying text`() {
|
||||
val r = Recorder()
|
||||
@@ -732,6 +823,91 @@ class GatewayEventMapperTest {
|
||||
assertTrue(r.interactions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moa progress uses one transient slot and transitions to aggregating`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent("moa.progress", obj("""{"refs_done":2,"refs_total":3,"label":"advisor-b"}"""))
|
||||
mapper.onEvent("moa.phase", obj("""{"phase":"aggregator","refs_done":3,"refs_total":3}"""))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: 2/3 advisors complete",
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: aggregating…",
|
||||
),
|
||||
r.statusUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy moa aggregating maps to the same transient phase`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent("moa.aggregating", obj("""{"aggregator":"local:aggregate"}"""))
|
||||
|
||||
assertEquals(
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: aggregating…",
|
||||
r.statusUpdates.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moa references retain safe blocks and neutralize failure sentinels`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":2,"count":3,"label":"advisor-b","text":"Useful second opinion"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":1,"count":3,"label":"advisor-a","text":" [failed: private provider detail]"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":3,"count":3,"label":"advisor-c","text":"[skipped: interrupted by user]"}"""),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayMoaReference(2, 3, "advisor-b", "Useful second opinion"),
|
||||
GatewayMoaReference(1, 3, "advisor-a", "", available = false),
|
||||
GatewayMoaReference(3, 3, "advisor-c", "", available = false),
|
||||
),
|
||||
r.moaReferences,
|
||||
)
|
||||
assertTrue(r.moaReferences.filterNot { it.available }.all { it.text.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all failed moa references surface only neutral unavailable state`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":1,"count":2,"label":"advisor-a","text":"[failed: secret detail]"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":2,"count":2,"label":"advisor-b","text":"[skipped: recursive preset]"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), r.moaReferences.mapNotNull { it.index })
|
||||
assertTrue(r.moaReferences.all { !it.available && it.text.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message output clears active moa status`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("moa.progress", obj("""{"refs_done":1,"refs_total":2}"""))
|
||||
mapper.onEvent("message.delta", obj("""{"text":"Final answer"}"""))
|
||||
|
||||
assertEquals(listOf(GatewayEventMapper.MOA_STATUS_KIND), r.statusClears)
|
||||
}
|
||||
|
||||
// --- Forward compat ---
|
||||
|
||||
@Test
|
||||
@@ -761,7 +937,7 @@ class GatewayEventMapperTest {
|
||||
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
"tool.output_risk", "moa.reference", "moa.aggregating",
|
||||
"tool.output_risk", "moa.reference", "moa.progress", "moa.phase", "moa.aggregating",
|
||||
).forEach { type ->
|
||||
// message.complete/error end the turn; use a fresh mapper for each
|
||||
mapperWith(Recorder()).onEvent(type, null)
|
||||
|
||||
@@ -230,6 +230,8 @@ class HermesApiClientTest {
|
||||
"run_events_sse": true,
|
||||
"session_resources": true,
|
||||
"session_chat_streaming": true,
|
||||
"model_options": true,
|
||||
"session_model_lock": true,
|
||||
"skills_api": true
|
||||
},
|
||||
"endpoints": {
|
||||
@@ -237,6 +239,8 @@ class HermesApiClientTest {
|
||||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||
"sessions": {"method": "GET", "path": "/api/sessions"},
|
||||
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
||||
"model_options": {"method": "GET", "path": "/api/model/options"},
|
||||
"session_model_lock": {"method": "POST", "path": "/api/sessions/{session_id}/model"},
|
||||
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
||||
}
|
||||
@@ -249,9 +253,137 @@ class HermesApiClientTest {
|
||||
assertEquals(true, capabilities?.sessionsChatStream)
|
||||
assertEquals(true, capabilities?.portable)
|
||||
assertEquals(true, capabilities?.runs)
|
||||
assertEquals(true, capabilities?.modelOptions)
|
||||
assertEquals(true, capabilities?.sessionModelLock)
|
||||
assertEquals("sessions", capabilities?.preferredChatEndpoint())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerModelOptions_preserveAuthenticatedAndUnavailableInventory() {
|
||||
val parsed = parseApiProviderModelOptionsBody(
|
||||
Json { ignoreUnknownKeys = true },
|
||||
"""
|
||||
{
|
||||
"model": "grok-4.3",
|
||||
"provider": "xai",
|
||||
"providers": [
|
||||
{
|
||||
"slug": "xai",
|
||||
"name": "xAI",
|
||||
"authenticated": true,
|
||||
"is_current": true,
|
||||
"models": ["grok-4.3", "grok-4.2"],
|
||||
"unavailable_models": ["grok-4.2"],
|
||||
"free_tier": true,
|
||||
"total_models": 2
|
||||
},
|
||||
{
|
||||
"slug": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"authenticated": false,
|
||||
"models": ["claude-opus-4-6"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("grok-4.3", parsed?.currentModel)
|
||||
assertEquals("xai", parsed?.currentProvider)
|
||||
assertEquals(listOf("grok-4.3", "grok-4.2"), parsed?.providers?.first()?.models)
|
||||
assertEquals(listOf("grok-4.2"), parsed?.providers?.first()?.unavailableModels)
|
||||
assertTrue(parsed?.providers?.first()?.authenticated == true)
|
||||
assertFalse(parsed?.providers?.last()?.authenticated == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerModelOptions_requireProviderEnvelope() {
|
||||
assertNull(parseApiProviderModelOptionsBody(Json, """{"data":[]}"""))
|
||||
assertNull(parseApiProviderModelOptionsBody(Json, "not-json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelLockAck_requiresExplicitRequestedRouteAndAcceptedState() {
|
||||
val ack = parseApiModelLockAck(
|
||||
Json,
|
||||
"""
|
||||
{
|
||||
"object": "hermes.session.model_lock",
|
||||
"session_id": "session-1",
|
||||
"runtime": {
|
||||
"requested": {"model": "grok-4.3", "provider": "xai"},
|
||||
"effective": {"model": "grok-4.3", "provider": "xai"},
|
||||
"model_lock": "accepted"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("session-1", ack?.sessionId)
|
||||
assertEquals("grok-4.3", ack?.model)
|
||||
assertEquals("xai", ack?.provider)
|
||||
assertEquals("accepted", ack?.state)
|
||||
assertEquals("grok-4.3", ack?.effectiveModel)
|
||||
assertEquals("xai", ack?.effectiveProvider)
|
||||
assertNull(parseApiModelLockAck(Json, """{"session_id":"session-1"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelOptionsWithoutSessionLockUsesLegacyHintContract() {
|
||||
val capabilities = ServerCapabilities(
|
||||
sessionsApi = true,
|
||||
sessionsChatStream = true,
|
||||
runs = false,
|
||||
portable = true,
|
||||
healthy = true,
|
||||
modelOptions = true,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
|
||||
assertEquals(ApiModelRoutingStrategy.LEGACY_HINT, apiModelRoutingStrategy(capabilities))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalRuntimeMustConfirmExactEffectiveRoute() {
|
||||
val expected = ApiModelSelectionAck.Locked(
|
||||
sessionId = "session-1",
|
||||
model = "fast-route",
|
||||
provider = "openai",
|
||||
effectiveModel = "gpt-5-mini",
|
||||
effectiveProvider = "openai",
|
||||
)
|
||||
val confirmed = Json.parseToJsonElement(
|
||||
"""{"model_lock":"confirmed","effective":{"model":"gpt-5-mini","provider":"openai"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
val wrongProvider = Json.parseToJsonElement(
|
||||
"""{"model_lock":"confirmed","effective":{"model":"gpt-5-mini","provider":"azure"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
val merelyAccepted = Json.parseToJsonElement(
|
||||
"""{"model_lock":"accepted","effective":{"model":"gpt-5-mini","provider":"openai"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
|
||||
assertTrue(confirmedRuntimeMatches(confirmed, expected))
|
||||
assertFalse(confirmedRuntimeMatches(wrongProvider, expected))
|
||||
assertFalse(confirmedRuntimeMatches(merelyAccepted, expected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedLockOmitsTurnModelWhileLegacyFallbackKeepsHint() {
|
||||
assertNull(
|
||||
sessionTurnModelHint(
|
||||
ApiModelSelectionAck.Locked("session-1", "grok-4.3", "xai"),
|
||||
"grok-4.3",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"fast-route",
|
||||
sessionTurnModelHint(
|
||||
ApiModelSelectionAck.LegacyModelHint("fast-route"),
|
||||
"fast-route",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
|
||||
val body = """{"status":"ok"}"""
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ModelOptionsResponseFenceTest {
|
||||
@Test
|
||||
fun acceptsOnlySameGenerationAndProfile() {
|
||||
assertTrue(isCurrentModelOptionsResponse(4, 4, "connection::alpha", "connection::alpha"))
|
||||
assertFalse(isCurrentModelOptionsResponse(3, 4, "connection::alpha", "connection::alpha"))
|
||||
assertFalse(isCurrentModelOptionsResponse(4, 4, "connection::alpha", "connection::beta"))
|
||||
}
|
||||
}
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NativeDashboardAuthTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: MemoryNativeTokenStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
store = MemoryNativeTokenStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capabilityGate_requiresAdvertisedNativeFlow() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
|
||||
assertFalse(client.supportsNativePkce(DashboardStatus(authRequired = true)))
|
||||
assertTrue(
|
||||
client.supportsNativePkce(
|
||||
DashboardStatus(authRequired = true, authFlows = listOf("cookie", "native_pkce")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun beginAuthorization_usesS256StateAndStrictLoopbackRedirect() {
|
||||
val client = NativeDashboardAuthClient(server.url("/prefix").toString(), store)
|
||||
val authorization = client.beginAuthorization(
|
||||
redirectUri = "http://127.0.0.1:43123/callback",
|
||||
provider = "nous",
|
||||
)
|
||||
val url = java.net.URI(authorization.authorizationUrl)
|
||||
val query = url.rawQuery.split("&").associate {
|
||||
val pair = it.split("=", limit = 2)
|
||||
java.net.URLDecoder.decode(pair[0], "UTF-8") to
|
||||
java.net.URLDecoder.decode(pair[1], "UTF-8")
|
||||
}
|
||||
|
||||
assertEquals("/prefix/auth/native/authorize", url.path)
|
||||
assertEquals("S256", query["code_challenge_method"])
|
||||
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
|
||||
assertEquals("nous", query["provider"])
|
||||
assertTrue(query.getValue("state").length >= 32)
|
||||
assertEquals(43, query.getValue("code_challenge").length)
|
||||
assertFalse(query.getValue("code_challenge").contains('='))
|
||||
assertNotEquals(query["state"], query["code_challenge"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
|
||||
val location = "https://portal.nousresearch.com/oauth/authorize" +
|
||||
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fgateway%2Fauth%2Fcallback"
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.test/gateway",
|
||||
canonicalDashboardBaseFromNousRedirect(location),
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
canonicalDashboardBaseFromNousRedirect(
|
||||
"https://portal.nousresearch.com/oauth/authorize" +
|
||||
"?redirect_uri=http%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
canonicalDashboardBaseFromNousRedirect(
|
||||
"https://attacker.example/oauth/authorize" +
|
||||
"?redirect_uri=https%3A%2F%2Fhermes.example.test%2Fauth%2Fcallback",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun beginAuthorization_rejectsHostnameLoopback() {
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
.beginAuthorization("http://localhost:43123/callback")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_validatesStateAndStoresTokens() {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"access","refresh_token":"refresh","expires_at":2000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
val tokens = client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=one-time-code&state=${authorization.state}",
|
||||
)
|
||||
|
||||
assertEquals("access", tokens.accessToken)
|
||||
assertEquals(tokens, store.load())
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/auth/native/token", request.path)
|
||||
val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
|
||||
assertEquals("one-time-code", payload.getValue("code").jsonPrimitive.content)
|
||||
val verifier = payload.getValue("code_verifier").jsonPrimitive.content
|
||||
val expectedChallenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
.trimEnd('=')
|
||||
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
|
||||
.split("&")
|
||||
.first { it.startsWith("code_challenge=") }
|
||||
.substringAfter("=")
|
||||
.let { java.net.URLDecoder.decode(it, Charsets.UTF_8) }
|
||||
assertEquals(expectedChallenge, authorizeChallenge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_rejectsWrongStateWithoutNetworkOrStorage() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
|
||||
val result = runCatching {
|
||||
client.exchangeCallback(authorization, "/callback?code=attacker-code&state=wrong")
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(0, server.requestCount)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_doesNotRestoreTokensAfterSessionClear() {
|
||||
val responseStarted = CountDownLatch(1)
|
||||
val releaseResponse = CountDownLatch(1)
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
responseStarted.countDown()
|
||||
check(releaseResponse.await(5, TimeUnit.SECONDS))
|
||||
return MockResponse().setBody(
|
||||
"""{"access_token":"late","refresh_token":"late-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
val failure = AtomicReference<Throwable?>()
|
||||
val exchange = Thread {
|
||||
runCatching {
|
||||
client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=late-code&state=${authorization.state}",
|
||||
)
|
||||
}.exceptionOrNull()?.let(failure::set)
|
||||
}.apply { start() }
|
||||
|
||||
assertTrue(responseStarted.await(5, TimeUnit.SECONDS))
|
||||
client.clearStoredSession()
|
||||
releaseResponse.countDown()
|
||||
exchange.join(5_000)
|
||||
|
||||
assertFalse(exchange.isAlive)
|
||||
assertTrue(failure.get() is java.io.IOException)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_doesNotCommitAfterAttemptCancellation() {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"cancelled","refresh_token":"refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
|
||||
val result = runCatching {
|
||||
client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=code&state=${authorization.state}",
|
||||
commitAllowed = { false },
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedBearerPolicy_rejectsCleartextDashboardRoute() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "must-not-leak",
|
||||
refreshToken = "must-not-refresh",
|
||||
expiresAt = 1,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
|
||||
val bearer = trustedDashboardBearerAuthOrNull(
|
||||
candidate = "http://hermes.local:9119",
|
||||
trusted = "http://hermes.local:9119",
|
||||
tokenStoreProvider = { store },
|
||||
)
|
||||
|
||||
assertEquals(null, bearer)
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bearerAuth_refreshesNearExpiryAndAuthenticatesTicketRequest() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "old-access",
|
||||
refreshToken = "refresh",
|
||||
expiresAt = 1005,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"new-access","refresh_token":"new-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("""{"ticket":"ticket","ttl_seconds":30}"""))
|
||||
val client = DashboardApiClient(
|
||||
server.url("/").toString(),
|
||||
DashboardApiClient.defaultClient(
|
||||
bearerAuth = DashboardBearerAuth(
|
||||
server.url("/").toString(),
|
||||
store,
|
||||
clockSeconds = { 1000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = kotlinx.coroutines.runBlocking { client.requestWsTicket().getOrThrow() }
|
||||
|
||||
assertEquals("ticket", result.ticket)
|
||||
val refresh = server.takeRequest()
|
||||
assertEquals("/auth/native/refresh", refresh.path)
|
||||
assertFalse(refresh.headers.names().contains("Authorization"))
|
||||
val ticket = server.takeRequest()
|
||||
assertEquals("Bearer new-access", ticket.getHeader("Authorization"))
|
||||
assertEquals("new-refresh", store.load()?.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hostileSetupOrigin_neverReceivesActiveConnectionBearer() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "must-not-leak",
|
||||
refreshToken = "refresh",
|
||||
expiresAt = 3000,
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("""{"auth_required":false}"""))
|
||||
val hostileUrl = server.url("/attacker").toString()
|
||||
val bearer = trustedDashboardBearerAuthOrNull(
|
||||
candidate = hostileUrl,
|
||||
trusted = "https://trusted.example/hermes",
|
||||
tokenStoreProvider = { store },
|
||||
)
|
||||
val client = DashboardApiClient(
|
||||
hostileUrl,
|
||||
DashboardApiClient.defaultClient(bearerAuth = bearer),
|
||||
)
|
||||
|
||||
kotlinx.coroutines.runBlocking { client.getStatus().getOrThrow() }
|
||||
|
||||
assertEquals(null, bearer)
|
||||
assertEquals(null, server.takeRequest().getHeader("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentClients_rotateSingleUseRefreshTokenExactlyOnce() {
|
||||
val shared = AtomicReference<NativeDashboardTokens?>(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "old-access",
|
||||
refreshToken = "single-use-refresh",
|
||||
expiresAt = 1005,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
val refreshCalls = AtomicInteger()
|
||||
val ticketCalls = AtomicInteger()
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
|
||||
"/auth/native/refresh" -> {
|
||||
refreshCalls.incrementAndGet()
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"new-access","refresh_token":"rotated-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
)
|
||||
}
|
||||
"/api/auth/ws-ticket" -> {
|
||||
ticketCalls.incrementAndGet()
|
||||
if (request.getHeader("Authorization") == "Bearer new-access") {
|
||||
MockResponse().setBody("""{"ticket":"ticket","ttl_seconds":30}""")
|
||||
} else {
|
||||
MockResponse().setResponseCode(401)
|
||||
}
|
||||
}
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
val storeA = SharedMemoryNativeTokenStore("connection-a", shared)
|
||||
val storeB = SharedMemoryNativeTokenStore("connection-a", shared)
|
||||
val clientA = dashboardClientWithBearer(storeA)
|
||||
val clientB = dashboardClientWithBearer(storeB)
|
||||
val start = CountDownLatch(1)
|
||||
val done = CountDownLatch(2)
|
||||
val failures = java.util.Collections.synchronizedList(mutableListOf<Throwable>())
|
||||
|
||||
listOf(clientA, clientB).forEach { client ->
|
||||
Thread {
|
||||
try {
|
||||
start.await()
|
||||
kotlinx.coroutines.runBlocking { client.requestWsTicket().getOrThrow() }
|
||||
} catch (error: Throwable) {
|
||||
failures += error
|
||||
} finally {
|
||||
done.countDown()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
start.countDown()
|
||||
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(failures.toString(), failures.isEmpty())
|
||||
assertEquals(1, refreshCalls.get())
|
||||
assertEquals(2, ticketCalls.get())
|
||||
assertEquals("rotated-refresh", shared.get()?.refreshToken)
|
||||
}
|
||||
|
||||
private fun dashboardClientWithBearer(store: NativeDashboardTokenStore): DashboardApiClient =
|
||||
DashboardApiClient(
|
||||
server.url("/").toString(),
|
||||
DashboardApiClient.defaultClient(
|
||||
bearerAuth = DashboardBearerAuth(
|
||||
server.url("/").toString(),
|
||||
store,
|
||||
clockSeconds = { 1000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class MemoryNativeTokenStore : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = "memory-${System.identityHashCode(this)}"
|
||||
private var tokens: NativeDashboardTokens? = null
|
||||
override fun load(): NativeDashboardTokens? = tokens
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
this.tokens = tokens
|
||||
}
|
||||
override fun clear() {
|
||||
tokens = null
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedMemoryNativeTokenStore(
|
||||
override val coordinationKey: String,
|
||||
private val shared: AtomicReference<NativeDashboardTokens?>,
|
||||
) : NativeDashboardTokenStore {
|
||||
override fun load(): NativeDashboardTokens? = shared.get()
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
shared.set(tokens)
|
||||
}
|
||||
override fun clear() {
|
||||
shared.set(null)
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NativeDashboardSignInCoordinatorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: CoordinatorTokenStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
store = CoordinatorTokenStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_bindsBeforeLaunch_forwardsProviderAndExchangesValidCallback() = runBlocking {
|
||||
server.enqueue(tokenResponse())
|
||||
val authClient = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val coordinator = NativeDashboardSignInCoordinator(authClient)
|
||||
|
||||
val tokens = completeSignIn(coordinator, provider = "google")
|
||||
|
||||
assertEquals("access-1", tokens.accessToken)
|
||||
assertEquals(tokens, store.tokens)
|
||||
val authorizeRequest = server.takeRequest()
|
||||
assertEquals("/auth/native/token", authorizeRequest.path)
|
||||
assertTrue(authorizeRequest.body.readUtf8().contains("\"code\":\"code-1\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_ignoresWrongStateThenAcceptsValidCallback() = runBlocking {
|
||||
server.enqueue(tokenResponse())
|
||||
val coordinator = NativeDashboardSignInCoordinator(
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store),
|
||||
)
|
||||
|
||||
coroutineScope {
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
val result = async {
|
||||
coordinator.signIn("github") { authorizationUrl.complete(it) }
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val redirect = URI(query(authorize)["redirect_uri"]!!)
|
||||
val state = query(authorize)["state"]!!
|
||||
|
||||
val rejected = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=attacker&state=wrong",
|
||||
)
|
||||
assertTrue(rejected.startsWith("HTTP/1.1 400"))
|
||||
assertFalse(result.isCompleted)
|
||||
|
||||
val accepted = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=code-1&state=$state",
|
||||
)
|
||||
assertTrue(accepted.startsWith("HTTP/1.1 200"))
|
||||
assertEquals("access-1", result.await().accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_timeoutClosesEphemeralListener() = runBlocking {
|
||||
val coordinator = NativeDashboardSignInCoordinator(
|
||||
authClient = NativeDashboardAuthClient(server.url("/").toString(), store),
|
||||
timeoutMillis = 100,
|
||||
)
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
runBlocking {
|
||||
coordinator.signIn("google") { authorizationUrl.complete(it) }
|
||||
}
|
||||
}
|
||||
val redirect = URI(query(URI(authorizationUrl.await()))["redirect_uri"]!!)
|
||||
assertThrows(Exception::class.java) {
|
||||
Socket("127.0.0.1", redirect.port).use { }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redirectMode_requiresExactCapability_andNativeTransportRequiresHttps() {
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.NativePkce,
|
||||
dashboardRedirectAuthMode(listOf("cookie", "native_pkce")),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
dashboardRedirectAuthMode(listOf("cookie", "NATIVE_PKCE")),
|
||||
)
|
||||
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://172.16.24.250:9119"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://100.71.8.56:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://203.0.113.10:9119"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun androidRedirectMode_usesBrowserForNous_andCookieFlowForSelfHostedOidc() {
|
||||
val flows = listOf("cookie", "native_pkce")
|
||||
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.NativePkce,
|
||||
androidDashboardRedirectAuthMode("nous", flows),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
androidDashboardRedirectAuthMode("oidc", flows),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
androidDashboardRedirectAuthMode("nous", listOf("cookie")),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun completeSignIn(
|
||||
coordinator: NativeDashboardSignInCoordinator,
|
||||
provider: String,
|
||||
): NativeDashboardTokens = coroutineScope {
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
val result = async {
|
||||
coordinator.signIn(provider) { authorizationUrl.complete(it) }
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val authorizeQuery = query(authorize)
|
||||
assertEquals(provider, authorizeQuery["provider"])
|
||||
assertEquals("S256", authorizeQuery["code_challenge_method"])
|
||||
val redirect = URI(authorizeQuery["redirect_uri"]!!)
|
||||
assertEquals("127.0.0.1", redirect.host)
|
||||
assertTrue(redirect.port > 0)
|
||||
val response = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=code-1&state=${authorizeQuery["state"]}",
|
||||
)
|
||||
assertTrue(response.startsWith("HTTP/1.1 200"))
|
||||
result.await()
|
||||
}
|
||||
|
||||
private fun sendCallback(redirect: URI, target: String): String =
|
||||
Socket("127.0.0.1", redirect.port).use { socket ->
|
||||
socket.getOutputStream().write(
|
||||
"GET $target HTTP/1.1\r\nHost: 127.0.0.1:${redirect.port}\r\n\r\n"
|
||||
.toByteArray(StandardCharsets.US_ASCII),
|
||||
)
|
||||
socket.getOutputStream().flush()
|
||||
BufferedReader(InputStreamReader(socket.getInputStream())).readLine()
|
||||
}
|
||||
|
||||
private fun query(uri: URI): Map<String, String> =
|
||||
uri.rawQuery.orEmpty()
|
||||
.split('&')
|
||||
.filter(String::isNotBlank)
|
||||
.associate { part ->
|
||||
val pieces = part.split('=', limit = 2)
|
||||
URLDecoder.decode(pieces[0], StandardCharsets.UTF_8) to
|
||||
URLDecoder.decode(pieces.getOrElse(1) { "" }, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun tokenResponse(): MockResponse = MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"expires_at": 4102444800,
|
||||
"provider": "google",
|
||||
"user_id": "user-1"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private class CoordinatorTokenStore : NativeDashboardTokenStore {
|
||||
override val coordinationKey = "coordinator-test"
|
||||
var tokens: NativeDashboardTokens? = null
|
||||
|
||||
override fun load(): NativeDashboardTokens? = tokens
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
this.tokens = tokens
|
||||
}
|
||||
override fun clear() {
|
||||
tokens = null
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -152,7 +152,7 @@ class StandardHermesVoiceClientTest {
|
||||
socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
): StandardHermesVoiceClient = StandardHermesVoiceClient(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
okHttpClient = DashboardApiClient.defaultClient(),
|
||||
dashboardHttpClientProvider = { DashboardApiClient.defaultClient() },
|
||||
dashboardUrlProvider = { server.url("/").toString() },
|
||||
webSocketFactory = socketFactory,
|
||||
)
|
||||
|
||||
+20
-1
@@ -83,17 +83,36 @@ class InteractionRequestNotifierTest {
|
||||
|
||||
val notification = manager.activeNotifications.single().notification
|
||||
val privateText = notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
|
||||
val expandedText = notification.extras
|
||||
.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
.toString()
|
||||
val privateTitle = notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
|
||||
val publicText = notification.publicVersion.extras
|
||||
.getCharSequence(Notification.EXTRA_TEXT)
|
||||
.toString()
|
||||
val visibleCopy = "$privateTitle $privateText $publicText"
|
||||
val visibleCopy = "$privateTitle $privateText $expandedText $publicText"
|
||||
assertFalse(visibleCopy.contains(secret.text))
|
||||
assertFalse(visibleCopy.contains(secret.envVar!!))
|
||||
assertTrue(visibleCopy.contains("Open Hermes"))
|
||||
assertTrue(expandedText.contains("Profile: Server default"))
|
||||
assertEquals("Respond securely", notification.actions.single().title)
|
||||
assertEquals(Notification.VISIBILITY_PRIVATE, notification.visibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameStoredSessionInTwoProfilesKeepsIndependentNotificationSlots() {
|
||||
val ask = approval(command = "private")
|
||||
|
||||
assertTrue(post(ask, SESSION_ID, "victor"))
|
||||
assertTrue(post(ask, SESSION_ID, "server-default"))
|
||||
|
||||
assertEquals(2, manager.activeNotifications.size)
|
||||
assertEquals(
|
||||
2,
|
||||
manager.activeNotifications.map { it.tag }.distinct().size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvedOrExpiredRequestCancelsOnlyItsStableSlot() {
|
||||
val first = approval(command = "first")
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class CollapsibleAttachmentGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `summary keeps filename type and count for any attachment state`() {
|
||||
val summary = attachmentGroupSummary(
|
||||
listOf(
|
||||
Attachment(
|
||||
contentType = "application/pdf",
|
||||
content = "",
|
||||
fileName = "report.pdf",
|
||||
state = AttachmentState.FAILED,
|
||||
),
|
||||
Attachment(
|
||||
contentType = "application/octet-stream",
|
||||
content = "",
|
||||
state = AttachmentState.LOADING,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
AttachmentGroupSummary(
|
||||
count = 2,
|
||||
firstName = "report.pdf",
|
||||
firstType = AttachmentRenderMode.PDF,
|
||||
remainingCount = 1,
|
||||
),
|
||||
summary,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsed group stays collapsed when attachment lifecycle updates`() {
|
||||
var attachments by mutableStateOf(
|
||||
listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = "result.png",
|
||||
state = AttachmentState.LOADING,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = "stable-message",
|
||||
attachments = attachments,
|
||||
) {
|
||||
Text("Attachment preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Attachment preview").assertExists()
|
||||
compose.onNodeWithContentDescription("Collapse attachments").assertExists()
|
||||
compose.onNodeWithTag("attachment-group-toggle-stable-message").performClick()
|
||||
compose.onNodeWithText("Attachment preview").assertDoesNotExist()
|
||||
compose.onNodeWithContentDescription("Expand attachments").assertExists()
|
||||
|
||||
compose.runOnIdle {
|
||||
attachments = attachments.map {
|
||||
it.copy(
|
||||
content = "loaded",
|
||||
cachedUri = "content://media/result",
|
||||
state = AttachmentState.LOADED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Attachment preview").assertDoesNotExist()
|
||||
compose.onNodeWithTag("attachment-group-toggle-stable-message").performClick()
|
||||
compose.onNodeWithText("Attachment preview").assertExists()
|
||||
}
|
||||
}
|
||||
+50
@@ -3,11 +3,18 @@ package com.hermesandroid.relay.ui.components
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageGenerationPlaceholderTest {
|
||||
|
||||
@Test
|
||||
fun `duration label is stable and clamps negative elapsed time`() {
|
||||
assertEquals("12.4s", formatGenerationDuration(12_440))
|
||||
assertEquals("0.0s", formatGenerationDuration(-100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active image generation uses diffusion placeholder`() {
|
||||
val active = ToolCall(
|
||||
@@ -119,4 +126,47 @@ class ImageGenerationPlaceholderTest {
|
||||
assertTrue(early < resolved)
|
||||
assertTrue(reset < resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rubiks sphere animates exactly one outer slice at a time`() {
|
||||
(0..100).forEach { frame ->
|
||||
val angles = rubiksSliceAngles(frame / 100f)
|
||||
val activeSlices = listOf(angles.topY, angles.frontZ, angles.rightX)
|
||||
.count { kotlin.math.abs(it) > 0.0001f }
|
||||
|
||||
assertTrue("overlapping slices at frame $frame", activeSlices <= 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotate preference cycles all image generation styles`() {
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.LatentGrid,
|
||||
resolveImageGenerationVisualStyle("rotate", 0),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.ParticleOrb,
|
||||
resolveImageGenerationVisualStyle("rotate", 1),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.Constellation,
|
||||
resolveImageGenerationVisualStyle("rotate", 2),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.LatentGrid,
|
||||
resolveImageGenerationVisualStyle("rotate", 3),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned image generation preference ignores rotation index`() {
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.ParticleOrb,
|
||||
resolveImageGenerationVisualStyle("sphere", 99),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.Constellation,
|
||||
resolveImageGenerationVisualStyle("nodes", 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -28,12 +29,17 @@ class ImageGenerationPlaceholderUiTest {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
ImageGenerationPlaceholder(Modifier.padding(16.dp))
|
||||
ImageGenerationPlaceholder(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
phaseOverride = 0.5f,
|
||||
elapsedOverrideMillis = 12_400,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.mainClock.advanceTimeBy(2_400)
|
||||
compose.onNodeWithContentDescription("Rendering image").assertExists()
|
||||
compose.onNodeWithText("12.4s").assertExists()
|
||||
compose.onRoot().captureRoboImage("build/verification-shots/image-generation-placeholder.png")
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -12,7 +12,7 @@ import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import com.hermesandroid.relay.viewmodel.backgroundRunAfterCancelRequest
|
||||
import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterResponseDone
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterPromotion
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -27,12 +27,10 @@ class VoiceModeOverlayStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun responseDone_keepsLogicalTurnActiveOnlyWhileBackgroundRunIsLive() {
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RUNNING))
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RECONNECTING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DELIVERING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DONE))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(null))
|
||||
fun promotion_keepsForegroundBusyOnlyForSpokenHandoff() {
|
||||
assertEquals(false, realtimeTurnActiveAfterPromotion(spokenHandoff = false))
|
||||
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = true))
|
||||
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatScrollSnapshotTest {
|
||||
@Test
|
||||
fun `completion releases only the retained live tail`() {
|
||||
assertNull(releaseRetainedLiveTail("assistant-live", "assistant-live"))
|
||||
assertEquals(
|
||||
"assistant-live",
|
||||
releaseRetainedLiveTail("assistant-live", "different-message"),
|
||||
)
|
||||
assertNull(releaseRetainedLiveTail(null, "assistant-live"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tall markdown tail is positioned by its trailing edge`() {
|
||||
assertEquals(
|
||||
1_208,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 2_400,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 600,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealth
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -9,6 +11,30 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardManageParityTest {
|
||||
@Test
|
||||
fun componentHealthLines_surfaceDegradedDetailsWithoutChangingReachability() {
|
||||
val lines = dashboardComponentHealthLines(
|
||||
DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = "degraded",
|
||||
components = listOf(
|
||||
DashboardComponentHealth(
|
||||
name = "platforms",
|
||||
status = "degraded",
|
||||
configured = 3,
|
||||
connected = 1,
|
||||
unhandled5xxCount5m = 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("platforms: degraded · 1/3 connected · 2 server errors / 5m"),
|
||||
lines,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
|
||||
val oauth = Json.parseToJsonElement(
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardWebViewAuthPolicyTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfHostedOidc_usesDashboardLoginWithoutNativeOrLoopbackParameters() {
|
||||
val url = DashboardApiClient.authLoginUrl(
|
||||
baseUrl = "https://hermes.example.test",
|
||||
provider = "self-hosted",
|
||||
next = "/",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.test/auth/login?provider=self-hosted&next=%2F",
|
||||
url,
|
||||
)
|
||||
assertFalse(url.contains("/auth/native/authorize"))
|
||||
assertFalse(url.contains("redirect_uri"))
|
||||
assertFalse(url.contains("127.0.0.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicDashboardCallback_importsCookieAndVerifiesAuthenticatedSession() = runTest {
|
||||
assertEquals(
|
||||
DashboardWebViewAuthNavigation.ImportAndVerify,
|
||||
dashboardWebViewAuthNavigation(
|
||||
"https://hermes.example.test",
|
||||
"https://hermes.example.test/auth/callback?code=public-code&state=public-state",
|
||||
),
|
||||
)
|
||||
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"authenticated":true,"username":"operator","provider":"self-hosted"}""",
|
||||
),
|
||||
)
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
val callbackUrl = server.url("/auth/callback?code=public-code").toString()
|
||||
assertEquals(
|
||||
1,
|
||||
importDashboardCookieHeader(
|
||||
store = store,
|
||||
url = callbackUrl,
|
||||
cookieHeader = "hermes_session=authenticated",
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(
|
||||
baseUrl = server.url("/").toString(),
|
||||
okHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(store))
|
||||
.build(),
|
||||
)
|
||||
|
||||
val session = client.currentSession().getOrThrow()
|
||||
|
||||
assertTrue(session.authenticated)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/auth/me", request.path)
|
||||
assertEquals("hermes_session=authenticated", request.getHeader("Cookie"))
|
||||
client.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignLoopbackCallbacksAreRejectedWhileProviderPagesContinue() {
|
||||
val dashboard = "https://hermes.example.test"
|
||||
listOf(
|
||||
"http://127.0.0.1:40179/callback?code=code",
|
||||
"http://localhost:40179/callback?code=code",
|
||||
"http://[::1]:40179/callback?code=code",
|
||||
).forEach { callback ->
|
||||
assertEquals(
|
||||
callback,
|
||||
DashboardWebViewAuthNavigation.RejectLoopbackCallback,
|
||||
dashboardWebViewAuthNavigation(dashboard, callback),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
DashboardWebViewAuthNavigation.Continue,
|
||||
dashboardWebViewAuthNavigation(
|
||||
dashboard,
|
||||
"https://auth.example.test/application/o/authorize/",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -60,6 +61,15 @@ class RelayErrorClassifierTest {
|
||||
|
||||
assertEquals("Session expired", err.title)
|
||||
assertTrue(err.body.contains("re-pair", ignoreCase = true))
|
||||
assertEquals(HumanErrorAction.Repair, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun certificateMismatchExposesRepairAction() {
|
||||
val err = classifyError(SSLException("certificate changed"))
|
||||
|
||||
assertEquals("Certificate mismatch", err.title)
|
||||
assertEquals(HumanErrorAction.Repair, err.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AssistantSpeechCursorTest {
|
||||
@Test
|
||||
fun `speaks every assistant bubble created during one tool run`() {
|
||||
val history = listOf(message("old", MessageRole.ASSISTANT, "Previous answer."))
|
||||
val cursor = AssistantSpeechCursor(history)
|
||||
|
||||
val interim = message(
|
||||
id = "interim",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "I'll check that.",
|
||||
streaming = false,
|
||||
)
|
||||
val first = cursor.poll(history + interim)
|
||||
assertEquals(listOf("I'll check that."), first.deltas.map { it.text })
|
||||
assertTrue(first.hasTurnAssistant)
|
||||
|
||||
val final = message(
|
||||
id = "final",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "The check is complete.",
|
||||
streaming = false,
|
||||
)
|
||||
val second = cursor.poll(history + interim + final)
|
||||
assertEquals(listOf("The check is complete."), second.deltas.map { it.text })
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("I'll check that.\n\nThe check is complete.", second.aggregateText)
|
||||
assertEquals("The check is complete.", second.finalAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final answer skips blank tool bubbles and intermediate commentary`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
val interim = message("interim", MessageRole.ASSISTANT, "I'll check that.")
|
||||
val toolOnly = message("tool", MessageRole.ASSISTANT, " ")
|
||||
val final = message("final", MessageRole.ASSISTANT, " The settled answer. ")
|
||||
|
||||
val batch = cursor.poll(listOf(interim, toolOnly, final))
|
||||
|
||||
assertEquals("The settled answer.", batch.finalAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history id adoption preserves stable ui identity without replay`() {
|
||||
val live = message(
|
||||
id = "client-id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Already spoken.",
|
||||
uiKey = "stable-bubble",
|
||||
)
|
||||
val cursor = AssistantSpeechCursor(listOf(live))
|
||||
val reconciled = live.copy(id = "server-id", uiKey = "stable-bubble")
|
||||
|
||||
val batch = cursor.poll(listOf(reconciled))
|
||||
|
||||
assertTrue(batch.deltas.isEmpty())
|
||||
assertFalse(batch.hasTurnAssistant)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new bubble requests a speech boundary without trailing punctuation`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
val interim = message("interim", MessageRole.ASSISTANT, "Let me check")
|
||||
val final = message("final", MessageRole.ASSISTANT, "Done.")
|
||||
|
||||
val first = cursor.poll(listOf(interim))
|
||||
val second = cursor.poll(listOf(interim, final))
|
||||
|
||||
assertFalse(first.deltas.single().startsNewBubble)
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("Done.", second.deltas.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `baseline history and repeated emissions are never replayed`() {
|
||||
val history = listOf(message("old", MessageRole.ASSISTANT, "Previous answer."))
|
||||
val cursor = AssistantSpeechCursor(history)
|
||||
|
||||
assertTrue(cursor.poll(history).deltas.isEmpty())
|
||||
assertFalse(cursor.poll(history).hasTurnAssistant)
|
||||
|
||||
val current = history + message("new", MessageRole.ASSISTANT, "Fresh reply.")
|
||||
assertEquals(listOf("Fresh reply."), cursor.poll(current).deltas.map { it.text })
|
||||
assertTrue(cursor.poll(current).deltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only strict suffix growth is spoken after transcript reconciliation`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
|
||||
cursor.poll(listOf(message("answer", MessageRole.ASSISTANT, "Working on")))
|
||||
val grown = cursor.poll(
|
||||
listOf(message("answer", MessageRole.ASSISTANT, "Working on it now.")),
|
||||
)
|
||||
assertEquals(listOf(" it now."), grown.deltas.map { it.text })
|
||||
|
||||
val rewritten = cursor.poll(
|
||||
listOf(message("answer", MessageRole.ASSISTANT, "Done.")),
|
||||
)
|
||||
assertTrue(rewritten.deltas.isEmpty())
|
||||
assertEquals("Done.", rewritten.aggregateText)
|
||||
}
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
streaming: Boolean = false,
|
||||
uiKey: String = id,
|
||||
) = ChatMessage(
|
||||
id = id,
|
||||
role = role,
|
||||
content = content,
|
||||
timestamp = 1L,
|
||||
isStreaming = streaming,
|
||||
uiKey = uiKey,
|
||||
)
|
||||
}
|
||||
+34
@@ -177,6 +177,40 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(gatewayClient.hasActiveTurn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayRichCardActionStaysOnGatewayInsteadOfDrainingThroughSessionsApi() {
|
||||
viewModel.sseFallbackEndpoint = "sessions"
|
||||
val cardMessageId = "card-message"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = cardMessageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.onTextDelta(
|
||||
cardMessageId,
|
||||
"""
|
||||
CARD:{"type":"approval_request","id":"test-card","actions":[{"label":"Approve","value":"approve","mode":"send_text"}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
handler.onTurnComplete(cardMessageId)
|
||||
val card = handler.messages.value.single { it.id == cardMessageId }.cards.single()
|
||||
val apiRequestsBeforeAction = apiServer.requestCount
|
||||
|
||||
viewModel.dispatchCardAction(
|
||||
messageId = cardMessageId,
|
||||
cardKey = card.id!!,
|
||||
action = card.actions.single(),
|
||||
)
|
||||
|
||||
val submit = gatewayHarness.awaitRpc("prompt.submit")
|
||||
assertEquals("approve", (submit["text"] as JsonPrimitive).content)
|
||||
assertEquals(apiRequestsBeforeAction, apiServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyPersonalityCatalogLoadsAndSurvivesRefreshFailure() {
|
||||
viewModel.updateApiClient(null)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user