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 }}
|
VERSION: ${{ steps.metadata.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
gh workflow run release-android.yml \
|
gh workflow run release-android.yml \
|
||||||
--ref="android-v${VERSION}" \
|
--ref=main \
|
||||||
-f version="$VERSION"
|
-f version="$VERSION"
|
||||||
|
|
||||||
- name: Approval summary
|
- name: Approval summary
|
||||||
@@ -97,4 +97,4 @@ jobs:
|
|||||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$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/approve-release-android.yml"
|
||||||
- ".github/workflows/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:
|
concurrency:
|
||||||
group: ci-android-${{ github.ref }}
|
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:
|
jobs:
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "android-v*"
|
- "android-v*"
|
||||||
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
||||||
# does not recursively start workflows. It explicitly dispatches this file
|
# does not recursively start workflows. It dispatches the current workflow
|
||||||
# at that tag instead. Manual tag pushes continue to use the push trigger.
|
# definition from main, while every job checks out the immutable tag. Manual
|
||||||
|
# tag pushes continue to use the push trigger.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -37,19 +38,22 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
env:
|
env:
|
||||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||||
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
|
|
||||||
REF_VERSION="$DISPATCHED_VERSION"
|
REF_VERSION="$DISPATCHED_VERSION"
|
||||||
fi
|
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||||
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
|
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||||
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
|
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||||
fi
|
fi
|
||||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
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"
|
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -111,6 +115,8 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
@@ -147,6 +153,8 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
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]
|
## [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.
|
- **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.
|
||||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
|
||||||
|
## [Android 1.5.2] - 2026-07-28
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- **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.
|
||||||
- **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.
|
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
|
||||||
- **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.
|
## [Android 1.5.1] - 2026-07-26
|
||||||
- **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.
|
|
||||||
|
### 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
|
## [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.
|
- **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.
|
- **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
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
## [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
|
## 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)
|
- **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.
|
- **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.
|
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?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,97 @@
|
|||||||
# Hermes-Relay — Dev Log
|
# 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
|
## 2026-07-24 — Post-connect permission setup
|
||||||
|
|
||||||
Android onboarding now finishes with a layered permission step after a
|
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
|
placeholder through the existing tool completion path. Coverage includes pure
|
||||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
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
|
## 2026-07-19 — Android 1.4.9 release preparation
|
||||||
|
|
||||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
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
|
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
||||||
`main`, and enter the version. Starting the workflow is the release approval. It
|
`main`, and enter the version. Starting the workflow is the release approval. It
|
||||||
verifies that `main` has the exact preflighted tree and creates the
|
verifies that `main` has the exact preflighted tree and creates the
|
||||||
`android-v<version>` tag. Manual stable tags are still guarded by the same
|
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
|
||||||
preflight proof in the tag workflow.
|
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
|
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
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
|
## 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.
|
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
|
## Summary
|
||||||
|
|
||||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
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.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
|
||||||
- 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.
|
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
|
||||||
- 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.
|
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
|
||||||
- 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.
|
- Full-screen in-app sign-in remains available for compatible dashboard providers.
|
||||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
|
||||||
|
|
||||||
## Install / Verify
|
## 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.
|
- 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
|
## Active — Remove temporary GitHub Pages docs redirects
|
||||||
|
|
||||||
PR #210 moved current source and production documentation to
|
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.
|
public model-options payload identifies excluded and disabled providers.
|
||||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||||
empty models are not authoritative evidence that a provider should be hidden.
|
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
|
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||||
once the harness can boot a credential-free upstream gateway deterministically.
|
once the harness can boot a credential-free upstream gateway deterministically.
|
||||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
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/
|
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
|
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
`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
|
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`
|
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
|
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
|
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
|
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
|
parses one full CommonMark document so global link references, indentation, and
|
||||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
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
|
- **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
|
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"
|
(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`)
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||||
applicationId = "com.axiomlabs.hermesrelay"
|
applicationId = "com.axiomlabs.hermesrelay"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
|
||||||
@@ -245,6 +245,7 @@ dependencies {
|
|||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.browser)
|
||||||
implementation(libs.appcompat)
|
implementation(libs.appcompat)
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
|
|||||||
@@ -5,5 +5,9 @@
|
|||||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
<activity
|
||||||
|
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</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:name=".notifications.ProactiveReplyReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Opt-in "Persistent connection" — holds the user's connection to
|
<!-- Protects user-started active turns automatically; the optional
|
||||||
Hermes open while backgrounded so messages and live features stay
|
"Persistent connection" setting extends the same foreground
|
||||||
responsive (relay-paired setups also keep device control +
|
protection to idle/background connectivity (and relay-paired
|
||||||
notification mirroring reachable). In main so BOTH flavors ship it
|
device features). In main so BOTH flavors ship it. specialUse
|
||||||
(Home-Assistant-class persistent connection). Off by default; only
|
|
||||||
runs while the user has explicitly enabled the toggle. specialUse
|
|
||||||
needs a Play Console foreground-service declaration at submission. -->
|
needs a Play Console foreground-service declaration at submission. -->
|
||||||
<service
|
<service
|
||||||
android:name=".network.upstream.GatewayKeepAliveService"
|
android:name=".network.upstream.GatewayKeepAliveService"
|
||||||
@@ -103,7 +101,7 @@
|
|||||||
android:foregroundServiceType="specialUse">
|
android:foregroundServiceType="specialUse">
|
||||||
<property
|
<property
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
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>
|
</service>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -1,29 +1,84 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
"version": "1.5.0",
|
"version": "1.5.2",
|
||||||
"title": "Voice that keeps pace",
|
"title": "Sign in without detours",
|
||||||
"date": "2026-07-22",
|
"date": "2026-07-28",
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"header": "A clearer voice studio",
|
"header": "Provider-compatible sign-in",
|
||||||
"bullets": [
|
"bullets": [
|
||||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
"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": [
|
"bullets": [
|
||||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": [
|
"bullets": [
|
||||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
"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.
|
* Complete self-hosted OIDC sign-in through the dashboard callback.
|
||||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
* Open Nous Portal securely in the system browser.
|
||||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
* 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.
|
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||||
*/
|
*/
|
||||||
val uiKey: String = 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. */
|
/** 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).
|
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||||
*/
|
*/
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
/** Server reports a persisted session runtime/model binding. */
|
||||||
|
val hasModelConfig: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val activityTimestamp: Long
|
val activityTimestamp: Long
|
||||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
|||||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
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
|
@Serializable
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ data class Connection(
|
|||||||
apiServerUrl: String,
|
apiServerUrl: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||||
|
dashboardUrl: String? = null,
|
||||||
): List<EndpointCandidate> {
|
): List<EndpointCandidate> {
|
||||||
val routes = buildList {
|
val routes = buildList {
|
||||||
endpointCandidateFromApiUrl(
|
endpointCandidateFromApiUrl(
|
||||||
@@ -255,6 +256,7 @@ data class Connection(
|
|||||||
apiServerUrl = apiServerUrl,
|
apiServerUrl = apiServerUrl,
|
||||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||||
|
dashboardUrl = dashboardUrl,
|
||||||
)?.let(::add)
|
)?.let(::add)
|
||||||
|
|
||||||
extraApiUrls
|
extraApiUrls
|
||||||
@@ -266,6 +268,7 @@ data class Connection(
|
|||||||
priority = index + 1,
|
priority = index + 1,
|
||||||
apiServerUrl = url,
|
apiServerUrl = url,
|
||||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||||
|
dashboardUrl = dashboardUrl,
|
||||||
)?.let(::add)
|
)?.let(::add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +343,7 @@ data class Connection(
|
|||||||
priority: Int,
|
priority: Int,
|
||||||
apiServerUrl: String,
|
apiServerUrl: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
|
dashboardUrl: String? = null,
|
||||||
): EndpointCandidate? {
|
): EndpointCandidate? {
|
||||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||||
?: return null
|
?: return null
|
||||||
@@ -363,12 +367,62 @@ data class Connection(
|
|||||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||||
priority = priority,
|
priority = priority,
|
||||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
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) },
|
?.let { DashboardEndpoint(url = it) },
|
||||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
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
|
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||||
* legacy API authority when present so an older API-only candidate and
|
* 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 {
|
companion object {
|
||||||
private const val TAG = "ConnectionStore"
|
private const val TAG = "ConnectionStore"
|
||||||
|
|
||||||
@@ -585,3 +562,40 @@ class ConnectionStore private constructor(
|
|||||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
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 {
|
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
?: return@withContext false
|
||||||
outputStream.flush()
|
outputStream.use {
|
||||||
|
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||||
|
it.flush()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -288,9 +290,10 @@ class DataManager(
|
|||||||
* - Clear DataStore preferences
|
* - Clear DataStore preferences
|
||||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||||
* - Clear any cached data
|
* - 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 {
|
try {
|
||||||
// Preserve onboarding state before clearing
|
// Preserve onboarding state before clearing
|
||||||
val onboarding = isOnboardingCompleted()
|
val onboarding = isOnboardingCompleted()
|
||||||
@@ -320,10 +323,11 @@ class DataManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "App data reset complete")
|
Log.d(TAG, "App data reset complete")
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset app data", e)
|
Log.e(TAG, "Failed to reset app data", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun deleteSensitivePreferenceFiles() {
|
private suspend fun deleteSensitivePreferenceFiles() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -384,16 +388,17 @@ class DataManager(
|
|||||||
* Reset only the onboarding completion flag.
|
* Reset only the onboarding completion flag.
|
||||||
* Next app launch will show onboarding again.
|
* Next app launch will show onboarding again.
|
||||||
*/
|
*/
|
||||||
suspend fun resetOnboarding() {
|
suspend fun resetOnboarding(): Boolean =
|
||||||
try {
|
try {
|
||||||
context.relayDataStore.edit { preferences ->
|
context.relayDataStore.edit { preferences ->
|
||||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Onboarding flag reset")
|
Log.d(TAG, "Onboarding flag reset")
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if onboarding has been completed.
|
* Check if onboarding has been completed.
|
||||||
@@ -412,13 +417,14 @@ class DataManager(
|
|||||||
/**
|
/**
|
||||||
* Mark onboarding as completed.
|
* Mark onboarding as completed.
|
||||||
*/
|
*/
|
||||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||||
try {
|
try {
|
||||||
context.relayDataStore.edit { preferences ->
|
context.relayDataStore.edit { preferences ->
|
||||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||||
}
|
}
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
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.
|
* 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
|
* In release builds, experimental features are hidden unless the user
|
||||||
* enables Developer Options (tap version 7 times in Settings > About).
|
* enables Developer Options (tap version 7 times in Settings > About).
|
||||||
*
|
*
|
||||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
|||||||
|
|
||||||
// DataStore keys
|
// DataStore keys
|
||||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
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. */
|
/** Whether the app is running a debug build. */
|
||||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
|||||||
/** Observe whether Developer Options have been unlocked. */
|
/** Observe whether Developer Options have been unlocked. */
|
||||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||||
context.relayDataStore.data.map { prefs ->
|
context.relayDataStore.data.map { prefs ->
|
||||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unlock Developer Options. */
|
/** 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) {
|
suspend fun lockDevOptions(context: Context) {
|
||||||
context.relayDataStore.edit { prefs ->
|
context.relayDataStore.edit { prefs ->
|
||||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
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 audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||||
val interactionMode: String = "tap",
|
val interactionMode: String = "tap",
|
||||||
val silenceThresholdMs: Long = 1250L,
|
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,
|
val realtimeTraceDetails: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
* 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.
|
* 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.
|
// un-namespaced means switching profiles never churns these.
|
||||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
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_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||||
booleanPreferencesKey("voice_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"
|
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
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_TRACE_DETAILS = false
|
||||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||||
|
|
||||||
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
|||||||
// --- global (shared across profiles) ---
|
// --- global (shared across profiles) ---
|
||||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
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]
|
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
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) }
|
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) {
|
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1278,6 +1278,7 @@ class RelayVoiceClient(
|
|||||||
model: String? = null,
|
model: String? = null,
|
||||||
voice: String? = null,
|
voice: String? = null,
|
||||||
sampleRate: Int? = null,
|
sampleRate: Int? = null,
|
||||||
|
finalAnswerOnly: Boolean = false,
|
||||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||||
@@ -1309,6 +1310,7 @@ class RelayVoiceClient(
|
|||||||
model = model,
|
model = model,
|
||||||
voice = voice,
|
voice = voice,
|
||||||
sampleRate = sampleRate,
|
sampleRate = sampleRate,
|
||||||
|
finalAnswerOnly = finalAnswerOnly,
|
||||||
)
|
)
|
||||||
if (sessionResult.isFailure) {
|
if (sessionResult.isFailure) {
|
||||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||||
@@ -1822,6 +1824,28 @@ class RelayVoiceClient(
|
|||||||
if (event.type == "hermes.run.promoted") {
|
if (event.type == "hermes.run.promoted") {
|
||||||
longRunningTurn.set(true)
|
longRunningTurn.set(true)
|
||||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
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) {
|
if (event.isAudioDelta) {
|
||||||
audioChunks += 1
|
audioChunks += 1
|
||||||
@@ -1847,13 +1871,12 @@ class RelayVoiceClient(
|
|||||||
responseDoneMs = event.responseDoneMs,
|
responseDoneMs = event.responseDoneMs,
|
||||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||||
)
|
)
|
||||||
if (persistent) {
|
if (persistent && activeTurn.compareAndSet(true, false)) {
|
||||||
// Turn boundary, not session boundary: keep the socket
|
// Turn boundary, not session boundary: keep the socket
|
||||||
// open for the next utterance.
|
// open for the next utterance.
|
||||||
activeTurn.set(false)
|
|
||||||
longRunningTurn.set(false)
|
longRunningTurn.set(false)
|
||||||
onTurnComplete(summary)
|
onTurnComplete(summary)
|
||||||
} else {
|
} else if (!persistent) {
|
||||||
if (claimTerminalSocket(
|
if (claimTerminalSocket(
|
||||||
webSocket,
|
webSocket,
|
||||||
generation,
|
generation,
|
||||||
@@ -2679,6 +2702,7 @@ class RelayVoiceClient(
|
|||||||
model: String? = null,
|
model: String? = null,
|
||||||
voice: String? = null,
|
voice: String? = null,
|
||||||
sampleRate: Int? = null,
|
sampleRate: Int? = null,
|
||||||
|
finalAnswerOnly: Boolean = false,
|
||||||
): Result<RealtimeSessionResponse> {
|
): Result<RealtimeSessionResponse> {
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
putProfile()
|
putProfile()
|
||||||
@@ -2694,6 +2718,9 @@ class RelayVoiceClient(
|
|||||||
sampleRate?.takeIf { it > 0 }?.let {
|
sampleRate?.takeIf { it > 0 }?.let {
|
||||||
put("sample_rate", JsonPrimitive(it))
|
put("sample_rate", JsonPrimitive(it))
|
||||||
}
|
}
|
||||||
|
if (finalAnswerOnly) {
|
||||||
|
put("final_answer_only", JsonPrimitive(true))
|
||||||
|
}
|
||||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||||
put("chat_session_id", JsonPrimitive(it))
|
put("chat_session_id", JsonPrimitive(it))
|
||||||
}
|
}
|
||||||
@@ -2980,6 +3007,9 @@ class RelayVoiceClient(
|
|||||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||||
floor = (obj["floor"] 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,
|
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
||||||
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||||
@@ -3374,6 +3404,7 @@ data class RealtimeVoiceEvent(
|
|||||||
// ADR 33: background-run promotion fields.
|
// ADR 33: background-run promotion fields.
|
||||||
val tier: String? = null,
|
val tier: String? = null,
|
||||||
val floor: String? = null,
|
val floor: String? = null,
|
||||||
|
val spokenHandoff: Boolean? = null,
|
||||||
// hermes.run.progress extras — drive the live background-run chip.
|
// hermes.run.progress extras — drive the live background-run chip.
|
||||||
val activeToolName: String? = null,
|
val activeToolName: String? = null,
|
||||||
val completedToolCount: Int? = 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.HermesCard
|
||||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
|
import com.hermesandroid.relay.data.MoaReference
|
||||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||||
import com.hermesandroid.relay.data.ToolCall
|
import com.hermesandroid.relay.data.ToolCall
|
||||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
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.MessageItem
|
||||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
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. */
|
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||||
internal const val MAX_MESSAGES = 500
|
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 {
|
private fun timestampToMillis(timestamp: Double?): Long {
|
||||||
val value = timestamp ?: return 0L
|
val value = timestamp ?: return 0L
|
||||||
@@ -155,6 +158,15 @@ class ChatHandler {
|
|||||||
*/
|
*/
|
||||||
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
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
|
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
* (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. */
|
/** Remove a provisional client-side message that never became a real turn. */
|
||||||
fun removeMessage(messageId: String) {
|
fun removeMessage(messageId: String) {
|
||||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||||
@@ -973,6 +1021,27 @@ class ChatHandler {
|
|||||||
startedAt = task.startedAt,
|
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(
|
val restoredAssistant = ChatMessage(
|
||||||
id = assistant.id,
|
id = assistant.id,
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
@@ -996,6 +1065,7 @@ class ChatHandler {
|
|||||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||||
?: assistant.cardDispatches,
|
?: assistant.cardDispatches,
|
||||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||||
|
moaReferences = restoredMoaReferences,
|
||||||
)
|
)
|
||||||
|
|
||||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||||
@@ -1153,11 +1223,20 @@ class ChatHandler {
|
|||||||
// so we can attach results back to the originating assistant message's ToolCall
|
// so we can attach results back to the originating assistant message's ToolCall
|
||||||
val toolResults = items.filter { it.role == "tool" }
|
val toolResults = items.filter { it.role == "tool" }
|
||||||
.associateBy { it.toolCallId }
|
.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
|
// Accumulator for media markers we find in loaded content — fired AFTER
|
||||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||||
// mutateMessage lookups find the newly-loaded messages.
|
// mutateMessage lookups find the newly-loaded messages.
|
||||||
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||||
|
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
|
||||||
|
|
||||||
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
||||||
// building the carry map, so the id-keyed delta-merge updates rows in
|
// 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
|
// silently misses those rows, so a gateway turn's tokens/badges survived
|
||||||
// only if a content match happened to cover them. See
|
// only if a content match happened to cover them. See
|
||||||
// [reconcileLiveIdsToServer].
|
// [reconcileLiveIdsToServer].
|
||||||
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
|
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
|
||||||
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
|
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
|
||||||
|
|
||||||
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
||||||
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
||||||
@@ -1207,12 +1286,13 @@ class ChatHandler {
|
|||||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||||
|
|
||||||
val loaded = items.mapNotNull { item ->
|
val loaded = renderedItems.mapNotNull { item ->
|
||||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||||
if (displayKind == "hidden") return@mapNotNull null
|
if (displayKind == "hidden") return@mapNotNull null
|
||||||
val role = when {
|
val role = when {
|
||||||
displayKind == "model_switch" ||
|
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 == "user" -> MessageRole.USER
|
||||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||||
item.role == "system" ->
|
item.role == "system" ->
|
||||||
@@ -1250,12 +1330,18 @@ class ChatHandler {
|
|||||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||||
val rawContent = rawServerContent
|
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
|
// Run the media marker parser on assistant content; strip matched
|
||||||
// lines and queue hits for post-assignment dispatch.
|
// lines and queue hits for post-assignment dispatch.
|
||||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||||
} else {
|
} else {
|
||||||
rawContent
|
persistedImages.cleanedText
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards are synchronous (no async fetch) so we attach them
|
// Cards are synchronous (no async fetch) so we attach them
|
||||||
@@ -1293,7 +1379,14 @@ class ChatHandler {
|
|||||||
// content-keyed queue. Inbound attachments are intentionally
|
// content-keyed queue. Inbound attachments are intentionally
|
||||||
// excluded — they come back via the marker re-dispatch.
|
// excluded — they come back via the marker re-dispatch.
|
||||||
val carriedAttachments = run {
|
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 {
|
when {
|
||||||
byId.isNotEmpty() -> byId
|
byId.isNotEmpty() -> byId
|
||||||
role == MessageRole.USER ->
|
role == MessageRole.USER ->
|
||||||
@@ -1301,6 +1394,15 @@ class ChatHandler {
|
|||||||
else -> emptyList()
|
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
|
// Server reasoning is authoritative when present; absent, keep the
|
||||||
// live-streamed thinking rather than blanking it on reload.
|
// live-streamed thinking rather than blanking it on reload.
|
||||||
val serverThinking =
|
val serverThinking =
|
||||||
@@ -1345,6 +1447,10 @@ class ChatHandler {
|
|||||||
} else {
|
} else {
|
||||||
prior.badges
|
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 {
|
} else {
|
||||||
// INSERT — a server message with no local row yet. Built from
|
// 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. */
|
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||||
@@ -1505,7 +1642,7 @@ class ChatHandler {
|
|||||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||||
when (item.displayKind?.trim()?.lowercase()) {
|
when (item.displayKind?.trim()?.lowercase()) {
|
||||||
"hidden" -> null
|
"hidden" -> null
|
||||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
|
||||||
else -> when (item.role) {
|
else -> when (item.role) {
|
||||||
"user" -> MessageRole.USER
|
"user" -> MessageRole.USER
|
||||||
"assistant" -> MessageRole.ASSISTANT
|
"assistant" -> MessageRole.ASSISTANT
|
||||||
@@ -1539,6 +1676,7 @@ class ChatHandler {
|
|||||||
else -> "$count background tasks completed"
|
else -> "$count background tasks completed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"auto_continue" -> "Continued after an interrupted turn"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1565,6 +1703,7 @@ class ChatHandler {
|
|||||||
val t = line.trim()
|
val t = line.trim()
|
||||||
if (t.isEmpty()) continue
|
if (t.isEmpty()) continue
|
||||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||||
|
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||||
if (sb.isNotEmpty()) sb.append('\n')
|
if (sb.isNotEmpty()) sb.append('\n')
|
||||||
sb.append(t)
|
sb.append(t)
|
||||||
@@ -1753,6 +1892,7 @@ class ChatHandler {
|
|||||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||||
source = item.source,
|
source = item.source,
|
||||||
|
hasModelConfig = item.hasModelConfig,
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.activityTimestamp }
|
}.sortedByDescending { it.activityTimestamp }
|
||||||
// Preserve the active session's optimistic row when the server list
|
// Preserve the active session's optimistic row when the server list
|
||||||
@@ -2593,6 +2733,44 @@ class ChatHandler {
|
|||||||
|
|
||||||
// --- Gateway subagent lanes ---
|
// --- 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
|
* Lane labels by task index, captured from `subagent.start` (goal
|
||||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ data class DashboardStatus(
|
|||||||
val authRequired: Boolean,
|
val authRequired: Boolean,
|
||||||
val authProviders: List<String> = emptyList(),
|
val authProviders: List<String> = emptyList(),
|
||||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||||
|
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||||
val version: String? = null,
|
val version: String? = null,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||||
@@ -158,6 +159,7 @@ data class DashboardCustomEndpointDraft(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val model: String,
|
val model: String,
|
||||||
|
val models: List<String> = emptyList(),
|
||||||
val apiKey: String? = null,
|
val apiKey: String? = null,
|
||||||
val contextLength: Int? = null,
|
val contextLength: Int? = null,
|
||||||
val discoverModels: Boolean = true,
|
val discoverModels: Boolean = true,
|
||||||
@@ -1136,23 +1138,42 @@ class DashboardApiClient(
|
|||||||
put("name", draft.name)
|
put("name", draft.name)
|
||||||
put("base_url", draft.baseUrl)
|
put("base_url", draft.baseUrl)
|
||||||
put("model", draft.model)
|
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.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||||
put("discover_models", draft.discoverModels)
|
put("discover_models", draft.discoverModels)
|
||||||
put("make_default", draft.makeDefault)
|
put("make_default", draft.makeDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||||
|
|
||||||
fun defaultClient(
|
fun defaultClient(
|
||||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||||
): OkHttpClient = OkHttpClient.Builder()
|
bearerAuth: DashboardBearerAuth? = null,
|
||||||
.cookieJar(DashboardCookieJar(cookieStore))
|
): OkHttpClient {
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
val builder = OkHttpClient.Builder()
|
||||||
// Skills-hub search fans out server-side with a 30s overall
|
.cookieJar(DashboardCookieJar(cookieStore))
|
||||||
// timeout; keep the read window above it so a slow-but-successful
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
// search doesn't die client-side at the edge.
|
// Skills-hub search fans out server-side with a 30s overall
|
||||||
.readTimeout(45, TimeUnit.SECONDS)
|
// timeout; keep the read window above it so a slow-but-successful
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
// search doesn't die client-side at the edge.
|
||||||
.build()
|
.readTimeout(45, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
bearerAuth?.let {
|
||||||
|
builder.addInterceptor(it)
|
||||||
|
builder.authenticator(it)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||||
val authObject = root["auth"] as? JsonObject
|
val authObject = root["auth"] as? JsonObject
|
||||||
@@ -1180,6 +1201,9 @@ class DashboardApiClient(
|
|||||||
?: false,
|
?: false,
|
||||||
authProviders = providers.map { it.name },
|
authProviders = providers.map { it.name },
|
||||||
authProviderDetails = providers,
|
authProviderDetails = providers,
|
||||||
|
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||||
|
(it as? JsonPrimitive)?.contentOrNull
|
||||||
|
},
|
||||||
version = root.stringField("version"),
|
version = root.stringField("version"),
|
||||||
message = root.stringField("message") ?: root.stringField("detail"),
|
message = root.stringField("message") ?: root.stringField("detail"),
|
||||||
nousSessionValid = root.stringField("nous_session_valid"),
|
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 {
|
interface DashboardCookieStore {
|
||||||
fun load(): List<StoredDashboardCookie>
|
fun load(): List<StoredDashboardCookie>
|
||||||
fun save(cookies: List<StoredDashboardCookie>)
|
fun save(cookies: List<StoredDashboardCookie>)
|
||||||
|
|||||||
+200
-13
@@ -164,6 +164,7 @@ class GatewayChatClient(
|
|||||||
private const val CONNECT_ATTEMPTS = 2
|
private const val CONNECT_ATTEMPTS = 2
|
||||||
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
||||||
private const val CANCELLED_TURN_SUBMIT_WAIT_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. */
|
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
|
||||||
private const val MAX_TURN_REJOINS = 4
|
private const val MAX_TURN_REJOINS = 4
|
||||||
@@ -284,6 +285,14 @@ class GatewayChatClient(
|
|||||||
private val _serverYolo = MutableStateFlow<Boolean?>(null)
|
private val _serverYolo = MutableStateFlow<Boolean?>(null)
|
||||||
val serverYolo: StateFlow<Boolean?> = _serverYolo.asStateFlow()
|
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)
|
private val _serverFast = MutableStateFlow<Boolean?>(null)
|
||||||
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
|
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
|
||||||
|
|
||||||
@@ -362,6 +371,15 @@ class GatewayChatClient(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var activeTurn: GatewayTurn? = null
|
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.
|
* Turns deliberately detached when the user switches profile/session.
|
||||||
* Upstream continues them server-side; retain the live→durable binding so
|
* Upstream continues them server-side; retain the live→durable binding so
|
||||||
@@ -820,24 +838,33 @@ class GatewayChatClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
response = rpc(
|
synchronized(recoveryEventLock) {
|
||||||
"session.resume",
|
recoveryEvents = mutableListOf()
|
||||||
buildJsonObject {
|
}
|
||||||
put("session_id", storedId)
|
response = try {
|
||||||
put("cols", DEFAULT_COLS)
|
rpc(
|
||||||
put("source", sessionSource)
|
"session.resume",
|
||||||
requestedProfile?.let { put("profile", it) }
|
buildJsonObject {
|
||||||
},
|
put("session_id", storedId)
|
||||||
).getOrElse { error ->
|
put("cols", DEFAULT_COLS)
|
||||||
preferredLiveId?.let { liveId ->
|
put("source", sessionSource)
|
||||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
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
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val recoveredLiveId = response.stringField("session_id")
|
val recoveredLiveId = response.stringField("session_id")
|
||||||
?: run {
|
?: run {
|
||||||
|
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||||
if (!preferredLiveId.isNullOrBlank()) {
|
if (!preferredLiveId.isNullOrBlank()) {
|
||||||
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
|
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
|
||||||
}
|
}
|
||||||
@@ -854,6 +881,9 @@ class GatewayChatClient(
|
|||||||
user = value.stringField("user").orEmpty(),
|
user = value.stringField("user").orEmpty(),
|
||||||
assistant = value.stringField("assistant").orEmpty(),
|
assistant = value.stringField("assistant").orEmpty(),
|
||||||
streaming = value.booleanField("streaming") == true,
|
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 ->
|
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||||
@@ -862,8 +892,20 @@ class GatewayChatClient(
|
|||||||
?.let(::GatewayQueuedTurn)
|
?.let(::GatewayQueuedTurn)
|
||||||
}
|
}
|
||||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
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) {
|
if (boundTurn == null || boundTurn.ended) {
|
||||||
boundTurn = GatewayTurn(
|
boundTurn = GatewayTurn(
|
||||||
callbacks = dispatchOn(callbacks),
|
callbacks = dispatchOn(callbacks),
|
||||||
@@ -873,6 +915,13 @@ class GatewayChatClient(
|
|||||||
activeTurn = turn
|
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 ->
|
queued?.let { queuedTurn ->
|
||||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||||
boundTurn.installQueuedSuccessor(registration)
|
boundTurn.installQueuedSuccessor(registration)
|
||||||
@@ -884,6 +933,7 @@ class GatewayChatClient(
|
|||||||
}
|
}
|
||||||
boundTurn.armWatchdog()
|
boundTurn.armWatchdog()
|
||||||
} else if (queued != null) {
|
} else if (queued != null) {
|
||||||
|
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||||
// its events flow through the completed checkpoint's mapper.
|
// its events flow through the completed checkpoint's mapper.
|
||||||
val priorBoundTurn = boundTurn
|
val priorBoundTurn = boundTurn
|
||||||
@@ -914,6 +964,7 @@ class GatewayChatClient(
|
|||||||
priorBoundTurn?.detach()
|
priorBoundTurn?.detach()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||||
if (boundTurn != null) {
|
if (boundTurn != null) {
|
||||||
if (activeTurn === boundTurn) activeTurn = null
|
if (activeTurn === boundTurn) activeTurn = null
|
||||||
boundTurn.discardDeferredEvents()
|
boundTurn.discardDeferredEvents()
|
||||||
@@ -929,6 +980,7 @@ class GatewayChatClient(
|
|||||||
status = response.stringField("status"),
|
status = response.stringField("status"),
|
||||||
inflight = inflight,
|
inflight = inflight,
|
||||||
queued = queued,
|
queued = queued,
|
||||||
|
autoContinue = autoContinue,
|
||||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||||
?.takeUnless { it.ended },
|
?.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"}` —
|
* Toggle fast mode (priority service tier) via `config.set {key:"fast"}` —
|
||||||
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
|
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
|
||||||
@@ -1452,6 +1582,7 @@ class GatewayChatClient(
|
|||||||
private suspend fun connectOnce() {
|
private suspend fun connectOnce() {
|
||||||
val connectStart = System.nanoTime()
|
val connectStart = System.nanoTime()
|
||||||
_processCapability.value = GatewayProcessCapability.Unknown
|
_processCapability.value = GatewayProcessCapability.Unknown
|
||||||
|
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||||
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
|
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.
|
* session can paint its real model up front rather than waiting for a turn.
|
||||||
*/
|
*/
|
||||||
private fun applySessionInfo(info: JsonObject) {
|
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")) {
|
if (info.containsKey("personality")) {
|
||||||
_serverPersonality.value =
|
_serverPersonality.value =
|
||||||
(info.stringField("personality") ?: "").ifBlank { "none" }
|
(info.stringField("personality") ?: "").ifBlank { "none" }
|
||||||
@@ -1845,6 +1984,25 @@ class GatewayChatClient(
|
|||||||
return
|
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
|
// A profile/session switch may leave an upstream turn running while a
|
||||||
// different profile becomes visible. Its events must never paint the
|
// different profile becomes visible. Its events must never paint the
|
||||||
// new transcript, but the terminal event still needs to reconcile 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
|
// `/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.
|
// through to the turn dispatch below so an in-flight turn still sees it.
|
||||||
if (type == "session.info" &&
|
if (type == "session.info" &&
|
||||||
(eventSessionId == null || liveSessionId == null || eventSessionId == liveSessionId)
|
(eventSessionId == null || eventSessionId == liveSessionId)
|
||||||
) {
|
) {
|
||||||
// Connection-level session info (model / provider / effort / persona /
|
// Connection-level session info (model / provider / effort / persona /
|
||||||
// yolo / fast / usage) — shared with the session.resume result via
|
// yolo / fast / usage) — shared with the session.resume result via
|
||||||
@@ -2046,6 +2204,7 @@ class GatewayChatClient(
|
|||||||
attachMethodForSocket = null
|
attachMethodForSocket = null
|
||||||
commandsCatalogCache = null
|
commandsCatalogCache = null
|
||||||
_processCapability.value = GatewayProcessCapability.Unknown
|
_processCapability.value = GatewayProcessCapability.Unknown
|
||||||
|
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||||
_connectionState.value = GatewayConnectionState.Idle
|
_connectionState.value = GatewayConnectionState.Idle
|
||||||
pendingRpcs.values.forEach {
|
pendingRpcs.values.forEach {
|
||||||
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
||||||
@@ -2224,6 +2383,7 @@ class GatewayChatClient(
|
|||||||
attachMethodForSocket = null
|
attachMethodForSocket = null
|
||||||
commandsCatalogCache = null
|
commandsCatalogCache = null
|
||||||
_processCapability.value = GatewayProcessCapability.Unknown
|
_processCapability.value = GatewayProcessCapability.Unknown
|
||||||
|
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||||
_connectionState.value = GatewayConnectionState.Idle
|
_connectionState.value = GatewayConnectionState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2705,6 +2865,9 @@ class GatewayChatClient(
|
|||||||
onInterimMessage = { text, alreadyStreamed ->
|
onInterimMessage = { text, alreadyStreamed ->
|
||||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||||
},
|
},
|
||||||
|
onInterimReconciled = { text ->
|
||||||
|
callbackDispatcher { callbacks.onInterimReconciled(text) }
|
||||||
|
},
|
||||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||||
@@ -2717,6 +2880,7 @@ class GatewayChatClient(
|
|||||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||||
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
||||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||||
|
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
|
||||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||||
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
||||||
@@ -2798,6 +2962,29 @@ private fun Throwable?.isMethodNotFound(): Boolean {
|
|||||||
msg.contains("unknown method", ignoreCase = true)
|
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? =
|
private fun JsonObject.stringField(key: String): String? =
|
||||||
(get(key) as? JsonPrimitive)?.contentOrNull
|
(get(key) as? JsonPrimitive)?.contentOrNull
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class GatewayEventMapper(
|
|||||||
private var syntheticToolCounter = 0
|
private var syntheticToolCounter = 0
|
||||||
private var providerWaitStatusActive = false
|
private var providerWaitStatusActive = false
|
||||||
private var compactionStatusActive = false
|
private var compactionStatusActive = false
|
||||||
|
private var moaStatusActive = false
|
||||||
private var pendingInteraction: GatewayAsk? = null
|
private var pendingInteraction: GatewayAsk? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,13 +217,18 @@ class GatewayEventMapper(
|
|||||||
"message.complete" -> {
|
"message.complete" -> {
|
||||||
// Non-streaming servers (or error turns) deliver everything
|
// Non-streaming servers (or error turns) deliver everything
|
||||||
// here; backfill whatever never streamed.
|
// 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 text = payload.string("text")
|
||||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||||
val duplicatesPreview = responsePreviewed &&
|
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||||
!text.isNullOrEmpty() &&
|
previewedText?.let { preview ->
|
||||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
preview.isNotEmpty() &&
|
||||||
if (!text.isNullOrEmpty() &&
|
(text.startsWith(preview) || preview.startsWith(text))
|
||||||
!duplicatesPreview &&
|
} == true
|
||||||
|
if (reconcilesInterim) {
|
||||||
|
callbacks.onInterimReconciled(text)
|
||||||
|
} else if (!text.isNullOrEmpty() &&
|
||||||
!isIntentionalSilenceMarker(text) &&
|
!isIntentionalSilenceMarker(text) &&
|
||||||
(!sawTextDelta || previewedText != null)
|
(!sawTextDelta || previewedText != null)
|
||||||
) {
|
) {
|
||||||
@@ -233,6 +239,12 @@ class GatewayEventMapper(
|
|||||||
callbacks.onThinkingDelta(reasoning)
|
callbacks.onThinkingDelta(reasoning)
|
||||||
}
|
}
|
||||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
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
|
turnEnded = true
|
||||||
callbacks.onComplete()
|
callbacks.onComplete()
|
||||||
}
|
}
|
||||||
@@ -290,9 +302,57 @@ class GatewayEventMapper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoA activity proves auto-compaction has resumed even though
|
"moa.reference" -> {
|
||||||
// Android does not currently render these upstream events.
|
clearProviderWaitAndCompaction()
|
||||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
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" -> {
|
"status.update" -> {
|
||||||
val text = payload.string("text")
|
val text = payload.string("text")
|
||||||
@@ -324,15 +384,31 @@ class GatewayEventMapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearActivityStatuses() {
|
private fun clearActivityStatuses() {
|
||||||
|
clearProviderWaitAndCompaction()
|
||||||
|
if (!moaStatusActive) return
|
||||||
|
moaStatusActive = false
|
||||||
|
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearProviderWaitAndCompaction() {
|
||||||
clearProviderWaitStatus()
|
clearProviderWaitStatus()
|
||||||
if (!compactionStatusActive) return
|
if (!compactionStatusActive) return
|
||||||
compactionStatusActive = false
|
compactionStatusActive = false
|
||||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setMoaStatus(text: String) {
|
||||||
|
moaStatusActive = true
|
||||||
|
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||||
const val COMPACTION_STATUS_KIND = "compacting"
|
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 OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||||
"reasoning.delta",
|
"reasoning.delta",
|
||||||
@@ -348,6 +424,11 @@ class GatewayEventMapper(
|
|||||||
"error",
|
"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) {
|
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||||
"clarify.request" -> GatewayAsk(
|
"clarify.request" -> GatewayAsk(
|
||||||
kind = GatewayAsk.Kind.CLARIFY,
|
kind = GatewayAsk.Kind.CLARIFY,
|
||||||
|
|||||||
+93
-16
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opt-in foreground service that holds the app process up so the app's
|
* Foreground service that holds the app process up so work the user already
|
||||||
* connection to Hermes survives Android's background-freeze / Doze — i.e.
|
* 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
|
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
||||||
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||||
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
* [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
|
* 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
|
* 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
|
* 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
|
* # It does NOT own the socket
|
||||||
*
|
*
|
||||||
* The service's only job is to hold the process in the foreground. The socket
|
* The service's only job is to hold the process in the foreground. The socket
|
||||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
* 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
|
* away) the ViewModel + socket die with the process, so the service stops
|
||||||
* itself rather than leave a notification that lies about being connected.
|
* 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"
|
private const val CHANNEL_NAME = "Persistent connection"
|
||||||
const val NOTIFICATION_ID = 4713
|
const val NOTIFICATION_ID = 4713
|
||||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
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)
|
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.applicationContext.startForegroundService(intent)
|
context.applicationContext.startForegroundService(intent)
|
||||||
} else {
|
} else {
|
||||||
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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 onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
runningInstance = this
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
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()
|
startForegroundNotification()
|
||||||
if (intent?.action == ACTION_STOP) {
|
if (intent?.action == ACTION_STOP) {
|
||||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
|
||||||
// Flip the pref off so ConnectionViewModel's collector won't
|
|
||||||
// restart us on the next foreground.
|
|
||||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
persistent = false
|
||||||
stopSelf()
|
if (activeTurns == 0) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
startForegroundNotification()
|
||||||
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
|
|||||||
// The socket lives in the ViewModel, which dies when the task is
|
// The socket lives in the ViewModel, which dies when the task is
|
||||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||||
|
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
if (runningInstance === this) runningInstance = null
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
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
|
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||||
// are all declared in the main manifest (both flavors), so the type is
|
// are all declared in the main manifest (both flavors), so the type is
|
||||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
// 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 stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
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)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(getString(R.string.gateway_keepalive_title))
|
.setContentTitle(title)
|
||||||
.setContentText(getString(R.string.gateway_keepalive_body))
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
.setContentIntent(tapPending)
|
.setContentIntent(tapPending)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.addAction(0, "Turn off", stopPending)
|
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
|
||||||
.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureChannel() {
|
private fun ensureChannel() {
|
||||||
|
|||||||
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
|
|||||||
Ready,
|
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
|
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||||
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
|
|||||||
*
|
*
|
||||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
* gateway while the dashboard probe is unresolved or ready. A capability-
|
||||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
* preferred SSE fallback is selected only after a definitive unavailable,
|
||||||
|
* unsupported, or sign-in-required verdict.
|
||||||
*/
|
*/
|
||||||
fun resolveStreamingEndpointPreference(
|
fun resolveStreamingEndpointPreference(
|
||||||
preference: String,
|
preference: String,
|
||||||
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
|
|||||||
capabilities: ServerCapabilities,
|
capabilities: ServerCapabilities,
|
||||||
): String = when (preference) {
|
): String = when (preference) {
|
||||||
"sessions", "completions", "runs", "gateway" -> preference
|
"sessions", "completions", "runs", "gateway" -> preference
|
||||||
else -> if (gateway == GatewayAvailability.Ready) {
|
else -> if (
|
||||||
|
gateway == GatewayAvailability.Ready ||
|
||||||
|
gateway == GatewayAvailability.Unknown
|
||||||
|
) {
|
||||||
"gateway"
|
"gateway"
|
||||||
} else {
|
} else {
|
||||||
capabilities.preferredChatEndpoint()
|
capabilities.preferredChatEndpoint()
|
||||||
@@ -95,6 +122,9 @@ data class GatewayInflightTurn(
|
|||||||
val user: String,
|
val user: String,
|
||||||
val assistant: String,
|
val assistant: String,
|
||||||
val streaming: Boolean,
|
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. */
|
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||||
@@ -102,6 +132,12 @@ data class GatewayQueuedTurn(
|
|||||||
val user: String,
|
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. */
|
/** Optional project identity attached to newer upstream session metadata. */
|
||||||
data class GatewaySessionProject(
|
data class GatewaySessionProject(
|
||||||
val id: String?,
|
val id: String?,
|
||||||
@@ -120,10 +156,11 @@ data class GatewaySessionRecovery(
|
|||||||
val queued: GatewayQueuedTurn?,
|
val queued: GatewayQueuedTurn?,
|
||||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||||
val handle: ActiveTurnHandle?,
|
val handle: ActiveTurnHandle?,
|
||||||
|
val autoContinue: GatewayAutoContinue? = null,
|
||||||
) {
|
) {
|
||||||
/** Whether upstream still owes this client live turn events. */
|
/** Whether upstream still owes this client live turn events. */
|
||||||
val hasPendingWork: Boolean
|
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. */
|
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||||
@@ -310,6 +347,14 @@ data class GatewayModelProvider(
|
|||||||
val totalModels: Int = 0,
|
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. */
|
/** Result of the gateway `model.options` RPC. */
|
||||||
data class GatewayModelOptions(
|
data class GatewayModelOptions(
|
||||||
val providers: List<GatewayModelProvider>,
|
val providers: List<GatewayModelProvider>,
|
||||||
@@ -317,6 +362,15 @@ data class GatewayModelOptions(
|
|||||||
val currentProvider: String,
|
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
|
* 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,
|
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||||
@@ -374,6 +428,12 @@ class GatewayTurnCallbacks(
|
|||||||
* sealing the current assistant segment.
|
* sealing the current assistant segment.
|
||||||
*/
|
*/
|
||||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
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 onThinkingDelta: (String) -> Unit,
|
||||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||||
@@ -398,6 +458,8 @@ class GatewayTurnCallbacks(
|
|||||||
val onToolGenerating: (toolName: String?) -> Unit,
|
val onToolGenerating: (toolName: String?) -> Unit,
|
||||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
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
|
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||||
* the turn until answered via the matching respond RPC or the turn is
|
* 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.booleanOrNull
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -77,6 +78,10 @@ data class ServerCapabilities(
|
|||||||
val portable: Boolean,
|
val portable: Boolean,
|
||||||
/** `/health` — basic reachability. */
|
/** `/health` — basic reachability. */
|
||||||
val healthy: Boolean,
|
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. */
|
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||||
fun preferredChatEndpoint(): String = when {
|
fun preferredChatEndpoint(): String = when {
|
||||||
@@ -100,6 +105,8 @@ data class ServerCapabilities(
|
|||||||
runs = false,
|
runs = false,
|
||||||
portable = false,
|
portable = false,
|
||||||
healthy = false,
|
healthy = false,
|
||||||
|
modelOptions = false,
|
||||||
|
sessionModelLock = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
|||||||
feature("chat_completions") ||
|
feature("chat_completions") ||
|
||||||
endpoint("chat_completions"),
|
endpoint("chat_completions"),
|
||||||
healthy = true,
|
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" }
|
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>? {
|
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||||
val data = try {
|
val data = try {
|
||||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||||
@@ -315,6 +447,8 @@ class HermesApiClient(
|
|||||||
isLenient = true
|
isLenient = true
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var lastCapabilities: ServerCapabilities? = null
|
||||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -634,6 +768,205 @@ class HermesApiClient(
|
|||||||
/** Compatibility view for callers that only need request ids. */
|
/** Compatibility view for callers that only need request ids. */
|
||||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
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 ---
|
// --- Server personalities ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -756,6 +1089,7 @@ class HermesApiClient(
|
|||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
modelOverride: String? = null,
|
modelOverride: String? = null,
|
||||||
profileName: String? = null,
|
profileName: String? = null,
|
||||||
|
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||||
): EventSource {
|
): EventSource {
|
||||||
if (!modelOverride.isNullOrBlank()) {
|
if (!modelOverride.isNullOrBlank()) {
|
||||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||||
@@ -786,6 +1120,7 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val completeCalled = AtomicBoolean(false)
|
val completeCalled = AtomicBoolean(false)
|
||||||
|
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||||
val receivedEvent = AtomicBoolean(false)
|
val receivedEvent = AtomicBoolean(false)
|
||||||
val drainRetryScheduled = AtomicBoolean(false)
|
val drainRetryScheduled = AtomicBoolean(false)
|
||||||
val turnSource = RetryingEventSource(request, mainHandler)
|
val turnSource = RetryingEventSource(request, mainHandler)
|
||||||
@@ -806,7 +1141,13 @@ class HermesApiClient(
|
|||||||
tracer.mark("ttfe")
|
tracer.mark("ttfe")
|
||||||
if (data == "[DONE]") {
|
if (data == "[DONE]") {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -882,9 +1223,17 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||||
"assistant.completed" -> {
|
"assistant.completed" -> {
|
||||||
|
val runtimeMatches = expectedModelLock?.let {
|
||||||
|
confirmedRuntimeMatches(event.runtime, it)
|
||||||
|
} ?: true
|
||||||
|
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
onUsage(event.usage)
|
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)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
onError("Response interrupted")
|
onError("Response interrupted")
|
||||||
}
|
}
|
||||||
@@ -896,9 +1245,15 @@ class HermesApiClient(
|
|||||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||||
"run.completed" -> {
|
"run.completed" -> {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
|
val runtimeMatches = expectedModelLock?.let {
|
||||||
|
confirmedRuntimeMatches(event.runtime, it)
|
||||||
|
} ?: true
|
||||||
|
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
onUsage(event.usage)
|
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")
|
onError("Run interrupted")
|
||||||
} else {
|
} else {
|
||||||
onComplete()
|
onComplete()
|
||||||
@@ -908,7 +1263,13 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
"done" -> {
|
"done" -> {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
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" -> {
|
"error" -> {
|
||||||
@@ -987,7 +1348,13 @@ class HermesApiClient(
|
|||||||
override fun onClosed(eventSource: EventSource) {
|
override fun onClosed(eventSource: EventSource) {
|
||||||
tracer.done()
|
tracer.done()
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
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) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
if (!healthy) {
|
||||||
|
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||||
|
return@withContext ServerCapabilities.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
val advertisedCapabilities = try {
|
val advertisedCapabilities = try {
|
||||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||||
@@ -1544,7 +1914,10 @@ class HermesApiClient(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
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
|
// Reusable HEAD probe — returns true if the route is registered
|
||||||
// (any status except 404 + network errors). Already inside the
|
// (any status except 404 + network errors). Already inside the
|
||||||
@@ -1589,7 +1962,7 @@ class HermesApiClient(
|
|||||||
runs = runs,
|
runs = runs,
|
||||||
portable = portable,
|
portable = portable,
|
||||||
healthy = true,
|
healthy = true,
|
||||||
)
|
).also { lastCapabilities = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
|||||||
*
|
*
|
||||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||||
* consumes `message` (or `input`) and `system_message` (or
|
* consumes `message` (or `input`) and `system_message` (or
|
||||||
* `instructions`, string only). `message` accepts either a plain string
|
* `instructions`, string only). Newer servers also parse per-request model
|
||||||
* or OpenAI-style content parts (text + `image_url`) via
|
* fields and reuse a backend-acknowledged session model lock when those
|
||||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
* fields are omitted. Android therefore acknowledges a lock first and
|
||||||
* `model`, and `profile` are NOT parsed.
|
* 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
|
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||||
* array), `instructions`, `conversation_history` (array of
|
* 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.
|
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||||
* Top-level `attachments` and `profile` are NOT parsed.
|
* Top-level `attachments` and `profile` are NOT parsed.
|
||||||
*
|
*
|
||||||
* Legacy hint fields we deliberately keep sending although current native
|
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||||
* `profile` on runs/completions, and `stream` on runs. They are
|
|
||||||
* configuration hints (never user content, so they cannot mask data
|
* configuration hints (never user content, so they cannot mask data
|
||||||
* loss) honored by legacy fork builds — the runs path in particular only
|
* loss) honored by legacy fork builds — the runs path in particular only
|
||||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
* 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
|
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||||
* on the API server (:8642) — current upstream api_server advertises
|
* on the API server (:8642) — current upstream api_server advertises
|
||||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
* must carry the same exact-origin cookie or native bearer session used by
|
||||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
* 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).
|
* 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.)
|
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||||
*/
|
*/
|
||||||
class StandardHermesVoiceClient(
|
class StandardHermesVoiceClient(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||||
private val dashboardUrlProvider: () -> String?,
|
private val dashboardUrlProvider: () -> String?,
|
||||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||||
@@ -66,12 +66,10 @@ class StandardHermesVoiceClient(
|
|||||||
) : VoiceAudioClient {
|
) : VoiceAudioClient {
|
||||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||||
|
|
||||||
private val callClient: OkHttpClient =
|
|
||||||
standardHermesDashboardAudioClient(okHttpClient)
|
|
||||||
|
|
||||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||||
val baseUrl = dashboardBaseUrl()
|
val baseUrl = dashboardBaseUrl()
|
||||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||||
|
val callClient = callClient(baseUrl)
|
||||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||||
}
|
}
|
||||||
@@ -102,7 +100,7 @@ class StandardHermesVoiceClient(
|
|||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||||
val transcript = root.stringField("transcript")
|
val transcript = root.stringField("transcript")
|
||||||
?: root.stringField("text")
|
?: root.stringField("text")
|
||||||
?: root.stringField("message")
|
?: root.stringField("message")
|
||||||
@@ -116,6 +114,7 @@ class StandardHermesVoiceClient(
|
|||||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||||
val baseUrl = dashboardBaseUrl()
|
val baseUrl = dashboardBaseUrl()
|
||||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||||
|
val callClient = callClient(baseUrl)
|
||||||
val cleanText = text.trim()
|
val cleanText = text.trim()
|
||||||
if (cleanText.isBlank()) {
|
if (cleanText.isBlank()) {
|
||||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||||
@@ -138,7 +137,7 @@ class StandardHermesVoiceClient(
|
|||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.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")
|
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||||
if (dataUrl.isNullOrBlank()) {
|
if (dataUrl.isNullOrBlank()) {
|
||||||
throw IOException("Hermes audio speak returned no audio")
|
throw IOException("Hermes audio speak returned no audio")
|
||||||
@@ -162,13 +161,14 @@ class StandardHermesVoiceClient(
|
|||||||
try {
|
try {
|
||||||
val baseUrl = dashboardBaseUrl()
|
val baseUrl = dashboardBaseUrl()
|
||||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||||
|
val callClient = callClient(baseUrl)
|
||||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||||
val ticketRequest = Request.Builder()
|
val ticketRequest = Request.Builder()
|
||||||
.url(ticketUrl)
|
.url(ticketUrl)
|
||||||
.post(ByteArray(0).toRequestBody(null))
|
.post(ByteArray(0).toRequestBody(null))
|
||||||
.build()
|
.build()
|
||||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
|
||||||
.getOrThrow()
|
.getOrThrow()
|
||||||
.stringField("ticket")
|
.stringField("ticket")
|
||||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||||
@@ -195,7 +195,14 @@ class StandardHermesVoiceClient(
|
|||||||
private fun dashboardBaseUrl(): String? =
|
private fun dashboardBaseUrl(): String? =
|
||||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
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 {
|
return try {
|
||||||
callClient.newCall(request).execute().use { response ->
|
callClient.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
|||||||
+5
-2
@@ -163,7 +163,8 @@ data class SessionItem(
|
|||||||
@SerialName("message_count") val messageCount: Int? = null,
|
@SerialName("message_count") val messageCount: Int? = null,
|
||||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||||
@SerialName("input_tokens") val inputTokens: 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?
|
val resolvedLastActivity: Double?
|
||||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||||
@@ -391,7 +392,9 @@ data class HermesSseEvent(
|
|||||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||||
val text: String? = null, // /v1/runs reasoning.available text
|
val text: String? = null, // /v1/runs reasoning.available text
|
||||||
// Usage/token fields (on assistant.completed / run.completed)
|
// 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. */
|
/** Resolve the event type from whichever field is populated. */
|
||||||
val resolvedType: String?
|
val resolvedType: String?
|
||||||
|
|||||||
+30
@@ -33,6 +33,7 @@ object InteractionRequestNotifier {
|
|||||||
private const val TAG = "InteractionNotifier"
|
private const val TAG = "InteractionNotifier"
|
||||||
internal const val CHANNEL_ID = "chat_interactions"
|
internal const val CHANNEL_ID = "chat_interactions"
|
||||||
private const val CHANNEL_NAME = "Hermes needs input"
|
private const val CHANNEL_NAME = "Hermes needs input"
|
||||||
|
private const val GROUP_KEY = "gateway-interactions"
|
||||||
internal const val NOTIFICATION_ID = 3823
|
internal const val NOTIFICATION_ID = 3823
|
||||||
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
||||||
|
|
||||||
@@ -72,6 +73,31 @@ object InteractionRequestNotifier {
|
|||||||
-> "Open Hermes to respond securely."
|
-> "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")
|
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||||
fun notify(
|
fun notify(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -106,6 +132,7 @@ object InteractionRequestNotifier {
|
|||||||
)
|
)
|
||||||
val title = safeTitle(ask)
|
val title = safeTitle(ask)
|
||||||
val body = safeBody(ask)
|
val body = safeBody(ask)
|
||||||
|
val expandedBody = safeExpandedBody(sessionId, ask, profile)
|
||||||
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle("Hermes needs your input")
|
.setContentTitle("Hermes needs your input")
|
||||||
@@ -117,6 +144,7 @@ object InteractionRequestNotifier {
|
|||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
|
||||||
.setContentIntent(tapPending)
|
.setContentIntent(tapPending)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
@@ -124,6 +152,8 @@ object InteractionRequestNotifier {
|
|||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
.setPublicVersion(publicVersion)
|
.setPublicVersion(publicVersion)
|
||||||
|
.setGroup(GROUP_KEY)
|
||||||
|
.addAction(0, actionLabel(ask), tapPending)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
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.EndpointCandidate
|
||||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||||
|
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||||
import com.hermesandroid.relay.data.VoiceSettings
|
import com.hermesandroid.relay.data.VoiceSettings
|
||||||
import com.hermesandroid.relay.data.capabilities
|
import com.hermesandroid.relay.data.capabilities
|
||||||
import com.hermesandroid.relay.data.displayLabel
|
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.diagnostics.DiagnosticsLog
|
||||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
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.upstream.GatewayAvailability
|
||||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
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
|
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||||
// time to tap the action before it auto-dismisses.
|
// time to tap the action before it auto-dismisses.
|
||||||
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||||
showSnackbar(
|
return showSnackbar(
|
||||||
message = err.body,
|
message = err.body,
|
||||||
actionLabel = err.actionLabel,
|
actionLabel = err.actionLabel,
|
||||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||||
@@ -599,15 +600,9 @@ fun RelayApp() {
|
|||||||
val standardVoiceClient = remember {
|
val standardVoiceClient = remember {
|
||||||
StandardHermesVoiceClient(
|
StandardHermesVoiceClient(
|
||||||
context = mediaContext,
|
context = mediaContext,
|
||||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
dashboardHttpClientProvider = { dashboardUrl ->
|
||||||
.cookieJar(
|
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
|
||||||
DynamicDashboardCookieJar {
|
},
|
||||||
connectionViewModel.activeDashboardCookieStore()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
|
||||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build(),
|
|
||||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||||
// Live read (null for the default profile) — sent defensively on
|
// Live read (null for the default profile) — sent defensively on
|
||||||
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
// /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
|
// The VM's cached per-connection store — the prewarm must NOT
|
||||||
// construct its own (each instance lazily pays a multi-second
|
// construct its own (each instance lazily pays a multi-second
|
||||||
// Keystore keyset build under a process-global Tink lock).
|
// Keystore keyset build under a process-global Tink lock).
|
||||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
|
||||||
?: return@LaunchedEffect
|
|
||||||
prewarmDashboardManage(
|
prewarmDashboardManage(
|
||||||
cookieStore = cookieStore,
|
clientFactory = {
|
||||||
|
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
|
||||||
|
},
|
||||||
connectionId = connection.id,
|
connectionId = connection.id,
|
||||||
dashboardUrl = effectiveDashboardUrl,
|
dashboardUrl = effectiveDashboardUrl,
|
||||||
effectiveProfileName = effectiveManageProfile,
|
effectiveProfileName = effectiveManageProfile,
|
||||||
@@ -1926,6 +1921,14 @@ fun RelayApp() {
|
|||||||
voiceViewModel = voiceViewModel,
|
voiceViewModel = voiceViewModel,
|
||||||
voiceClient = voiceClient,
|
voiceClient = voiceClient,
|
||||||
maxBubbleWidth = maxBubbleWidth,
|
maxBubbleWidth = maxBubbleWidth,
|
||||||
|
voicePresentationMode = VoicePresentationMode.fromStorage(
|
||||||
|
voiceSettings.presentationMode,
|
||||||
|
),
|
||||||
|
onVoicePresentationModeChange = { mode ->
|
||||||
|
connectionSwitchScope.launch {
|
||||||
|
voicePreferences.setPresentationMode(mode)
|
||||||
|
}
|
||||||
|
},
|
||||||
openAgentSheetOnEntry = openAgentSheetArg,
|
openAgentSheetOnEntry = openAgentSheetArg,
|
||||||
onAgentSheetArgConsumed = {
|
onAgentSheetArgConsumed = {
|
||||||
backStackEntry.arguments?.putBoolean(
|
backStackEntry.arguments?.putBoolean(
|
||||||
@@ -1943,6 +1946,16 @@ fun RelayApp() {
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRepairConnection = {
|
||||||
|
navController.navigate(
|
||||||
|
Screen.Pair.route(
|
||||||
|
connectionId = activeConnectionId,
|
||||||
|
autoStart = "relay",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
// Empty-chat "needs connection" card also offers the offline
|
// Empty-chat "needs connection" card also offers the offline
|
||||||
// demo, so a skipped / never-connected first run can explore
|
// demo, so a skipped / never-connected first run can explore
|
||||||
// without leaving Chat. Safe here — this state only shows when
|
// without leaving Chat. Safe here — this state only shows when
|
||||||
@@ -2301,8 +2314,8 @@ fun RelayApp() {
|
|||||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||||
relayVoiceReady = relayVoiceReady,
|
relayVoiceReady = relayVoiceReady,
|
||||||
dashboardUrl = voiceDashboardUrl,
|
dashboardUrl = voiceDashboardUrl,
|
||||||
dashboardCookieStoreProvider = {
|
dashboardClientProvider = { dashboardUrl ->
|
||||||
connectionViewModel.activeDashboardCookieStore()
|
connectionViewModel.dashboardClientForActive(dashboardUrl)
|
||||||
},
|
},
|
||||||
onOpenManage = {
|
onOpenManage = {
|
||||||
navController.navigate(Screen.Manage.route) {
|
navController.navigate(Screen.Manage.route) {
|
||||||
@@ -2640,6 +2653,17 @@ fun RelayApp() {
|
|||||||
onNavigateToRealtimeVoice = {
|
onNavigateToRealtimeVoice = {
|
||||||
navController.navigate(Screen.RealtimeVoiceTest.route)
|
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) {
|
composable(Screen.RealtimeVoiceTest.route) {
|
||||||
|
|||||||
+5
-10
@@ -268,7 +268,6 @@ fun ActiveCardRelayStatusSection(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ActiveCardFeaturesSection(
|
fun ActiveCardFeaturesSection(
|
||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
relayEnabled: Boolean,
|
|
||||||
onOpenApiInfo: () -> Unit,
|
onOpenApiInfo: () -> Unit,
|
||||||
onOpenDashboard: () -> Unit,
|
onOpenDashboard: () -> Unit,
|
||||||
onOpenRelayInfo: () -> Unit,
|
onOpenRelayInfo: () -> Unit,
|
||||||
@@ -349,21 +348,19 @@ fun ActiveCardFeaturesSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val relayValue = when {
|
val relayValue = when {
|
||||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
|
||||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||||
relayReady -> stringResource(R.string.active_section_ready)
|
relayReady -> stringResource(R.string.active_section_ready)
|
||||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||||
else -> stringResource(R.string.active_section_configured)
|
else -> stringResource(R.string.active_section_configured)
|
||||||
}
|
}
|
||||||
val relayTone = when {
|
val relayTone = when {
|
||||||
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
|
!relayConfigured -> CapabilityTone.Neutral
|
||||||
relayReady -> CapabilityTone.Good
|
relayReady -> CapabilityTone.Good
|
||||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||||
else -> CapabilityTone.Info
|
else -> CapabilityTone.Info
|
||||||
}
|
}
|
||||||
|
|
||||||
val terminalValue = when {
|
val terminalValue = when {
|
||||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
|
||||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||||
else -> stringResource(R.string.active_section_optional)
|
else -> stringResource(R.string.active_section_optional)
|
||||||
@@ -614,7 +611,6 @@ private fun CapabilityRow(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ActiveCardAdvancedSection(
|
fun ActiveCardAdvancedSection(
|
||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
relayEnabled: Boolean,
|
|
||||||
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
||||||
onPairRelay: () -> Unit,
|
onPairRelay: () -> Unit,
|
||||||
onInsecureAckRequested: () -> Unit,
|
onInsecureAckRequested: () -> Unit,
|
||||||
@@ -654,7 +650,7 @@ fun ActiveCardAdvancedSection(
|
|||||||
Text(stringResource(R.string.active_section_done))
|
Text(stringResource(R.string.active_section_done))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
|
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -731,7 +727,7 @@ fun ActiveCardAdvancedSection(
|
|||||||
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
||||||
Text(stringResource(R.string.active_section_other_relay_methods))
|
Text(stringResource(R.string.active_section_other_relay_methods))
|
||||||
}
|
}
|
||||||
if (relayEnabled && relayEditorOpen) {
|
if (relayEditorOpen) {
|
||||||
Surface(
|
Surface(
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
@@ -746,7 +742,7 @@ fun ActiveCardAdvancedSection(
|
|||||||
Text(stringResource(R.string.active_section_done))
|
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
|
@Composable
|
||||||
private fun ManualUrlSubsection(
|
private fun ManualUrlSubsection(
|
||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
relayEnabled: Boolean,
|
|
||||||
showApi: Boolean,
|
showApi: Boolean,
|
||||||
showRelay: Boolean,
|
showRelay: Boolean,
|
||||||
) {
|
) {
|
||||||
@@ -960,7 +955,7 @@ private fun ManualUrlSubsection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relayEnabled && showRelay) {
|
if (showRelay) {
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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 androidx.compose.ui.res.stringResource
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
@@ -261,8 +262,12 @@ fun AgentTextFlow(
|
|||||||
motionEnabled: Boolean,
|
motionEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
|
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
|
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||||
// text on both paths so assistive tech hears the words.
|
// 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.Connection
|
||||||
import com.hermesandroid.relay.data.ConnectionValidation
|
import com.hermesandroid.relay.data.ConnectionValidation
|
||||||
import com.hermesandroid.relay.data.EndpointCandidate
|
import com.hermesandroid.relay.data.EndpointCandidate
|
||||||
import com.hermesandroid.relay.data.FeatureFlags
|
|
||||||
import com.hermesandroid.relay.data.displayLabel
|
import com.hermesandroid.relay.data.displayLabel
|
||||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||||
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
|
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
|
||||||
@@ -197,8 +196,6 @@ fun ConnectionWizard(
|
|||||||
|
|
||||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||||
val authState by connectionViewModel.authState.collectAsState()
|
val authState by connectionViewModel.authState.collectAsState()
|
||||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
|
||||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
|
||||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||||
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||||
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
|
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||||
@@ -621,7 +618,6 @@ fun ConnectionWizard(
|
|||||||
WizardStep.RelayChoice -> RelayChoiceStep(
|
WizardStep.RelayChoice -> RelayChoiceStep(
|
||||||
connectionLabel = activeConnection?.label.orEmpty(),
|
connectionLabel = activeConnection?.label.orEmpty(),
|
||||||
dashboardUrl = currentDashboardUrl,
|
dashboardUrl = currentDashboardUrl,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
onPickScan = {
|
onPickScan = {
|
||||||
chosenMethod = PairMethod.Scan
|
chosenMethod = PairMethod.Scan
|
||||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
@@ -638,7 +634,6 @@ fun ConnectionWizard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
WizardStep.Method -> MethodStep(
|
WizardStep.Method -> MethodStep(
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
onPickStandard = {
|
onPickStandard = {
|
||||||
chosenMethod = PairMethod.Standard
|
chosenMethod = PairMethod.Standard
|
||||||
standardError = null
|
standardError = null
|
||||||
@@ -1701,7 +1696,6 @@ private fun FoundCapabilityLine(label: String, value: String, ready: Boolean) {
|
|||||||
private fun RelayChoiceStep(
|
private fun RelayChoiceStep(
|
||||||
connectionLabel: String,
|
connectionLabel: String,
|
||||||
dashboardUrl: String,
|
dashboardUrl: String,
|
||||||
relayEnabled: Boolean,
|
|
||||||
onPickScan: () -> Unit,
|
onPickScan: () -> Unit,
|
||||||
onPickEnterCode: () -> Unit,
|
onPickEnterCode: () -> Unit,
|
||||||
onPickShowCode: () -> Unit,
|
onPickShowCode: () -> Unit,
|
||||||
@@ -1769,14 +1763,12 @@ private fun RelayChoiceStep(
|
|||||||
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
|
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
|
||||||
onClick = onPickEnterCode,
|
onClick = onPickEnterCode,
|
||||||
)
|
)
|
||||||
if (relayEnabled) {
|
MethodTile(
|
||||||
MethodTile(
|
icon = Icons.Filled.PhonelinkLock,
|
||||||
icon = Icons.Filled.PhonelinkLock,
|
title = stringResource(R.string.cw_method_show_code_title),
|
||||||
title = stringResource(R.string.cw_method_show_code_title),
|
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
onClick = onPickShowCode,
|
||||||
onClick = onPickShowCode,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
|
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -1794,7 +1786,6 @@ private fun RelayChoiceStep(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MethodStep(
|
private fun MethodStep(
|
||||||
relayEnabled: Boolean,
|
|
||||||
onPickStandard: () -> Unit,
|
onPickStandard: () -> Unit,
|
||||||
onPickScan: () -> Unit,
|
onPickScan: () -> Unit,
|
||||||
onPickEnterCode: () -> Unit,
|
onPickEnterCode: () -> Unit,
|
||||||
@@ -1931,14 +1922,12 @@ private fun MethodStep(
|
|||||||
onClick = onPickEnterCode,
|
onClick = onPickEnterCode,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (relayEnabled) {
|
MethodTile(
|
||||||
MethodTile(
|
icon = Icons.Filled.PhonelinkLock,
|
||||||
icon = Icons.Filled.PhonelinkLock,
|
title = stringResource(R.string.cw_method_show_code_title),
|
||||||
title = stringResource(R.string.cw_method_show_code_title),
|
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
onClick = onPickShowCode,
|
||||||
onClick = onPickShowCode,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSkip != null) {
|
if (onSkip != null) {
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
+918
-11
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,11 @@ fun MarkdownContent(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = LocalBrand.current.isDark
|
val isDarkTheme = LocalBrand.current.isDark
|
||||||
|
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
color = textColor,
|
||||||
|
)
|
||||||
val highlightsBuilder = remember(isDarkTheme) {
|
val highlightsBuilder = remember(isDarkTheme) {
|
||||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = 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
|
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so 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
|
// 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(
|
typography = markdownTypography(
|
||||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
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,
|
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||||
color = textColor.copy(alpha = 0.85f),
|
color = textColor.copy(alpha = 0.85f),
|
||||||
),
|
),
|
||||||
// Prose, list items, and quotes all sit at the 14sp body size so a
|
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
|
||||||
// paragraph and the bullet list under it share one rhythm — the library
|
// The library default 'text'/list role is bodyLarge (16sp), while
|
||||||
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
|
// bodyMedium was previously 14sp and unnecessarily small for long chat.
|
||||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
paragraph = chatBodyStyle,
|
||||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
text = chatBodyStyle,
|
||||||
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
bullet = chatBodyStyle,
|
||||||
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
ordered = chatBodyStyle,
|
||||||
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
list = chatBodyStyle,
|
||||||
quote = MaterialTheme.typography.bodyMedium.copy(
|
quote = chatBodyStyle.copy(
|
||||||
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = textColor.copy(alpha = 0.9f),
|
||||||
),
|
),
|
||||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||||
// + the tinted chip already signal "code" without also shrinking it, and
|
// + the tinted chip already signal "code" without also shrinking it, and
|
||||||
@@ -125,7 +132,7 @@ fun MarkdownContent(
|
|||||||
code = MaterialTheme.typography.bodySmall.copy(
|
code = MaterialTheme.typography.bodySmall.copy(
|
||||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = textColor,
|
||||||
),
|
),
|
||||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||||
@@ -302,7 +309,10 @@ fun StreamingMarkdownContent(
|
|||||||
// code and deliberately spaced prose are not altered.
|
// code and deliberately spaced prose are not altered.
|
||||||
text = content.withoutLeadingBlankLines(),
|
text = content.withoutLeadingBlankLines(),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.hermesandroid.relay.ui.components
|
package com.hermesandroid.relay.ui.components
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
@@ -139,6 +138,8 @@ fun MessageBubble(
|
|||||||
* happening.
|
* happening.
|
||||||
*/
|
*/
|
||||||
recoveringAnswer: Boolean = false,
|
recoveringAnswer: Boolean = false,
|
||||||
|
imageGenerationStylePreference: String = "rotate",
|
||||||
|
imageGenerationRotationIndex: Int = 0,
|
||||||
) {
|
) {
|
||||||
val isUser = message.role == MessageRole.USER
|
val isUser = message.role == MessageRole.USER
|
||||||
val isSystem = message.role == MessageRole.SYSTEM
|
val isSystem = message.role == MessageRole.SYSTEM
|
||||||
@@ -173,7 +174,7 @@ fun MessageBubble(
|
|||||||
|
|
||||||
val textColor = when (message.role) {
|
val textColor = when (message.role) {
|
||||||
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
||||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
|
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
|
||||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +212,23 @@ fun MessageBubble(
|
|||||||
isStreaming = message.isStreaming,
|
isStreaming = message.isStreaming,
|
||||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
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
|
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||||
// renderers below, sourced as locally as possible (here, not threaded
|
// 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.
|
// Message bubble.
|
||||||
//
|
//
|
||||||
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
||||||
@@ -427,7 +469,10 @@ fun MessageBubble(
|
|||||||
// Plain text for user and system messages
|
// Plain text for user and system messages
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
),
|
||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -485,44 +530,78 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image generation owns the bubble's progress slot. It replaces
|
// Image generation owns the bubble's progress slot. Keep the
|
||||||
// the generic first-token dots, remains mounted through the
|
// selected progress treatment mounted under the real result,
|
||||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
// then reveal the same collapsible attachment surface without
|
||||||
// attachment renderer in this same Surface.
|
// rebuilding the surrounding message bubble.
|
||||||
Crossfade(
|
if (hasImageGenerationCall) {
|
||||||
targetState = showImageGeneration,
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
animationSpec = tween(durationMillis = 220),
|
ImageGenerationResultTransition(
|
||||||
label = "imageGenerationToResult",
|
generating = showImageGeneration,
|
||||||
) { generating ->
|
startedAtMillis = imageGenerationStartMillis,
|
||||||
if (generating) {
|
visualStyle = imageGenerationVisualStyle,
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
) {
|
||||||
ImageGenerationPlaceholder()
|
if (message.attachments.isNotEmpty()) {
|
||||||
} else if (message.attachments.isNotEmpty()) {
|
CollapsibleAttachmentGroup(
|
||||||
// Two or more loaded images collapse into one grid +
|
messageKey = message.uiKey,
|
||||||
// swipe-across gallery. Every other item stays on the
|
attachments = message.attachments,
|
||||||
// unified attachment path, retaining original indices.
|
) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
attachmentItems.forEach { item ->
|
||||||
attachmentItems.forEach { item ->
|
when (item) {
|
||||||
when (item) {
|
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
maxWidth = maxBubbleWidth - 24.dp,
|
||||||
maxWidth = maxBubbleWidth - 24.dp,
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
)
|
||||||
)
|
is AttachmentLayoutItem.Single -> {
|
||||||
is AttachmentLayoutItem.Single -> {
|
val index = item.attachmentIndex
|
||||||
val index = item.attachmentIndex
|
InboundAttachmentCard(
|
||||||
InboundAttachmentCard(
|
attachment = message.attachments[index],
|
||||||
attachment = message.attachments[index],
|
onRetry = { onAttachmentRetry(message.id, index) },
|
||||||
onRetry = { onAttachmentRetry(message.id, index) },
|
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
maxWidth = maxBubbleWidth - 24.dp,
|
||||||
maxWidth = maxBubbleWidth - 24.dp,
|
modifier = Modifier.padding(vertical = 2.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
|
// Streaming indicator — only while awaiting the first token. Once
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ fun ThinkingBlock(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||||
timestamp: Long? = null,
|
timestamp: Long? = null,
|
||||||
|
headerText: String? = null,
|
||||||
|
accessibilityLabel: String = "Thinking",
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(isStreaming) }
|
var expanded by remember { mutableStateOf(isStreaming) }
|
||||||
val locale = LocalLocale.current.platformLocale
|
val locale = LocalLocale.current.platformLocale
|
||||||
@@ -65,13 +67,13 @@ fun ThinkingBlock(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Psychology,
|
imageVector = Icons.Filled.Psychology,
|
||||||
contentDescription = "Thinking",
|
contentDescription = accessibilityLabel,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
tint = MaterialTheme.colorScheme.tertiary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (isStreaming) "Thinking..." else "Thought process",
|
text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
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.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.GraphicEq
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
|
import com.hermesandroid.relay.data.HermesCardAction
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
import com.hermesandroid.relay.data.ToolCall
|
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.AvatarRenderState
|
||||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||||
@@ -146,7 +149,8 @@ fun VoiceModeOverlay(
|
|||||||
voiceOutputEnabled: Boolean? = null,
|
voiceOutputEnabled: Boolean? = null,
|
||||||
voiceOutputFallbackEnabled: Boolean? = null,
|
voiceOutputFallbackEnabled: Boolean? = null,
|
||||||
onOverlayRequest: () -> Unit = {},
|
onOverlayRequest: () -> Unit = {},
|
||||||
onCompactModeChange: (Boolean) -> Unit = {},
|
presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||||
|
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||||
// === END PHASE3-voice-mode-transcript ===
|
// === END PHASE3-voice-mode-transcript ===
|
||||||
// === v0.4.1 JIT permission-denied chip ===
|
// === v0.4.1 JIT permission-denied chip ===
|
||||||
// Tapped when the user clicks the permission-denied chip. Default no-op
|
// Tapped when the user clicks the permission-denied chip. Default no-op
|
||||||
@@ -159,22 +163,21 @@ fun VoiceModeOverlay(
|
|||||||
onBackgroundRunCancel: () -> Unit = {},
|
onBackgroundRunCancel: () -> Unit = {},
|
||||||
onBackgroundRunTap: () -> Unit = {},
|
onBackgroundRunTap: () -> Unit = {},
|
||||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||||
|
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit =
|
||||||
|
{ _, _, _ -> },
|
||||||
|
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit =
|
||||||
|
{ _, _, _ -> },
|
||||||
// === END v0.4.1 ===
|
// === END v0.4.1 ===
|
||||||
) {
|
) {
|
||||||
val surface = MaterialTheme.colorScheme.surface
|
val surface = MaterialTheme.colorScheme.surface
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
var controlsExpanded by remember { mutableStateOf(false) }
|
var controlsExpanded by remember { mutableStateOf(false) }
|
||||||
var focusMode by remember { mutableStateOf(true) }
|
val focusMode = presentationMode == VoicePresentationMode.Focus
|
||||||
val setFocusMode: (Boolean) -> Unit = { focused ->
|
val setFocusMode: (Boolean) -> Unit = { focused ->
|
||||||
focusMode = focused
|
onPresentationModeChange(
|
||||||
onCompactModeChange(!focused)
|
if (focused) VoicePresentationMode.Focus else VoicePresentationMode.Conversation,
|
||||||
}
|
)
|
||||||
|
|
||||||
LaunchedEffect(uiState.voiceMode) {
|
|
||||||
if (!uiState.voiceMode) {
|
|
||||||
setFocusMode(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice errors surface ONLY on the overlay's own inline top banner
|
// 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
|
// chips, pill) didn't handle so stray taps/swipes don't fall
|
||||||
// through to the chat + session drawer behind it. Children run on
|
// through to the chat + session drawer behind it. Children run on
|
||||||
// the same Main pass leaf-first, so this only catches the gaps.
|
// 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.
|
// chat stays interactive, so no scrim is installed.
|
||||||
.then(
|
.then(
|
||||||
if (focusMode) {
|
if (focusMode) {
|
||||||
@@ -404,6 +407,11 @@ fun VoiceModeOverlay(
|
|||||||
message = msg,
|
message = msg,
|
||||||
showThinking = showThinking,
|
showThinking = showThinking,
|
||||||
expanded = msg.id == latestId || msg.isStreaming,
|
expanded = msg.id == latestId || msg.isStreaming,
|
||||||
|
onViewConversation = {
|
||||||
|
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||||
|
},
|
||||||
|
onCardAction = onCardAction,
|
||||||
|
onCardInput = onCardInput,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (pendingTranscriptText != null) {
|
if (pendingTranscriptText != null) {
|
||||||
@@ -418,6 +426,11 @@ fun VoiceModeOverlay(
|
|||||||
),
|
),
|
||||||
showThinking = showThinking,
|
showThinking = showThinking,
|
||||||
expanded = true,
|
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
|
// mode too — a running task with no visible presence reads as lost
|
||||||
// (the chip previously existed ONLY in the focus layout).
|
// (the chip previously existed ONLY in the focus layout).
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -821,6 +834,15 @@ private fun VoiceSessionPill(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (!focusMode) {
|
||||||
|
ConversationVoiceMicButton(
|
||||||
|
uiState = uiState,
|
||||||
|
onMicTap = onMicTap,
|
||||||
|
onMicRelease = onMicRelease,
|
||||||
|
onInterrupt = onInterrupt,
|
||||||
|
onPauseAutoMode = onPauseAutoMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
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),
|
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),
|
.padding(top = 10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Moved out of the collapsed header (4a): the current
|
// The expanded body keeps the current interaction mode
|
||||||
// interaction-mode pill plus the inline mic control. The
|
// visible; Conversation's persistent mic stays in the
|
||||||
// compact mic only appears in compact mode, where the
|
// collapsed header so it never disappears.
|
||||||
// full-size bottom mic button is hidden.
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -866,15 +887,6 @@ private fun VoiceSessionPill(
|
|||||||
) {
|
) {
|
||||||
StatusPill(uiState.interactionMode.label())
|
StatusPill(uiState.interactionMode.label())
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (!focusMode) {
|
|
||||||
CompactVoiceMicButton(
|
|
||||||
uiState = uiState,
|
|
||||||
onMicTap = onMicTap,
|
|
||||||
onMicRelease = onMicRelease,
|
|
||||||
onInterrupt = onInterrupt,
|
|
||||||
onPauseAutoMode = onPauseAutoMode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -915,7 +927,11 @@ private fun VoiceSessionPill(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -958,7 +974,7 @@ private fun VoiceSessionPill(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CompactVoiceMicButton(
|
private fun ConversationVoiceMicButton(
|
||||||
uiState: VoiceUiState,
|
uiState: VoiceUiState,
|
||||||
onMicTap: () -> Unit,
|
onMicTap: () -> Unit,
|
||||||
onMicRelease: () -> Unit,
|
onMicRelease: () -> Unit,
|
||||||
@@ -1224,6 +1240,9 @@ private fun CompactTranscriptRow(
|
|||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
showThinking: Boolean,
|
showThinking: Boolean,
|
||||||
expanded: 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
|
if (message.role == MessageRole.SYSTEM) return
|
||||||
|
|
||||||
@@ -1248,6 +1267,13 @@ private fun CompactTranscriptRow(
|
|||||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||||
else -> MaterialTheme.colorScheme.secondary
|
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(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -1285,7 +1311,7 @@ private fun CompactTranscriptRow(
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
isVoiceActionBubble && hasText -> MarkdownContent(
|
isVoiceActionBubble && hasText -> MarkdownContent(
|
||||||
content = message.content,
|
content = markdownBody,
|
||||||
textColor = MaterialTheme.colorScheme.onSurface,
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
message.role == MessageRole.USER && hasText -> Text(
|
message.role == MessageRole.USER && hasText -> Text(
|
||||||
@@ -1295,12 +1321,107 @@ private fun CompactTranscriptRow(
|
|||||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
hasText -> Text(
|
hasText -> MarkdownContent(
|
||||||
text = message.content,
|
content = markdownBody,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 6,
|
modifier = Modifier.weight(1f),
|
||||||
overflow = TextOverflow.Ellipsis,
|
)
|
||||||
|
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
|
val remaining = 7 - versionTapCount
|
||||||
when {
|
when {
|
||||||
remaining <= 0 -> {
|
remaining <= 0 -> {
|
||||||
scope.launch { FeatureFlags.unlockDevOptions(context) }
|
|
||||||
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
|
|
||||||
versionTapCount = 0
|
versionTapCount = 0
|
||||||
onUnlockDeveloperOptions()
|
scope.launch {
|
||||||
|
FeatureFlags.unlockDevOptions(context)
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
devUnlockedMsg,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
onUnlockDeveloperOptions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
remaining <= 3 -> {
|
remaining <= 3 -> {
|
||||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ fun AppearanceSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||||
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||||
|
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
@@ -526,6 +527,48 @@ fun AppearanceSettingsScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
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.Attachment
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
import com.hermesandroid.relay.data.Connection
|
import com.hermesandroid.relay.data.Connection
|
||||||
|
import com.hermesandroid.relay.data.HermesCardAction
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
|
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||||
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
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.components.VoiceModeOverlay
|
||||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||||
import com.hermesandroid.relay.ui.showHumanError
|
import com.hermesandroid.relay.ui.showHumanError
|
||||||
|
import com.hermesandroid.relay.util.HumanErrorAction
|
||||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||||
@@ -280,6 +284,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
|
|||||||
previous.messageCount == messageCount &&
|
previous.messageCount == messageCount &&
|
||||||
previous.lastMessageUiKey == lastMessageUiKey
|
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(
|
private class ChatTailTransitionRef(
|
||||||
var snapshot: ChatScrollSnapshot? = null,
|
var snapshot: ChatScrollSnapshot? = null,
|
||||||
)
|
)
|
||||||
@@ -439,6 +454,8 @@ fun ChatScreen(
|
|||||||
voiceViewModel: VoiceViewModel,
|
voiceViewModel: VoiceViewModel,
|
||||||
voiceClient: RelayVoiceClient? = null,
|
voiceClient: RelayVoiceClient? = null,
|
||||||
maxBubbleWidth: Dp = 300.dp,
|
maxBubbleWidth: Dp = 300.dp,
|
||||||
|
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||||
|
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||||
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
||||||
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
||||||
// fires so the host can clear the nav arg (prevents re-open on tab
|
// fires so the host can clear the nav arg (prevents re-open on tab
|
||||||
@@ -451,6 +468,7 @@ fun ChatScreen(
|
|||||||
// don't wire navigation.
|
// don't wire navigation.
|
||||||
onNavigateToConnections: () -> Unit = {},
|
onNavigateToConnections: () -> Unit = {},
|
||||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||||
|
onRepairConnection: () -> Unit = onNavigateToConnect,
|
||||||
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
|
// 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.
|
// skipped / never-connected first run can explore without a server. null hides it.
|
||||||
onTryDemo: (() -> Unit)? = null,
|
onTryDemo: (() -> Unit)? = null,
|
||||||
@@ -465,9 +483,10 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
|
||||||
val chatAlpha by animateFloatAsState(
|
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),
|
animationSpec = tween(300),
|
||||||
label = "chatAlpha",
|
label = "chatAlpha",
|
||||||
)
|
)
|
||||||
@@ -477,7 +496,13 @@ fun ChatScreen(
|
|||||||
val snackbarHost = LocalSnackbarHost.current
|
val snackbarHost = LocalSnackbarHost.current
|
||||||
LaunchedEffect(chatViewModel) {
|
LaunchedEffect(chatViewModel) {
|
||||||
chatViewModel.errorEvents.collect { err ->
|
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
|
// Animation settings
|
||||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||||
|
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||||
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
||||||
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
||||||
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.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
|
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
||||||
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
||||||
// empty / new-chat view (no messages) — it teaches the long-press entry
|
// empty / new-chat view (no messages) — it teaches the long-press entry
|
||||||
@@ -773,6 +813,25 @@ fun ChatScreen(
|
|||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
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.) →
|
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
|
||||||
// transient snackbar, never a chat bubble.
|
// transient snackbar, never a chat bubble.
|
||||||
@@ -870,7 +929,6 @@ fun ChatScreen(
|
|||||||
if (!voiceUiState.voiceMode) {
|
if (!voiceUiState.voiceMode) {
|
||||||
voiceOverlayHost.hide()
|
voiceOverlayHost.hide()
|
||||||
pendingVoiceOverlayPermission = false
|
pendingVoiceOverlayPermission = false
|
||||||
voiceCompactMode = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,10 +1173,12 @@ fun ChatScreen(
|
|||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||||
|
val settlingVisibleTail = completionSettlingUiKey != null &&
|
||||||
|
messages.lastOrNull()?.uiKey == completionSettlingUiKey
|
||||||
messages.isNotEmpty() &&
|
messages.isNotEmpty() &&
|
||||||
!isAtBottom &&
|
!isAtBottom &&
|
||||||
!programmaticBottomScroll &&
|
!programmaticBottomScroll &&
|
||||||
!((isStreaming || retainingVisibleTail) &&
|
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
|
||||||
smoothAutoScroll &&
|
smoothAutoScroll &&
|
||||||
!userScrolledAway)
|
!userScrolledAway)
|
||||||
}
|
}
|
||||||
@@ -1316,34 +1376,99 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
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
|
completionSettlingUiKey = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
var settledFrames = 0
|
var settledFrames = 0
|
||||||
repeat(6) {
|
var previousMarkdownTailSize: Int? = null
|
||||||
|
var previousMarkdownFooterSize: Int? = null
|
||||||
|
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
|
||||||
|
repeat(60) completionFrame@{
|
||||||
withFrameNanos { }
|
withFrameNanos { }
|
||||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||||
completionSettlingUiKey = null
|
completionSettlingUiKey = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listState.canScrollForward) {
|
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
|
||||||
settledFrames = 0
|
if (listState.canScrollForward) {
|
||||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
settledFrames = 0
|
||||||
if (viewportHeight > 0) {
|
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||||
listState.scroll(MutatePriority.Default) {
|
if (viewportHeight > 0) {
|
||||||
scrollBy(viewportHeight.toFloat())
|
listState.scroll(MutatePriority.Default) {
|
||||||
|
scrollBy(viewportHeight.toFloat())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return@completionFrame
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
settledFrames += 1
|
settledFrames += 1
|
||||||
if (settledFrames >= 2) {
|
if (settledFrames < 2) return@completionFrame
|
||||||
completionSettlingUiKey = null
|
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||||
return@LaunchedEffect
|
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
|
completionSettlingUiKey = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2284,30 +2409,17 @@ fun ChatScreen(
|
|||||||
isLastInGroup = isLastInGroup,
|
isLastInGroup = isLastInGroup,
|
||||||
retainStreamingLayout = retainLiveLayout,
|
retainStreamingLayout = retainLiveLayout,
|
||||||
recoveringAnswer = recoveringAnswer,
|
recoveringAnswer = recoveringAnswer,
|
||||||
|
imageGenerationStylePreference = imageGenerationStyle,
|
||||||
|
imageGenerationRotationIndex =
|
||||||
|
imageGenerationOrdinals[message.uiKey] ?: 0,
|
||||||
onAttachmentRetry = { msgId, idx ->
|
onAttachmentRetry = { msgId, idx ->
|
||||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||||
},
|
},
|
||||||
onAttachmentManualFetch = { msgId, idx ->
|
onAttachmentManualFetch = { msgId, idx ->
|
||||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||||
},
|
},
|
||||||
onCardAction = { msgId, cardKey, action ->
|
onCardAction = handleCardAction,
|
||||||
// OPEN_URL is resolved at the UI layer
|
onCardInput = handleCardInput,
|
||||||
// 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)
|
|
||||||
},
|
|
||||||
onEditMessage = if (
|
onEditMessage = if (
|
||||||
isGatewayTransport &&
|
isGatewayTransport &&
|
||||||
!isStreaming &&
|
!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 {
|
} else {
|
||||||
sseModelOptions.forEach { model ->
|
sseModelOptions.forEach { model ->
|
||||||
add(
|
add(
|
||||||
@@ -3154,14 +3278,13 @@ fun ChatScreen(
|
|||||||
voiceConfigScope = activeVoiceScope,
|
voiceConfigScope = activeVoiceScope,
|
||||||
voiceOutputEnabled = activeVoiceEnabled,
|
voiceOutputEnabled = activeVoiceEnabled,
|
||||||
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||||
|
presentationMode = voicePresentationMode,
|
||||||
|
onPresentationModeChange = onVoicePresentationModeChange,
|
||||||
onOverlayRequest = showVoiceSystemOverlay,
|
onOverlayRequest = showVoiceSystemOverlay,
|
||||||
// Gear button in the overlay's expanded controls. The overlay
|
// Gear button in the overlay's expanded controls. The overlay
|
||||||
// exits voice mode before invoking this, so navigation lands
|
// exits voice mode before invoking this, so navigation lands
|
||||||
// on Voice Settings with no overlay left on top.
|
// on Voice Settings with no overlay left on top.
|
||||||
onOpenSettings = onNavigateToVoiceSettings,
|
onOpenSettings = onNavigateToVoiceSettings,
|
||||||
onCompactModeChange = { compact ->
|
|
||||||
voiceCompactMode = compact
|
|
||||||
},
|
|
||||||
// === v0.4.1 JIT permission-denied chip ===
|
// === v0.4.1 JIT permission-denied chip ===
|
||||||
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
||||||
// Permissions for the running package. Use BuildConfig
|
// Permissions for the running package. Use BuildConfig
|
||||||
@@ -3185,6 +3308,8 @@ fun ChatScreen(
|
|||||||
onHermesConfirmationAnswer = { answer ->
|
onHermesConfirmationAnswer = { answer ->
|
||||||
voiceViewModel.answerHermesConfirmation(answer)
|
voiceViewModel.answerHermesConfirmation(answer)
|
||||||
},
|
},
|
||||||
|
onCardAction = handleCardAction,
|
||||||
|
onCardInput = handleCardInput,
|
||||||
// === END v0.4.1 ===
|
// === END v0.4.1 ===
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.data.Connection
|
import com.hermesandroid.relay.data.Connection
|
||||||
import com.hermesandroid.relay.data.capabilities
|
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.ActiveCardAdvancedSection
|
||||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||||
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
||||||
@@ -110,9 +109,6 @@ fun ConnectionDetailScreen(
|
|||||||
val connections by connectionViewModel.connections.collectAsState()
|
val connections by connectionViewModel.connections.collectAsState()
|
||||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
|
||||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
|
||||||
|
|
||||||
val connection = connections.firstOrNull { it.id == connectionId }
|
val connection = connections.firstOrNull { it.id == connectionId }
|
||||||
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
||||||
LaunchedEffect(connection == null) {
|
LaunchedEffect(connection == null) {
|
||||||
@@ -275,7 +271,6 @@ fun ConnectionDetailScreen(
|
|||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
connection = connection,
|
connection = connection,
|
||||||
relayUiState = relayUiState,
|
relayUiState = relayUiState,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
onReconnect = onReconnect,
|
onReconnect = onReconnect,
|
||||||
onRepair = { onRepair(connectionId) },
|
onRepair = { onRepair(connectionId) },
|
||||||
onOpenApiInfo = { showApiInfoSheet = true },
|
onOpenApiInfo = { showApiInfoSheet = true },
|
||||||
@@ -303,7 +298,6 @@ fun ConnectionDetailScreen(
|
|||||||
|
|
||||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onPairRelay = { onRepair(connectionId) },
|
onPairRelay = { onRepair(connectionId) },
|
||||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||||
@@ -423,7 +417,6 @@ private fun ActiveOverview(
|
|||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
relayUiState: RelayUiState,
|
relayUiState: RelayUiState,
|
||||||
relayEnabled: Boolean,
|
|
||||||
onReconnect: () -> Unit,
|
onReconnect: () -> Unit,
|
||||||
onRepair: () -> Unit,
|
onRepair: () -> Unit,
|
||||||
onOpenApiInfo: () -> Unit,
|
onOpenApiInfo: () -> Unit,
|
||||||
@@ -498,7 +491,6 @@ private fun ActiveOverview(
|
|||||||
|
|
||||||
ActiveCardFeaturesSection(
|
ActiveCardFeaturesSection(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
onOpenApiInfo = onOpenApiInfo,
|
onOpenApiInfo = onOpenApiInfo,
|
||||||
onOpenDashboard = onOpenDashboard,
|
onOpenDashboard = onOpenDashboard,
|
||||||
onOpenRelayInfo = onOpenRelayInfo,
|
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.DashboardCookieStore
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
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.DashboardStatus
|
||||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||||
@@ -464,15 +465,8 @@ fun DashboardManagementScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||||
{
|
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||||
DashboardApiClient(
|
|
||||||
baseUrl = dashboardUrl,
|
|
||||||
okHttpClient = DashboardApiClient.defaultClient(
|
|
||||||
cookieStore = cookieStoreFactory(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadDashboardSection(
|
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
|
val signInStatus = status
|
||||||
if (signInStatus?.authRequired == true && authenticated != true) {
|
if (signInStatus?.authRequired == true && authenticated != true) {
|
||||||
item {
|
item {
|
||||||
@@ -1916,7 +1945,7 @@ private suspend fun fetchDashboardSectionStateWith(
|
|||||||
* start lands on an already-populated Manage tab.
|
* start lands on an already-populated Manage tab.
|
||||||
*/
|
*/
|
||||||
internal suspend fun prewarmDashboardManage(
|
internal suspend fun prewarmDashboardManage(
|
||||||
cookieStore: DashboardCookieStore,
|
clientFactory: () -> DashboardApiClient,
|
||||||
connectionId: String,
|
connectionId: String,
|
||||||
dashboardUrl: String,
|
dashboardUrl: String,
|
||||||
effectiveProfileName: String? = null,
|
effectiveProfileName: String? = null,
|
||||||
@@ -1945,12 +1974,7 @@ internal suspend fun prewarmDashboardManage(
|
|||||||
// encrypted cookie store per section: 8 Keystore keyset builds, each
|
// encrypted cookie store per section: 8 Keystore keyset builds, each
|
||||||
// holding Tink's process-global lock for seconds on StrongBox devices,
|
// holding Tink's process-global lock for seconds on StrongBox devices,
|
||||||
// which starved main-thread keystore users and froze the UI at startup.
|
// which starved main-thread keystore users and froze the UI at startup.
|
||||||
val client = withContext(Dispatchers.IO) {
|
val client = withContext(Dispatchers.IO) { clientFactory() }
|
||||||
DashboardApiClient(
|
|
||||||
baseUrl = dashboardUrl,
|
|
||||||
okHttpClient = DashboardApiClient.defaultClient(cookieStore = cookieStore),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
val preamble = try {
|
val preamble = try {
|
||||||
fetchDashboardPreamble(client)
|
fetchDashboardPreamble(client)
|
||||||
@@ -2598,6 +2622,7 @@ private fun DashboardOAuthSignInDialog(
|
|||||||
dashboardUrl: String,
|
dashboardUrl: String,
|
||||||
provider: DashboardAuthProvider,
|
provider: DashboardAuthProvider,
|
||||||
cookieStoreFactory: () -> DashboardCookieStore,
|
cookieStoreFactory: () -> DashboardCookieStore,
|
||||||
|
clientFactory: () -> DashboardApiClient,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
@@ -2634,16 +2659,7 @@ private fun DashboardOAuthSignInDialog(
|
|||||||
statusText = context.getString(R.string.dashboard_oauth_verifying)
|
statusText = context.getString(R.string.dashboard_oauth_verifying)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val session = withDashboardClient(
|
val session = withDashboardClient(clientFactory = clientFactory) { client ->
|
||||||
clientFactory = {
|
|
||||||
DashboardApiClient(
|
|
||||||
baseUrl = dashboardUrl,
|
|
||||||
okHttpClient = DashboardApiClient.defaultClient(
|
|
||||||
cookieStore = cookieStoreFactory(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { client ->
|
|
||||||
client.currentSession().getOrNull()
|
client.currentSession().getOrNull()
|
||||||
}
|
}
|
||||||
if (session?.authenticated == true) {
|
if (session?.authenticated == true) {
|
||||||
@@ -3466,6 +3482,7 @@ private fun CustomEndpointDialog(
|
|||||||
var discoverModels by remember(existing) {
|
var discoverModels by remember(existing) {
|
||||||
mutableStateOf(existing?.meta?.contains("discover=off") != true)
|
mutableStateOf(existing?.meta?.contains("discover=off") != true)
|
||||||
}
|
}
|
||||||
|
var validatedModels by remember(existing) { mutableStateOf(emptyList<String>()) }
|
||||||
var busy by remember(existing) { mutableStateOf(false) }
|
var busy by remember(existing) { mutableStateOf(false) }
|
||||||
var message by remember(existing) { mutableStateOf<String?>(null) }
|
var message by remember(existing) { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
@@ -3474,6 +3491,7 @@ private fun CustomEndpointDialog(
|
|||||||
name = name.trim(),
|
name = name.trim(),
|
||||||
baseUrl = baseUrl.trim(),
|
baseUrl = baseUrl.trim(),
|
||||||
model = model.trim(),
|
model = model.trim(),
|
||||||
|
models = validatedModels,
|
||||||
apiKey = apiKey.takeIf { it.isNotBlank() },
|
apiKey = apiKey.takeIf { it.isNotBlank() },
|
||||||
contextLength = contextLength.toIntOrNull(),
|
contextLength = contextLength.toIntOrNull(),
|
||||||
discoverModels = discoverModels,
|
discoverModels = discoverModels,
|
||||||
@@ -3488,8 +3506,24 @@ private fun CustomEndpointDialog(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
|
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(
|
||||||
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
|
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(
|
OutlinedTextField(
|
||||||
apiKey,
|
apiKey,
|
||||||
{ apiKey = it },
|
{ apiKey = it },
|
||||||
@@ -3518,6 +3552,11 @@ private fun CustomEndpointDialog(
|
|||||||
busy = false
|
busy = false
|
||||||
message = result.fold(
|
message = result.fold(
|
||||||
onSuccess = { validation ->
|
onSuccess = { validation ->
|
||||||
|
validatedModels = validation.models
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.distinct()
|
||||||
|
.take(256)
|
||||||
validation.message.ifBlank {
|
validation.message.ifBlank {
|
||||||
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
|
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
|
||||||
}
|
}
|
||||||
@@ -3837,6 +3876,23 @@ internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryI
|
|||||||
)
|
)
|
||||||
} ?: emptyList()
|
} ?: 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 {
|
private fun summarizeRoot(root: JsonElement): String {
|
||||||
return when (root) {
|
return when (root) {
|
||||||
is JsonObject -> {
|
is JsonObject -> {
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
package com.hermesandroid.relay.ui.screens
|
package com.hermesandroid.relay.ui.screens
|
||||||
|
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -21,12 +22,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -37,11 +40,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
||||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
|
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.DashboardAuthProvider
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
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.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.importDashboardCookieHeader
|
||||||
|
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
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.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||||
@@ -66,7 +78,9 @@ fun DashboardSignInScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAuthenticated: () -> Unit,
|
onAuthenticated: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current.applicationContext
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val appContext = context.applicationContext
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||||
@@ -78,22 +92,22 @@ fun DashboardSignInScreen(
|
|||||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||||
var actionInFlight by remember { mutableStateOf(false) }
|
var actionInFlight by remember { mutableStateOf(false) }
|
||||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var actionIsError by remember { mutableStateOf(false) }
|
||||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
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) }
|
var authenticationComplete by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val cookieStoreFactory = remember(context, connectionId) {
|
val cookieStoreFactory = remember(appContext, connectionId) {
|
||||||
{
|
{
|
||||||
connectionViewModel.activeDashboardCookieStore()
|
connectionViewModel.activeDashboardCookieStore()
|
||||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
?: EncryptedDashboardCookieStore(appContext, connectionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||||
{
|
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||||
DashboardApiClient(
|
|
||||||
baseUrl = dashboardUrl,
|
|
||||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||||
@@ -115,7 +129,7 @@ fun DashboardSignInScreen(
|
|||||||
|
|
||||||
fun finishAuthentication() {
|
fun finishAuthentication() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
invalidateDashboardManageCache(context.cacheDir)
|
invalidateDashboardManageCache(appContext.cacheDir)
|
||||||
connectionViewModel.refreshStandardVoice()
|
connectionViewModel.refreshStandardVoice()
|
||||||
connectionViewModel.refreshDashboardProfiles()
|
connectionViewModel.refreshDashboardProfiles()
|
||||||
authenticationComplete = true
|
authenticationComplete = true
|
||||||
@@ -125,18 +139,20 @@ fun DashboardSignInScreen(
|
|||||||
LaunchedEffect(dashboardUrl, connectionId) {
|
LaunchedEffect(dashboardUrl, connectionId) {
|
||||||
if (dashboardUrl.isBlank()) {
|
if (dashboardUrl.isBlank()) {
|
||||||
loading = false
|
loading = false
|
||||||
actionMessage = context.getString(R.string.dashboard_no_url_configured)
|
actionMessage = resources.getString(R.string.dashboard_no_url_configured)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
val client = clientFactory()
|
val client = clientFactory()
|
||||||
try {
|
try {
|
||||||
val status = client.getStatus().getOrElse {
|
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
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
providers = status.authProviderDetails.ifEmpty {
|
providers = client.getAuthProviders().getOrNull()
|
||||||
client.getAuthProviders().getOrNull().orEmpty()
|
?.takeIf { it.isNotEmpty() }
|
||||||
}
|
?: status.authProviderDetails
|
||||||
|
authFlows = status.authFlows
|
||||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||||
connectionViewModel.recordDashboardStatus(
|
connectionViewModel.recordDashboardStatus(
|
||||||
status = status,
|
status = status,
|
||||||
@@ -157,6 +173,7 @@ fun DashboardSignInScreen(
|
|||||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||||
actionInFlight = true
|
actionInFlight = true
|
||||||
actionMessage = null
|
actionMessage = null
|
||||||
|
actionIsError = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val client = clientFactory()
|
val client = clientFactory()
|
||||||
try {
|
try {
|
||||||
@@ -166,10 +183,12 @@ fun DashboardSignInScreen(
|
|||||||
finishAuthentication()
|
finishAuthentication()
|
||||||
} else {
|
} else {
|
||||||
actionMessage = result.exceptionOrNull()?.message
|
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) {
|
} 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 {
|
} finally {
|
||||||
actionInFlight = false
|
actionInFlight = false
|
||||||
client.shutdown()
|
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 ->
|
oauthProvider?.let { provider ->
|
||||||
DashboardOAuthDialog(
|
DashboardOAuthScreen(
|
||||||
dashboardUrl = dashboardUrl,
|
dashboardUrl = dashboardUrl,
|
||||||
provider = provider,
|
provider = provider,
|
||||||
cookieStoreFactory = cookieStoreFactory,
|
cookieStoreFactory = cookieStoreFactory,
|
||||||
|
clientFactory = clientFactory,
|
||||||
onDismiss = { oauthProvider = null },
|
onDismiss = { oauthProvider = null },
|
||||||
onAuthenticated = { session ->
|
onAuthenticated = { session ->
|
||||||
oauthProvider = null
|
oauthProvider = null
|
||||||
@@ -193,13 +277,17 @@ fun DashboardSignInScreen(
|
|||||||
client.shutdown()
|
client.shutdown()
|
||||||
}
|
}
|
||||||
actionMessage = session.provider?.let {
|
actionMessage = session.provider?.let {
|
||||||
context.getString(R.string.dashboard_signed_in_with, it)
|
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||||
} ?: context.getString(R.string.dashboard_signed_in)
|
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||||
finishAuthentication()
|
finishAuthentication()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { actionMessage = it },
|
onError = {
|
||||||
|
actionMessage = it
|
||||||
|
actionIsError = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -235,8 +323,15 @@ fun DashboardSignInScreen(
|
|||||||
providers = providers,
|
providers = providers,
|
||||||
actionInFlight = actionInFlight,
|
actionInFlight = actionInFlight,
|
||||||
actionMessage = actionMessage,
|
actionMessage = actionMessage,
|
||||||
|
actionIsError = actionIsError,
|
||||||
|
nativeSignInInFlight = nativeSignInJob != null,
|
||||||
onSignIn = ::submitPassword,
|
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>,
|
providers: List<DashboardAuthProvider>,
|
||||||
actionInFlight: Boolean,
|
actionInFlight: Boolean,
|
||||||
actionMessage: String?,
|
actionMessage: String?,
|
||||||
|
actionIsError: Boolean,
|
||||||
|
nativeSignInInFlight: Boolean,
|
||||||
onSignIn: (String, String, String) -> Unit,
|
onSignIn: (String, String, String) -> Unit,
|
||||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||||
|
onCancelNativeSignIn: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var username by remember { mutableStateOf("") }
|
var username by remember { mutableStateOf("") }
|
||||||
var password 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))
|
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 (passwordProvider != null || providers.isEmpty()) {
|
||||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -360,15 +466,25 @@ private fun DashboardSignInForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
actionMessage?.let {
|
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
|
@Composable
|
||||||
private fun DashboardOAuthDialog(
|
private fun DashboardOAuthScreen(
|
||||||
dashboardUrl: String,
|
dashboardUrl: String,
|
||||||
provider: DashboardAuthProvider,
|
provider: DashboardAuthProvider,
|
||||||
cookieStoreFactory: () -> DashboardCookieStore,
|
cookieStoreFactory: () -> DashboardCookieStore,
|
||||||
|
clientFactory: () -> DashboardApiClient,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
@@ -381,6 +497,8 @@ private fun DashboardOAuthDialog(
|
|||||||
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
||||||
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
||||||
var checking by remember { mutableStateOf(false) }
|
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) {
|
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||||
DashboardApiClient.authLoginUrl(
|
DashboardApiClient.authLoginUrl(
|
||||||
baseUrl = dashboardUrl,
|
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 loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||||
val root = dashboardUrl.trim().trimEnd('/')
|
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
|
||||||
val relative = loadedUrl.trim().removePrefix(root)
|
DashboardWebViewAuthNavigation.Continue -> return
|
||||||
val stillAuthenticating = relative.startsWith("/login", true) ||
|
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
|
||||||
relative.startsWith("/auth/login", true) ||
|
statusText = notAcceptedStatus
|
||||||
relative.startsWith("/auth/callback", true)
|
onError(notAcceptedStatus)
|
||||||
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
|
return
|
||||||
|
}
|
||||||
|
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
|
||||||
|
}
|
||||||
val manager = CookieManager.getInstance()
|
val manager = CookieManager.getInstance()
|
||||||
manager.flush()
|
manager.flush()
|
||||||
val imported = importDashboardCookieHeader(
|
val imported = importDashboardCookieHeader(
|
||||||
@@ -408,10 +529,7 @@ private fun DashboardOAuthDialog(
|
|||||||
checking = true
|
checking = true
|
||||||
statusText = verifyingStatus
|
statusText = verifyingStatus
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val client = DashboardApiClient(
|
val client = clientFactory()
|
||||||
dashboardUrl,
|
|
||||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
val session = client.currentSession().getOrNull()
|
val session = client.currentSession().getOrNull()
|
||||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||||
@@ -429,39 +547,162 @@ private fun DashboardOAuthDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
BackHandler(onBack = onDismiss)
|
||||||
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onDismiss) {
|
|
||||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
|
|
||||||
}
|
|
||||||
Text(statusText, style = MaterialTheme.typography.bodySmall)
|
|
||||||
AndroidView(
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
factory = { viewContext ->
|
|
||||||
CookieManager.getInstance().setAcceptCookie(true)
|
|
||||||
WebView(viewContext).apply {
|
|
||||||
settings.javaScriptEnabled = true
|
|
||||||
settings.domStorageEnabled = true
|
|
||||||
webViewClient = object : WebViewClient() {
|
|
||||||
override fun shouldOverrideUrlLoading(
|
|
||||||
view: WebView,
|
|
||||||
request: WebResourceRequest,
|
|
||||||
): Boolean = false
|
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView, url: String?) {
|
DisposableEffect(Unit) {
|
||||||
super.onPageFinished(view, url)
|
onDispose {
|
||||||
maybeVerify(url)
|
webView?.stopLoading()
|
||||||
}
|
webView?.destroy()
|
||||||
}
|
webView = null
|
||||||
loadUrl(loginUrl)
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
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.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
||||||
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
|
* unlock. Hosts experimental labs, test tools, and data-management utilities.
|
||||||
* (clear session / wipe caches), and any experimental toggles.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
|
|||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToRealtimeVoice: () -> Unit = {},
|
onNavigateToRealtimeVoice: () -> Unit = {},
|
||||||
|
onNavigateToImageGenerationLab: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isDarkTheme = LocalBrand.current.isDark
|
val isDarkTheme = LocalBrand.current.isDark
|
||||||
|
|
||||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
|
||||||
|
|
||||||
// Data management local state — unfolded from the private
|
// Data management local state — unfolded from the private
|
||||||
// DataManagementSection helper in the old SettingsScreen.
|
// DataManagementSection helper in the old SettingsScreen.
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
connectionViewModel.resetOnboarding()
|
connectionViewModel.resetOnboarding { success ->
|
||||||
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
|
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(
|
Icon(
|
||||||
imageVector = Icons.Filled.RestartAlt,
|
imageVector = Icons.Filled.RestartAlt,
|
||||||
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
|
|||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
|
|||||||
|
|
||||||
HorizontalDivider()
|
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
|
// Lock developer options
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch { FeatureFlags.lockDevOptions(context) }
|
scope.launch {
|
||||||
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
|
FeatureFlags.lockDevOptions(context)
|
||||||
onBack()
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.dev_settings_locked_toast),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Lock,
|
imageVector = Icons.Filled.Lock,
|
||||||
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
showExportDialog = false
|
showExportDialog = false
|
||||||
connectionViewModel.exportSettings { json ->
|
connectionViewModel.exportSettings { json ->
|
||||||
backupJson = json
|
if (json == null) {
|
||||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
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(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showResetDialog = false
|
showResetDialog = false
|
||||||
connectionViewModel.resetAppData()
|
connectionViewModel.resetAppData { success ->
|
||||||
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
|
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)
|
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.ConfigFieldType
|
||||||
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
|
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
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.ElevenLabsVoices
|
||||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
|
||||||
import com.hermesandroid.relay.network.upstream.applyConfigEdits
|
import com.hermesandroid.relay.network.upstream.applyConfigEdits
|
||||||
import com.hermesandroid.relay.network.upstream.configValueAt
|
import com.hermesandroid.relay.network.upstream.configValueAt
|
||||||
import com.hermesandroid.relay.network.upstream.parseConfigSchema
|
import com.hermesandroid.relay.network.upstream.parseConfigSchema
|
||||||
@@ -184,12 +182,12 @@ fun VoiceSettingsScreen(
|
|||||||
*/
|
*/
|
||||||
connectionId: String? = null,
|
connectionId: String? = null,
|
||||||
/**
|
/**
|
||||||
* Dashboard base URL + per-connection cookie store provider for the
|
* Dashboard base URL + trusted per-connection client provider for the
|
||||||
* standard-path server voice-config editor (`/api/config`, cookie auth).
|
* standard-path server voice-config editor (`/api/config`, session auth).
|
||||||
* Null on connections with no dashboard — the editor card is then hidden.
|
* Null on connections with no dashboard — the editor card is then hidden.
|
||||||
*/
|
*/
|
||||||
dashboardUrl: String? = null,
|
dashboardUrl: String? = null,
|
||||||
dashboardCookieStoreProvider: (() -> DashboardCookieStore?)? = null,
|
dashboardClientProvider: ((String) -> DashboardApiClient)? = null,
|
||||||
onOpenManage: (() -> Unit)? = null,
|
onOpenManage: (() -> Unit)? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||||
@@ -208,17 +206,12 @@ fun VoiceSettingsScreen(
|
|||||||
// just observes it; the editor cards push saves back through the VM.
|
// just observes it; the editor cards push saves back through the VM.
|
||||||
val configState by settingsViewModel.configState.collectAsState()
|
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
|
// Built once per (dashboardUrl, connection); shut down on dispose. Null when
|
||||||
// the connection has no dashboard, which hides the card entirely.
|
// the connection has no dashboard, which hides the card entirely.
|
||||||
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
|
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
|
||||||
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
|
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
|
||||||
DashboardApiClient(
|
dashboardClientProvider?.invoke(url)
|
||||||
baseUrl = url,
|
|
||||||
okHttpClient = DashboardApiClient.defaultClient(
|
|
||||||
cookieStore = dashboardCookieStoreProvider?.invoke() ?: InMemoryDashboardCookieStore(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
DisposableEffect(dashboardConfigClient) {
|
DisposableEffect(dashboardConfigClient) {
|
||||||
onDispose { dashboardConfigClient?.shutdown() }
|
onDispose { dashboardConfigClient?.shutdown() }
|
||||||
@@ -3231,6 +3224,17 @@ private fun GlobalVoiceControlsCard(
|
|||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
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(
|
||||||
text = stringResource(R.string.voice_settings_interaction_mode),
|
text = stringResource(R.string.voice_settings_interaction_mode),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ import javax.net.ssl.SSLPeerUnverifiedException
|
|||||||
* showHumanError in RelayApp.kt.
|
* showHumanError in RelayApp.kt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
enum class HumanErrorAction {
|
||||||
|
Repair,
|
||||||
|
}
|
||||||
|
|
||||||
data class HumanError(
|
data class HumanError(
|
||||||
val title: String,
|
val title: String,
|
||||||
val body: String,
|
val body: String,
|
||||||
val retryable: Boolean = false,
|
val retryable: Boolean = false,
|
||||||
val actionLabel: String? = null,
|
val actionLabel: String? = null,
|
||||||
|
val action: HumanErrorAction? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun titlePrefix(context: String?, ctx: Context?): String = ctx?.let { c ->
|
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",
|
body = "Your session is no longer valid — re-pair this device",
|
||||||
retryable = false,
|
retryable = false,
|
||||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||||
|
action = HumanErrorAction.Repair,
|
||||||
)
|
)
|
||||||
"403" in msg || "forbidden" in msg -> HumanError(
|
"403" in msg || "forbidden" in msg -> HumanError(
|
||||||
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
|
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",
|
body = "The server certificate changed since you paired — re-pair to trust it",
|
||||||
retryable = false,
|
retryable = false,
|
||||||
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
actionLabel = ctx?.getString(R.string.error_classify_repair) ?: "Re-pair",
|
||||||
|
action = HumanErrorAction.Repair,
|
||||||
)
|
)
|
||||||
is SecurityException -> HumanError(
|
is SecurityException -> HumanError(
|
||||||
title = ctx?.getString(R.string.error_classify_perm_needed) ?: "Permission needed",
|
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.DashboardAuthSession
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
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.upstream.ToolsetInfo
|
||||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
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.data.KEY_GATEWAY_KEEP_ALIVE
|
||||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||||
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
|
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
|
||||||
@@ -210,12 +212,28 @@ internal fun resolveEffectiveDashboardUrl(
|
|||||||
endpoint?.dashboard?.url
|
endpoint?.dashboard?.url
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?.let { return it }
|
?.let { return it }
|
||||||
endpoint?.api?.url
|
endpoint?.api?.url?.let { apiUrl ->
|
||||||
?.let(Connection::deriveDefaultDashboardUrl)
|
connection.dashboardUrl
|
||||||
?.let { return it }
|
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
|
||||||
|
?.let { return it }
|
||||||
|
Connection.deriveDefaultDashboardUrl(apiUrl)?.let { return it }
|
||||||
|
}
|
||||||
return connection.resolvedDashboardUrl
|
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
|
* Add Relay transport metadata to the connection's existing standard routes
|
||||||
* without adopting the Relay QR's API/Dashboard identity.
|
* without adopting the Relay QR's API/Dashboard identity.
|
||||||
@@ -390,6 +408,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
// Animation
|
// Animation
|
||||||
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
||||||
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
|
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")
|
private val KEY_CHAT_RECENT_PROMPTS = booleanPreferencesKey("chat_recent_prompts")
|
||||||
|
|
||||||
// Chat scroll behavior
|
// Chat scroll behavior
|
||||||
@@ -623,7 +642,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
context = application,
|
context = application,
|
||||||
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
|
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
|
||||||
dashboardUrlProvider = { activeDashboardUrl() },
|
dashboardUrlProvider = { activeDashboardUrl() },
|
||||||
gatewayKeepAliveProvider = { gatewayKeepAlive.value },
|
gatewayKeepAliveProvider = {
|
||||||
|
gatewayKeepAlive.value || ActiveTurnKeepAliveRegistry.snapshot.value.required
|
||||||
|
},
|
||||||
// Lets the dashboard cookie store ride the connection's token keyset
|
// Lets the dashboard cookie store ride the connection's token keyset
|
||||||
// (one keyset build instead of two on cold start).
|
// (one keyset build instead of two on cold start).
|
||||||
tokenStoreKeyProvider = { cid ->
|
tokenStoreKeyProvider = { cid ->
|
||||||
@@ -770,12 +791,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
apiServerUrl = apiServerUrl,
|
apiServerUrl = apiServerUrl,
|
||||||
relayUrl = relayUrl,
|
relayUrl = relayUrl,
|
||||||
extraApiUrls = extraApiUrls,
|
extraApiUrls = extraApiUrls,
|
||||||
|
dashboardUrl = activeConnection.value?.resolvedDashboardUrl,
|
||||||
),
|
),
|
||||||
existing = activeConnection.value?.routeCandidates.orEmpty(),
|
existing = activeConnection.value?.routeCandidates.orEmpty(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun effectiveApiServerUrlSnapshot(): String =
|
private fun effectiveApiServerUrlSnapshot(): String =
|
||||||
connectionManager.activeEndpoint.value?.api?.url ?: _apiServerUrl.value
|
resolveEffectiveApiServerUrl(
|
||||||
|
savedUrl = _apiServerUrl.value,
|
||||||
|
endpoint = connectionManager.activeEndpoint.value,
|
||||||
|
)
|
||||||
|
|
||||||
private fun effectiveRelayUrlSnapshot(): String =
|
private fun effectiveRelayUrlSnapshot(): String =
|
||||||
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
|
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
|
||||||
@@ -851,7 +876,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||||
|
|
||||||
// --- API Server state ---
|
// --- 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()
|
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
|
||||||
|
|
||||||
private val _apiServerReachable = MutableStateFlow(false)
|
private val _apiServerReachable = MutableStateFlow(false)
|
||||||
@@ -958,6 +988,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
fun activeDashboardCookieStore(): DashboardCookieStore? =
|
fun activeDashboardCookieStore(): DashboardCookieStore? =
|
||||||
upstreamTransport.activeDashboardCookieStore()
|
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. */
|
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
|
||||||
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
|
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
|
||||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||||
@@ -1017,17 +1057,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime route for chat/API traffic. The persisted API URL remains the
|
* Runtime route for chat/API traffic. Once API fallback is explicitly
|
||||||
* connection's base config; a resolver-selected endpoint temporarily wins
|
* configured, a resolver-selected endpoint temporarily wins so paired
|
||||||
* so paired devices can roam between LAN, Tailscale, and operator VPN
|
* devices can roam without rewriting stored settings. Discovery alone
|
||||||
* routes without rewriting stored settings.
|
* never enables the optional API surface.
|
||||||
*/
|
*/
|
||||||
val effectiveApiServerUrl: StateFlow<String> = combine(
|
val effectiveApiServerUrl: StateFlow<String> = combine(
|
||||||
_apiServerUrl,
|
_apiServerUrl,
|
||||||
connectionManager.activeEndpoint,
|
connectionManager.activeEndpoint,
|
||||||
) { savedUrl, endpoint ->
|
) { savedUrl, endpoint ->
|
||||||
endpoint?.api?.url ?: savedUrl
|
resolveEffectiveApiServerUrl(savedUrl, endpoint)
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a chat turn is currently streaming — mirrored from
|
* Whether a chat turn is currently streaming — mirrored from
|
||||||
@@ -1671,17 +1711,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Drive the keep-alive: flip the active client's no-background-close
|
// Drive the keep-alive from either the user's always-on preference or
|
||||||
// flag and start/stop the foreground service. Both flavors — the
|
// 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
|
// GatewayKeepAliveService is declared in the main manifest (Play permits
|
||||||
// this Home-Assistant-class persistent-connection use case). Mirrors
|
// this Home-Assistant-class persistent-connection use case). Mirrors
|
||||||
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
gatewayKeepAlive.collect { enabled ->
|
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
|
||||||
upstreamTransport.applyGatewayKeepAlive(enabled)
|
persistent to turns
|
||||||
|
}.distinctUntilChanged().collect { (persistent, turns) ->
|
||||||
|
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
|
||||||
val ctx = getApplication<Application>()
|
val ctx = getApplication<Application>()
|
||||||
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
|
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
|
||||||
else runCatching { GatewayKeepAliveService.stop(ctx) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1740,6 +1782,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, 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) {
|
fun setAnimationEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
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.
|
// Smooth auto-scroll during chat streaming.
|
||||||
// When enabled, the chat list smoothly follows new tokens, tool cards, and
|
// 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
|
// 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 {
|
} else {
|
||||||
current.dashboardUrl
|
current.dashboardUrl
|
||||||
}
|
}
|
||||||
|
val newRouteCandidates = Connection.reconcileDashboardRoutes(
|
||||||
|
dashboardUrl = newDashboardUrl,
|
||||||
|
candidates = payload.endpoints.orEmpty(),
|
||||||
|
)
|
||||||
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
|
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
|
||||||
current.relayUrl != newRelayUrl ||
|
current.relayUrl != newRelayUrl ||
|
||||||
current.dashboardUrl != newDashboardUrl ||
|
current.dashboardUrl != newDashboardUrl ||
|
||||||
current.routeCandidates != payload.endpoints.orEmpty()
|
current.routeCandidates != newRouteCandidates
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
connectionStore.updateConnection(
|
connectionStore.updateConnection(
|
||||||
current.copy(
|
current.copy(
|
||||||
apiServerUrl = payload.serverUrl,
|
apiServerUrl = payload.serverUrl,
|
||||||
relayUrl = newRelayUrl,
|
relayUrl = newRelayUrl,
|
||||||
dashboardUrl = newDashboardUrl,
|
dashboardUrl = newDashboardUrl,
|
||||||
routeCandidates = payload.endpoints.orEmpty(),
|
routeCandidates = newRouteCandidates,
|
||||||
preferredRouteRole = current.preferredRouteRole
|
preferredRouteRole = current.preferredRouteRole
|
||||||
?.takeIf { preferred ->
|
?.takeIf { preferred ->
|
||||||
payload.endpoints.orEmpty().any {
|
payload.endpoints.orEmpty().any {
|
||||||
@@ -4874,6 +4935,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
current.copy(
|
current.copy(
|
||||||
label = nextLabel,
|
label = nextLabel,
|
||||||
dashboardUrl = normalized,
|
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()
|
probeStandardVoice()
|
||||||
@@ -4922,7 +4999,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
dashboardCookieStoreFor(connectionId).clear()
|
upstreamTransport.clearDashboardAuthentication(connectionId)
|
||||||
}
|
}
|
||||||
connectionStore.setDashboardStatus(
|
connectionStore.setDashboardStatus(
|
||||||
connectionId = connectionId,
|
connectionId = connectionId,
|
||||||
@@ -6063,16 +6140,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
) {
|
) {
|
||||||
val activeId = connectionStore.activeConnectionId.value ?: return
|
val activeId = connectionStore.activeConnectionId.value ?: return
|
||||||
val current = connectionStore.connections.value.firstOrNull { it.id == activeId } ?: 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 {
|
val nextDashboardUrl = when {
|
||||||
dashboardUrlOverride != null -> {
|
dashboardUrlOverride != null -> {
|
||||||
dashboardUrlOverride
|
dashboardUrlOverride
|
||||||
@@ -6086,6 +6153,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
}
|
}
|
||||||
else -> current.dashboardUrl
|
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 (
|
if (
|
||||||
current.apiServerUrl == apiServerUrl &&
|
current.apiServerUrl == apiServerUrl &&
|
||||||
current.relayUrl == relayUrl &&
|
current.relayUrl == relayUrl &&
|
||||||
@@ -6318,50 +6398,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetOnboarding() {
|
fun resetOnboarding(onResult: (Boolean) -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataManager.resetOnboarding()
|
val success = dataManager.resetOnboarding()
|
||||||
_onboardingCompleted.value = false
|
if (success) {
|
||||||
|
_onboardingCompleted.value = false
|
||||||
|
}
|
||||||
|
onResult(success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetAppData() {
|
fun resetAppData(onResult: (Boolean) -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
disconnectRelay()
|
val success = runCatching {
|
||||||
authManager.clearSession()
|
disconnectRelay()
|
||||||
authManager.clearApiKey()
|
authManager.clearSession()
|
||||||
dataManager.resetAppData()
|
authManager.clearApiKey()
|
||||||
profileController.profileSelectionStore.clearAll()
|
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||||
profileController.profileLockStore.clearAll()
|
profileController.profileSelectionStore.clearAll()
|
||||||
profileController.profilePresentationStore.clearAll()
|
profileController.profileLockStore.clearAll()
|
||||||
profileController.profileSessionStore.clearAll()
|
profileController.profilePresentationStore.clearAll()
|
||||||
_apiServerUrl.value = ""
|
profileController.profileSessionStore.clearAll()
|
||||||
_relayUrl.value = ""
|
_apiServerUrl.value = ""
|
||||||
rebuildApiClient()
|
_relayUrl.value = ""
|
||||||
shutdownClientOffMain(profileChatApiClient)
|
rebuildApiClient()
|
||||||
profileChatApiClient = null
|
shutdownClientOffMain(profileChatApiClient)
|
||||||
profileChatApiClientUrl = null
|
profileChatApiClient = null
|
||||||
profileChatApiClientKey = null
|
profileChatApiClientUrl = null
|
||||||
profileController.clearSelectionState()
|
profileChatApiClientKey = null
|
||||||
_lastSessionId.value = 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 {
|
viewModelScope.launch {
|
||||||
val json = dataManager.exportSettings(
|
val json = runCatching {
|
||||||
serverUrl = _relayUrl.value,
|
dataManager.exportSettings(
|
||||||
theme = theme.value,
|
serverUrl = _relayUrl.value,
|
||||||
onboardingCompleted = _onboardingCompleted.value,
|
theme = theme.value,
|
||||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
onboardingCompleted = _onboardingCompleted.value,
|
||||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||||
// written to the backup anyway, so an empty list keeps the
|
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||||
// signature stable until the param is removed in a later pass.
|
// written to the backup anyway, so an empty list keeps the
|
||||||
sessionLabels = emptyList(),
|
// signature stable until the param is removed in a later pass.
|
||||||
apiServerUrl = _apiServerUrl.value,
|
sessionLabels = emptyList(),
|
||||||
relayUrl = _relayUrl.value
|
apiServerUrl = _apiServerUrl.value,
|
||||||
)
|
relayUrl = _relayUrl.value
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
android.util.Log.e("ConnectionVM", "Failed to prepare settings backup", it)
|
||||||
|
}.getOrNull()
|
||||||
onResult(json)
|
onResult(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6383,24 +6475,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
onResult(false)
|
onResult(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// Apply imported settings
|
val success = runCatching {
|
||||||
if (backup.connections.isNotEmpty()) {
|
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)
|
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 ->
|
connectionStore.activeConnection.value?.let { restored ->
|
||||||
restorePersistedActiveConnectionContext(restored)
|
restorePersistedActiveConnectionContext(restored)
|
||||||
}
|
}
|
||||||
} else {
|
if (backup.connections.isEmpty()) {
|
||||||
// Prefer v2 fields, fall back to v1 serverUrl for relay
|
// Preserve v1/v2 compatibility after clearing the current
|
||||||
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
|
// multi-connection snapshot.
|
||||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||||
}
|
}
|
||||||
setTheme(backup.theme)
|
check(dataManager.setOnboardingCompleted(backup.onboardingCompleted)) {
|
||||||
if (backup.onboardingCompleted) {
|
"Failed to restore onboarding state"
|
||||||
dataManager.setOnboardingCompleted(true)
|
}
|
||||||
_onboardingCompleted.value = true
|
_onboardingCompleted.value = backup.onboardingCompleted
|
||||||
}
|
}.onFailure {
|
||||||
onResult(true)
|
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -99,6 +100,113 @@ private enum class StandardSpeechStreamState {
|
|||||||
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
||||||
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
|
!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 =
|
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||||
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
||||||
|
|
||||||
@@ -224,9 +332,8 @@ enum class BackgroundRunPhase {
|
|||||||
DONE,
|
DONE,
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun realtimeTurnActiveAfterResponseDone(backgroundPhase: BackgroundRunPhase?): Boolean =
|
internal fun realtimeTurnActiveAfterPromotion(spokenHandoff: Boolean?): Boolean =
|
||||||
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
spokenHandoff != false
|
||||||
backgroundPhase == BackgroundRunPhase.RECONNECTING
|
|
||||||
|
|
||||||
internal fun preserveRealtimeTurnOnStop(backgroundPhase: BackgroundRunPhase?): Boolean =
|
internal fun preserveRealtimeTurnOnStop(backgroundPhase: BackgroundRunPhase?): Boolean =
|
||||||
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
backgroundPhase == BackgroundRunPhase.RUNNING ||
|
||||||
@@ -372,7 +479,7 @@ data class VoiceStats(
|
|||||||
* ### Sentence-boundary streaming TTS
|
* ### Sentence-boundary streaming TTS
|
||||||
* The SSE stream emits text one token at a time, but TTS wants whole
|
* The SSE stream emits text one token at a time, but TTS wants whole
|
||||||
* sentences to sound natural. We observe [ChatViewModel.messages],
|
* 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
|
* feed each completed sentence into a bounded [ttsQueue]. A dedicated
|
||||||
* consumer coroutine pulls from the queue, synthesizes each sentence,
|
* consumer coroutine pulls from the queue, synthesizes each sentence,
|
||||||
* and plays them back-to-back via [VoicePlayer.awaitCompletion].
|
* and plays them back-to-back via [VoicePlayer.awaitCompletion].
|
||||||
@@ -380,10 +487,9 @@ data class VoiceStats(
|
|||||||
* ### Integration note (V2a → V2b cleanup)
|
* ### Integration note (V2a → V2b cleanup)
|
||||||
* This first version uses the public [ChatViewModel.messages] StateFlow
|
* This first version uses the public [ChatViewModel.messages] StateFlow
|
||||||
* to observe streaming deltas rather than adding a `// VOICE HOOK`
|
* to observe streaming deltas rather than adding a `// VOICE HOOK`
|
||||||
* callback inside ChatViewModel. It's clean but depends on the
|
* callback inside ChatViewModel. A per-turn cursor fences pre-existing
|
||||||
* "last message with isStreaming=true" invariant — if ChatViewModel
|
* history and follows all assistant bubbles until ChatViewModel reports
|
||||||
* ever streams multiple assistant messages concurrently this will need
|
* that the complete Hermes run has ended.
|
||||||
* a dedicated per-turn flow. See `DEVLOG.md` and V2b ticket.
|
|
||||||
*/
|
*/
|
||||||
class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
@@ -550,6 +656,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private var voicePreferences: VoicePreferencesRepository? = null
|
private var voicePreferences: VoicePreferencesRepository? = null
|
||||||
private var voicePreferencesJob: Job? = null
|
private var voicePreferencesJob: Job? = null
|
||||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||||
|
private var finalAnswerOnly: Boolean = false
|
||||||
private var realtimeTraceDetails: Boolean = false
|
private var realtimeTraceDetails: Boolean = false
|
||||||
private var realtimePersistentSession: Boolean = true
|
private var realtimePersistentSession: Boolean = true
|
||||||
private var realtimeModel: String = ""
|
private var realtimeModel: String = ""
|
||||||
@@ -672,8 +779,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/** Tracks which assistant-message IDs have already been consumed so
|
/** Tracks which assistant-message IDs have already been consumed so
|
||||||
* we don't re-process older turns when the history list updates. */
|
* we don't re-process older turns when the history list updates. */
|
||||||
private var lastObservedMessageId: String? = null
|
private var assistantSpeechCursor: AssistantSpeechCursor? = null
|
||||||
private var lastObservedContentLength: Int = 0
|
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
|
||||||
private var sentenceBuffer: StringBuilder = StringBuilder()
|
private var sentenceBuffer: StringBuilder = StringBuilder()
|
||||||
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
|
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
|
||||||
private val brokeredToolSpeechKeys = mutableSetOf<String>()
|
private val brokeredToolSpeechKeys = mutableSetOf<String>()
|
||||||
@@ -776,15 +883,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private var ttsChunksThisResponse: Int = 0
|
private var ttsChunksThisResponse: Int = 0
|
||||||
private var lastTtsChunkFinishedAtMs: Long = 0L
|
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]. */
|
/** MP3 files produced by synthesize — trimmed to [TTS_CACHE_CAP]. */
|
||||||
private val ttsFileHistory = ArrayDeque<File>()
|
private val ttsFileHistory = ArrayDeque<File>()
|
||||||
|
|
||||||
@@ -1279,10 +1377,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
*/
|
*/
|
||||||
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
|
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
|
||||||
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
|
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
|
||||||
|
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
|
||||||
val realtimeSelectionChanged =
|
val realtimeSelectionChanged =
|
||||||
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
|
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
|
||||||
if (
|
if (
|
||||||
voiceEngineMode != nextEngineMode ||
|
voiceEngineMode != nextEngineMode ||
|
||||||
|
finalAnswerPolicyChanged ||
|
||||||
realtimeTraceDetails != settings.realtimeTraceDetails ||
|
realtimeTraceDetails != settings.realtimeTraceDetails ||
|
||||||
realtimeSelectionChanged
|
realtimeSelectionChanged
|
||||||
) {
|
) {
|
||||||
@@ -1290,6 +1390,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
TAG,
|
TAG,
|
||||||
"Voice prefs updated engine=${nextEngineMode.storageValue} " +
|
"Voice prefs updated engine=${nextEngineMode.storageValue} " +
|
||||||
"interaction=${settings.interactionMode} " +
|
"interaction=${settings.interactionMode} " +
|
||||||
|
"finalAnswerOnly=${settings.finalAnswerOnly} " +
|
||||||
"realtimeTraceDetails=${settings.realtimeTraceDetails} " +
|
"realtimeTraceDetails=${settings.realtimeTraceDetails} " +
|
||||||
"realtimeModel=${settings.realtimeModel.ifBlank { "relay-default" }} " +
|
"realtimeModel=${settings.realtimeModel.ifBlank { "relay-default" }} " +
|
||||||
"realtimeVoice=${settings.realtimeVoice.ifBlank { "relay-default" }}",
|
"realtimeVoice=${settings.realtimeVoice.ifBlank { "relay-default" }}",
|
||||||
@@ -1301,11 +1402,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
|
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
|
||||||
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
|
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
|
||||||
(realtimePersistentSession && !settings.realtimePersistentSession) ||
|
(realtimePersistentSession && !settings.realtimePersistentSession) ||
|
||||||
|
finalAnswerPolicyChanged ||
|
||||||
realtimeSelectionChanged
|
realtimeSelectionChanged
|
||||||
) {
|
) {
|
||||||
closeRealtimeSession()
|
closeRealtimeSession()
|
||||||
}
|
}
|
||||||
voiceEngineMode = nextEngineMode
|
voiceEngineMode = nextEngineMode
|
||||||
|
finalAnswerOnly = settings.finalAnswerOnly
|
||||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||||
realtimePersistentSession = settings.realtimePersistentSession
|
realtimePersistentSession = settings.realtimePersistentSession
|
||||||
realtimeModel = settings.realtimeModel
|
realtimeModel = settings.realtimeModel
|
||||||
@@ -1737,8 +1840,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
sentenceBuffer = StringBuilder()
|
sentenceBuffer = StringBuilder()
|
||||||
resetRealtimeSpeechCoalescer()
|
resetRealtimeSpeechCoalescer()
|
||||||
pendingRawDelta = StringBuilder()
|
pendingRawDelta = StringBuilder()
|
||||||
lastObservedMessageId = null
|
assistantSpeechCursor = null
|
||||||
lastObservedContentLength = 0
|
voiceTurnSessionFence = null
|
||||||
streamComplete = false
|
streamComplete = false
|
||||||
currentTurnPcm = ByteArray(0)
|
currentTurnPcm = ByteArray(0)
|
||||||
resetBrokeredToolSpeechState()
|
resetBrokeredToolSpeechState()
|
||||||
@@ -2144,8 +2247,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
sentenceBuffer = StringBuilder()
|
sentenceBuffer = StringBuilder()
|
||||||
resetRealtimeSpeechCoalescer()
|
resetRealtimeSpeechCoalescer()
|
||||||
pendingRawDelta = StringBuilder()
|
pendingRawDelta = StringBuilder()
|
||||||
lastObservedMessageId = null
|
assistantSpeechCursor = null
|
||||||
lastObservedContentLength = 0
|
voiceTurnSessionFence = null
|
||||||
streamComplete = false
|
streamComplete = false
|
||||||
currentTurnPcm = ByteArray(0)
|
currentTurnPcm = ByteArray(0)
|
||||||
resetBrokeredToolSpeechState()
|
resetBrokeredToolSpeechState()
|
||||||
@@ -3061,8 +3164,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Reset sentence buffering state for the new turn.
|
// Reset sentence buffering state for the new turn.
|
||||||
sentenceBuffer = StringBuilder()
|
sentenceBuffer = StringBuilder()
|
||||||
pendingRawDelta = StringBuilder()
|
pendingRawDelta = StringBuilder()
|
||||||
lastObservedMessageId = null
|
assistantSpeechCursor = AssistantSpeechCursor(chatVm.messages.value)
|
||||||
lastObservedContentLength = 0
|
voiceTurnSessionFence = VoiceTurnSessionFence(chatVm.currentSessionId.value)
|
||||||
streamComplete = false
|
streamComplete = false
|
||||||
idleFlushJob?.cancel()
|
idleFlushJob?.cancel()
|
||||||
idleFlushJob = null
|
idleFlushJob = null
|
||||||
@@ -3073,23 +3176,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||||
clearSpokenChunksState()
|
clearSpokenChunksState()
|
||||||
|
|
||||||
// Capture the id of the assistant message that currently sits at
|
if (!finalAnswerOnly) {
|
||||||
// the end of history. StateFlow.collect replays the current value
|
prepareStandardSpeechStream()
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Route the transcribed text through the normal chat pipeline.
|
// Route the transcribed text through the normal chat pipeline.
|
||||||
// This will create a user message + kick off the SSE stream.
|
// This creates the user row synchronously before kicking off the
|
||||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
// 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 {
|
private suspend fun runVoiceRelayPreflight(engineLabel: String): Boolean {
|
||||||
@@ -3147,8 +3248,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
sentenceBuffer = StringBuilder()
|
sentenceBuffer = StringBuilder()
|
||||||
pendingRawDelta = StringBuilder()
|
pendingRawDelta = StringBuilder()
|
||||||
lastObservedMessageId = null
|
assistantSpeechCursor = null
|
||||||
lastObservedContentLength = 0
|
voiceTurnSessionFence = null
|
||||||
streamComplete = false
|
streamComplete = false
|
||||||
idleFlushJob?.cancel()
|
idleFlushJob?.cancel()
|
||||||
idleFlushJob = null
|
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.
|
// W3: per-turn throttle independent of the per-key dedupe above.
|
||||||
// Suppress the TTS enqueue (UI state + diagnostics already
|
// Suppress the TTS enqueue (UI state + diagnostics already
|
||||||
// applied) when spoken status is too frequent or has hit the
|
// applied) when spoken status is too frequent or has hit the
|
||||||
@@ -3288,6 +3389,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
conversationContext = conversationContext,
|
conversationContext = conversationContext,
|
||||||
model = realtimeModel,
|
model = realtimeModel,
|
||||||
voice = realtimeVoice,
|
voice = realtimeVoice,
|
||||||
|
finalAnswerOnly = finalAnswerOnly,
|
||||||
onHandoff = { event -> recordRealtimeVoiceHandoff(sessionGeneration, event) },
|
onHandoff = { event -> recordRealtimeVoiceHandoff(sessionGeneration, event) },
|
||||||
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
|
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
|
||||||
onTurnComplete = { summary ->
|
onTurnComplete = { summary ->
|
||||||
@@ -3332,9 +3434,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
if (suppressCommandResponse) {
|
if (suppressCommandResponse) {
|
||||||
if (event.type == "voice.response.done") {
|
if (event.type == "voice.response.done") {
|
||||||
providerRealtimeAgentTurnActive.set(
|
providerRealtimeAgentTurnActive.set(false)
|
||||||
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase),
|
|
||||||
)
|
|
||||||
suppressLocalCommandResponse = false
|
suppressLocalCommandResponse = false
|
||||||
realtimeAudioSuppressed = false
|
realtimeAudioSuppressed = false
|
||||||
}
|
}
|
||||||
@@ -3555,10 +3655,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"hermes.run.promoted" -> {
|
"hermes.run.promoted" -> {
|
||||||
providerRealtimeAgentTurnActive.set(true)
|
providerRealtimeAgentTurnActive.set(
|
||||||
// The run detached to the background; the provider speaks the
|
realtimeTurnActiveAfterPromotion(event.spokenHandoff),
|
||||||
// handoff. Surface a persistent chip so the user knows a long
|
)
|
||||||
// task is still in flight (ADR 33 Tier B/C).
|
// 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"
|
val tier = event.tier ?: "promoted"
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
@@ -3762,9 +3864,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
hermesConfirmation = null,
|
hermesConfirmation = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
providerRealtimeAgentTurnActive.set(
|
providerRealtimeAgentTurnActive.set(false)
|
||||||
realtimeTurnActiveAfterResponseDone(_uiState.value.backgroundRun?.phase)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
"voice.error" -> {
|
"voice.error" -> {
|
||||||
realtimeConfirmationControl = null
|
realtimeConfirmationControl = null
|
||||||
@@ -4223,64 +4323,116 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observe [ChatViewModel.messages]. When the last assistant message
|
* Observe every assistant bubble created by the active Hermes run. A tool
|
||||||
* grows (isStreaming=true), diff the content against our last snapshot,
|
* turn can finalize one bubble while the run is still active and later
|
||||||
* push the new delta into [sentenceBuffer], and flush completed
|
* append the final answer in another bubble, so completion is keyed to
|
||||||
* sentences into [ttsQueue]. On isStreaming=false, flush the remaining
|
* [ChatViewModel.isStreaming], not an individual message flag.
|
||||||
* buffer and end the turn.
|
|
||||||
*/
|
*/
|
||||||
private fun startStreamObserver(chatVm: ChatViewModel) {
|
private fun startStreamObserver(chatVm: ChatViewModel) {
|
||||||
streamObserverJob?.cancel()
|
streamObserverJob?.cancel()
|
||||||
streamObserverJob = viewModelScope.launch {
|
val cursor = assistantSpeechCursor ?: AssistantSpeechCursor(chatVm.messages.value).also {
|
||||||
chatVm.messages.collect { messages ->
|
assistantSpeechCursor = it
|
||||||
val lastAssistant = messages.lastOrNull {
|
}
|
||||||
it.role == MessageRole.ASSISTANT
|
val sessionFence = voiceTurnSessionFence
|
||||||
} ?: return@collect
|
?: VoiceTurnSessionFence(chatVm.currentSessionId.value).also {
|
||||||
|
voiceTurnSessionFence = it
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.
|
* device through the normal tool loop.
|
||||||
*/
|
*/
|
||||||
private fun observeHermesToolLoopForSpeech(message: ChatMessage) {
|
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
|
var spokenForMessage = brokeredToolSpeechCounts[message.id] ?: 0
|
||||||
message.toolCalls.forEach { tool ->
|
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.ChatMode
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
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.EncryptedDashboardCookieStore
|
||||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
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.ServerCapabilities
|
||||||
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
|
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
|
||||||
|
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -78,6 +84,10 @@ class UpstreamTransportController(
|
|||||||
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
|
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
|
||||||
private val dashboardCookieStores =
|
private val dashboardCookieStores =
|
||||||
ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
|
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,
|
* Cookie store for [connectionId] — ONE instance per connection,
|
||||||
@@ -107,6 +117,28 @@ class UpstreamTransportController(
|
|||||||
return dashboardCookieStoreFor(connectionId)
|
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 ----------------------------------------
|
// --- DashboardApiClient factory ----------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +152,7 @@ class UpstreamTransportController(
|
|||||||
baseUrl = dashboardUrl,
|
baseUrl = dashboardUrl,
|
||||||
okHttpClient = DashboardApiClient.defaultClient(
|
okHttpClient = DashboardApiClient.defaultClient(
|
||||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||||
|
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,9 +166,80 @@ class UpstreamTransportController(
|
|||||||
baseUrl = dashboardUrl,
|
baseUrl = dashboardUrl,
|
||||||
okHttpClient = DashboardApiClient.defaultClient(
|
okHttpClient = DashboardApiClient.defaultClient(
|
||||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
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 ----------------------------------------------
|
// --- Gateway availability ----------------------------------------------
|
||||||
|
|
||||||
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||||
@@ -226,6 +330,8 @@ class UpstreamTransportController(
|
|||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
gatewayClientCache?.third?.shutdown()
|
gatewayClientCache?.third?.shutdown()
|
||||||
gatewayClientCache = null
|
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_profile_lock_desc">Fixe o app em um perfil de agente</string>
|
||||||
<string name="settings_quick_controls">Controles rápidos</string>
|
<string name="settings_quick_controls">Controles rápidos</string>
|
||||||
<string name="settings_persistent_connection">Conexão persistente</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_persistent_connection_desc">Manter a conexão mesmo sem nenhum chat em andamento</string>
|
||||||
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</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">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_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||||
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
|
<string name="settings_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">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_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">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_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">Bloquear opções do desenvolvedor</string>
|
||||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta seção e desativar recursos experimentais</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_export_failed">Falha na exportação</string>
|
||||||
<string name="dev_settings_imported">Configurações importadas</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_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_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_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>
|
<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_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_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_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_mode">Modo de interação</string>
|
||||||
<string name="voice_settings_interaction_tap">Tocar para falar</string>
|
<string name="voice_settings_interaction_tap">Tocar para falar</string>
|
||||||
<string name="voice_settings_interaction_hold">Manter pressionado 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_collapse_cd">Recolher controles de voz</string>
|
||||||
<string name="voice_overlay_expand_cd">Expandir 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_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_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_overlay">Sobreposição</string>
|
||||||
<string name="voice_overlay_exit">Sair</string>
|
<string name="voice_overlay_exit">Sair</string>
|
||||||
<string name="voice_overlay_settings_cd">Configurações de voz</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_confirms_on_next_message">Confirma na próxima mensagem</string>
|
||||||
<string name="conn_info_connect">Conectar</string>
|
<string name="conn_info_connect">Conectar</string>
|
||||||
<string name="conn_info_connected">Conectado</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_connecting">Conectando…</string>
|
||||||
<string name="conn_info_connection">Conexão</string>
|
<string name="conn_info_connection">Conexão</string>
|
||||||
<string name="conn_info_connection_state">Estado da 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_unmute">Ativar som</string>
|
||||||
<string name="attachment_mute">Silenciar</string>
|
<string name="attachment_mute">Silenciar</string>
|
||||||
<string name="attachment_title">Anexo</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 -->
|
<!-- CrashReportDialog -->
|
||||||
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
|
<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>
|
<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_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_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="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>
|
</resources>
|
||||||
|
|||||||
@@ -672,8 +672,8 @@
|
|||||||
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
|
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
|
||||||
<string name="settings_quick_controls">快捷控制</string>
|
<string name="settings_quick_controls">快捷控制</string>
|
||||||
<string name="settings_persistent_connection">持久连接</string>
|
<string name="settings_persistent_connection">持久连接</string>
|
||||||
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
|
<string name="settings_persistent_connection_desc">即使没有聊天运行也保持连接</string>
|
||||||
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
|
<string name="settings_connect_on_demand">活跃聊天会自动保持连接 · 空闲时关闭连接以节省电量</string>
|
||||||
<string name="settings_turn_complete_alerts">聊天提醒</string>
|
<string name="settings_turn_complete_alerts">聊天提醒</string>
|
||||||
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||||
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</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">Relay 功能</string>
|
||||||
<string name="dev_settings_relay_features_desc">显示 Relay 服务器和配对设置,用于 Bridge/Terminal 开发</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">实时语音实验室</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_open_realtime_voice_lab_cd">打开实时语音实验室</string>
|
||||||
<string name="dev_settings_lock_dev_options">锁定开发者选项</string>
|
<string name="dev_settings_lock_dev_options">锁定开发者选项</string>
|
||||||
<string name="dev_settings_lock_dev_options_desc">隐藏此分区并禁用实验性功能</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_export_failed">导出失败</string>
|
||||||
<string name="dev_settings_imported">设置已导入</string>
|
<string name="dev_settings_imported">设置已导入</string>
|
||||||
<string name="dev_settings_import_failed">导入失败——文件无效</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_title">导出敏感备份?</string>
|
||||||
<string name="dev_settings_export_sensitive_backup_body">此备份包含已保存的连接、API 密钥、Relay 会话令牌、设备 ID 和仪表盘 Cookie。任何持有该文件的人都可能访问您的 Hermes 服务器。</string>
|
<string name="dev_settings_export_sensitive_backup_body">此备份包含已保存的连接、API 密钥、Relay 会话令牌、设备 ID 和仪表盘 Cookie。任何持有该文件的人都可能访问您的 Hermes 服务器。</string>
|
||||||
<string name="dev_settings_export_action">导出</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_save_realtime_agent">保存实时 Agent</string>
|
||||||
<string name="voice_settings_global_controls_title">全局语音控制</string>
|
<string name="voice_settings_global_controls_title">全局语音控制</string>
|
||||||
<string name="voice_settings_global_controls_desc">这些设置适用于所有个人资料上的两个语音引擎。</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_mode">交互模式</string>
|
||||||
<string name="voice_settings_interaction_tap">点击说话</string>
|
<string name="voice_settings_interaction_tap">点击说话</string>
|
||||||
<string name="voice_settings_interaction_hold">按住说话</string>
|
<string name="voice_settings_interaction_hold">按住说话</string>
|
||||||
@@ -2087,8 +2090,12 @@
|
|||||||
<string name="voice_overlay_collapse_cd">收起语音控制</string>
|
<string name="voice_overlay_collapse_cd">收起语音控制</string>
|
||||||
<string name="voice_overlay_expand_cd">展开语音控制</string>
|
<string name="voice_overlay_expand_cd">展开语音控制</string>
|
||||||
<string name="voice_overlay_exit_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_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_overlay">浮窗</string>
|
||||||
<string name="voice_overlay_exit">退出</string>
|
<string name="voice_overlay_exit">退出</string>
|
||||||
<string name="voice_overlay_settings_cd">语音设置</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_confirms_on_next_message">下条消息确认</string>
|
||||||
<string name="conn_info_connect">连接</string>
|
<string name="conn_info_connect">连接</string>
|
||||||
<string name="conn_info_connected">已连接</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_connecting">连接中…</string>
|
||||||
<string name="conn_info_connection">连接</string>
|
<string name="conn_info_connection">连接</string>
|
||||||
<string name="conn_info_connection_state">连接状态</string>
|
<string name="conn_info_connection_state">连接状态</string>
|
||||||
@@ -2583,6 +2603,22 @@
|
|||||||
<string name="attachment_unmute">取消静音</string>
|
<string name="attachment_unmute">取消静音</string>
|
||||||
<string name="attachment_mute">静音</string>
|
<string name="attachment_mute">静音</string>
|
||||||
<string name="attachment_title">附件</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 -->
|
<!-- CrashReportDialog -->
|
||||||
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
||||||
@@ -3271,4 +3307,31 @@
|
|||||||
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
|
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
|
||||||
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
|
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
|
||||||
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</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>
|
</resources>
|
||||||
|
|||||||
@@ -672,8 +672,8 @@
|
|||||||
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
|
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
|
||||||
<string name="settings_quick_controls">Schnellsteuerung</string>
|
<string name="settings_quick_controls">Schnellsteuerung</string>
|
||||||
<string name="settings_persistent_connection">Dauerhafte Verbindung</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_persistent_connection_desc">Auch ohne laufenden Chat verbunden bleiben</string>
|
||||||
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</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">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_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||||
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
|
<string name="settings_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">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_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">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_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">Entwickleroptionen sperren</string>
|
||||||
<string name="dev_settings_lock_dev_options_desc">Diesen Abschnitt ausblenden und experimentelle Funktionen deaktivieren</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_export_failed">Export fehlgeschlagen</string>
|
||||||
<string name="dev_settings_imported">Einstellungen importiert</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_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_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_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>
|
<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_save_realtime_agent">Echtzeit-Agent speichern</string>
|
||||||
<string name="voice_settings_global_controls_title">Globale Sprachsteuerung</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_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_mode">Interaktionsmodus</string>
|
||||||
<string name="voice_settings_interaction_tap">Tippen zum Sprechen</string>
|
<string name="voice_settings_interaction_tap">Tippen zum Sprechen</string>
|
||||||
<string name="voice_settings_interaction_hold">Halten 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_collapse_cd">Sprachsteuerung einklappen</string>
|
||||||
<string name="voice_overlay_expand_cd">Sprachsteuerung ausklappen</string>
|
<string name="voice_overlay_expand_cd">Sprachsteuerung ausklappen</string>
|
||||||
<string name="voice_overlay_exit_cd">Sprachmodus beenden</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_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_overlay">Overlay</string>
|
||||||
<string name="voice_overlay_exit">Beenden</string>
|
<string name="voice_overlay_exit">Beenden</string>
|
||||||
<string name="voice_overlay_settings_cd">Spracheinstellungen</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_confirms_on_next_message">Bei nächster Nachricht bestätigen</string>
|
||||||
<string name="conn_info_connect">Verbinden</string>
|
<string name="conn_info_connect">Verbinden</string>
|
||||||
<string name="conn_info_connected">Verbunden</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_connecting">Verbindung wird hergestellt…</string>
|
||||||
<string name="conn_info_connection">Verbindung</string>
|
<string name="conn_info_connection">Verbindung</string>
|
||||||
<string name="conn_info_connection_state">Verbindungsstatus</string>
|
<string name="conn_info_connection_state">Verbindungsstatus</string>
|
||||||
@@ -2586,6 +2606,23 @@
|
|||||||
<string name="attachment_unmute">Ton an</string>
|
<string name="attachment_unmute">Ton an</string>
|
||||||
<string name="attachment_mute">Stummschalten</string>
|
<string name="attachment_mute">Stummschalten</string>
|
||||||
<string name="attachment_title">Anhang</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 -->
|
<!-- CrashReportDialog -->
|
||||||
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
|
<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_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_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="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>
|
</resources>
|
||||||
|
|||||||
@@ -599,8 +599,8 @@
|
|||||||
<string name="settings_profile_lock_desc">Anclar la aplicación a un perfil de agente</string>
|
<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_quick_controls">Controles rápidos</string>
|
||||||
<string name="settings_persistent_connection">Conexión persistente</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_persistent_connection_desc">Mantener la conexión aunque no haya ningún chat en curso</string>
|
||||||
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</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">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_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||||
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
|
<string name="settings_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">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_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">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_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">Bloquear opciones de desarrollador</string>
|
||||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta sección y desactivar las funciones experimentales</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_export_failed">Exportación fallida</string>
|
||||||
<string name="dev_settings_imported">Configuraciones importadas</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_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_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_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>
|
<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_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_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_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_mode">Modo de interacción</string>
|
||||||
<string name="voice_settings_interaction_tap">Toca para hablar</string>
|
<string name="voice_settings_interaction_tap">Toca para hablar</string>
|
||||||
<string name="voice_settings_interaction_hold">Espera 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_collapse_cd">Contraer controles de voz</string>
|
||||||
<string name="voice_overlay_expand_cd">Ampliar los 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_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_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_overlay">Cubrir</string>
|
||||||
<string name="voice_overlay_exit">Salida</string>
|
<string name="voice_overlay_exit">Salida</string>
|
||||||
<string name="voice_overlay_settings_cd">Configuraciones de voz</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_confirms_on_next_message">Confirma en el siguiente mensaje</string>
|
||||||
<string name="conn_info_connect">Conectar</string>
|
<string name="conn_info_connect">Conectar</string>
|
||||||
<string name="conn_info_connected">Conectado</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_connecting">Conectando…</string>
|
||||||
<string name="conn_info_connection">Conexión</string>
|
<string name="conn_info_connection">Conexión</string>
|
||||||
<string name="conn_info_connection_state">Estado de 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_unmute">Dejar de silenciar</string>
|
||||||
<string name="attachment_mute">Silenciar</string>
|
<string name="attachment_mute">Silenciar</string>
|
||||||
<string name="attachment_title">Adjunto</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_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_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||||
<string name="crash_dismiss">Descartar</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_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_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="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>
|
</resources>
|
||||||
|
|||||||
@@ -672,8 +672,8 @@
|
|||||||
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
|
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
|
||||||
<string name="settings_quick_controls">クイックコントロール</string>
|
<string name="settings_quick_controls">クイックコントロール</string>
|
||||||
<string name="settings_persistent_connection">永続的な接続</string>
|
<string name="settings_persistent_connection">永続的な接続</string>
|
||||||
<string name="settings_persistent_connection_desc">Hermes への接続をバックグラウンドで開いたままにします</string>
|
<string name="settings_persistent_connection_desc">チャットが実行されていないときも接続を維持</string>
|
||||||
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
|
<string name="settings_connect_on_demand">アクティブなチャットは自動的に接続を維持 · アイドル時はバッテリー節約のため接続を閉じます</string>
|
||||||
<string name="settings_turn_complete_alerts">チャット通知</string>
|
<string name="settings_turn_complete_alerts">チャット通知</string>
|
||||||
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||||
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</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">Relay の特徴</string>
|
||||||
<string name="dev_settings_relay_features_desc">Relay サーバーと Bridge/ターミナル開発用のペアリング設定を表示</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">リアルタイム音声ラボ</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_open_realtime_voice_lab_cd">リアルタイム音声ラボを開く</string>
|
||||||
<string name="dev_settings_lock_dev_options">開発者向けオプションをロックする</string>
|
<string name="dev_settings_lock_dev_options">開発者向けオプションをロックする</string>
|
||||||
<string name="dev_settings_lock_dev_options_desc">このセクションを非表示にし、実験的な機能を無効にします</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_export_failed">エクスポートに失敗しました</string>
|
||||||
<string name="dev_settings_imported">インポートされた設定</string>
|
<string name="dev_settings_imported">インポートされた設定</string>
|
||||||
<string name="dev_settings_import_failed">インポートに失敗しました - 無効なファイル</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_title">機密性の高いバックアップをエクスポートしますか?</string>
|
||||||
<string name="dev_settings_export_sensitive_backup_body">このバックアップには、保存された接続、API キー、Relay セッション トークン、デバイス ID、およびダッシュボード Cookie が含まれます。ファイルを持っている人は誰でも、Hermes サーバーにアクセスできる可能性があります。</string>
|
<string name="dev_settings_export_sensitive_backup_body">このバックアップには、保存された接続、API キー、Relay セッション トークン、デバイス ID、およびダッシュボード Cookie が含まれます。ファイルを持っている人は誰でも、Hermes サーバーにアクセスできる可能性があります。</string>
|
||||||
<string name="dev_settings_export_action">輸出</string>
|
<string name="dev_settings_export_action">輸出</string>
|
||||||
@@ -1578,6 +1579,8 @@
|
|||||||
<string name="voice_settings_save_realtime_agent">リアルタイムエージェントを保存する</string>
|
<string name="voice_settings_save_realtime_agent">リアルタイムエージェントを保存する</string>
|
||||||
<string name="voice_settings_global_controls_title">グローバル音声コントロール</string>
|
<string name="voice_settings_global_controls_title">グローバル音声コントロール</string>
|
||||||
<string name="voice_settings_global_controls_desc">これらの設定は、すべてのプロファイルの両方の音声エンジンに適用されます。</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_mode">インタラクションモード</string>
|
||||||
<string name="voice_settings_interaction_tap">タップして話す</string>
|
<string name="voice_settings_interaction_tap">タップして話す</string>
|
||||||
<string name="voice_settings_interaction_hold">押し続けて話す</string>
|
<string name="voice_settings_interaction_hold">押し続けて話す</string>
|
||||||
@@ -2103,8 +2106,12 @@
|
|||||||
<string name="voice_overlay_collapse_cd">音声コントロールを折りたたむ</string>
|
<string name="voice_overlay_collapse_cd">音声コントロールを折りたたむ</string>
|
||||||
<string name="voice_overlay_expand_cd">音声コントロールを拡張する</string>
|
<string name="voice_overlay_expand_cd">音声コントロールを拡張する</string>
|
||||||
<string name="voice_overlay_exit_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_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_overlay">かぶせる</string>
|
||||||
<string name="voice_overlay_exit">出口</string>
|
<string name="voice_overlay_exit">出口</string>
|
||||||
<string name="voice_overlay_settings_cd">音声設定</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_confirms_on_next_message">次のメッセージで確認します</string>
|
||||||
<string name="conn_info_connect">接続する</string>
|
<string name="conn_info_connect">接続する</string>
|
||||||
<string name="conn_info_connected">接続済み</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_connecting">接続中…</string>
|
||||||
<string name="conn_info_connection">繋がり</string>
|
<string name="conn_info_connection">繋がり</string>
|
||||||
<string name="conn_info_connection_state">接続状態</string>
|
<string name="conn_info_connection_state">接続状態</string>
|
||||||
@@ -2597,6 +2617,22 @@
|
|||||||
<string name="attachment_unmute">ミュートを解除する</string>
|
<string name="attachment_unmute">ミュートを解除する</string>
|
||||||
<string name="attachment_mute">ミュート</string>
|
<string name="attachment_mute">ミュート</string>
|
||||||
<string name="attachment_title">アタッチメント</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 -->
|
<!-- CrashReportDialog -->
|
||||||
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
||||||
@@ -3337,4 +3373,31 @@
|
|||||||
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
|
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
|
||||||
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
|
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
|
||||||
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</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>
|
</resources>
|
||||||
|
|||||||
@@ -202,6 +202,8 @@
|
|||||||
<string name="conn_label_session">Session</string>
|
<string name="conn_label_session">Session</string>
|
||||||
<string name="conn_label_route">Route</string>
|
<string name="conn_label_route">Route</string>
|
||||||
<string name="conn_label_status">Status</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_connect">Connect</string>
|
||||||
<string name="conn_label_connections">Connections</string>
|
<string name="conn_label_connections">Connections</string>
|
||||||
<string name="conn_detail_add_connection">Add a Vanilla Hermes API/dashboard connection</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_profile_lock_desc">Pin the app to one agent profile</string>
|
||||||
<string name="settings_quick_controls">Quick Controls</string>
|
<string name="settings_quick_controls">Quick Controls</string>
|
||||||
<string name="settings_persistent_connection">Persistent connection</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_persistent_connection_desc">Stay connected even when no chat is running</string>
|
||||||
<string name="settings_connect_on_demand">Connect on demand only · saves battery</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">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_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||||
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
|
<string name="settings_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">Font</string>
|
||||||
<string name="appearance_font_desc">Sets the typeface across the whole app. Code and timestamps stay monospaced.</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_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">ASCII sphere</string>
|
||||||
<string name="appearance_ascii_sphere_desc">Show animated sphere on empty chat screen and ambient mode</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>
|
<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">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_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">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_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">Lock developer options</string>
|
||||||
<string name="dev_settings_lock_dev_options_desc">Hide this section and disable experimental features</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>
|
<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_export_failed">Export failed</string>
|
||||||
<string name="dev_settings_imported">Settings imported</string>
|
<string name="dev_settings_imported">Settings imported</string>
|
||||||
<string name="dev_settings_import_failed">Import failed — invalid file</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_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_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>
|
<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_save_realtime_agent">Save realtime agent</string>
|
||||||
<string name="voice_settings_global_controls_title">Global Voice Controls</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_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_mode">Interaction mode</string>
|
||||||
<string name="voice_settings_interaction_tap">Tap to talk</string>
|
<string name="voice_settings_interaction_tap">Tap to talk</string>
|
||||||
<string name="voice_settings_interaction_hold">Hold 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_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_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_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>
|
<string name="dashboard_close_signin">Close sign-in</string>
|
||||||
|
|
||||||
<!-- Error body -->
|
<!-- Error body -->
|
||||||
@@ -2195,8 +2213,12 @@
|
|||||||
<string name="voice_overlay_collapse_cd">Collapse voice controls</string>
|
<string name="voice_overlay_collapse_cd">Collapse voice controls</string>
|
||||||
<string name="voice_overlay_expand_cd">Expand 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_exit_cd">Exit voice mode</string>
|
||||||
<string name="voice_overlay_compact">Compact</string>
|
<string name="voice_overlay_conversation">Conversation</string>
|
||||||
<string name="voice_overlay_focus">Focus</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_overlay">Overlay</string>
|
||||||
<string name="voice_overlay_exit">Exit</string>
|
<string name="voice_overlay_exit">Exit</string>
|
||||||
<string name="voice_overlay_settings_cd">Voice settings</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_confirms_on_next_message">Confirms on next message</string>
|
||||||
<string name="conn_info_connect">Connect</string>
|
<string name="conn_info_connect">Connect</string>
|
||||||
<string name="conn_info_connected">Connected</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_connecting">Connecting…</string>
|
||||||
<string name="conn_info_connection">Connection</string>
|
<string name="conn_info_connection">Connection</string>
|
||||||
<string name="conn_info_connection_state">Connection state</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">Yes</string>
|
||||||
<string name="conn_info_yes_hidden">Yes (hidden)</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">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_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 -->
|
<!-- EndpointsCard -->
|
||||||
|
|
||||||
@@ -2691,6 +2738,23 @@
|
|||||||
<string name="attachment_unmute">Unmute</string>
|
<string name="attachment_unmute">Unmute</string>
|
||||||
<string name="attachment_mute">Mute</string>
|
<string name="attachment_mute">Mute</string>
|
||||||
<string name="attachment_title">Attachment</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 -->
|
<!-- CrashReportDialog -->
|
||||||
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
||||||
|
|||||||
@@ -126,6 +126,20 @@ class ChatTurnCheckpointStoreTest {
|
|||||||
startedAt = 1_002L,
|
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(
|
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
|
||||||
id = "run-1",
|
id = "run-1",
|
||||||
title = "Research",
|
title = "Research",
|
||||||
|
|||||||
@@ -111,6 +111,75 @@ class ConnectionDashboardFieldsTest {
|
|||||||
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
|
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
|
@Test
|
||||||
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
|
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
|
||||||
val route = Connection.endpointCandidateFromDashboardUrl(
|
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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -65,4 +67,44 @@ class VoicePreferencesRepositoryTest {
|
|||||||
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
|
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
|
||||||
assertEquals("leo", settings.realtimeVoice)
|
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)
|
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
|
@Test
|
||||||
fun voiceOutputSessionResponseParsesResumeMetadata() {
|
fun voiceOutputSessionResponseParsesResumeMetadata() {
|
||||||
val response = Json.decodeFromString(
|
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 =
|
private fun relayUrl(server: MockWebServer): String =
|
||||||
"ws://${server.hostName}:${server.port}"
|
"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
|
package com.hermesandroid.relay.network.upstream
|
||||||
|
|
||||||
import com.hermesandroid.relay.data.Attachment
|
import com.hermesandroid.relay.data.Attachment
|
||||||
|
import com.hermesandroid.relay.data.AttachmentState
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
import com.hermesandroid.relay.data.ChatSession
|
import com.hermesandroid.relay.data.ChatSession
|
||||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||||
|
import com.hermesandroid.relay.data.ChatTurnMoaReferenceCheckpoint
|
||||||
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
||||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
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.RelayStreamEventEnvelope
|
||||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -800,6 +804,57 @@ class ChatHandlerTest {
|
|||||||
assertEquals("2 background tasks completed", msg.content)
|
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
|
@Test
|
||||||
fun loadMessageHistory_skipsToolMessages() {
|
fun loadMessageHistory_skipsToolMessages() {
|
||||||
val items = listOf(
|
val items = listOf(
|
||||||
@@ -849,6 +904,231 @@ class ChatHandlerTest {
|
|||||||
assertEquals("", handler.messages.value[0].content)
|
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) ---
|
// --- loadMessageHistory: outbound attachment preservation (GAP 1) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1237,6 +1517,49 @@ class ChatHandlerTest {
|
|||||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
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
|
@Test
|
||||||
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
||||||
// Once the first reload adopts the server id, subsequent reloads match by
|
// Once the first reload adopts the server id, subsequent reloads match by
|
||||||
@@ -1737,6 +2060,20 @@ class ChatHandlerTest {
|
|||||||
completedAt = 7L,
|
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",
|
turnStatus = "Running terminal",
|
||||||
priorUserMessageCount = 1,
|
priorUserMessageCount = 1,
|
||||||
@@ -1755,10 +2092,107 @@ class ChatHandlerTest {
|
|||||||
assertFalse(restored.toolCalls[0].isComplete)
|
assertFalse(restored.toolCalls[0].isComplete)
|
||||||
assertTrue(restored.toolCalls[1].isComplete)
|
assertTrue(restored.toolCalls[1].isComplete)
|
||||||
assertEquals(true, restored.toolCalls[1].success)
|
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)
|
assertTrue(handler.isStreaming.value)
|
||||||
assertEquals("Running terminal", handler.turnStatus.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 ---
|
// --- Helper ---
|
||||||
|
|
||||||
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
||||||
|
|||||||
+4
-1
@@ -686,6 +686,7 @@ class DashboardApiClientTest {
|
|||||||
name = "Local",
|
name = "Local",
|
||||||
baseUrl = "https://llm.example/v1",
|
baseUrl = "https://llm.example/v1",
|
||||||
model = "qwen",
|
model = "qwen",
|
||||||
|
models = listOf("qwen", "qwen-vl", " qwen ", ""),
|
||||||
apiKey = "never-persist-this",
|
apiKey = "never-persist-this",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -701,7 +702,9 @@ class DashboardApiClientTest {
|
|||||||
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
||||||
val save = server.takeRequest()
|
val save = server.takeRequest()
|
||||||
assertEquals("/api/providers/custom-endpoints", save.path)
|
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/validate", server.takeRequest().path)
|
||||||
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
||||||
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
||||||
|
|||||||
+324
-1
@@ -63,6 +63,9 @@ class GatewayClientHarness(
|
|||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var recoveryInflightStreaming: Boolean? = null
|
var recoveryInflightStreaming: Boolean? = null
|
||||||
|
var recoveryInflightError: String? = null
|
||||||
|
var recoveryInflightRecoverable: Boolean = false
|
||||||
|
var recoveryAutoContinueAttempt: Int? = null
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var recoveryQueuedUser: String? = null
|
var recoveryQueuedUser: String? = null
|
||||||
@@ -93,6 +96,12 @@ class GatewayClientHarness(
|
|||||||
@Volatile
|
@Volatile
|
||||||
var reasoningDisplay = "hide"
|
var reasoningDisplay = "hide"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var approvalMode = "smart"
|
||||||
|
|
||||||
|
/** Config keys rejected with the older-gateway unknown-key response. */
|
||||||
|
val unsupportedConfigKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var askResponseStatus = "ok"
|
var askResponseStatus = "ok"
|
||||||
|
|
||||||
@@ -141,6 +150,23 @@ class GatewayClientHarness(
|
|||||||
)
|
)
|
||||||
return
|
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) {
|
val result: JsonObject? = when (method) {
|
||||||
"session.create" -> buildJsonObject {
|
"session.create" -> buildJsonObject {
|
||||||
put("session_id", "live-1")
|
put("session_id", "live-1")
|
||||||
@@ -250,6 +276,7 @@ class GatewayClientHarness(
|
|||||||
put("value", reasoningEffort)
|
put("value", reasoningEffort)
|
||||||
put("display", reasoningDisplay)
|
put("display", reasoningDisplay)
|
||||||
}
|
}
|
||||||
|
"approvals.mode" -> buildJsonObject { put("value", approvalMode) }
|
||||||
else -> JsonObject(emptyMap())
|
else -> JsonObject(emptyMap())
|
||||||
}
|
}
|
||||||
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
||||||
@@ -268,6 +295,18 @@ class GatewayClientHarness(
|
|||||||
put("key", "fast")
|
put("key", "fast")
|
||||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
|
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())
|
||||||
}
|
}
|
||||||
else -> JsonObject(emptyMap())
|
else -> JsonObject(emptyMap())
|
||||||
@@ -299,16 +338,27 @@ class GatewayClientHarness(
|
|||||||
put("info", buildJsonObject { put("project", project) })
|
put("info", buildJsonObject { put("project", project) })
|
||||||
}
|
}
|
||||||
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
||||||
if (recoveryRunning || recoveryInflightStreaming != null) {
|
if (recoveryRunning || recoveryInflightStreaming != null || recoveryInflightError != null) {
|
||||||
put("inflight", buildJsonObject {
|
put("inflight", buildJsonObject {
|
||||||
put("user", "research this")
|
put("user", "research this")
|
||||||
put("assistant", recoveryAssistant)
|
put("assistant", recoveryAssistant)
|
||||||
put("streaming", inflightStreaming)
|
put("streaming", inflightStreaming)
|
||||||
|
recoveryInflightError?.let { error ->
|
||||||
|
put("status", "error")
|
||||||
|
put("error", error)
|
||||||
|
put("recoverable", recoveryInflightRecoverable)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
recoveryQueuedUser?.let { user ->
|
recoveryQueuedUser?.let { user ->
|
||||||
put("queued", buildJsonObject { put("user", 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)
|
fun recoveryResult(sessionId: String): JsonObject = recoveryPayload(sessionId)
|
||||||
@@ -427,6 +477,7 @@ class GatewayChatClientTest {
|
|||||||
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
||||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||||
|
val moaReferences = ConcurrentLinkedQueue<GatewayMoaReference>()
|
||||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||||
val reconcileRequests = AtomicInteger(0)
|
val reconcileRequests = AtomicInteger(0)
|
||||||
val completeLatch = CountDownLatch(1)
|
val completeLatch = CountDownLatch(1)
|
||||||
@@ -447,6 +498,7 @@ class GatewayChatClientTest {
|
|||||||
onError = { errors += it; completeLatch.countDown() },
|
onError = { errors += it; completeLatch.countDown() },
|
||||||
onToolGenerating = { toolGenerating += it ?: "" },
|
onToolGenerating = { toolGenerating += it ?: "" },
|
||||||
onSubagentEvent = { subagentEvents += it },
|
onSubagentEvent = { subagentEvents += it },
|
||||||
|
onMoaReference = { moaReferences += it },
|
||||||
onInteractionRequest = { interactions += it },
|
onInteractionRequest = { interactions += it },
|
||||||
onInteractionExpired = { },
|
onInteractionExpired = { },
|
||||||
onInteractionResolved = { interactionResolutions += it },
|
onInteractionResolved = { interactionResolutions += it },
|
||||||
@@ -491,6 +543,14 @@ class GatewayChatClientTest {
|
|||||||
client = buildClient(rpcTimeoutMs, promptSubmitTimeoutMs, turnIdleTimeoutMs)
|
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
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
harness = GatewayClientHarness()
|
harness = GatewayClientHarness()
|
||||||
@@ -1642,6 +1702,146 @@ class GatewayChatClientTest {
|
|||||||
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
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
|
@Test
|
||||||
fun `reasoning settings update targets live session when present`() {
|
fun `reasoning settings update targets live session when present`() {
|
||||||
val r = Recorder()
|
val r = Recorder()
|
||||||
@@ -1696,6 +1896,25 @@ class GatewayChatClientTest {
|
|||||||
assertFalse(rpc.containsKey("scope"))
|
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 ---
|
// --- Edit & regenerate ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1960,6 +2179,110 @@ class GatewayChatClientTest {
|
|||||||
recovery.handle!!.detach()
|
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
|
@Test
|
||||||
fun `recoverTurn keeps inflight and queued resume live`() {
|
fun `recoverTurn keeps inflight and queued resume live`() {
|
||||||
harness.recoveryInflightStreaming = true
|
harness.recoveryInflightStreaming = true
|
||||||
|
|||||||
+12
-4
@@ -5,8 +5,9 @@ import org.junit.Test
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
|
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
|
||||||
* tier sits above the capability-preferred SSE endpoint, but only for "auto"
|
* tier sits above the capability-preferred SSE endpoint for "auto". An
|
||||||
* and only when the dashboard probe reports Ready.
|
* unresolved cold-start probe remains on Gateway until it produces a
|
||||||
|
* definitive fallback verdict.
|
||||||
*/
|
*/
|
||||||
class GatewayEndpointResolutionTest {
|
class GatewayEndpointResolutionTest {
|
||||||
|
|
||||||
@@ -35,9 +36,16 @@ class GatewayEndpointResolutionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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(
|
listOf(
|
||||||
GatewayAvailability.Unknown,
|
|
||||||
GatewayAvailability.SignInRequired,
|
GatewayAvailability.SignInRequired,
|
||||||
GatewayAvailability.Unreachable,
|
GatewayAvailability.Unreachable,
|
||||||
GatewayAvailability.Unsupported,
|
GatewayAvailability.Unsupported,
|
||||||
|
|||||||
+177
-1
@@ -19,6 +19,7 @@ class GatewayEventMapperTest {
|
|||||||
private class Recorder {
|
private class Recorder {
|
||||||
val textDeltas = mutableListOf<String>()
|
val textDeltas = mutableListOf<String>()
|
||||||
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
||||||
|
val reconciledInterims = mutableListOf<String>()
|
||||||
val thinkingDeltas = mutableListOf<String>()
|
val thinkingDeltas = mutableListOf<String>()
|
||||||
val toolStarts = mutableListOf<Pair<String, String>>()
|
val toolStarts = mutableListOf<Pair<String, String>>()
|
||||||
val toolDones = mutableListOf<Pair<String, String?>>()
|
val toolDones = mutableListOf<Pair<String, String?>>()
|
||||||
@@ -26,6 +27,7 @@ class GatewayEventMapperTest {
|
|||||||
val toolOutputRisks = mutableListOf<GatewayToolOutputRisk>()
|
val toolOutputRisks = mutableListOf<GatewayToolOutputRisk>()
|
||||||
val toolGenerating = mutableListOf<String?>()
|
val toolGenerating = mutableListOf<String?>()
|
||||||
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
||||||
|
val moaReferences = mutableListOf<GatewayMoaReference>()
|
||||||
val interactions = mutableListOf<GatewayAsk>()
|
val interactions = mutableListOf<GatewayAsk>()
|
||||||
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
|
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
|
||||||
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
|
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
|
||||||
@@ -45,6 +47,7 @@ class GatewayEventMapperTest {
|
|||||||
onStart = { starts++ },
|
onStart = { starts++ },
|
||||||
onTextDelta = { textDeltas += it },
|
onTextDelta = { textDeltas += it },
|
||||||
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
|
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
|
||||||
|
onInterimReconciled = { text -> reconciledInterims += text },
|
||||||
onThinkingDelta = { thinkingDeltas += it },
|
onThinkingDelta = { thinkingDeltas += it },
|
||||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||||
onToolCallDone = { id, preview -> toolDones += id to preview },
|
onToolCallDone = { id, preview -> toolDones += id to preview },
|
||||||
@@ -57,6 +60,7 @@ class GatewayEventMapperTest {
|
|||||||
onError = { errors += it },
|
onError = { errors += it },
|
||||||
onToolGenerating = { toolGenerating += it },
|
onToolGenerating = { toolGenerating += it },
|
||||||
onSubagentEvent = { subagentEvents += it },
|
onSubagentEvent = { subagentEvents += it },
|
||||||
|
onMoaReference = { moaReferences += it },
|
||||||
onInteractionRequest = { interactions += it },
|
onInteractionRequest = { interactions += it },
|
||||||
onInteractionExpired = { interactionExpiries += it },
|
onInteractionExpired = { interactionExpiries += it },
|
||||||
onInteractionResolved = { interactionResolutions += it },
|
onInteractionResolved = { interactionResolutions += it },
|
||||||
@@ -231,6 +235,93 @@ class GatewayEventMapperTest {
|
|||||||
assertEquals(1, r.completes)
|
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
|
@Test
|
||||||
fun `already streamed interim seals without replaying text`() {
|
fun `already streamed interim seals without replaying text`() {
|
||||||
val r = Recorder()
|
val r = Recorder()
|
||||||
@@ -732,6 +823,91 @@ class GatewayEventMapperTest {
|
|||||||
assertTrue(r.interactions.isEmpty())
|
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 ---
|
// --- Forward compat ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -761,7 +937,7 @@ class GatewayEventMapperTest {
|
|||||||
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
||||||
"tool.generating", "subagent.start", "subagent.thinking",
|
"tool.generating", "subagent.start", "subagent.thinking",
|
||||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
"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 ->
|
).forEach { type ->
|
||||||
// message.complete/error end the turn; use a fresh mapper for each
|
// message.complete/error end the turn; use a fresh mapper for each
|
||||||
mapperWith(Recorder()).onEvent(type, null)
|
mapperWith(Recorder()).onEvent(type, null)
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ class HermesApiClientTest {
|
|||||||
"run_events_sse": true,
|
"run_events_sse": true,
|
||||||
"session_resources": true,
|
"session_resources": true,
|
||||||
"session_chat_streaming": true,
|
"session_chat_streaming": true,
|
||||||
|
"model_options": true,
|
||||||
|
"session_model_lock": true,
|
||||||
"skills_api": true
|
"skills_api": true
|
||||||
},
|
},
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
@@ -237,6 +239,8 @@ class HermesApiClientTest {
|
|||||||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||||
"sessions": {"method": "GET", "path": "/api/sessions"},
|
"sessions": {"method": "GET", "path": "/api/sessions"},
|
||||||
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
"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"},
|
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
||||||
}
|
}
|
||||||
@@ -249,9 +253,137 @@ class HermesApiClientTest {
|
|||||||
assertEquals(true, capabilities?.sessionsChatStream)
|
assertEquals(true, capabilities?.sessionsChatStream)
|
||||||
assertEquals(true, capabilities?.portable)
|
assertEquals(true, capabilities?.portable)
|
||||||
assertEquals(true, capabilities?.runs)
|
assertEquals(true, capabilities?.runs)
|
||||||
|
assertEquals(true, capabilities?.modelOptions)
|
||||||
|
assertEquals(true, capabilities?.sessionModelLock)
|
||||||
assertEquals("sessions", capabilities?.preferredChatEndpoint())
|
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
|
@Test
|
||||||
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
|
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
|
||||||
val body = """{"status":"ok"}"""
|
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,
|
socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||||
): StandardHermesVoiceClient = StandardHermesVoiceClient(
|
): StandardHermesVoiceClient = StandardHermesVoiceClient(
|
||||||
context = mockk<Context>(relaxed = true),
|
context = mockk<Context>(relaxed = true),
|
||||||
okHttpClient = DashboardApiClient.defaultClient(),
|
dashboardHttpClientProvider = { DashboardApiClient.defaultClient() },
|
||||||
dashboardUrlProvider = { server.url("/").toString() },
|
dashboardUrlProvider = { server.url("/").toString() },
|
||||||
webSocketFactory = socketFactory,
|
webSocketFactory = socketFactory,
|
||||||
)
|
)
|
||||||
|
|||||||
+20
-1
@@ -83,17 +83,36 @@ class InteractionRequestNotifierTest {
|
|||||||
|
|
||||||
val notification = manager.activeNotifications.single().notification
|
val notification = manager.activeNotifications.single().notification
|
||||||
val privateText = notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
|
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 privateTitle = notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
|
||||||
val publicText = notification.publicVersion.extras
|
val publicText = notification.publicVersion.extras
|
||||||
.getCharSequence(Notification.EXTRA_TEXT)
|
.getCharSequence(Notification.EXTRA_TEXT)
|
||||||
.toString()
|
.toString()
|
||||||
val visibleCopy = "$privateTitle $privateText $publicText"
|
val visibleCopy = "$privateTitle $privateText $expandedText $publicText"
|
||||||
assertFalse(visibleCopy.contains(secret.text))
|
assertFalse(visibleCopy.contains(secret.text))
|
||||||
assertFalse(visibleCopy.contains(secret.envVar!!))
|
assertFalse(visibleCopy.contains(secret.envVar!!))
|
||||||
assertTrue(visibleCopy.contains("Open Hermes"))
|
assertTrue(visibleCopy.contains("Open Hermes"))
|
||||||
|
assertTrue(expandedText.contains("Profile: Server default"))
|
||||||
|
assertEquals("Respond securely", notification.actions.single().title)
|
||||||
assertEquals(Notification.VISIBILITY_PRIVATE, notification.visibility)
|
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
|
@Test
|
||||||
fun resolvedOrExpiredRequestCancelsOnlyItsStableSlot() {
|
fun resolvedOrExpiredRequestCancelsOnlyItsStableSlot() {
|
||||||
val first = approval(command = "first")
|
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 com.hermesandroid.relay.data.ToolCall
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotEquals
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ImageGenerationPlaceholderTest {
|
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
|
@Test
|
||||||
fun `active image generation uses diffusion placeholder`() {
|
fun `active image generation uses diffusion placeholder`() {
|
||||||
val active = ToolCall(
|
val active = ToolCall(
|
||||||
@@ -119,4 +126,47 @@ class ImageGenerationPlaceholderTest {
|
|||||||
assertTrue(early < resolved)
|
assertTrue(early < resolved)
|
||||||
assertTrue(reset < 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.Modifier
|
||||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -28,12 +29,17 @@ class ImageGenerationPlaceholderUiTest {
|
|||||||
compose.mainClock.autoAdvance = false
|
compose.mainClock.autoAdvance = false
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
ImageGenerationPlaceholder(Modifier.padding(16.dp))
|
ImageGenerationPlaceholder(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
phaseOverride = 0.5f,
|
||||||
|
elapsedOverrideMillis = 12_400,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.mainClock.advanceTimeBy(2_400)
|
compose.mainClock.advanceTimeBy(2_400)
|
||||||
compose.onNodeWithContentDescription("Rendering image").assertExists()
|
compose.onNodeWithContentDescription("Rendering image").assertExists()
|
||||||
|
compose.onNodeWithText("12.4s").assertExists()
|
||||||
compose.onRoot().captureRoboImage("build/verification-shots/image-generation-placeholder.png")
|
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.backgroundRunAfterCancelRequest
|
||||||
import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
||||||
import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
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 com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -27,12 +27,10 @@ class VoiceModeOverlayStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun responseDone_keepsLogicalTurnActiveOnlyWhileBackgroundRunIsLive() {
|
fun promotion_keepsForegroundBusyOnlyForSpokenHandoff() {
|
||||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RUNNING))
|
assertEquals(false, realtimeTurnActiveAfterPromotion(spokenHandoff = false))
|
||||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RECONNECTING))
|
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = true))
|
||||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DELIVERING))
|
assertEquals(true, realtimeTurnActiveAfterPromotion(spokenHandoff = null))
|
||||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DONE))
|
|
||||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(null))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,11 +1,43 @@
|
|||||||
package com.hermesandroid.relay.ui.screens
|
package com.hermesandroid.relay.ui.screens
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotEquals
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ChatScrollSnapshotTest {
|
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
|
@Test
|
||||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||||
val streaming = snapshot(isStreaming = true)
|
val streaming = snapshot(isStreaming = true)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.hermesandroid.relay.ui.screens
|
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 com.hermesandroid.relay.viewmodel.PendingMcpOAuth
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@@ -9,6 +11,30 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DashboardManageParityTest {
|
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
|
@Test
|
||||||
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
|
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
|
||||||
val oauth = Json.parseToJsonElement(
|
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.ConnectException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -60,6 +61,15 @@ class RelayErrorClassifierTest {
|
|||||||
|
|
||||||
assertEquals("Session expired", err.title)
|
assertEquals("Session expired", err.title)
|
||||||
assertTrue(err.body.contains("re-pair", ignoreCase = true))
|
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
|
@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())
|
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
|
@Test
|
||||||
fun dashboardOnlyPersonalityCatalogLoadsAndSurvivesRefreshFailure() {
|
fun dashboardOnlyPersonalityCatalogLoadsAndSurvivesRefreshFailure() {
|
||||||
viewModel.updateApiClient(null)
|
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