Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524e319f95 | ||
|
|
647d1f9aea | ||
|
|
08545ed32d | ||
|
|
e791c6410b | ||
|
|
8c8c3975f2 | ||
|
|
41601d67ab | ||
|
|
366b424615 | ||
|
|
5cd9baaaab | ||
|
|
8acba9b353 | ||
|
|
26a612f088 | ||
|
|
6dd6ce2d13 | ||
|
|
b60c5d9eeb | ||
|
|
9e201e54d7 | ||
|
|
8bb503eb6d | ||
|
|
c223dc690d | ||
|
|
44e3bb75cd | ||
|
|
4834fcbdf5 | ||
|
|
1cec79517e | ||
|
|
957be876a0 | ||
|
|
6b32c7aeef | ||
|
|
29706e1548 | ||
|
|
6579b621ff | ||
|
|
befe8399ab | ||
|
|
c10b87b94c | ||
|
|
5e9d8840ae | ||
|
|
e3512b9fa1 | ||
|
|
40eff9c5c6 | ||
|
|
accf464911 | ||
|
|
b26c2cc2a1 | ||
|
|
28e0c34227 | ||
|
|
35e95da6a7 | ||
|
|
484bfdc5dc | ||
|
|
c7c24b2874 | ||
|
|
3e8e0728db | ||
|
|
b12712a79a | ||
|
|
1658439d05 | ||
|
|
ef1abdae3f | ||
|
|
eece12a815 | ||
|
|
0cdea3ad33 | ||
|
|
a8ca61297d | ||
|
|
5762cdf8af | ||
|
|
dff633c902 | ||
|
|
45d8a73609 | ||
|
|
390a4dd8d8 | ||
|
|
90ab705a88 | ||
|
|
054aab1c09 | ||
|
|
bae1762951 | ||
|
|
27705d8291 | ||
|
|
da7ea8ffe0 | ||
|
|
5c5c55d982 | ||
|
|
0208098687 | ||
|
|
34fc4c4693 |
@@ -117,6 +117,9 @@ jobs:
|
||||
- name: Build Linux x64
|
||||
run: npm run build:bin:linux
|
||||
|
||||
- name: Build Linux arm64
|
||||
run: npm run build:bin:linux-arm
|
||||
|
||||
- name: Build macOS x64
|
||||
run: npm run build:bin:mac-x64
|
||||
|
||||
@@ -138,19 +141,27 @@ jobs:
|
||||
|
||||
- name: Smoke-test Linux binary
|
||||
run: |
|
||||
set -e
|
||||
set -euo pipefail
|
||||
chmod +x dist/bin/hermes-relay-linux-x64
|
||||
for cmd in --version --help doctor; do
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
|
||||
set +e
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1)
|
||||
exit_code=$?
|
||||
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
|
||||
set -e
|
||||
if [ "$exit_code" -ne 0 ] || [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' failed or produced no output (exit=$exit_code)"
|
||||
echo "Raw output was: [$out]"
|
||||
exit 1
|
||||
fi
|
||||
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
|
||||
done
|
||||
|
||||
- name: Verify Linux arm64 artifact architecture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
file dist/bin/hermes-relay-linux-arm64 | tee /tmp/hermes-relay-linux-arm64.file
|
||||
grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)' /tmp/hermes-relay-linux-arm64.file
|
||||
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -158,6 +169,7 @@ jobs:
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-linux-arm64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
@@ -196,6 +208,60 @@ jobs:
|
||||
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
|
||||
}
|
||||
|
||||
smoke-macos-cli-release-asset:
|
||||
name: Smoke exact macOS CLI release asset
|
||||
runs-on: macos-latest
|
||||
needs:
|
||||
- validate-release
|
||||
- build-cli-binaries
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Launch native release asset and inspect both architectures
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$(uname -m)" in
|
||||
x86_64) native_asset=hermes-relay-darwin-x64 ;;
|
||||
arm64) native_asset=hermes-relay-darwin-arm64 ;;
|
||||
*) echo "Unsupported macOS runner architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
chmod +x "release-assets/$native_asset"
|
||||
version_output=$("release-assets/$native_asset" --version)
|
||||
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
|
||||
"release-assets/$native_asset" --help | grep -Fq 'Usage:'
|
||||
file release-assets/hermes-relay-darwin-x64 | grep -Fq 'x86_64'
|
||||
file release-assets/hermes-relay-darwin-arm64 | grep -Eq '(arm64|arm64e)'
|
||||
|
||||
smoke-linux-arm64-cli-release-asset:
|
||||
name: Smoke exact Linux arm64 CLI release asset
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs:
|
||||
- validate-release
|
||||
- build-cli-binaries
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Launch native arm64 release asset
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset=release-assets/hermes-relay-linux-arm64
|
||||
test "$(uname -m)" = "aarch64"
|
||||
chmod +x "$asset"
|
||||
version_output=$("$asset" --version)
|
||||
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
|
||||
"$asset" --help | grep -Fq 'Usage:'
|
||||
file "$asset" | grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)'
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
@@ -419,6 +485,8 @@ jobs:
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- smoke-windows-cli-release-asset
|
||||
- smoke-macos-cli-release-asset
|
||||
- smoke-linux-arm64-cli-release-asset
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
@@ -466,6 +534,7 @@ jobs:
|
||||
files: |
|
||||
release-assets/cli-binaries/hermes-relay-win-x64.exe
|
||||
release-assets/cli-binaries/hermes-relay-linux-x64
|
||||
release-assets/cli-binaries/hermes-relay-linux-arm64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
|
||||
|
||||
@@ -95,3 +95,4 @@ keystore.properties
|
||||
desktop/tray/ui/vendor/
|
||||
# Generated from assets/screenshots/02_chat.png before docs dev/build.
|
||||
/user-docs/public/chat-demo.png
|
||||
/user-docs/public/product/desktop-ui/
|
||||
|
||||
@@ -6,18 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
|
||||
|
||||
## [Android 1.13.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release and candidate names use one public product hierarchy.** Future releases use `Hermes-Relay Android`, `Hermes-Relay Plugin`, or `Hermes-Relay CLI+UI` display names, while isolated Android review and release-candidate installs use `HR Candidate`, without changing immutable tags, package identities, updater contracts, or artifact filenames.
|
||||
- **Review candidates are an explicit PR opt-in with one trusted handoff comment.** Maintainers can apply `review-candidate` for exact-head Android and Relay bundles; a separate reporter updates the PR with the artifact, expiry, source SHA, and bounded review instructions without executing fork code with write permission.
|
||||
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
|
||||
- **Android releases and review candidates use clear public product names.** Stable builds use `Hermes-Relay Android`, while isolated review installs use `HR Candidate` without changing package identities or update contracts.
|
||||
- **Review candidates are explicit and source-pinned.** Maintainers can opt a PR into a matched Android and Relay bundle with checksums, expiry, source SHA, and bounded review instructions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
|
||||
- **Android chats no longer retain a stale busy composer.** A completed Gateway bubble settles automatically when its exact session has no live or detached turn, new-chat navigation clears stale visible ownership, and Stop remains an immediate escape hatch. (#416, #418)
|
||||
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
|
||||
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
|
||||
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
|
||||
|
||||
## [Plugin 1.10.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay provides normalized provider usage without exposing credentials.** The authenticated Dashboard route resolves the active Codex pool entry, structured Nous balances, and OpenCode Go windows on the Hermes host; explicitly enabled paired clients receive the same provider-neutral schema.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Plugin releases use the `Hermes-Relay Plugin` public name.** The display name is aligned with Android and CLI+UI while the `server-v*` compatibility tag remains unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## [0.4.0-beta.5] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
|
||||
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Desktop releases use the `Hermes-Relay CLI+UI` public name.** The beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
|
||||
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
|
||||
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
|
||||
|
||||
@@ -73,7 +116,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Android screen-on idle no longer continuously redraws the ASCII sphere.** Idle holds a stable frame while thinking, streaming, and voice states retain full-rate motion; inactive voice waveforms and closed session drawers also stop their frame loops.
|
||||
- **Android capture and audio effects release power-sensitive resources at their actual lifecycle boundaries.** Screen capture attaches its MediaProjection surface only for a requested frame, unattended Bridge wake locks release when the command finishes, and barge-in AEC/noise suppression attach to the microphone capture session instead of playback.
|
||||
- **Experimental wake-word listening reuses its PCM normalization buffer.** Continuous opt-in listening no longer allocates a new float frame for every inference call.
|
||||
|
||||
## [1.10.0] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
# Hermes-Relay CLI+UI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-15
|
||||
**Release Date:** 2026-08-25
|
||||
|
||||
This patch keeps the Windows management UI usable when the Relay daemon is stopped or its status cannot be read.
|
||||
This beta makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, adds a native
|
||||
Linux ARM64 build, and hardens installation and update discovery.
|
||||
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Linux ARM64 is a first-class release target.** The one-line installer,
|
||||
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
|
||||
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
|
||||
cover connections, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
|
||||
beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stopped daemons no longer block the management UI.** Missing, stale, malformed, or temporarily unavailable daemon status falls back to an explicit stopped state while hosts, settings, activity, CLI details, diagnostics, and daemon controls continue loading normally.
|
||||
- **Starting the daemon restores live status without reopening the UI.** A valid running status continues through the same bounded, single-flight snapshot path introduced in beta.3.
|
||||
- **The daemon reconnects instead of exiting after an interrupted Relay socket.** Relay restarts and repeated transient replacement failures stay on bounded automatic backoff, and terminal failures persist an accurate stopped reason for the UI.
|
||||
- **Oversized desktop-tool output no longer closes the shared connection.** PowerShell output and every serialized desktop response stay inside the Relay WebSocket budget.
|
||||
- **Current CUA Driver releases remain compatible by contract.** Driver 0.20 and newer are accepted when their manifest and required tools match Hermes, and Windows uses the manifest-declared direct standard-mode runtime instead of a stale machine-wide daemon.
|
||||
- **Install and update discovery paginates the multi-surface release history.** Desktop releases remain discoverable after more Android and Server releases, Windows cooperative updates clean their released backup, and unsigned installers retain the normal SmartScreen warning.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -42,6 +58,7 @@ hermes-relay --version
|
||||
hermes-relay hosts list --json
|
||||
hermes-relay daemon start
|
||||
hermes-relay daemon status --json
|
||||
hermes-relay computer-use status --json
|
||||
```
|
||||
|
||||
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
|
||||
|
||||
@@ -62,6 +62,18 @@ automotive device verified foreground preservation, AssistStructure and screensh
|
||||
delivery, immediate listening, contextual response, and one-shot consumption;
|
||||
broader firmware certification remains tracked in `TODO.md`.
|
||||
|
||||
## 2026-08-23 — Windows attachment retry and Hermes-home resolution
|
||||
|
||||
Android now recognizes Windows absolute paths during manual inbound-media retry.
|
||||
Cellular-deferred `MEDIA:C:\...` documents use Relay's authenticated
|
||||
`/media/by-path` route instead of being sent to the opaque-token route and
|
||||
misreported as expired. A Robolectric/MockWebServer regression covers a spaced
|
||||
Markdown filename and asserts the exact route and decoded path query.
|
||||
|
||||
Relay configuration now derives its default `config.yaml` and session-persistence
|
||||
paths from `HERMES_HOME` when present. `RELAY_HERMES_CONFIG` remains the explicit
|
||||
override. Focused Python tests cover both resolution paths.
|
||||
|
||||
## 2026-08-23 — GitHub Discussions community surface
|
||||
|
||||
GitHub Discussions is enabled as the repository's lightweight community surface.
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 21, 2026
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This release makes delayed phone delivery and active Bridge access easier to understand. Relay now identifies messages flushed after reconnect, emits one completion signal for the backlog, and reports permanent, timed, and unlimited phone capabilities through status surfaces.
|
||||
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
|
||||
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## Added
|
||||
|
||||
- **Reconnect backlog context.** Messages flushed from the bounded offline queue carry an explicit delayed-delivery marker, followed by one ordered completion event with the delivered count.
|
||||
- **Granular phone capability status.** Relay status and `android_phone_status` report permanent, timed, and unlimited Bridge capabilities alongside existing Android permissions and safety state.
|
||||
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
|
||||
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Phone surfacing semantics are explicit.** Default delivery persists to Threads and notifies, Inbox delivery remains silent, and Session delivery targets an available active conversation before falling back to a notification.
|
||||
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
|
||||
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-beta-756cff.svg" alt="CLI (beta)"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,12 +36,12 @@
|
||||
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
|
||||
|
||||
- **📱 Android app** — streaming chat, hands-free voice, native plugin pages, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. Add a floating Petdex companion or optionally make Hermes your Android assistant; sideload builds can also let the agent read and act on your screen.
|
||||
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
- **⌨️ Hermes-Relay CLI** *(beta)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, voice, Petdex, and ordinary installed-plugin pages need **no Relay plugin**. Add the optional Relay only when you want terminal, phone control, agent-created page drafts, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough for the upstream standard path: chat, management, voice, Petdex, and ordinary installed-plugin pages. The Hermes-Relay plugin is optional for that base but encouraged for the complete current experience: Terminal/TUI, notifications, media, desktop tools, enhanced voice, Relay sessions, page drafts, and optional Device Control. Hermes-Relay prefers compatible upstream surfaces as they become available instead of keeping duplicate extension paths. **Connect Hermes first, then grant Hermes-Relay separately; the same one-time invite contract pairs Android or the Desktop CLI.**
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
|
||||
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — upstream Hermes owns Chat, Manage, and standard Voice; the encouraged Relay extension fills current gaps for Terminal, notifications, media, enhanced voice, sessions, desktop tools, and optional Device Control." width="900">
|
||||
</p>
|
||||
|
||||
## Quick Start (Android)
|
||||
@@ -50,7 +50,7 @@ Install → connect → talk, in about two minutes.
|
||||
|
||||
### 1 · Install the app
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, sessions, and Manage work with standard Hermes; pairing the Hermes-Relay plugin adds Terminal/TUI, media, notifications, and Relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
|
||||
@@ -71,27 +71,21 @@ an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/gui
|
||||
covers Windows, remote access, and dashboard authentication. You do not need to
|
||||
enable the separate API server or invent an API key for the standard path.
|
||||
|
||||
For plugin-enabled setups, optional **Hermes Secure Link** presents Relay, API,
|
||||
and Dashboard routes through one pairing-pinned TLS origin. It protects traffic
|
||||
to the paired endpoint while each service keeps its own authentication; it does
|
||||
not provide reachability or independently identify the physical host. You still
|
||||
use LAN routing, Tailscale or another VPN, or an operator-managed public route
|
||||
to reach the listener. Secure Link is off by default and requires a fresh QR
|
||||
pairing after it is enabled. See the
|
||||
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
|
||||
|
||||
**Hermes Reach** is an experimental, advanced outbound-broker route. It remains
|
||||
available for development and self-hosted evaluation, but it is disabled by
|
||||
default, ordered after supported routes, and not recommended for normal remote
|
||||
access. Use Tailscale for the easiest supported remote setup, or a public TLS
|
||||
domain / Direct Secure Link when you want to own the complete network path.
|
||||
Start on a trusted LAN. For away-from-home access, Tailscale is the recommended
|
||||
path. Secure Link, public TLS, and experimental routing options are covered in
|
||||
the [remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
|
||||
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
|
||||
configured provider when prompted. The app probes the available upstream
|
||||
capabilities and finishes with a connection summary.
|
||||
For a plugin-enabled host, open the Web Dashboard's **Relay** page, click
|
||||
**Connect mobile app**, and scan that tokenless QR from Android **Connect → Scan
|
||||
Hermes setup QR**. It contains only the Dashboard address and configures the
|
||||
upstream Chat, sessions, Manage, sign-in, and standard voice connection.
|
||||
|
||||
Without the Dashboard plugin, use **Find Hermes on LAN** or enter the Dashboard
|
||||
address manually (conventionally `http://<host>:9119`). Sign in through the
|
||||
Dashboard's configured provider when prompted. The app probes the available
|
||||
upstream capabilities and finishes with a connection summary.
|
||||
|
||||
The separate API server can be discovered automatically or added later under
|
||||
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
|
||||
@@ -106,49 +100,47 @@ The wizard probes everything and finishes with a capability card:
|
||||
| **Manage** | Models, keys, skills, and profiles are available from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **API fallback** | Optional API route available/unavailable |
|
||||
| **Relay** | Optional extensions — fine to leave unpaired |
|
||||
| **Relay** | Recommended extensions paired/unpaired; never blocks the upstream path |
|
||||
|
||||
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
|
||||
the whole Vanilla Hermes setup.
|
||||
|
||||
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
### 4 · Recommended: pair Relay for the complete experience
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, the realtime voice engine, or approval-gated agent-created plugin-page drafts:
|
||||
Install Relay for Terminal/TUI, notifications, media handoff, desktop tools,
|
||||
enhanced voice, Relay sessions, approval-gated page drafts, and optional Device
|
||||
Control:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
Use the legacy installer instead if you also want the systemd user service,
|
||||
shell shims, and the full clone/update workflow:
|
||||
Use `--no-ssl` only on a trusted LAN or VPN. Use the
|
||||
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/) before
|
||||
exposing any Hermes surface beyond that network.
|
||||
|
||||
Refresh or restart the Dashboard/Gateway, open **Relay → Pair new device**, and
|
||||
scan the one-time QR from Android **Settings → Connections → Pair Hermes Relay**.
|
||||
Leave mode on **Auto** for the recommended route discovery. The same dialog
|
||||
shows a copyable invite for Desktop CLI clients:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
|
||||
```
|
||||
|
||||
Installed Hermes plugins can expose bounded, host-rendered pages to Android
|
||||
through the authenticated Dashboard without running plugin code on the phone.
|
||||
Relay 1.5.0 additionally supports approval-gated agent-created page drafts. The
|
||||
plugin-manager install owns the plugin code, dashboard tab, CLI commands, and
|
||||
agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
optional legacy API compatibility hook when an older Hermes build needs it. Scan
|
||||
the QR from the phone's Connections screen — or use
|
||||
`hermes pair --register-code ABCD12` with the manual code from Android
|
||||
**Settings → Connections → Advanced**.
|
||||
As alternatives, `hermes pair` renders the same Android QR and pasteable invite
|
||||
in a terminal, while URL + six-character code and `--register-code` remain
|
||||
manual fallbacks when QR or clipboard transfer is unavailable.
|
||||
|
||||
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
|
||||
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
**Next:** [Android + Hermes-Relay Quick Start](https://hermes-relay.dev/docs/guide/quick-start) ·
|
||||
[Desktop CLI pairing](https://hermes-relay.dev/docs/desktop/pairing) ·
|
||||
[server, TLS, legacy install, and uninstall reference](https://hermes-relay.dev/docs/reference/relay-server)
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ when installing the Hermes-Relay plugin. The API fallback is optional; the Hermes-Relay plugin is encouraged for the complete experience.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -193,16 +185,16 @@ tracked independently so community corrections remain easy to contribute.
|
||||
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
|
||||
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
|
||||
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
|
||||
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
|
||||
- **Device Control (Sideload + Hermes-Relay required)** — the agent can read the screen and act: tap, type, swipe, scroll, screenshots, clipboard, media keys, and batched macros. This is not included in the Google Play build. It is guarded by a per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
|
||||
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(beta)</sub>
|
||||
|
||||
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
> **Beta.** Self-contained CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
|
||||
|
||||
@@ -220,6 +212,14 @@ It pairs against the **same relay and credential store** as the Android app —
|
||||
|
||||
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/overview.png" alt="Hermes-Relay CLI UI connected overview" width="100%"><br><sub><b>Connection & activity</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/host-access.png" alt="Hermes-Relay CLI UI host access presets" width="100%"><br><sub><b>Per-host access</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/settings.png" alt="Hermes-Relay CLI UI computer control and updates" width="100%"><br><sub><b>Control & maintenance</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Structured Windows computer control prefers a compatible local CUA Driver
|
||||
runtime for window-targeted background actions and virtual per-session agent
|
||||
cursors. It remains behind Hermes host policy, grants, targeting, audit, and
|
||||
@@ -348,7 +348,7 @@ hermes-relay/
|
||||
|
||||
<br>
|
||||
|
||||
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
|
||||
End users should follow the [recommended Hermes-Relay setup](#4--recommended-pair-relay-for-the-complete-experience) above. For local development:
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.12.1
|
||||
# Hermes-Relay Android v1.13.1
|
||||
|
||||
**Release Date:** August 22, 2026
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.12.1-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.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,18 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android sharing and recovery dependable. Shared links, text, images, and files open as complete reviewable drafts; connection renewal no longer stalls; offline and history failures are visible; and secure-storage recovery appears in Diagnostics.
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Open shared links, text, images, files, and mixed or multi-item shares as a fresh reviewable draft without sending automatically.
|
||||
- Keep Add and Renew connection setup on the correct connection-scoped authentication store, with bounded Retry or Cancel recovery instead of an indefinite preparation screen.
|
||||
- Surface unavailable chat routes and profile-history failures clearly instead of silently dropping Send or presenting missing history as an empty conversation.
|
||||
- Report Android Keystore fallback, encrypted-store recovery, and temporary credential storage in Diagnostics without exposing credentials.
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.12.1** (versionCode **48**).
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin is not required for standard Android chat, sharing, session continuity, or Gateway recovery.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -6,6 +6,47 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Certify Android session activity across lifecycle and profile boundaries
|
||||
|
||||
The contract fixture now covers every upstream live status, complete-snapshot
|
||||
disappearance, client-side ownership of duplicate durable ids across profiles,
|
||||
and older Gateways without `session.active_list`. Before calling the status
|
||||
model device-certified:
|
||||
|
||||
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
|
||||
completion, Stop, reconnect, app restart, and process recreation against
|
||||
current vanilla upstream.
|
||||
- Verify All Profiles with duplicate session ids across two profiles and two
|
||||
saved connections; no late snapshot or old socket generation may mark the
|
||||
wrong row live.
|
||||
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
|
||||
remains Checking, ambiguous or partially
|
||||
resolved process-wide snapshots infer no absence, a complete empty snapshot
|
||||
settles every unambiguously owned scope, and REST `is_active=true` never
|
||||
renders as Working.
|
||||
- Run a background process that outlives its parent turn and verify Background
|
||||
work remains separate from the conversation's Idle state.
|
||||
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
|
||||
activity route with explicit profile ownership so multi-profile clients do
|
||||
not need to resolve process-wide rows from durable keys.
|
||||
|
||||
---
|
||||
|
||||
## Bot Mode follow-ups after multi-gateway aggregation
|
||||
|
||||
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
|
||||
ownership, install-identity collapse, source-qualified handles, offline cache,
|
||||
route-pooled Gateway clients, and dedicated owner-routed Bot Chats without a
|
||||
foreground connection switch. Keep autonomous cross-gateway delivery on
|
||||
upstream peer/server authority rather than making Android an unreliable
|
||||
background courier. Writable group rooms stay blocked until upstream publishes
|
||||
one canonical room read/write/control contract; do not reproduce Desktop's
|
||||
local orchestrator in the phone. Route-scoped outbound attachments, Relay media,
|
||||
voice, and proactive completion notifications can be added independently when
|
||||
their credential and lifecycle ownership is explicit.
|
||||
|
||||
---
|
||||
|
||||
## Certify Android assistant screen context on physical firmware
|
||||
|
||||
Host-side coverage and one Android 15 automotive device prove the primary flow.
|
||||
|
||||
@@ -1,62 +1,59 @@
|
||||
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
|
||||
Hermes-Relay is the native Android companion for the Hermes agent you run. Chat, talk hands-free, continue sessions, and manage models, keys, skills, profiles, and automations from your phone.
|
||||
|
||||
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
|
||||
It is not a hosted AI service. Your Hermes agent stays on infrastructure you control, and the app talks only to instances you configure.
|
||||
|
||||
QUICK START
|
||||
|
||||
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
|
||||
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
|
||||
1. Start the Hermes Dashboard/Gateway on your computer or home server with hermes dashboard.
|
||||
2. Install Hermes-Relay from Google Play.
|
||||
3. For the recommended full setup, install the Hermes-Relay plugin on the host and refresh the Web Dashboard. A Relay page will appear.
|
||||
4. Scan Connect mobile app from Android Connect. Then scan Pair new device from Android Settings > Connections.
|
||||
|
||||
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
|
||||
The QR codes are separate on purpose. Connect mobile app adds the standard Dashboard/Gateway connection. Pair new device grants a time-limited Hermes-Relay session for the additional capabilities you approve.
|
||||
|
||||
Standard Hermes without the plugin is supported. Choose Find Hermes on LAN or enter the Dashboard address you open in a browser, normally http://<host>:9119. Pair the Hermes-Relay plugin later when you want the full experience.
|
||||
|
||||
HOW IT WORKS
|
||||
|
||||
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
|
||||
Chat, sessions, Manage, sign-in, and standard voice use the unmodified Hermes Dashboard/Gateway. The separate Hermes API server is an optional fallback for advanced or headless setups; it is not required for the normal Android connection.
|
||||
|
||||
GOOGLE PLAY BUILD
|
||||
The encouraged Hermes-Relay plugin adds Terminal/TUI, notifications, media handoff, enhanced voice, Relay sessions, desktop-tool handoff, and time-limited per-feature grants. When upstream Hermes provides a compatible capability, Hermes-Relay prefers it instead of duplicating it.
|
||||
|
||||
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
|
||||
GOOGLE PLAY AND SIDELOAD
|
||||
|
||||
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
|
||||
|
||||
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
|
||||
|
||||
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
|
||||
|
||||
FEATURES
|
||||
|
||||
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
|
||||
|
||||
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
|
||||
|
||||
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
|
||||
|
||||
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
|
||||
|
||||
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
|
||||
|
||||
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
|
||||
|
||||
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
|
||||
|
||||
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
|
||||
|
||||
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
|
||||
|
||||
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
|
||||
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
|
||||
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
|
||||
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
|
||||
- Create, switch, search, rename, pin, archive, and continue sessions.
|
||||
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
|
||||
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
|
||||
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
|
||||
|
||||
SECURITY AND PRIVACY
|
||||
|
||||
- API keys and relay tokens are stored in encrypted Android storage.
|
||||
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
|
||||
- Dashboard sessions and Hermes-Relay tokens use encrypted Android storage.
|
||||
- Cleartext is limited to trusted local-network setups. Use a VPN or HTTPS remotely.
|
||||
- No telemetry, ads, tracking, or third-party analytics SDKs.
|
||||
- Notification access and the microphone are optional and user-controlled.
|
||||
- All app traffic goes only to servers you configure.
|
||||
- Notification and microphone access are optional and user-controlled.
|
||||
- App traffic goes only to servers you configure.
|
||||
|
||||
REQUIREMENTS
|
||||
|
||||
- Android 8.0 or later.
|
||||
- A running Hermes agent for chat, management, and voice.
|
||||
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
|
||||
- Network access to your server by local network, VPN, or internet.
|
||||
- A reachable Hermes Dashboard/Gateway.
|
||||
- The Hermes-Relay plugin is encouraged for the complete experience but never blocks standard Hermes.
|
||||
- Network access through a local network, VPN, or operator-managed internet route.
|
||||
|
||||
OPEN SOURCE
|
||||
|
||||
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
|
||||
Hermes-Relay is MIT licensed. Source, setup guides, downloads, and issue tracking are on GitHub.
|
||||
|
||||
This app is a community project and is not affiliated with or endorsed by NousResearch.
|
||||
This community project is not affiliated with or endorsed by NousResearch.
|
||||
|
||||
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 168 KiB |
@@ -1 +1 @@
|
||||
Your Hermes AI agent, in your pocket - chat, voice, and control.
|
||||
Your Hermes agent on Android — chat, voice, sessions, and Manage.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
|
||||
@@ -1 +1 @@
|
||||
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Follow live Hermes state",
|
||||
"bullets": [
|
||||
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
|
||||
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.0",
|
||||
"title": "Bots, usage, and reliable chat",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Talk across saved gateways",
|
||||
"bullets": [
|
||||
"Use Bot Mode as one messenger-style workspace for bots and read-only groups across saved Hermes gateways.",
|
||||
"Keep every Bot Chat bound to its exact gateway and profile without changing the foreground connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Understand account limits",
|
||||
"bullets": [
|
||||
"Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage and limits screen.",
|
||||
"Choose Summary, Expanded, or Hidden presentation while provider credentials remain on the Hermes host."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Keep chat and voice in context",
|
||||
"bullets": [
|
||||
"Settle orphaned Gateway busy state automatically while preserving another session's active or detached turn.",
|
||||
"Include bounded visible text and an available screenshot in the first compatible Assistant voice turn."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.12.1",
|
||||
"title": "Sharing and recovery that work",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
v1.12.1 - Sharing and recovery that work
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
* Open shared links, text, images, and files as a reviewable draft without auto-sending.
|
||||
* Add or renew a connection without getting stuck during secure setup.
|
||||
* See clear recovery guidance when chat or profile history is unavailable.
|
||||
* Find secret-free secure-storage fallback and recovery evidence in Diagnostics.
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
data class BotGatewayRouteKey(
|
||||
val connectionId: String,
|
||||
val profileName: String,
|
||||
) {
|
||||
init {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
require(profileName.isNotBlank()) { "profileName must not be blank" }
|
||||
}
|
||||
}
|
||||
|
||||
class BotGatewayRoute(
|
||||
val key: BotGatewayRouteKey,
|
||||
val connectionLabel: String,
|
||||
val installId: String? = null,
|
||||
) {
|
||||
val connectionId: String get() = key.connectionId
|
||||
val profileName: String get() = key.profileName
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is BotGatewayRoute && key == other.key
|
||||
override fun hashCode(): Int = key.hashCode()
|
||||
override fun toString(): String = "BotGatewayRoute(key=$key, label=$connectionLabel)"
|
||||
}
|
||||
|
||||
/** Bounded session summary published by upstream `profiles.list`. */
|
||||
data class BotSessionSummary(
|
||||
val id: String,
|
||||
val resolvedId: String = id,
|
||||
val title: String = "",
|
||||
val rootTitle: String = "",
|
||||
val preview: String = "",
|
||||
val startedAtMs: Long = 0L,
|
||||
val lastActiveAtMs: Long = 0L,
|
||||
val messageCount: Int = 0,
|
||||
)
|
||||
|
||||
data class BotRosterEntry(
|
||||
val profile: Profile,
|
||||
val displayName: String,
|
||||
val route: BotGatewayRoute? = null,
|
||||
val handle: String = profile.name,
|
||||
val stale: Boolean = false,
|
||||
val botTitle: String = "",
|
||||
val hidden: Boolean = false,
|
||||
val lastSession: BotSessionSummary? = null,
|
||||
val workerSession: BotSessionSummary? = null,
|
||||
val canonicalSession: BotSessionSummary? = null,
|
||||
) {
|
||||
val latestActivityAtMs: Long
|
||||
get() = maxOf(
|
||||
canonicalSession?.lastActiveAtMs ?: 0L,
|
||||
lastSession?.lastActiveAtMs ?: 0L,
|
||||
)
|
||||
|
||||
val presenceActivityAtMs: Long
|
||||
get() = maxOf(latestActivityAtMs, workerSession?.lastActiveAtMs ?: 0L)
|
||||
|
||||
val latestPreview: String
|
||||
get() = canonicalSession?.preview?.takeIf(String::isNotBlank)
|
||||
?: lastSession?.preview.orEmpty()
|
||||
}
|
||||
|
||||
data class BotGroupMember(
|
||||
val name: String,
|
||||
val handle: String? = null,
|
||||
val connectionId: String? = null,
|
||||
val connectionLabel: String? = null,
|
||||
)
|
||||
|
||||
data class BotGroupMessage(
|
||||
val id: String? = null,
|
||||
val senderName: String,
|
||||
val senderKind: String,
|
||||
val senderSource: String? = null,
|
||||
val text: String,
|
||||
val atMs: Long,
|
||||
)
|
||||
|
||||
data class BotGroupRoom(
|
||||
val key: String,
|
||||
val roomId: String? = null,
|
||||
val name: String,
|
||||
val revision: Long = 0L,
|
||||
val members: List<BotGroupMember> = emptyList(),
|
||||
val messages: List<BotGroupMessage> = emptyList(),
|
||||
val sourceConnectionIds: Set<String> = emptySet(),
|
||||
val stale: Boolean = false,
|
||||
) {
|
||||
val latestMessage: BotGroupMessage? get() = messages.maxByOrNull(BotGroupMessage::atMs)
|
||||
val latestActivityAtMs: Long get() = latestMessage?.atMs ?: 0L
|
||||
}
|
||||
|
||||
data class BotModeRoster(
|
||||
val bots: List<BotRosterEntry> = emptyList(),
|
||||
val groups: List<BotGroupRoom> = emptyList(),
|
||||
val botModeProtocolSupported: Boolean = false,
|
||||
)
|
||||
|
||||
data class BotGatewayRosterStatus(
|
||||
val connectionId: String,
|
||||
val label: String,
|
||||
val installId: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val stale: Boolean = false,
|
||||
val error: String? = null,
|
||||
val botCount: Int = 0,
|
||||
)
|
||||
|
||||
data class BotChatTarget(
|
||||
/** Durable registry-row identity. */
|
||||
val storedSessionId: String,
|
||||
/** Compression-lineage tip that should be resumed. */
|
||||
val resolvedSessionId: String = storedSessionId,
|
||||
)
|
||||
|
||||
data class BotModeState(
|
||||
val loading: Boolean = false,
|
||||
val roster: BotModeRoster = BotModeRoster(),
|
||||
val gateways: List<BotGatewayRosterStatus> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -451,7 +451,8 @@ data class ChatSession(
|
||||
val outputTokens: Int = 0,
|
||||
val actualCostUsd: Double? = null,
|
||||
val estimatedCostUsd: Double? = null,
|
||||
val isActive: Boolean = false,
|
||||
/** Upstream REST five-minute recency hint; never evidence that a turn is running. */
|
||||
val recentlyActive: Boolean = false,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class ProviderUsageLandingMode(val storedValue: String) {
|
||||
Summary("summary"),
|
||||
Expanded("expanded"),
|
||||
Hidden("hidden"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromStoredValue(value: String?): ProviderUsageLandingMode =
|
||||
entries.firstOrNull { it.storedValue == value } ?: Summary
|
||||
}
|
||||
}
|
||||
|
||||
data class ProviderUsagePreferences(
|
||||
val landingMode: ProviderUsageLandingMode = ProviderUsageLandingMode.Summary,
|
||||
val visibleProviders: Set<String> = DEFAULT_VISIBLE_PROVIDERS,
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go")
|
||||
}
|
||||
}
|
||||
|
||||
class ProviderUsagePreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
internal val KEY_LANDING_MODE = stringPreferencesKey("provider_usage_landing_mode")
|
||||
internal val KEY_VISIBLE_PROVIDERS = stringSetPreferencesKey("provider_usage_visible_providers")
|
||||
}
|
||||
|
||||
val preferences: Flow<ProviderUsagePreferences> = dataStore.data
|
||||
.map { prefs ->
|
||||
ProviderUsagePreferences(
|
||||
landingMode = ProviderUsageLandingMode.fromStoredValue(prefs[KEY_LANDING_MODE]),
|
||||
visibleProviders = prefs[KEY_VISIBLE_PROVIDERS]
|
||||
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setLandingMode(mode: ProviderUsageLandingMode) {
|
||||
dataStore.edit { it[KEY_LANDING_MODE] = mode.storedValue }
|
||||
}
|
||||
|
||||
suspend fun setProviderVisible(providerId: String, visible: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[KEY_VISIBLE_PROVIDERS]
|
||||
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS
|
||||
prefs[KEY_VISIBLE_PROVIDERS] = if (visible) current + providerId else current - providerId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/** Stable ownership boundary for live activity. Runtime ids are aliases, never owners. */
|
||||
@ConsistentCopyVisibility
|
||||
data class SessionActivityOwner private constructor(
|
||||
val connectionId: String,
|
||||
val profile: String,
|
||||
val storedSessionId: String,
|
||||
) {
|
||||
companion object {
|
||||
fun of(connectionId: String, profile: String, storedSessionId: String) =
|
||||
SessionActivityOwner(
|
||||
connectionId = connectionId.trim(),
|
||||
profile = profile.trim().lowercase(Locale.ROOT),
|
||||
storedSessionId = storedSessionId.trim(),
|
||||
).also {
|
||||
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
|
||||
require(it.profile.isNotEmpty()) { "profile must not be blank" }
|
||||
require(it.storedSessionId.isNotEmpty()) { "storedSessionId must not be blank" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConsistentCopyVisibility
|
||||
data class SessionActivityScope private constructor(
|
||||
val connectionId: String,
|
||||
val profile: String,
|
||||
) {
|
||||
companion object {
|
||||
fun of(connectionId: String, profile: String) = SessionActivityScope(
|
||||
connectionId = connectionId.trim(),
|
||||
profile = profile.trim().lowercase(Locale.ROOT),
|
||||
).also {
|
||||
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
|
||||
require(it.profile.isNotEmpty()) { "profile must not be blank" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SessionActivityPhase {
|
||||
Starting,
|
||||
Working,
|
||||
NeedsInput,
|
||||
BackgroundWork,
|
||||
Idle,
|
||||
}
|
||||
|
||||
enum class SessionActivityFreshness {
|
||||
Confirmed,
|
||||
Revalidating,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
enum class SessionActivityEvidenceSource {
|
||||
Directory,
|
||||
LocalSend,
|
||||
ActiveList,
|
||||
SessionEvent,
|
||||
PendingInput,
|
||||
Terminal,
|
||||
Checkpoint,
|
||||
Process,
|
||||
}
|
||||
|
||||
data class SessionActivityEvidence(
|
||||
val source: SessionActivityEvidenceSource,
|
||||
val generation: Long,
|
||||
val observedAtMillis: Long,
|
||||
)
|
||||
|
||||
data class SessionActivityRecord(
|
||||
val owner: SessionActivityOwner,
|
||||
/** Authoritative turn state before exact pending-input and background-process overlays. */
|
||||
val turnPhase: SessionActivityPhase,
|
||||
val freshness: SessionActivityFreshness,
|
||||
val evidence: SessionActivityEvidence,
|
||||
val runtimeId: String? = null,
|
||||
val pendingInputs: Map<String, Long?> = emptyMap(),
|
||||
val backgroundProcessIds: Set<String> = emptySet(),
|
||||
) {
|
||||
fun phase(nowMillis: Long = Long.MIN_VALUE): SessionActivityPhase {
|
||||
val hasPendingInput = pendingInputs.any { (_, expiresAt) -> expiresAt == null || expiresAt > nowMillis }
|
||||
return when {
|
||||
hasPendingInput -> SessionActivityPhase.NeedsInput
|
||||
turnPhase != SessionActivityPhase.Idle -> turnPhase
|
||||
backgroundProcessIds.isNotEmpty() -> SessionActivityPhase.BackgroundWork
|
||||
else -> SessionActivityPhase.Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation projection that never labels uncertain or background activity as Working. */
|
||||
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
|
||||
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
|
||||
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
|
||||
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
|
||||
SessionActivityPhase.Starting -> SessionActivityState.Starting
|
||||
SessionActivityPhase.Working -> SessionActivityState.Working
|
||||
SessionActivityPhase.NeedsInput -> SessionActivityState.NeedsInput
|
||||
SessionActivityPhase.BackgroundWork -> SessionActivityState.BackgroundWork
|
||||
SessionActivityPhase.Idle -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SessionLiveStatus {
|
||||
Starting,
|
||||
Working,
|
||||
Waiting,
|
||||
Idle,
|
||||
}
|
||||
|
||||
data class SessionLiveRuntime(
|
||||
/** Null when transport data cannot be resolved uniquely to a stored session owner. */
|
||||
val owner: SessionActivityOwner?,
|
||||
val runtimeId: String,
|
||||
val status: SessionLiveStatus,
|
||||
)
|
||||
|
||||
sealed interface SessionActivityUpdate {
|
||||
val generation: Long
|
||||
val observedAtMillis: Long
|
||||
|
||||
data class BeginGeneration(
|
||||
val scope: SessionActivityScope,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ObserveOwner(
|
||||
val owner: SessionActivityOwner,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class LocalSend(
|
||||
val owner: SessionActivityOwner,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class LiveState(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String?,
|
||||
val status: SessionLiveStatus,
|
||||
val source: SessionActivityEvidenceSource = SessionActivityEvidenceSource.SessionEvent,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class RuntimeState(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimeId: String,
|
||||
val status: SessionLiveStatus,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ActiveList(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimes: List<SessionLiveRuntime>,
|
||||
/** True only when every upstream row was safely attributable for this scope. */
|
||||
val isCompleteForScope: Boolean,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class StatusUnavailable(
|
||||
val scope: SessionActivityScope,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class Terminal(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String? = null,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class RestoreCheckpoint(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String?,
|
||||
val phase: SessionActivityPhase,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class PendingInputOpened(
|
||||
val owner: SessionActivityOwner,
|
||||
val requestId: String,
|
||||
val expiresAtMillis: Long? = null,
|
||||
val confirmed: Boolean = true,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class PendingInputClosed(
|
||||
val owner: SessionActivityOwner,
|
||||
val requestId: String,
|
||||
val confirmed: Boolean = true,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ProcessState(
|
||||
val owner: SessionActivityOwner,
|
||||
val processId: String,
|
||||
val running: Boolean,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class Tick(
|
||||
val nowMillis: Long,
|
||||
override val generation: Long = Long.MAX_VALUE,
|
||||
override val observedAtMillis: Long = nowMillis,
|
||||
) : SessionActivityUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for session activity. Every update is generation-gated per connection/profile.
|
||||
* An unsuccessful/unsupported refresh never manufactures an idle result.
|
||||
*/
|
||||
data class SessionActivityRegistry(
|
||||
val records: Map<SessionActivityOwner, SessionActivityRecord> = emptyMap(),
|
||||
private val runtimeAliases: Map<RuntimeAlias, SessionActivityOwner> = emptyMap(),
|
||||
private val generations: Map<SessionActivityScope, Long> = emptyMap(),
|
||||
) {
|
||||
fun record(owner: SessionActivityOwner): SessionActivityRecord? = records[owner]
|
||||
|
||||
fun ownerForRuntime(scope: SessionActivityScope, runtimeId: String): SessionActivityOwner? =
|
||||
runtimeAliases[RuntimeAlias(scope, runtimeId.trim(), generations[scope] ?: return null)]
|
||||
|
||||
fun presentationStates(nowMillis: Long = Long.MIN_VALUE): Map<SessionActivityOwner, SessionActivityState> =
|
||||
records.mapNotNull { (owner, record) -> record.presentationState(nowMillis)?.let { owner to it } }.toMap()
|
||||
|
||||
fun reduce(update: SessionActivityUpdate): SessionActivityRegistry {
|
||||
if (update is SessionActivityUpdate.Tick) return expirePendingInputs(update.nowMillis)
|
||||
val scope = update.scope()
|
||||
val currentGeneration = generations[scope]
|
||||
if (currentGeneration != null && update.generation < currentGeneration) return this
|
||||
|
||||
var state = this
|
||||
if (currentGeneration == null || update.generation > currentGeneration) {
|
||||
state = state.beginGeneration(scope, update.generation)
|
||||
}
|
||||
|
||||
return when (update) {
|
||||
is SessionActivityUpdate.BeginGeneration -> state
|
||||
is SessionActivityUpdate.ObserveOwner -> state.observeOwner(update)
|
||||
is SessionActivityUpdate.LocalSend -> state.putTurn(
|
||||
update.owner, null, SessionActivityPhase.Starting, SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.LocalSend, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.LiveState -> state.putLiveState(update)
|
||||
is SessionActivityUpdate.RuntimeState -> {
|
||||
val owner = state.ownerForRuntime(update.scope, update.runtimeId) ?: return state
|
||||
state.putTurn(
|
||||
owner, update.runtimeId, update.status.phase(), SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.SessionEvent, update.generation, update.observedAtMillis,
|
||||
)
|
||||
}
|
||||
is SessionActivityUpdate.ActiveList -> state.applyActiveList(update)
|
||||
is SessionActivityUpdate.StatusUnavailable -> state.markUnavailable(update.scope)
|
||||
is SessionActivityUpdate.Terminal -> state.settleTerminal(update)
|
||||
is SessionActivityUpdate.RestoreCheckpoint -> state.restoreCheckpoint(update)
|
||||
is SessionActivityUpdate.PendingInputOpened -> state.updatePendingInput(
|
||||
update.owner, update.requestId, update.expiresAtMillis, true,
|
||||
update.confirmed, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.PendingInputClosed -> state.updatePendingInput(
|
||||
update.owner, update.requestId, null, false,
|
||||
update.confirmed, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.ProcessState -> state.updateProcess(update)
|
||||
is SessionActivityUpdate.Tick -> state
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginGeneration(scope: SessionActivityScope, generation: Long): SessionActivityRegistry {
|
||||
val refreshedRecords = records.mapValues { (owner, record) ->
|
||||
if (owner.scope() == scope) {
|
||||
record.copy(freshness = SessionActivityFreshness.Revalidating)
|
||||
} else record
|
||||
}
|
||||
return copy(
|
||||
records = refreshedRecords,
|
||||
runtimeAliases = runtimeAliases.filterKeys { it.scope != scope },
|
||||
generations = generations + (scope to generation),
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
val observed = SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Revalidating,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
return copy(records = records + (update.owner to observed))
|
||||
}
|
||||
|
||||
private fun putLiveState(update: SessionActivityUpdate.LiveState): SessionActivityRegistry = putTurn(
|
||||
owner = update.owner,
|
||||
runtimeId = update.runtimeId,
|
||||
phase = update.status.phase(),
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
source = update.source,
|
||||
generation = update.generation,
|
||||
observedAtMillis = update.observedAtMillis,
|
||||
)
|
||||
|
||||
private fun putTurn(
|
||||
owner: SessionActivityOwner,
|
||||
runtimeId: String?,
|
||||
phase: SessionActivityPhase,
|
||||
freshness: SessionActivityFreshness,
|
||||
source: SessionActivityEvidenceSource,
|
||||
generation: Long,
|
||||
observedAtMillis: Long,
|
||||
): SessionActivityRegistry {
|
||||
val previous = records[owner]
|
||||
val record = SessionActivityRecord(
|
||||
owner = owner,
|
||||
turnPhase = phase,
|
||||
freshness = freshness,
|
||||
evidence = SessionActivityEvidence(source, generation, observedAtMillis),
|
||||
runtimeId = runtimeId ?: previous?.runtimeId,
|
||||
pendingInputs = previous?.pendingInputs.orEmpty(),
|
||||
backgroundProcessIds = previous?.backgroundProcessIds.orEmpty(),
|
||||
)
|
||||
val alias = runtimeId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return copy(
|
||||
records = records + (owner to record),
|
||||
runtimeAliases = if (alias == null) runtimeAliases else {
|
||||
runtimeAliases + (RuntimeAlias(owner.scope(), alias, generation) to owner)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyActiveList(update: SessionActivityUpdate.ActiveList): SessionActivityRegistry {
|
||||
require(update.runtimes.all { it.owner == null || it.owner.scope() == update.scope }) {
|
||||
"Active-list rows must belong to the snapshot scope"
|
||||
}
|
||||
var state = copy(runtimeAliases = runtimeAliases.filterKeys { it.scope != update.scope })
|
||||
val resolvedRuntimes = update.runtimes.filter { it.owner != null }
|
||||
val observedOwners = resolvedRuntimes.mapTo(mutableSetOf()) { requireNotNull(it.owner) }
|
||||
resolvedRuntimes.forEach { runtime ->
|
||||
val resolvedOwner = requireNotNull(runtime.owner)
|
||||
state = state.putTurn(
|
||||
resolvedOwner, runtime.runtimeId, runtime.status.phase(), SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.ActiveList, update.generation, update.observedAtMillis,
|
||||
)
|
||||
if (runtime.status == SessionLiveStatus.Idle) {
|
||||
val idleRecord = requireNotNull(state.records[resolvedOwner]).copy(pendingInputs = emptyMap())
|
||||
state = state.copy(records = state.records + (resolvedOwner to idleRecord))
|
||||
}
|
||||
}
|
||||
val snapshotCanSettle = update.isCompleteForScope && resolvedRuntimes.size == update.runtimes.size
|
||||
if (!snapshotCanSettle) return state
|
||||
val settled = state.records.mapValues { (owner, record) ->
|
||||
if (
|
||||
owner.scope() == update.scope && owner !in observedOwners &&
|
||||
record.shouldSettleWhenAbsent()
|
||||
) {
|
||||
record.copy(
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
runtimeId = null,
|
||||
pendingInputs = emptyMap(),
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
} else record
|
||||
}
|
||||
return state.copy(records = settled)
|
||||
}
|
||||
|
||||
private fun markUnavailable(scope: SessionActivityScope): SessionActivityRegistry = copy(
|
||||
records = records.mapValues { (owner, record) ->
|
||||
if (
|
||||
owner.scope() == scope && record.evidence.source in setOf(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
SessionActivityEvidenceSource.Checkpoint,
|
||||
)
|
||||
) {
|
||||
record.copy(freshness = SessionActivityFreshness.Unavailable)
|
||||
} else record
|
||||
},
|
||||
)
|
||||
|
||||
private fun settleTerminal(update: SessionActivityUpdate.Terminal): SessionActivityRegistry {
|
||||
val settled = putTurn(
|
||||
update.owner,
|
||||
runtimeId = null,
|
||||
phase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
source = SessionActivityEvidenceSource.Terminal,
|
||||
generation = update.generation,
|
||||
observedAtMillis = update.observedAtMillis,
|
||||
)
|
||||
val record = requireNotNull(settled.records[update.owner]).copy(
|
||||
runtimeId = null,
|
||||
pendingInputs = emptyMap(),
|
||||
)
|
||||
return settled.copy(
|
||||
records = settled.records + (update.owner to record),
|
||||
runtimeAliases = settled.runtimeAliases.filterNot { (alias, owner) ->
|
||||
alias.scope == update.owner.scope() && owner == update.owner &&
|
||||
(update.runtimeId == null || alias.runtimeId == update.runtimeId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreCheckpoint(update: SessionActivityUpdate.RestoreCheckpoint): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
return putTurn(
|
||||
update.owner, update.runtimeId, update.phase, SessionActivityFreshness.Revalidating,
|
||||
SessionActivityEvidenceSource.Checkpoint, update.generation, update.observedAtMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updatePendingInput(
|
||||
owner: SessionActivityOwner,
|
||||
requestId: String,
|
||||
expiresAtMillis: Long?,
|
||||
opened: Boolean,
|
||||
confirmed: Boolean,
|
||||
generation: Long,
|
||||
observedAtMillis: Long,
|
||||
): SessionActivityRegistry {
|
||||
val previous = records[owner] ?: SessionActivityRecord(
|
||||
owner = owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = if (confirmed) {
|
||||
SessionActivityFreshness.Confirmed
|
||||
} else {
|
||||
SessionActivityFreshness.Revalidating
|
||||
},
|
||||
evidence = SessionActivityEvidence(
|
||||
if (confirmed) {
|
||||
SessionActivityEvidenceSource.PendingInput
|
||||
} else {
|
||||
SessionActivityEvidenceSource.Checkpoint
|
||||
},
|
||||
generation,
|
||||
observedAtMillis,
|
||||
),
|
||||
)
|
||||
val pending = if (opened) {
|
||||
previous.pendingInputs + (requestId to expiresAtMillis)
|
||||
} else {
|
||||
previous.pendingInputs - requestId
|
||||
}
|
||||
return copy(records = records + (owner to previous.copy(
|
||||
pendingInputs = pending,
|
||||
freshness = if (confirmed) SessionActivityFreshness.Confirmed else previous.freshness,
|
||||
evidence = if (confirmed) {
|
||||
SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.PendingInput,
|
||||
generation,
|
||||
observedAtMillis,
|
||||
)
|
||||
} else previous.evidence,
|
||||
)))
|
||||
}
|
||||
|
||||
private fun updateProcess(update: SessionActivityUpdate.ProcessState): SessionActivityRegistry {
|
||||
val previous = records[update.owner] ?: SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Process,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
val processes = if (update.running) {
|
||||
previous.backgroundProcessIds + update.processId
|
||||
} else {
|
||||
previous.backgroundProcessIds - update.processId
|
||||
}
|
||||
return copy(records = records + (update.owner to previous.copy(
|
||||
backgroundProcessIds = processes,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Process,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)))
|
||||
}
|
||||
|
||||
private fun expirePendingInputs(nowMillis: Long): SessionActivityRegistry = copy(
|
||||
records = records.mapValues { (_, record) ->
|
||||
record.copy(pendingInputs = record.pendingInputs.filterValues { it == null || it > nowMillis })
|
||||
},
|
||||
)
|
||||
|
||||
private fun SessionActivityRecord.shouldSettleWhenAbsent(): Boolean =
|
||||
runtimeId != null || evidence.source in setOf(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
SessionActivityEvidenceSource.Checkpoint,
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
)
|
||||
|
||||
private fun SessionActivityUpdate.scope(): SessionActivityScope = when (this) {
|
||||
is SessionActivityUpdate.BeginGeneration -> scope
|
||||
is SessionActivityUpdate.ObserveOwner -> owner.scope()
|
||||
is SessionActivityUpdate.RuntimeState -> scope
|
||||
is SessionActivityUpdate.ActiveList -> scope
|
||||
is SessionActivityUpdate.StatusUnavailable -> scope
|
||||
is SessionActivityUpdate.Terminal -> owner.scope()
|
||||
is SessionActivityUpdate.LocalSend -> owner.scope()
|
||||
is SessionActivityUpdate.LiveState -> owner.scope()
|
||||
is SessionActivityUpdate.RestoreCheckpoint -> owner.scope()
|
||||
is SessionActivityUpdate.PendingInputOpened -> owner.scope()
|
||||
is SessionActivityUpdate.PendingInputClosed -> owner.scope()
|
||||
is SessionActivityUpdate.ProcessState -> owner.scope()
|
||||
is SessionActivityUpdate.Tick -> error("Tick has no scope")
|
||||
}
|
||||
|
||||
private fun SessionActivityOwner.scope() = SessionActivityScope.of(connectionId, profile)
|
||||
|
||||
private fun SessionLiveStatus.phase(): SessionActivityPhase = when (this) {
|
||||
SessionLiveStatus.Starting -> SessionActivityPhase.Starting
|
||||
SessionLiveStatus.Working -> SessionActivityPhase.Working
|
||||
SessionLiveStatus.Waiting -> SessionActivityPhase.NeedsInput
|
||||
SessionLiveStatus.Idle -> SessionActivityPhase.Idle
|
||||
}
|
||||
|
||||
data class RuntimeAlias(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimeId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package com.hermesandroid.relay.data
|
||||
|
||||
/** Live activity surfaced beside a session without conflating it with selection. */
|
||||
enum class SessionActivityState {
|
||||
Starting,
|
||||
Working,
|
||||
NeedsInput,
|
||||
BackgroundWork,
|
||||
Checking,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -1444,4 +1445,86 @@ class RelayHttpClient(
|
||||
val value = header?.trim()?.lowercase() ?: return false
|
||||
return value == "1" || value == "true"
|
||||
}
|
||||
|
||||
/** Provider-neutral compatibility fetch for gateways without `account.usage`. */
|
||||
suspend fun fetchProviderUsage(
|
||||
profile: String? = null,
|
||||
sessionId: String? = null,
|
||||
): Result<ProviderUsageResponse?> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = "$httpBase/usage/providers".toHttpUrlOrNull()
|
||||
?.newBuilder()
|
||||
?.apply {
|
||||
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
addQueryParameter("profile", it)
|
||||
}
|
||||
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
addQueryParameter("session_id", it)
|
||||
}
|
||||
}
|
||||
?.build()
|
||||
?: return@withContext Result.failure(
|
||||
IllegalArgumentException("Invalid relay URL: $httpBase")
|
||||
)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
// Older or operator-disabled hosts simply do not expose
|
||||
// account usage. This is capability absence, not an error.
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
502 -> "Provider usage upstream error (HTTP ${response.code})"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Empty response body"))
|
||||
}
|
||||
val parsed = runCatching {
|
||||
sessionsJson.decodeFromString(
|
||||
ProviderUsageResponse.serializer(),
|
||||
body,
|
||||
)
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "fetchProviderUsage parse error: ${it.message}")
|
||||
return@withContext Result.failure(IOException("Unrecognized usage payload"))
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchProviderUsage failed: ${e.message}")
|
||||
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchProviderUsage unexpected error: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,7 +2075,7 @@ class ChatHandler {
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
recentlyActive = item.isActive,
|
||||
updatedAt = activityAtMs,
|
||||
startedAt = startedAtMs,
|
||||
lastActivityAt = lastActivityAtMs,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network.upstream
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -56,6 +57,7 @@ import okio.BufferedSink
|
||||
@Serializable
|
||||
data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
@SerialName("install_id") val installId: String? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
@@ -447,6 +449,25 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun getConfig(): Result<JsonObject> = getJsonObject("/api/config")
|
||||
|
||||
suspend fun getProviderUsage(
|
||||
profile: String? = null,
|
||||
sessionId: String? = null,
|
||||
): Result<ProviderUsageResponse?> {
|
||||
val query = buildList {
|
||||
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
add("profile=${queryValue(it)}")
|
||||
}
|
||||
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
add("session_id=${queryValue(it)}")
|
||||
}
|
||||
}
|
||||
val suffix = query.joinToString(prefix = if (query.isEmpty()) "" else "?", separator = "&")
|
||||
return getJsonObject("/api/plugins/hermes-relay/provider-usage$suffix")
|
||||
.mapCatching { root ->
|
||||
json.decodeFromJsonElement(ProviderUsageResponse.serializer(), root)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The config SCHEMA: `{fields: {<dot.path>: {type, description, category,
|
||||
* options?}}, category_order: [...]}`. Describes how to render each field;
|
||||
@@ -1464,8 +1485,16 @@ class DashboardApiClient(
|
||||
fun authLoginUrl(provider: String, next: String = "/"): String =
|
||||
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
|
||||
|
||||
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
|
||||
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
|
||||
fun gatewayWebSocketUrl(
|
||||
ticket: String,
|
||||
path: String = "/api/ws",
|
||||
profile: String? = null,
|
||||
): String? = gatewayWebSocketUrl(
|
||||
baseUrl = baseUrl,
|
||||
ticket = ticket,
|
||||
path = path,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
fun shutdown() = shutdownOffMainThread("DashboardApiClient-shutdown") {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
@@ -1722,6 +1751,7 @@ class DashboardApiClient(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
?: false,
|
||||
installId = root.stringField("install_id")?.trim()?.takeIf(String::isNotEmpty)?.take(256),
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
|
||||
@@ -14,6 +14,13 @@ import com.hermesandroid.relay.data.GatewayProfilePatch
|
||||
import com.hermesandroid.relay.data.GatewayProfileSection
|
||||
import com.hermesandroid.relay.data.GatewayProfileSkill
|
||||
import com.hermesandroid.relay.data.GatewayProfileToolset
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotGroupMember
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.BotSessionSummary
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
@@ -45,6 +52,7 @@ import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -91,6 +99,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
*/
|
||||
class GatewayChatClient(
|
||||
initialDashboardClient: DashboardApiClient,
|
||||
private val fixedSessionProfile: String? = null,
|
||||
okHttpClient: OkHttpClient? = null,
|
||||
private val callbackDispatcher: (block: () -> Unit) -> Unit = MainThreadDispatcher,
|
||||
/** Surface for "this server has no usable /api/ws" — flips availability to Unsupported. */
|
||||
@@ -123,6 +132,7 @@ class GatewayChatClient(
|
||||
private var profileSetAssetSupported: Boolean? = null
|
||||
companion object {
|
||||
private const val TAG = "GatewayChatClient"
|
||||
private const val BOT_CHAT_TITLE = "Bot Chat"
|
||||
|
||||
/**
|
||||
* Idle-progress turn watchdog — reset on EVERY received gateway event
|
||||
@@ -269,6 +279,12 @@ class GatewayChatClient(
|
||||
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
|
||||
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
|
||||
|
||||
/** Per-socket capability for upstream's process-wide live-session snapshot. */
|
||||
private val _activeSessionCapability =
|
||||
MutableStateFlow(GatewayActiveSessionCapability.Unknown)
|
||||
val activeSessionCapability: StateFlow<GatewayActiveSessionCapability> =
|
||||
_activeSessionCapability.asStateFlow()
|
||||
|
||||
/**
|
||||
* Active personality the gateway is applying, as a config value ("none" when
|
||||
* the overlay is cleared, otherwise the personality name). Tracks the
|
||||
@@ -389,7 +405,8 @@ class GatewayChatClient(
|
||||
var sessionProfileProvider: () -> String? = { null }
|
||||
|
||||
private fun currentSessionProfile(): String? =
|
||||
sessionProfileProvider().takeIf { !it.isNullOrBlank() }
|
||||
fixedSessionProfile?.trim()?.takeIf(String::isNotBlank)
|
||||
?: sessionProfileProvider().takeIf { !it.isNullOrBlank() }
|
||||
|
||||
/**
|
||||
* Supplies non-model overrides for each fresh `session.create`. Model and
|
||||
@@ -787,10 +804,30 @@ class GatewayChatClient(
|
||||
*/
|
||||
fun hasActiveTurn(): Boolean = activeTurn?.ended == false || backgroundTurns.isNotEmpty()
|
||||
|
||||
/** True only when [storedId] still owns a foreground or deliberately detached turn. */
|
||||
fun hasActiveTurnForSession(storedId: String): Boolean =
|
||||
(activeTurn?.ended == false && storedSessionId == storedId) ||
|
||||
backgroundTurns.values.any { it.storedSessionId == storedId }
|
||||
|
||||
/** Live id to persist beside a durable stored id while a turn is active. */
|
||||
fun currentLiveSessionId(storedId: String): String? =
|
||||
liveSessionId?.takeIf { storedSessionId == storedId }
|
||||
|
||||
/**
|
||||
* Exact durable/profile owner already held by this client for [runtimeId].
|
||||
* Unlike `session.active_list`, this mapping is safe for multiplexed profiles
|
||||
* because Android recorded it when the runtime was created/resumed/detached.
|
||||
*/
|
||||
fun knownSessionOwner(runtimeId: String): GatewayKnownSessionOwner? {
|
||||
if (runtimeId == liveSessionId) {
|
||||
val storedId = storedSessionId ?: return null
|
||||
return GatewayKnownSessionOwner(storedId, liveSessionProfile)
|
||||
}
|
||||
return backgroundTurns[runtimeId]?.let { owner ->
|
||||
GatewayKnownSessionOwner(owner.storedSessionId, owner.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
|
||||
* sustained network change). If a turn is in flight, the current socket is
|
||||
@@ -1416,6 +1453,21 @@ class GatewayChatClient(
|
||||
.onSuccess { commandsCatalogCache = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral account limits owned by upstream Hermes. Current hosts
|
||||
* may not expose this additive method yet; callers should treat JSON-RPC
|
||||
* method-not-found as capability absence and use the optional Relay
|
||||
* compatibility surface when paired.
|
||||
*/
|
||||
suspend fun providerUsage(): Result<JsonObject> {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
return rpc("account.usage", JsonObject(emptyMap()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a schedule through upstream's authenticated `cron.manage` RPC.
|
||||
* No Relay scheduler or compatibility endpoint is involved.
|
||||
@@ -1480,6 +1532,102 @@ class GatewayChatClient(
|
||||
}.onSuccess { profileListSupported = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich Bot Mode roster from the upstream Gateway. Kept separate from
|
||||
* [listProfiles] because session previews and room projections are useful
|
||||
* to the messenger surface but needlessly expensive for ordinary profile
|
||||
* selectors.
|
||||
*/
|
||||
suspend fun listBotModeRoster(): Result<BotModeRoster> {
|
||||
if (profileListSupported == false) {
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
val response = rpc(
|
||||
"profiles.list",
|
||||
buildJsonObject { put("include_sessions", true) },
|
||||
)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileListSupported = false
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
|
||||
}
|
||||
return response.mapCatching(::parseBotModeRoster)
|
||||
.onSuccess { profileListSupported = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the profile's one canonical hidden `Bot Chat`, creating it only
|
||||
* after an authoritative exact-title lookup returned no row. Lookup errors
|
||||
* fail closed so a transient connection problem can never fork the bot's
|
||||
* durable conversation.
|
||||
*/
|
||||
suspend fun ensureCanonicalBotChat(profileName: String): Result<BotChatTarget> = runCatching {
|
||||
val profile = profileName.trim().takeIf(String::isNotEmpty)
|
||||
?: throw IllegalArgumentException("profile name required")
|
||||
connectMutex.withLock {
|
||||
ensureConnected()
|
||||
val existing = rpc(
|
||||
"session.list",
|
||||
buildJsonObject {
|
||||
put("profile", profile)
|
||||
put("title", BOT_CHAT_TITLE)
|
||||
put("include_hidden", true)
|
||||
put("limit", 200)
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException(
|
||||
"Could not check $profile's Bot Chat registry: ${error.message}",
|
||||
)
|
||||
}
|
||||
val row = (existing["sessions"] as? JsonArray)
|
||||
?.firstOrNull() as? JsonObject
|
||||
if (row != null) {
|
||||
val stored = row.stringField("id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayPreflightException("Bot Chat registry returned no session id")
|
||||
val resolved = row.stringField("resolved_id")?.takeIf(String::isNotBlank) ?: stored
|
||||
return@withLock BotChatTarget(storedSessionId = stored, resolvedSessionId = resolved)
|
||||
}
|
||||
|
||||
if (hasActiveTurn()) {
|
||||
throw GatewayPreflightException("Wait for the current Hermes turn to finish before creating Bot Chat")
|
||||
}
|
||||
val created = rpc(
|
||||
"session.create",
|
||||
buildJsonObject {
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
put("profile", profile)
|
||||
put("title", BOT_CHAT_TITLE)
|
||||
put("hidden", true)
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException("Bot Chat creation failed: ${error.message}")
|
||||
}
|
||||
requireConfirmedSessionProfile(created, profile)
|
||||
val live = created.stringField("session_id")
|
||||
?: throw GatewayPreflightException("Bot Chat creation returned no session id")
|
||||
val stored = created.stringField("stored_session_id") ?: live
|
||||
|
||||
// `session.create` is lazy. Title the live runtime immediately so
|
||||
// the durable exact-title registry exists before navigation or a
|
||||
// second tap; newer upstream materializes the row here.
|
||||
rpc(
|
||||
"session.title",
|
||||
buildJsonObject {
|
||||
put("session_id", live)
|
||||
put("title", BOT_CHAT_TITLE)
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException("Bot Chat could not be materialized: ${error.message}")
|
||||
}
|
||||
BotChatTarget(storedSessionId = stored, resolvedSessionId = stored)
|
||||
}
|
||||
}
|
||||
|
||||
/** Create through the Gateway so auth behavior is explicit and server-owned. */
|
||||
suspend fun createProfile(request: GatewayProfileCreateRequest): Result<GatewayProfileCreateResult> {
|
||||
if (profileCreateSupported == false) {
|
||||
@@ -1939,6 +2087,47 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch authoritative in-memory execution states from current upstream
|
||||
* Hermes. `session.active_list` is process-wide: it accepts only an optional
|
||||
* current runtime id and does not profile-filter its rows. Accordingly this
|
||||
* transport returns rows unscoped and never derives activity from REST
|
||||
* `is_active` or stamps the selected profile onto a row.
|
||||
*/
|
||||
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
|
||||
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
|
||||
return GatewayActiveSessionsResult.Unsupported
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (error: Exception) {
|
||||
return GatewayActiveSessionsResult.TransientFailure(error)
|
||||
}
|
||||
val result = rpc(
|
||||
"session.active_list",
|
||||
buildJsonObject {
|
||||
liveSessionId?.let { put("current_session_id", it) }
|
||||
},
|
||||
)
|
||||
val error = result.exceptionOrNull()
|
||||
if (error.isMethodNotFound()) {
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
|
||||
return GatewayActiveSessionsResult.Unsupported
|
||||
}
|
||||
if (error != null) {
|
||||
return GatewayActiveSessionsResult.TransientFailure(error)
|
||||
}
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
|
||||
return try {
|
||||
val payload = result.getOrThrow()
|
||||
val rows = payload["sessions"] as? JsonArray
|
||||
?: throw GatewayRpcException("session.active_list returned no sessions array")
|
||||
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
|
||||
} catch (parseError: Exception) {
|
||||
GatewayActiveSessionsResult.TransientFailure(parseError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop one process owned by the current live gateway session. */
|
||||
suspend fun killProcess(processId: String): Result<Unit> {
|
||||
if (processId.isBlank()) {
|
||||
@@ -2377,13 +2566,17 @@ class GatewayChatClient(
|
||||
private suspend fun connectOnce() {
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
|
||||
}
|
||||
val ticketMs = (System.nanoTime() - connectStart) / 1_000_000
|
||||
val url = dashboardClient.gatewayWebSocketUrl(ticket.ticket)
|
||||
val url = dashboardClient.gatewayWebSocketUrl(
|
||||
ticket = ticket.ticket,
|
||||
profile = currentSessionProfile(),
|
||||
)
|
||||
?: throw GatewayConnectAttemptException("could not build /api/ws URL")
|
||||
|
||||
_connectionState.value = GatewayConnectionState.Connecting
|
||||
@@ -2620,6 +2813,28 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseGatewayActiveSession(
|
||||
element: kotlinx.serialization.json.JsonElement,
|
||||
): GatewayActiveSession {
|
||||
val row = element as? JsonObject
|
||||
?: throw GatewayRpcException("session.active_list returned a non-object row")
|
||||
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayRpcException("session.active_list row returned no runtime id")
|
||||
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayRpcException("session.active_list row returned no session key")
|
||||
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
|
||||
?: throw GatewayRpcException("session.active_list row returned an unknown status")
|
||||
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
|
||||
?: throw GatewayRpcException("session.active_list row returned no last_active")
|
||||
return GatewayActiveSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = storedId,
|
||||
status = status,
|
||||
lastActiveEpochSeconds = lastActive,
|
||||
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
|
||||
)
|
||||
}
|
||||
|
||||
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
|
||||
if (error.isMethodNotFound()) {
|
||||
_processCapability.value = GatewayProcessCapability.Unsupported
|
||||
@@ -3093,6 +3308,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
pendingRpcs.values.forEach {
|
||||
@@ -3278,6 +3494,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
}
|
||||
@@ -4149,6 +4366,122 @@ data class GatewayCompressResult(
|
||||
get() = messages.isNotEmpty()
|
||||
}
|
||||
|
||||
internal fun parseBotModeRoster(payload: JsonObject): BotModeRoster {
|
||||
val rawRows = (payload["profiles"] as? JsonArray).orEmpty()
|
||||
val rows = rawRows.mapNotNull { it as? JsonObject }
|
||||
val bots = rows.mapNotNull(::parseBotRosterEntry)
|
||||
val defaultRow = rows.firstOrNull {
|
||||
(it["is_default"] as? JsonPrimitive)?.booleanOrNull == true
|
||||
} ?: rows.firstOrNull { it.stringField("name") == "default" }
|
||||
return BotModeRoster(
|
||||
bots = bots,
|
||||
groups = parseBotGroupRooms(defaultRow),
|
||||
botModeProtocolSupported =
|
||||
(payload["bot_mode_protocol"] as? JsonPrimitive)?.booleanOrNull == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
|
||||
val name = row.stringField("name")?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
val uiMeta = (row["ui_meta"] as? JsonObject)
|
||||
?.takeIf { it.toString().toByteArray(Charsets.UTF_8).size <= 65_536 }
|
||||
?: JsonObject(emptyMap())
|
||||
val botMeta = uiMeta["hermes-bots"] as? JsonObject
|
||||
val title = botMeta?.stringField("title")?.trim()?.take(128).orEmpty()
|
||||
val displayName = title.takeIf(String::isNotBlank)
|
||||
?: row.stringField("display_name")?.trim()?.takeIf(String::isNotBlank)?.take(128)
|
||||
?: name
|
||||
return BotRosterEntry(
|
||||
profile = Profile(
|
||||
name = name,
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description")?.take(512).orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
),
|
||||
displayName = displayName,
|
||||
botTitle = title,
|
||||
hidden = (botMeta?.get("hidden") as? JsonPrimitive)?.booleanOrNull == true,
|
||||
lastSession = parseBotSessionSummary(row["last_session"] as? JsonObject),
|
||||
workerSession = parseBotSessionSummary(row["worker_session"] as? JsonObject),
|
||||
canonicalSession = parseBotSessionSummary(row["canonical_session"] as? JsonObject),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBotSessionSummary(row: JsonObject?): BotSessionSummary? {
|
||||
row ?: return null
|
||||
val id = row.stringField("id")?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return BotSessionSummary(
|
||||
id = id,
|
||||
resolvedId = row.stringField("resolved_id")?.trim()?.takeIf(String::isNotEmpty) ?: id,
|
||||
title = row.stringField("title")?.take(256).orEmpty(),
|
||||
rootTitle = row.stringField("root_title")?.take(256).orEmpty(),
|
||||
preview = row.stringField("preview")?.take(512).orEmpty(),
|
||||
startedAtMs = normalizeHermesEpoch(row.longField("started_at")),
|
||||
lastActiveAtMs = normalizeHermesEpoch(row.longField("last_active")),
|
||||
messageCount = (row["message_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBotGroupRooms(defaultRow: JsonObject?): List<BotGroupRoom> {
|
||||
val uiMeta = defaultRow?.get("ui_meta") as? JsonObject ?: return emptyList()
|
||||
if (uiMeta.toString().toByteArray(Charsets.UTF_8).size > 65_536) return emptyList()
|
||||
val snapshot = uiMeta["hermes-bots-groups"] as? JsonObject ?: return emptyList()
|
||||
val rooms = snapshot["rooms"] as? JsonObject ?: return emptyList()
|
||||
return rooms.entries.take(64).mapNotNull { (key, raw) ->
|
||||
val room = raw as? JsonObject ?: return@mapNotNull null
|
||||
val name = room.stringField("name")?.trim()?.takeIf(String::isNotEmpty)?.take(128)
|
||||
?: key.substringAfter(':').take(128)
|
||||
val members = (room["members"] as? JsonArray).orEmpty().take(6).mapNotNull { memberRaw ->
|
||||
val member = memberRaw as? JsonObject ?: return@mapNotNull null
|
||||
val memberName = member.stringField("name")?.trim()?.takeIf(String::isNotEmpty)
|
||||
?: return@mapNotNull null
|
||||
BotGroupMember(
|
||||
name = memberName.take(128),
|
||||
handle = member.stringField("handle")?.take(128),
|
||||
connectionId = member.stringField("connectionId")?.take(128),
|
||||
connectionLabel = member.stringField("connectionLabel")?.take(128),
|
||||
)
|
||||
}
|
||||
val messages = (room["log"] as? JsonArray).orEmpty().takeLast(16).mapNotNull { messageRaw ->
|
||||
val message = messageRaw as? JsonObject ?: return@mapNotNull null
|
||||
val from = message["from"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
val text = message.stringField("text")?.trim()?.takeIf(String::isNotEmpty)?.take(1_200)
|
||||
?: return@mapNotNull null
|
||||
BotGroupMessage(
|
||||
id = message.stringField("id")?.take(160),
|
||||
senderName = from.stringField("name")?.trim()?.takeIf(String::isNotEmpty)?.take(128)
|
||||
?: "Bot",
|
||||
senderKind = from.stringField("kind")?.take(32) ?: "member",
|
||||
senderSource = from.stringField("source")?.take(128),
|
||||
text = text,
|
||||
atMs = normalizeHermesEpoch(message.longField("at")),
|
||||
)
|
||||
}
|
||||
BotGroupRoom(
|
||||
key = key.take(256),
|
||||
roomId = room.stringField("roomId")?.take(128),
|
||||
name = name,
|
||||
revision = room.longField("revision"),
|
||||
members = members,
|
||||
messages = messages,
|
||||
)
|
||||
}.sortedByDescending(BotGroupRoom::latestActivityAtMs)
|
||||
}
|
||||
|
||||
private fun JsonObject.longField(key: String): Long =
|
||||
(get(key) as? JsonPrimitive)?.longOrNull
|
||||
?: (get(key) as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.toLong()
|
||||
?: 0L
|
||||
|
||||
private fun normalizeHermesEpoch(value: Long): Long = when {
|
||||
value <= 0L -> 0L
|
||||
value < 10_000_000_000L -> value * 1_000L
|
||||
else -> value
|
||||
}
|
||||
|
||||
private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
|
||||
|
||||
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/** Authoritative execution state reported by upstream `session.active_list`. */
|
||||
enum class GatewayActiveSessionStatus(val wireValue: String) {
|
||||
Idle("idle"),
|
||||
Starting("starting"),
|
||||
Working("working"),
|
||||
Waiting("waiting");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
|
||||
"idle" -> Idle
|
||||
"starting" -> Starting
|
||||
"working" -> Working
|
||||
"waiting" -> Waiting
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-memory runtime returned by upstream `session.active_list`.
|
||||
*
|
||||
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
|
||||
* identify their profile, so [profile] stays null unless a future gateway
|
||||
* explicitly sends one. Callers must resolve [storedSessionId] against their
|
||||
* own profile-scoped session registry and fail closed when ownership is
|
||||
* ambiguous; the transport never synthesizes profile attribution.
|
||||
*/
|
||||
data class GatewayActiveSession(
|
||||
/** Per-process runtime id used by live Gateway events and session RPCs. */
|
||||
val runtimeSessionId: String,
|
||||
/** Durable history id (`session_key`) used by the REST/session database. */
|
||||
val storedSessionId: String,
|
||||
val status: GatewayActiveSessionStatus,
|
||||
/** Unix epoch seconds from upstream's in-memory runtime record. */
|
||||
val lastActiveEpochSeconds: Double,
|
||||
/** Future-compatible only; null for the current upstream contract. */
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
/** Exact owner already known by this client for a foreground or detached runtime. */
|
||||
data class GatewayKnownSessionOwner(
|
||||
val storedSessionId: String,
|
||||
val profile: String?,
|
||||
)
|
||||
|
||||
/** Whether the current Gateway socket exposes `session.active_list`. */
|
||||
enum class GatewayActiveSessionCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of one process-wide live-session snapshot request. Unsupported is
|
||||
* intentionally distinct from transport/protocol failure so callers can use
|
||||
* another source only for older gateways, while failures remain Unknown.
|
||||
*/
|
||||
sealed interface GatewayActiveSessionsResult {
|
||||
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
|
||||
data object Unsupported : GatewayActiveSessionsResult
|
||||
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-level background-process events. These are deliberately separate
|
||||
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
|
||||
|
||||
@@ -250,6 +250,7 @@ data class SessionItem(
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
|
||||
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
|
||||
/** REST recency heuristic from upstream; not live Gateway execution state. */
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@SerialName("has_model_config")
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.hermesandroid.relay.network.usage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ProviderUsageResponse(
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
@SerialName("fetched_at") val fetchedAt: String? = null,
|
||||
val capabilities: Set<String> = emptySet(),
|
||||
val providers: List<ProviderUsageProvider> = emptyList(),
|
||||
) {
|
||||
val relayEnhanced: Boolean
|
||||
get() = capabilities.containsAll(RELAY_ENHANCED_CAPABILITIES)
|
||||
|
||||
companion object {
|
||||
val RELAY_ENHANCED_CAPABILITIES = setOf(
|
||||
"credential_pools",
|
||||
"structured_balances",
|
||||
"opencode_go",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProviderUsageProvider(
|
||||
val id: String,
|
||||
@SerialName("display_name") val displayName: String,
|
||||
val status: String,
|
||||
val source: String? = null,
|
||||
@SerialName("fetched_at") val fetchedAt: String? = null,
|
||||
val plan: String? = null,
|
||||
val windows: List<ProviderUsageWindow> = emptyList(),
|
||||
val details: List<String> = emptyList(),
|
||||
val balances: List<ProviderUsageBalance> = emptyList(),
|
||||
@SerialName("renews_at") val renewsAt: String? = null,
|
||||
@SerialName("action_url") val actionUrl: String? = null,
|
||||
val credentials: List<ProviderUsageCredential> = emptyList(),
|
||||
@SerialName("active_credential_id") val activeCredentialId: String? = null,
|
||||
@SerialName("active_credential_state") val activeCredentialState: String = "unknown",
|
||||
@SerialName("active_observed_at") val activeObservedAt: String? = null,
|
||||
val message: String? = null,
|
||||
) {
|
||||
val available: Boolean get() = status == STATUS_AVAILABLE
|
||||
|
||||
companion object {
|
||||
const val STATUS_AVAILABLE = "available"
|
||||
const val STATUS_NOT_CONFIGURED = "not_configured"
|
||||
const val STATUS_UNAVAILABLE = "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProviderUsageBalance(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val amount: Double,
|
||||
val currency: String = "USD",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProviderUsageCredential(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val active: Boolean = false,
|
||||
val status: String,
|
||||
@SerialName("pool_status") val poolStatus: String? = null,
|
||||
@SerialName("last_status_at") val lastStatusAt: String? = null,
|
||||
@SerialName("reset_at") val resetAt: String? = null,
|
||||
val plan: String? = null,
|
||||
val windows: List<ProviderUsageWindow> = emptyList(),
|
||||
val details: List<String> = emptyList(),
|
||||
val message: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val STATUS_AVAILABLE = "available"
|
||||
const val STATUS_AT_LIMIT = "at_limit"
|
||||
const val STATUS_UNAVAILABLE = "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProviderUsageWindow(
|
||||
val id: String,
|
||||
val label: String,
|
||||
@SerialName("used_percent") val usedPercent: Double? = null,
|
||||
@SerialName("reset_at") val resetAt: String? = null,
|
||||
val detail: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.hermesandroid.relay.network.usage
|
||||
|
||||
import com.hermesandroid.relay.network.relay.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
/** Relay-enhanced usage with an upstream fallback for hosts without Relay support. */
|
||||
class ProviderUsageRepository(
|
||||
private val gatewayClientProvider: () -> GatewayChatClient?,
|
||||
private val dashboardClientProvider: () -> DashboardApiClient? = { null },
|
||||
private val relayHttpClient: RelayHttpClient,
|
||||
private val profileProvider: () -> String? = { null },
|
||||
private val sessionProvider: () -> String? = { null },
|
||||
) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
suspend fun fetch(): Result<ProviderUsageResponse?> {
|
||||
val profile = profileProvider()
|
||||
val session = sessionProvider()
|
||||
val dashboard = dashboardClientProvider()
|
||||
if (dashboard != null) {
|
||||
val enhanced = dashboard.getProviderUsage(profile, session)
|
||||
if (enhanced.isSuccess && enhanced.getOrNull() != null) return enhanced
|
||||
}
|
||||
|
||||
val relay = relayHttpClient.fetchProviderUsage(
|
||||
profile = profile,
|
||||
sessionId = session,
|
||||
)
|
||||
if (relay.isSuccess && relay.getOrNull() != null) return relay
|
||||
|
||||
val gateway = gatewayClientProvider()
|
||||
if (gateway != null) {
|
||||
val upstream = gateway.providerUsage()
|
||||
.mapCatching { json.decodeFromJsonElement<ProviderUsageResponse>(it) }
|
||||
if (upstream.isSuccess) return upstream
|
||||
}
|
||||
return relay
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -153,6 +154,9 @@ import com.hermesandroid.relay.ui.screens.DiagnosticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.BridgeScreen
|
||||
// === PHASE3-safety-rails: bridge safety route ===
|
||||
import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.BotGroupDetailScreen
|
||||
import com.hermesandroid.relay.ui.screens.BotChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.BotModeScreen
|
||||
// === END PHASE3-safety-rails ===
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
@@ -166,6 +170,7 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
|
||||
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
@@ -397,6 +402,28 @@ sealed class Screen(
|
||||
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
data object BotMode : Screen("bot_mode", "Bot Mode", Icons.Filled.Groups)
|
||||
data object BotGroup : Screen(
|
||||
"bot_mode/groups/{roomKey}",
|
||||
"Bot group",
|
||||
Icons.Filled.Groups,
|
||||
) {
|
||||
const val ARG_ROOM_KEY: String = "roomKey"
|
||||
fun route(roomKey: String): String =
|
||||
"bot_mode/groups/${android.net.Uri.encode(roomKey)}"
|
||||
}
|
||||
data object BotChat : Screen(
|
||||
"bot_mode/chat/{connectionId}/{profileName}/{sessionId}",
|
||||
"Bot Chat",
|
||||
Icons.AutoMirrored.Filled.Chat,
|
||||
) {
|
||||
const val ARG_CONNECTION_ID: String = "connectionId"
|
||||
const val ARG_PROFILE_NAME: String = "profileName"
|
||||
const val ARG_SESSION_ID: String = "sessionId"
|
||||
fun route(connectionId: String, profileName: String, sessionId: String): String =
|
||||
"bot_mode/chat/${android.net.Uri.encode(connectionId)}/" +
|
||||
"${android.net.Uri.encode(profileName)}/${android.net.Uri.encode(sessionId)}"
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
|
||||
@@ -504,6 +531,7 @@ sealed class Screen(
|
||||
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
|
||||
// above for the surviving route.)
|
||||
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
|
||||
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
data object CustomTheme : Screen("settings/appearance/custom-theme", "Custom", Icons.Filled.Settings)
|
||||
@@ -672,6 +700,7 @@ fun RelayApp() {
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
|
||||
@@ -2027,8 +2056,108 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.BotMode.route) {
|
||||
BotModeScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenBotChat = { route, sessionId ->
|
||||
navController.navigate(
|
||||
Screen.BotChat.route(
|
||||
connectionId = route.connectionId,
|
||||
profileName = route.profileName,
|
||||
sessionId = sessionId,
|
||||
),
|
||||
)
|
||||
},
|
||||
onOpenGroup = { roomKey ->
|
||||
navController.navigate(Screen.BotGroup.route(roomKey))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.BotGroup.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.BotGroup.ARG_ROOM_KEY) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val roomKey = entry.arguments?.getString(Screen.BotGroup.ARG_ROOM_KEY)
|
||||
val botModeState by connectionViewModel.botModeState.collectAsState()
|
||||
BotGroupDetailScreen(
|
||||
room = botModeState.roster.groups.firstOrNull { it.key == roomKey },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.BotChat.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.BotChat.ARG_CONNECTION_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.BotChat.ARG_PROFILE_NAME) { type = NavType.StringType },
|
||||
navArgument(Screen.BotChat.ARG_SESSION_ID) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val connectionId = entry.arguments?.getString(Screen.BotChat.ARG_CONNECTION_ID).orEmpty()
|
||||
val profileName = entry.arguments?.getString(Screen.BotChat.ARG_PROFILE_NAME).orEmpty()
|
||||
val sessionId = entry.arguments?.getString(Screen.BotChat.ARG_SESSION_ID).orEmpty()
|
||||
val botModeState by connectionViewModel.botModeState.collectAsState()
|
||||
val connection = connections.firstOrNull { it.id == connectionId }
|
||||
val bot = botModeState.roster.bots.firstOrNull {
|
||||
it.route?.connectionId == connectionId && it.profile.name == profileName
|
||||
}
|
||||
val route = bot?.route ?: connection?.let {
|
||||
com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey(connectionId, profileName),
|
||||
connectionLabel = it.label,
|
||||
)
|
||||
}
|
||||
val currentRouteUrl = connection?.let {
|
||||
if (it.id == activeConnectionId) effectiveDashboardUrl else it.resolvedDashboardUrl
|
||||
}.orEmpty()
|
||||
val lease = remember(route?.key, currentRouteUrl) {
|
||||
route?.let(connectionViewModel::acquireBotGateway)?.getOrNull()
|
||||
}
|
||||
val botDashboardClient = remember(route?.key, currentRouteUrl) {
|
||||
route?.let(connectionViewModel::botDashboardClient)?.getOrNull()
|
||||
}
|
||||
DisposableEffect(lease, botDashboardClient) {
|
||||
onDispose {
|
||||
lease?.close()
|
||||
botDashboardClient?.shutdown()
|
||||
}
|
||||
}
|
||||
if (
|
||||
route == null || bot == null || lease == null ||
|
||||
botDashboardClient == null || sessionId.isBlank()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_chat_open_failed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val botChatViewModel: ChatViewModel = viewModel(
|
||||
key = "bot-chat:${route.connectionId}:${route.profileName}:$sessionId",
|
||||
)
|
||||
BotChatScreen(
|
||||
route = route,
|
||||
bot = bot,
|
||||
sessionId = sessionId,
|
||||
gatewayClient = lease.client,
|
||||
dashboardClient = botDashboardClient,
|
||||
chatViewModel = botChatViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.Manage.route) {
|
||||
if (isDemoMode) {
|
||||
// Demo is offline — Manage talks to the live dashboard,
|
||||
@@ -2260,6 +2389,9 @@ fun RelayApp() {
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route)
|
||||
},
|
||||
onNavigateToProviderUsage = {
|
||||
navController.navigate(Screen.ProviderUsage.route)
|
||||
},
|
||||
onNavigateToPlugins = {
|
||||
navController.navigate(Screen.Plugins.route)
|
||||
},
|
||||
@@ -2318,6 +2450,13 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.ProviderUsage.route) {
|
||||
UsageLimitsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Plugins.route) {
|
||||
PluginsScreen(
|
||||
viewModel = pluginsViewModel,
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
|
||||
private const val SPHERE_TWO_PI = 6.2832f
|
||||
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
|
||||
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
|
||||
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
|
||||
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
|
||||
|
||||
internal enum class SphereMotionMode {
|
||||
Still,
|
||||
AmbientLayer,
|
||||
Procedural,
|
||||
}
|
||||
|
||||
internal fun sphereMotionMode(
|
||||
state: SphereState,
|
||||
voiceMode: Boolean,
|
||||
motionVisible: Boolean,
|
||||
fixedTime: Float?,
|
||||
fixedColorPhase: Float?,
|
||||
): SphereMotionMode {
|
||||
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
|
||||
return SphereMotionMode.Still
|
||||
}
|
||||
return if (state == SphereState.Idle && !voiceMode) {
|
||||
SphereMotionMode.AmbientLayer
|
||||
} else {
|
||||
SphereMotionMode.Procedural
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
@@ -64,7 +93,8 @@ fun MorphingSphere(
|
||||
voiceMode: Boolean = false,
|
||||
skin: SphereSkin = LocalSphereSkin.current,
|
||||
fixedTime: Float? = null,
|
||||
fixedColorPhase: Float? = null
|
||||
fixedColorPhase: Float? = null,
|
||||
motionVisible: Boolean = true,
|
||||
) {
|
||||
val brand = LocalBrand.current
|
||||
// Gate reactive inputs on what the skin declares it honors — this is the
|
||||
@@ -104,16 +134,21 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// Continuous motion runs only for active agent/voice states. Idle is a
|
||||
// stable frame: the 58x34 text grid is expensive enough that even a
|
||||
// throttled cosmetic drift dominated measured screen-on CPU. Active states
|
||||
// retain full display-rate motion and dt-based timing.
|
||||
// Active states retain the full procedural animation. Visible Idle uses a
|
||||
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
|
||||
// for ambient drift was the measured screen-on hotspot, while transforming
|
||||
// its cached layer preserves the intended living Sphere at far lower cost.
|
||||
val animatedTime = remember { mutableFloatStateOf(0f) }
|
||||
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
|
||||
val fullFrameRate = state != SphereState.Idle || effVoiceMode
|
||||
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
|
||||
if (driveAnimation) {
|
||||
LaunchedEffect(fullFrameRate) {
|
||||
val motionMode = sphereMotionMode(
|
||||
state = state,
|
||||
voiceMode = effVoiceMode,
|
||||
motionVisible = motionVisible,
|
||||
fixedTime = fixedTime,
|
||||
fixedColorPhase = fixedColorPhase,
|
||||
)
|
||||
if (motionMode == SphereMotionMode.Procedural) {
|
||||
LaunchedEffect(motionMode) {
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
@@ -127,6 +162,25 @@ fun MorphingSphere(
|
||||
}
|
||||
}
|
||||
}
|
||||
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(motionMode) {
|
||||
if (motionMode != SphereMotionMode.AmbientLayer) {
|
||||
idleBreathPhase.floatValue = 0f
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
|
||||
lastNanos = now
|
||||
idleBreathPhase.floatValue =
|
||||
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
|
||||
SPHERE_TWO_PI
|
||||
// The frame wait plus this delay caps the gentle layer-only pulse
|
||||
// near 5fps while active procedural states retain display-rate motion.
|
||||
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val time = fixedTime ?: animatedTime.floatValue
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
|
||||
@@ -138,7 +192,18 @@ fun MorphingSphere(
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
val glyphStrings = remember { HashMap<Char, String>(32) }
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
if (motionMode == SphereMotionMode.AmbientLayer) {
|
||||
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val canvasW = size.width
|
||||
val canvasH = size.height
|
||||
val cellW = canvasW / cols
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.SweepGradient
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -50,6 +51,7 @@ import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -80,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -115,6 +120,7 @@ import com.hermesandroid.relay.ui.theme.resolveProfileAccent
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal enum class SessionDrawerFilter {
|
||||
All,
|
||||
@@ -203,6 +209,8 @@ fun SessionDrawerContent(
|
||||
autoTitlesSupported: Boolean = true,
|
||||
archiveSupported: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
onNewChat: () -> Unit,
|
||||
onNewDefaultChat: (() -> Unit)? = null,
|
||||
onSelectSession: (String) -> Unit,
|
||||
@@ -274,6 +282,11 @@ fun SessionDrawerContent(
|
||||
}
|
||||
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
|
||||
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
|
||||
val scopedActivityStates = scopedSessionActivityStates(
|
||||
rows = sourceRows,
|
||||
activityStates = activityStates,
|
||||
allowBareSessionIds = !showAllProfiles,
|
||||
)
|
||||
val sourceSessions = sourceRows.map { it.session }
|
||||
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
|
||||
@@ -316,8 +329,13 @@ fun SessionDrawerContent(
|
||||
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
|
||||
}
|
||||
.toList()
|
||||
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, activityStates)
|
||||
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, activityStates)
|
||||
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, scopedActivityStates)
|
||||
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, scopedActivityStates)
|
||||
val drawerNowMillis = rememberDrawerClock(
|
||||
isEnabled = isOpen && (
|
||||
viewOptions.showUpdated || viewOptions.grouping == SessionDrawerGrouping.Project
|
||||
),
|
||||
)
|
||||
val drawerTitle = if (showAllProfiles) {
|
||||
stringResource(R.string.drawer_all_profiles)
|
||||
} else {
|
||||
@@ -526,6 +544,28 @@ fun SessionDrawerContent(
|
||||
Text(stringResource(R.string.drawer_new_chat))
|
||||
}
|
||||
|
||||
onOpenBotMode?.let { openBotMode ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = openBotMode,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Groups, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(stringResource(R.string.bot_mode_title))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_drawer_summary),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchExpanded || query.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
@@ -703,6 +743,7 @@ fun SessionDrawerContent(
|
||||
ProjectGroupHeader(
|
||||
label = label,
|
||||
rows = group.rows,
|
||||
nowMillis = drawerNowMillis,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedProjectGroups = if (expanded) {
|
||||
@@ -725,9 +766,7 @@ fun SessionDrawerContent(
|
||||
if (expanded) items(group.rows, key = ::sessionRowKey) { row ->
|
||||
val session = row.session
|
||||
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
|
||||
val activityState = activityStates[sessionRowKey(row)]
|
||||
?: activityStates[session.sessionId]
|
||||
?: if (session.isActive) SessionActivityState.Working else null
|
||||
val activityState = scopedActivityStates[sessionRowKey(row)]
|
||||
SessionItem(
|
||||
modifier = if (isProjectGroup) Modifier.padding(start = 42.dp) else Modifier,
|
||||
session = session,
|
||||
@@ -739,6 +778,7 @@ fun SessionDrawerContent(
|
||||
showUpdated = viewOptions.showUpdated,
|
||||
showTokens = viewOptions.showTokens,
|
||||
showCost = viewOptions.showCost,
|
||||
nowMillis = drawerNowMillis,
|
||||
actionsEnabled = !provisional,
|
||||
isActive = !showAllProfiles && session.sessionId == currentSessionId,
|
||||
activityState = activityState,
|
||||
@@ -1192,7 +1232,11 @@ private fun SessionDrawerOrdering.label(): String = when (this) {
|
||||
|
||||
private fun SessionDrawerStatus.label(): String = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Starting -> "Starting"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.BackgroundWork -> "Background work"
|
||||
SessionDrawerStatus.Checking -> "Checking"
|
||||
SessionDrawerStatus.Unavailable -> "Unavailable"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
@@ -1217,6 +1261,7 @@ private fun compactMetric(value: Double): String = when {
|
||||
private fun ProjectGroupHeader(
|
||||
label: String,
|
||||
rows: List<ProfileSessionRow>,
|
||||
nowMillis: Long,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
@@ -1265,7 +1310,7 @@ private fun ProjectGroupHeader(
|
||||
append(context.resources.getQuantityString(R.plurals.drawer_project_session_count, rows.size, rows.size))
|
||||
if (latestActivity > 0L) {
|
||||
append(" · ")
|
||||
append(formatTimestamp(latestActivity, locale, context))
|
||||
append(formatTimestamp(latestActivity, locale, context, nowMillis))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1294,6 +1339,7 @@ private fun SessionItem(
|
||||
showUpdated: Boolean,
|
||||
showTokens: Boolean,
|
||||
showCost: Boolean,
|
||||
nowMillis: Long,
|
||||
actionsEnabled: Boolean,
|
||||
isActive: Boolean,
|
||||
activityState: SessionActivityState?,
|
||||
@@ -1312,11 +1358,7 @@ private fun SessionItem(
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val context = LocalContext.current
|
||||
val untitledLabel = stringResource(R.string.drawer_untitled)
|
||||
val activityLabel = when (activityState) {
|
||||
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
|
||||
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
|
||||
null -> null
|
||||
}
|
||||
val activityLabel = activityState?.let { stringResource(sessionActivityLabelResource(it)) }
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
@@ -1416,7 +1458,7 @@ private fun SessionItem(
|
||||
sourceBadge(session.source)?.let { badge ->
|
||||
SourceChip(badge)
|
||||
}
|
||||
if (showUpdated) sessionTimestampText(session, locale, context)?.let { timestamp ->
|
||||
if (showUpdated) sessionTimestampText(session, locale, context, nowMillis)?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1558,6 +1600,16 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
internal fun sessionActivityLabelResource(state: SessionActivityState): Int = when (state) {
|
||||
SessionActivityState.Starting -> R.string.drawer_activity_starting
|
||||
SessionActivityState.Working -> R.string.drawer_activity_working
|
||||
SessionActivityState.NeedsInput -> R.string.drawer_activity_needs_input
|
||||
SessionActivityState.BackgroundWork -> R.string.drawer_activity_background_work
|
||||
SessionActivityState.Checking -> R.string.drawer_activity_checking
|
||||
SessionActivityState.Unavailable -> R.string.drawer_activity_unavailable
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
|
||||
val icon: ImageVector = when (badge.kind) {
|
||||
@@ -1662,18 +1714,29 @@ private fun ProfileBadge(
|
||||
@Composable
|
||||
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(7.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color),
|
||||
modifier = if (state == SessionActivityState.BackgroundWork) {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.border(1.dp, color, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
@@ -1691,10 +1754,17 @@ private fun Modifier.sessionActivityBorder(
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val shouldRotate = animated && state == SessionActivityState.Working
|
||||
val shouldRotate = animated && (
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
)
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
@@ -1800,20 +1870,45 @@ private fun Modifier.sessionActivityBorder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
|
||||
@Composable
|
||||
private fun rememberDrawerClock(isEnabled: Boolean): Long {
|
||||
var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(isEnabled, lifecycleOwner) {
|
||||
if (!isEnabled) return@LaunchedEffect
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true) {
|
||||
nowMillis = System.currentTimeMillis()
|
||||
delay(MINUTE_MILLIS - (nowMillis % MINUTE_MILLIS))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nowMillis
|
||||
}
|
||||
|
||||
internal fun sessionTimestampText(
|
||||
session: ChatSession,
|
||||
locale: Locale,
|
||||
context: Context,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
val hasDistinctActivity =
|
||||
session.lastActivityAt > 0L &&
|
||||
session.startTimestamp > 0L &&
|
||||
session.lastActivityAt != session.startTimestamp
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_updated) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context, nowMillis)}"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
private fun formatTimestamp(
|
||||
millis: Long,
|
||||
locale: Locale,
|
||||
context: Context,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String {
|
||||
val diff = nowMillis - millis
|
||||
return when {
|
||||
diff < 60_000 -> context.getString(R.string.drawer_just_now)
|
||||
diff < 3_600_000 -> "${diff / 60_000}m ago"
|
||||
@@ -1821,3 +1916,5 @@ private fun formatTimestamp(millis: Long, locale: Locale, context: Context): Str
|
||||
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
|
||||
}
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import java.util.Locale
|
||||
@@ -23,7 +24,11 @@ internal enum class SessionDrawerOrdering {
|
||||
|
||||
internal enum class SessionDrawerStatus {
|
||||
NeedsInput,
|
||||
Starting,
|
||||
Working,
|
||||
BackgroundWork,
|
||||
Checking,
|
||||
Unavailable,
|
||||
Idle,
|
||||
}
|
||||
|
||||
@@ -55,7 +60,7 @@ internal data class SessionDrawerGroup(
|
||||
)
|
||||
|
||||
internal fun sessionRowKey(row: ProfileSessionRow): String =
|
||||
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
"${AgentDisplay.profileSessionKey(row.profile).lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
|
||||
internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
val raw = (session.gitRepoRoot ?: session.workingDirectory)
|
||||
@@ -69,12 +74,34 @@ internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
internal fun sessionDrawerStatus(
|
||||
row: ProfileSessionRow,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
): SessionDrawerStatus = when (
|
||||
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
|
||||
) {
|
||||
): SessionDrawerStatus = when (activityStates[sessionRowKey(row)]) {
|
||||
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
|
||||
SessionActivityState.Starting -> SessionDrawerStatus.Starting
|
||||
SessionActivityState.Working -> SessionDrawerStatus.Working
|
||||
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
|
||||
SessionActivityState.BackgroundWork -> SessionDrawerStatus.BackgroundWork
|
||||
SessionActivityState.Checking -> SessionDrawerStatus.Checking
|
||||
SessionActivityState.Unavailable -> SessionDrawerStatus.Unavailable
|
||||
null -> SessionDrawerStatus.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes live activity to the drawer's profile-scoped row identity.
|
||||
*
|
||||
* A selected-profile drawer may accept the legacy bare session id because every row belongs
|
||||
* to that one explicit profile. All Profiles must use composite keys exclusively: session ids
|
||||
* are only unique inside their owning profile.
|
||||
*/
|
||||
internal fun scopedSessionActivityStates(
|
||||
rows: List<ProfileSessionRow>,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
allowBareSessionIds: Boolean,
|
||||
): Map<String, SessionActivityState> = buildMap {
|
||||
rows.forEach { row ->
|
||||
val rowKey = sessionRowKey(row)
|
||||
val state = activityStates[rowKey]
|
||||
?: activityStates[row.session.sessionId].takeIf { allowBareSessionIds }
|
||||
state?.let { put(rowKey, it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
|
||||
@@ -98,8 +125,12 @@ internal fun filterAndSortSessionRows(
|
||||
.toList()
|
||||
val statusRank = mapOf(
|
||||
SessionDrawerStatus.NeedsInput to 0,
|
||||
SessionDrawerStatus.Working to 1,
|
||||
SessionDrawerStatus.Idle to 2,
|
||||
SessionDrawerStatus.Starting to 1,
|
||||
SessionDrawerStatus.Working to 2,
|
||||
SessionDrawerStatus.BackgroundWork to 3,
|
||||
SessionDrawerStatus.Checking to 4,
|
||||
SessionDrawerStatus.Unavailable to 5,
|
||||
SessionDrawerStatus.Idle to 6,
|
||||
)
|
||||
val comparator = when (options.ordering) {
|
||||
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
|
||||
@@ -149,7 +180,11 @@ internal fun groupSessionRows(
|
||||
private val SessionDrawerStatus.displayLabel: String
|
||||
get() = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Starting -> "Starting"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.BackgroundWork -> "Background work"
|
||||
SessionDrawerStatus.Checking -> "Checking"
|
||||
SessionDrawerStatus.Unavailable -> "Unavailable"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components.avatar
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.SphereReactivity
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
|
||||
/**
|
||||
* Default ambient visualization — the ASCII [MorphingSphere].
|
||||
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
|
||||
|
||||
@Composable
|
||||
override fun Render(state: AvatarRenderState, modifier: Modifier) {
|
||||
val appForeground by AppForegroundTracker.isForeground.collectAsState()
|
||||
MorphingSphere(
|
||||
modifier = modifier,
|
||||
state = state.state,
|
||||
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
|
||||
// call did with fixedTime/fixedColorPhase = 0f.
|
||||
fixedTime = if (state.paused) 0f else null,
|
||||
fixedColorPhase = if (state.paused) 0f else null,
|
||||
motionVisible = appForeground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BotChatScreen(
|
||||
route: BotGatewayRoute,
|
||||
bot: BotRosterEntry,
|
||||
sessionId: String,
|
||||
gatewayClient: GatewayChatClient,
|
||||
dashboardClient: DashboardApiClient,
|
||||
chatViewModel: ChatViewModel,
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val handler = remember(route.key) { ChatHandler() }
|
||||
val context = LocalContext.current
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
val iconPath by connectionViewModel
|
||||
.profileIconFlow(route.connectionId, route.profileName)
|
||||
.collectAsState(initial = null)
|
||||
val listState = rememberLazyListState()
|
||||
var composer by remember(route.key, sessionId) { mutableStateOf("") }
|
||||
|
||||
DisposableEffect(chatViewModel, gatewayClient, dashboardClient, route.key) {
|
||||
chatViewModel.initialize(apiClient = null, chatHandler = handler)
|
||||
chatViewModel.initializeGatewayOnly(context)
|
||||
chatViewModel.streamingEndpoint = "gateway"
|
||||
chatViewModel.sseFallbackEndpoint = "sessions"
|
||||
chatViewModel.setSelectedProfileProvider { bot.profile }
|
||||
chatViewModel.setSessionProfileNameProvider { route.profileName }
|
||||
chatViewModel.setEffectiveProfileProvider { bot.profile }
|
||||
chatViewModel.setDisplayProfileProvider { bot.profile }
|
||||
chatViewModel.setDisplayAliasProvider { bot.displayName }
|
||||
chatViewModel.setIsolatedProfileApiProvider { false }
|
||||
chatViewModel.setProfileSelectionHandler { selected ->
|
||||
selected?.name == route.profileName
|
||||
}
|
||||
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
|
||||
dashboardClient.getSessionMessages(
|
||||
sessionId = storedSessionId,
|
||||
profile = route.profileName,
|
||||
mode = mode,
|
||||
)
|
||||
}
|
||||
chatViewModel.updateApiClient(null)
|
||||
chatViewModel.updateGatewayClient(gatewayClient)
|
||||
chatViewModel.setCanonicalBotChatMode(true)
|
||||
chatViewModel.setChatVisible(true)
|
||||
chatViewModel.openProfileSession(
|
||||
profileName = route.profileName,
|
||||
profile = bot.profile,
|
||||
contextKey = AgentDisplay.profileContextKey(route.connectionId, route.profileName),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
onDispose {
|
||||
chatViewModel.setChatVisible(false)
|
||||
chatViewModel.setCanonicalBotChatMode(false)
|
||||
chatViewModel.updateGatewayClient(null)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = RelayRefresh.Background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.bot_mode_back_to_bots),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
color = RelayRefresh.Navy3,
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
if (!iconPath.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(iconPath.orEmpty()),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
bot.displayName.firstOrNull()?.uppercase() ?: "H",
|
||||
color = RelayRefresh.Relay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
bot.displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"${route.connectionLabel} · @${bot.handle}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (bot.stale) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = RelayRefresh.Background,
|
||||
),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
modifier = Modifier.imePadding(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = composer,
|
||||
onValueChange = { composer = it },
|
||||
placeholder = { Text(stringResource(R.string.chat_placeholder_message)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
minLines = 1,
|
||||
maxLines = 6,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isStreaming) {
|
||||
chatViewModel.cancelStream()
|
||||
} else {
|
||||
val text = composer.trim()
|
||||
if (text.isNotEmpty()) {
|
||||
composer = ""
|
||||
chatViewModel.sendMessage(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = isStreaming || composer.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
if (isStreaming) Icons.Filled.Stop else Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(
|
||||
if (isStreaming) R.string.chat_input_stop_streaming
|
||||
else R.string.chat_input_send_message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
isLoading && messages.isEmpty() -> CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(28.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
messages.isEmpty() -> Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_no_messages),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
bot.profile.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 14.dp,
|
||||
vertical = 10.dp,
|
||||
),
|
||||
) {
|
||||
itemsIndexed(messages, key = { _, message -> message.uiKey }) { index, message ->
|
||||
val first = index == 0 || messages[index - 1].role != message.role
|
||||
val last = index == messages.lastIndex || messages[index + 1].role != message.role
|
||||
MessageBubble(
|
||||
message = message,
|
||||
modifier = Modifier.padding(top = if (first) 6.dp else 1.dp),
|
||||
maxBubbleWidth = 344.dp,
|
||||
isFirstInGroup = first,
|
||||
isLastInGroup = last,
|
||||
onAttachmentRetry = chatViewModel::manualFetchAttachment,
|
||||
onAttachmentManualFetch = chatViewModel::manualFetchAttachment,
|
||||
onCardAction = chatViewModel::dispatchCardAction,
|
||||
onCardInput = chatViewModel::answerAsk,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
error?.takeIf(String::isNotBlank)?.let { message ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
message,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal enum class BotModeFilter { All, Bots, Groups }
|
||||
|
||||
private sealed interface BotModeRow {
|
||||
val activityAtMs: Long
|
||||
|
||||
data class Bot(val value: BotRosterEntry) : BotModeRow {
|
||||
override val activityAtMs: Long = value.latestActivityAtMs
|
||||
}
|
||||
|
||||
data class Group(val value: BotGroupRoom) : BotModeRow {
|
||||
override val activityAtMs: Long = value.latestActivityAtMs
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BotModeScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenBotChat: (route: BotGatewayRoute, sessionId: String) -> Unit,
|
||||
onOpenGroup: (roomKey: String) -> Unit,
|
||||
) {
|
||||
val state by connectionViewModel.botModeState.collectAsState()
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val resources = LocalResources.current
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var openingProfile by remember { mutableStateOf<String?>(null) }
|
||||
var showCreateBot by remember { mutableStateOf(false) }
|
||||
var creatingBot by remember { mutableStateOf(false) }
|
||||
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val chatOpenFailed = stringResource(R.string.bot_mode_chat_open_failed)
|
||||
val botCreateFailed = stringResource(R.string.bot_mode_create_failed)
|
||||
|
||||
LaunchedEffect(activeConnection?.id) {
|
||||
while (true) {
|
||||
connectionViewModel.refreshBotMode()
|
||||
delay(BOT_MODE_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(connections, selectedGatewayId) {
|
||||
if (selectedGatewayId != null && connections.none { it.id == selectedGatewayId }) {
|
||||
selectedGatewayId = null
|
||||
}
|
||||
}
|
||||
|
||||
BotModeContent(
|
||||
state = state,
|
||||
connections = connections,
|
||||
activeConnection = activeConnection,
|
||||
selectedGatewayId = selectedGatewayId,
|
||||
onBack = onBack,
|
||||
onRefresh = connectionViewModel::refreshBotMode,
|
||||
onSelectGateway = { selectedGatewayId = it },
|
||||
openingProfile = openingProfile,
|
||||
onOpenBot = { bot ->
|
||||
val route = bot.route ?: return@BotModeContent
|
||||
openingProfile = bot.profile.name
|
||||
scope.launch {
|
||||
val result = connectionViewModel.ensureCanonicalBotChat(route)
|
||||
.map { it.resolvedSessionId }
|
||||
result.fold(
|
||||
onSuccess = { onOpenBotChat(route, it) },
|
||||
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
|
||||
)
|
||||
openingProfile = null
|
||||
}
|
||||
},
|
||||
onOpenGroup = { onOpenGroup(it.key) },
|
||||
onNewBot = { showCreateBot = true },
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
botAvatar = { bot, size ->
|
||||
BotProfileAvatar(
|
||||
connectionViewModel = connectionViewModel,
|
||||
bot = bot,
|
||||
size = size,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (showCreateBot) {
|
||||
CreateBotDialog(
|
||||
saving = creatingBot,
|
||||
onDismiss = { showCreateBot = false },
|
||||
onCreate = { name, title, description ->
|
||||
scope.launch {
|
||||
creatingBot = true
|
||||
val targetConnectionId = selectedGatewayId ?: activeConnection?.id
|
||||
val createResult = if (targetConnectionId == null) {
|
||||
Result.failure(IllegalStateException(botCreateFailed))
|
||||
} else {
|
||||
connectionViewModel.createBot(
|
||||
targetConnectionId,
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
)
|
||||
}
|
||||
createResult.fold(
|
||||
onSuccess = {
|
||||
showCreateBot = false
|
||||
snackbar.showSnackbar(
|
||||
resources.getString(R.string.bot_mode_created, title),
|
||||
)
|
||||
},
|
||||
onFailure = { snackbar.showSnackbar(it.message ?: botCreateFailed) },
|
||||
)
|
||||
creatingBot = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun BotModeContent(
|
||||
state: BotModeState,
|
||||
connections: List<Connection>,
|
||||
activeConnection: Connection?,
|
||||
selectedGatewayId: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSelectGateway: (String?) -> Unit,
|
||||
openingProfile: String? = null,
|
||||
onOpenBot: (BotRosterEntry) -> Unit,
|
||||
onOpenGroup: (BotGroupRoom) -> Unit,
|
||||
onNewBot: () -> Unit,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
botAvatar: @Composable (BotRosterEntry, Dp) -> Unit = { bot, size ->
|
||||
BotFallbackAvatar(bot.displayName, size)
|
||||
},
|
||||
) {
|
||||
var filter by remember { mutableStateOf(BotModeFilter.All) }
|
||||
var searchOpen by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var gatewayMenuOpen by remember { mutableStateOf(false) }
|
||||
val visibleBots = state.roster.bots
|
||||
.filterNot(BotRosterEntry::hidden)
|
||||
.filter { selectedGatewayId == null || it.route?.connectionId == selectedGatewayId }
|
||||
val visibleGroups = state.roster.groups.filter { group ->
|
||||
selectedGatewayId == null || selectedGatewayId in group.sourceConnectionIds
|
||||
}
|
||||
val activeBots = visibleBots.filter { bot ->
|
||||
!bot.stale && bot.presenceActivityAtMs >= nowMs - ACTIVE_WINDOW_MS
|
||||
}.sortedByDescending(BotRosterEntry::presenceActivityAtMs).take(6)
|
||||
val needle = query.trim()
|
||||
val rows = buildList<BotModeRow> {
|
||||
if (filter != BotModeFilter.Groups) {
|
||||
addAll(visibleBots.filter { bot ->
|
||||
needle.isBlank() || listOf(
|
||||
bot.displayName,
|
||||
bot.profile.name,
|
||||
bot.profile.description,
|
||||
bot.latestPreview,
|
||||
).any { it.contains(needle, ignoreCase = true) }
|
||||
}.map(BotModeRow::Bot))
|
||||
}
|
||||
if (filter != BotModeFilter.Bots) {
|
||||
addAll(visibleGroups.filter { room ->
|
||||
needle.isBlank() || room.name.contains(needle, ignoreCase = true) ||
|
||||
room.latestMessage?.text.orEmpty().contains(needle, ignoreCase = true)
|
||||
}.map(BotModeRow::Group))
|
||||
}
|
||||
}.sortedByDescending(BotModeRow::activityAtMs)
|
||||
|
||||
Scaffold(
|
||||
containerColor = RelayRefresh.Background,
|
||||
snackbarHost = snackbarHost,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { searchOpen = !searchOpen }) {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.bot_mode_search),
|
||||
tint = if (searchOpen || query.isNotBlank()) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onNewBot,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.bot_mode_new_bot))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
if (searchOpen || query.isNotBlank()) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.bot_mode_search_hint)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||
Surface(
|
||||
onClick = { gatewayMenuOpen = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
activeConnection?.label?.trim()?.firstOrNull()?.uppercase() ?: "H",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
selectedGatewayId
|
||||
?.let { id -> connections.firstOrNull { it.id == id }?.label }
|
||||
?: stringResource(R.string.bot_mode_all_gateways),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(Icons.Filled.ExpandMore, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = gatewayMenuOpen,
|
||||
onDismissRequest = { gatewayMenuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.bot_mode_all_gateways)) },
|
||||
leadingIcon = if (selectedGatewayId == null) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
} else null,
|
||||
onClick = {
|
||||
gatewayMenuOpen = false
|
||||
onSelectGateway(null)
|
||||
},
|
||||
)
|
||||
connections.forEach { connection ->
|
||||
val selected = connection.id == selectedGatewayId
|
||||
val gatewayStatus = state.gateways.firstOrNull { it.connectionId == connection.id }
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(connection.label)
|
||||
if (gatewayStatus?.stale == true || gatewayStatus?.error != null) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
} else null,
|
||||
onClick = {
|
||||
gatewayMenuOpen = false
|
||||
if (!selected) onSelectGateway(connection.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BotModeFilterBar(filter = filter, onFilter = { filter = it })
|
||||
|
||||
if (activeBots.isNotEmpty() && filter != BotModeFilter.Groups && needle.isBlank()) {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_active_now),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
items(activeBots, key = { it.profile.name }) { bot ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(78.dp)
|
||||
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
|
||||
) {
|
||||
Box {
|
||||
botAvatar(bot, 56.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(14.dp),
|
||||
shape = CircleShape,
|
||||
color = ACTIVE_GREEN,
|
||||
border = BorderStroke(2.dp, RelayRefresh.Background),
|
||||
) {}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
bot.displayName,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
bot.route?.connectionLabel.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading && rows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
rows.isEmpty() -> BotModeEmptyState(error = state.error, onRefresh = onRefresh)
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 96.dp, top = 4.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = rows,
|
||||
key = { _, row -> when (row) {
|
||||
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
|
||||
is BotModeRow.Group -> "group:${row.value.key}"
|
||||
} },
|
||||
) { index, row ->
|
||||
when (row) {
|
||||
is BotModeRow.Bot -> BotConversationRow(
|
||||
bot = row.value,
|
||||
connectionLabel = row.value.route?.connectionLabel,
|
||||
opening = openingProfile == row.value.profile.name,
|
||||
onClick = { onOpenBot(row.value) },
|
||||
avatar = { botAvatar(row.value, 56.dp) },
|
||||
nowMs = nowMs,
|
||||
)
|
||||
is BotModeRow.Group -> BotGroupRow(
|
||||
room = row.value,
|
||||
onClick = { onOpenGroup(row.value) },
|
||||
nowMs = nowMs,
|
||||
)
|
||||
}
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 88.dp, end = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotModeFilterBar(filter: BotModeFilter, onFilter: (BotModeFilter) -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
BotModeFilter.entries.forEach { item ->
|
||||
val selected = item == filter
|
||||
Surface(
|
||||
onClick = { onFilter(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
color = if (selected) {
|
||||
RelayRefresh.ElectricMuted.copy(alpha = 0.56f)
|
||||
} else Color.Transparent,
|
||||
) {
|
||||
Text(
|
||||
text = when (item) {
|
||||
BotModeFilter.All -> stringResource(R.string.bot_mode_filter_all)
|
||||
BotModeFilter.Bots -> stringResource(R.string.bot_mode_filter_bots)
|
||||
BotModeFilter.Groups -> stringResource(R.string.bot_mode_filter_groups)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
color = if (selected) {
|
||||
RelayRefresh.Ink
|
||||
} else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotConversationRow(
|
||||
bot: BotRosterEntry,
|
||||
connectionLabel: String?,
|
||||
opening: Boolean,
|
||||
onClick: () -> Unit,
|
||||
avatar: @Composable () -> Unit,
|
||||
nowMs: Long,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = !opening, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
avatar()
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
bot.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
bot.latestActivityAtMs.toBotModeTime(nowMs),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (!connectionLabel.isNullOrBlank()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"$connectionLabel · @${bot.handle}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (bot.stale) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
when {
|
||||
opening -> stringResource(R.string.bot_mode_opening_chat)
|
||||
bot.latestPreview.isNotBlank() -> bot.latestPreview
|
||||
bot.profile.description.isNotBlank() -> bot.profile.description
|
||||
else -> stringResource(R.string.bot_mode_no_messages)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotGroupRow(room: BotGroupRoom, onClick: () -> Unit, nowMs: Long) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GroupAvatar(56.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
room.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
room.latestActivityAtMs.toBotModeTime(nowMs),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_read_only),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (room.stale) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_offline),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
val latest = room.latestMessage
|
||||
Text(
|
||||
if (latest == null) {
|
||||
stringResource(R.string.bot_mode_group_no_messages)
|
||||
} else {
|
||||
"${latest.senderName}: ${latest.text}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotModeEmptyState(
|
||||
error: String?,
|
||||
onRefresh: () -> Unit,
|
||||
actionLabel: String? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
error ?: stringResource(R.string.bot_mode_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onRefresh) {
|
||||
Text(actionLabel ?: stringResource(R.string.chat_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotProfileAvatar(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
bot: BotRosterEntry,
|
||||
size: Dp,
|
||||
) {
|
||||
val pathFlow = bot.route?.let { route ->
|
||||
connectionViewModel.profileIconFlow(route.connectionId, route.profileName)
|
||||
} ?: connectionViewModel.profileIconFlow(bot.profile.name)
|
||||
val path by pathFlow.collectAsState(initial = null)
|
||||
if (path.isNullOrBlank()) {
|
||||
BotFallbackAvatar(bot.displayName, size)
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier.size(size),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = File(path.orEmpty()),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BotFallbackAvatar(label: String, size: Dp) {
|
||||
Surface(
|
||||
modifier = Modifier.size(size),
|
||||
shape = CircleShape,
|
||||
color = RelayRefresh.Navy3,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
label.trim().firstOrNull()?.uppercase() ?: "H",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = RelayRefresh.Relay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupAvatar(size: Dp) {
|
||||
Surface(
|
||||
modifier = Modifier.size(size),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(size * 0.52f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BotGroupDetailScreen(room: BotGroupRoom?, onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
containerColor = RelayRefresh.Background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(room?.name ?: stringResource(R.string.bot_mode_group_title))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_read_only),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BotModeEmptyState(
|
||||
error = stringResource(R.string.bot_mode_group_missing),
|
||||
onRefresh = onBack,
|
||||
actionLabel = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_group_read_only_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
items(room.messages, key = { it.id ?: "${it.atMs}:${it.senderName}:${it.text.hashCode()}" }) { message ->
|
||||
BotGroupMessageBubble(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotGroupMessageBubble(message: BotGroupMessage) {
|
||||
val user = message.senderKind == "user"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (user) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (user) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.86f),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
message.senderName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (user) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(message.text, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
message.atMs.toBotModeTime(System.currentTimeMillis()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateBotDialog(
|
||||
saving: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (name: String, title: String, description: String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(stringResource(R.string.bot_mode_new_bot)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it.lowercase().replace(' ', '-').take(64) },
|
||||
label = { Text(stringResource(R.string.bot_mode_bot_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it.take(128) },
|
||||
label = { Text(stringResource(R.string.bot_mode_bot_title)) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it.take(512) },
|
||||
label = { Text(stringResource(R.string.bot_mode_bot_description)) },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.bot_mode_create_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCreate(name, title.ifBlank { name }, description) },
|
||||
enabled = name.isNotBlank() && !saving,
|
||||
) { Text(stringResource(R.string.bot_mode_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun Long.toBotModeTime(nowMs: Long): String {
|
||||
if (this <= 0L) return ""
|
||||
return DateUtils.getRelativeTimeSpanString(
|
||||
this,
|
||||
nowMs,
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
).toString()
|
||||
}
|
||||
|
||||
private const val ACTIVE_WINDOW_MS = 90_000L
|
||||
private const val BOT_MODE_REFRESH_MS = 30_000L
|
||||
private val ACTIVE_GREEN = Color(0xFF4DD675)
|
||||
@@ -179,7 +179,6 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -297,21 +296,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
|
||||
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
|
||||
private val CHAT_PET_ROUTES = setOf("chat")
|
||||
|
||||
internal fun resolveSessionActivityStates(
|
||||
background: Map<String, SessionActivityState>,
|
||||
currentSessionId: String?,
|
||||
isStreaming: Boolean,
|
||||
needsInput: Boolean,
|
||||
): Map<String, SessionActivityState> = background.toMutableMap().apply {
|
||||
currentSessionId?.let { sessionId ->
|
||||
when {
|
||||
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
|
||||
isStreaming -> put(sessionId, SessionActivityState.Working)
|
||||
else -> remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveChatHeaderSubtitle(
|
||||
isStreaming: Boolean,
|
||||
statusText: String,
|
||||
@@ -726,6 +710,7 @@ fun ChatScreen(
|
||||
// existing test/preview call sites keep compiling.
|
||||
onNavigateToVoiceSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
|
||||
@@ -870,15 +855,9 @@ fun ChatScreen(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
|
||||
val sessionActivityStates = remember(
|
||||
backgroundSessionActivityStates,
|
||||
currentSessionId,
|
||||
isStreaming,
|
||||
pendingAsk,
|
||||
) {
|
||||
resolveSessionActivityStates(
|
||||
background = backgroundSessionActivityStates,
|
||||
currentSessionId = currentSessionId,
|
||||
val sessionActivityStates = backgroundSessionActivityStates
|
||||
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
|
||||
chatViewModel.updateCurrentSessionActivity(
|
||||
isStreaming = isStreaming,
|
||||
needsInput = pendingAsk != null,
|
||||
)
|
||||
@@ -906,6 +885,54 @@ fun ChatScreen(
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
|
||||
var allProfileSessionsLoading by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
suspend fun refreshAllProfileSessions(showError: Boolean) {
|
||||
if (isProfileLocked || allProfileSessionsLoading) return
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
recentlyActive = item.isActive,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
source = item.source,
|
||||
pinned = item.pinned,
|
||||
archived = item.archived,
|
||||
workingDirectory = item.cwd,
|
||||
gitBranch = item.gitBranch,
|
||||
gitRepoRoot = item.gitRepoRoot,
|
||||
pullRequestNumber = item.pullRequest?.number,
|
||||
pullRequestUrl = item.pullRequest?.url,
|
||||
pullRequestState = item.pullRequest?.state,
|
||||
pullRequestDraft = item.pullRequest?.draft == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
chatViewModel.updateSessionActivityDirectory(
|
||||
rows = allProfileSessions.map { it.profile to it.session.sessionId },
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (showError) snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
}
|
||||
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
|
||||
val explicitBindingProfileName = conversationBinding.profileName
|
||||
.takeIf { conversationBinding.hasExplicitOwner }
|
||||
@@ -1305,6 +1332,13 @@ fun ChatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
LaunchedEffect(chatViewModel, drawerState) {
|
||||
chatViewModel.sessionDirectoryRefreshRequests.collect {
|
||||
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
|
||||
refreshAllProfileSessions(showError = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
PetInteractionLayer(
|
||||
owner = "chat-interaction-layer",
|
||||
active = shouldHideChatPet(
|
||||
@@ -1392,7 +1426,6 @@ fun ChatScreen(
|
||||
}
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||
remember(chatViewModel, context) {
|
||||
{ messageId, cardKey, action ->
|
||||
@@ -2021,6 +2054,7 @@ fun ChatScreen(
|
||||
// shows up without a manual reload. Cheap dashboard read; the optimistic
|
||||
// row for the active session is preserved by ChatHandler.updateSessions.
|
||||
LaunchedEffect(drawerState.isOpen) {
|
||||
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
|
||||
if (drawerState.isOpen && chatReady) {
|
||||
chatViewModel.refreshSessions()
|
||||
}
|
||||
@@ -2344,6 +2378,10 @@ fun ChatScreen(
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
archiveSupported = sessionArchivingSupported,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onOpenBotMode = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToBotMode()
|
||||
},
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -2426,48 +2464,7 @@ fun ChatScreen(
|
||||
onProfileColorChange = connectionViewModel::setProfileColor,
|
||||
onRefreshAllProfiles = {
|
||||
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
source = item.source,
|
||||
pinned = item.pinned,
|
||||
archived = item.archived,
|
||||
workingDirectory = item.cwd,
|
||||
gitBranch = item.gitBranch,
|
||||
gitRepoRoot = item.gitRepoRoot,
|
||||
pullRequestNumber = item.pullRequest?.number,
|
||||
pullRequestUrl = item.pullRequest?.url,
|
||||
pullRequestState = item.pullRequest?.state,
|
||||
pullRequestDraft = item.pullRequest?.draft == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
refreshAllProfileSessions(showError = true)
|
||||
}
|
||||
},
|
||||
onSelectProfileSession = { profileName, sessionId ->
|
||||
|
||||
@@ -116,7 +116,10 @@ fun ConnectionsSettingsScreen(
|
||||
val configured by connectionViewModel.relayConfigured.collectAsState()
|
||||
configured
|
||||
} else {
|
||||
false
|
||||
// Preview/screenshot hosts do not construct a ConnectionViewModel.
|
||||
// Fall back to the persisted pairing metadata so their active card is
|
||||
// honest instead of showing a connected Relay as "Optional".
|
||||
connections.firstOrNull { it.id == activeConnectionId }?.hasConfiguredRelay() == true
|
||||
}
|
||||
val startupConnectionId: String? = if (connectionViewModel != null) {
|
||||
val startupId by connectionViewModel.startupConnectionId.collectAsState()
|
||||
@@ -566,7 +569,7 @@ private fun ConnectionSurfaceSummary(
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
|
||||
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive && activeConnectionViewModel != null) {
|
||||
resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -70,6 +71,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -96,11 +98,17 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProviderUsageLandingMode
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferences
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.ui.components.AgentAvatarFace
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
|
||||
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
|
||||
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
|
||||
import com.hermesandroid.relay.ui.components.pet.LocalPetCompanionCoordinator
|
||||
import com.hermesandroid.relay.ui.components.pet.petObstacleSurface
|
||||
import com.hermesandroid.relay.ui.components.pet.petPerchSurface
|
||||
@@ -114,6 +122,7 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val SETTINGS_PET_SURFACE_ROUTE = "settings"
|
||||
private val SETTINGS_PET_SURFACE_ROUTES = setOf(SETTINGS_PET_SURFACE_ROUTE)
|
||||
@@ -170,6 +179,7 @@ fun SettingsScreen(
|
||||
// expandable sections, so there's nothing left to link to twice.
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToProviderUsage: () -> Unit,
|
||||
onNavigateToPlugins: () -> Unit,
|
||||
onNavigateToChatSettings: () -> Unit,
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
@@ -200,9 +210,57 @@ fun SettingsScreen(
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val providerUsagePreferencesRepository = remember(context) {
|
||||
ProviderUsagePreferencesRepository(context)
|
||||
}
|
||||
val providerUsagePreferences by providerUsagePreferencesRepository.preferences.collectAsState(
|
||||
initial = ProviderUsagePreferences(),
|
||||
)
|
||||
val providerUsageRepository = remember(connectionViewModel) {
|
||||
ProviderUsageRepository(
|
||||
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
|
||||
dashboardClientProvider = {
|
||||
connectionViewModel.activeDashboardUrl()?.let(
|
||||
connectionViewModel::dashboardClientForActive,
|
||||
)
|
||||
},
|
||||
relayHttpClient = connectionViewModel.relayHttpClient,
|
||||
profileProvider = { connectionViewModel.selectedProfile.value?.name },
|
||||
sessionProvider = { chatViewModel.currentSessionId.value },
|
||||
)
|
||||
}
|
||||
var providerUsageResponse by remember { mutableStateOf<ProviderUsageResponse?>(null) }
|
||||
var providerUsageLoaded by remember { mutableStateOf(false) }
|
||||
var providerUsageRefreshing by remember { mutableStateOf(false) }
|
||||
var providerUsageRefreshKey by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(
|
||||
activeConnection?.id,
|
||||
selectedProfile?.name,
|
||||
currentSessionId,
|
||||
providerUsagePreferences.landingMode,
|
||||
providerUsageRefreshKey,
|
||||
) {
|
||||
if (providerUsagePreferences.landingMode == ProviderUsageLandingMode.Hidden) {
|
||||
providerUsageResponse = null
|
||||
providerUsageLoaded = true
|
||||
} else {
|
||||
if (providerUsageResponse == null) providerUsageLoaded = false
|
||||
providerUsageRefreshing = providerUsageResponse != null
|
||||
providerUsageRepository.fetch().getOrNull()?.let { providerUsageResponse = it }
|
||||
providerUsageLoaded = true
|
||||
providerUsageRefreshing = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(providerUsagePreferences.landingMode) {
|
||||
while (providerUsagePreferences.landingMode != ProviderUsageLandingMode.Hidden) {
|
||||
delay(300_000)
|
||||
providerUsageRefreshKey++
|
||||
}
|
||||
}
|
||||
// Active Agent card inputs — personality + profile drive the title,
|
||||
// ring-accent, and subtitle.
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
@@ -477,6 +535,16 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
ProviderUsageLandingCard(
|
||||
response = providerUsageResponse,
|
||||
loaded = providerUsageLoaded,
|
||||
refreshing = providerUsageRefreshing,
|
||||
preferences = providerUsagePreferences,
|
||||
onDisplay = onNavigateToProviderUsage,
|
||||
onRefresh = { providerUsageRefreshKey++ },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsSectionHeader(stringResource(R.string.settings_hermes))
|
||||
|
||||
SettingsCategoryRow(
|
||||
@@ -1257,6 +1325,135 @@ private fun SettingsStatusPill(pill: SettingsStatusPillModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageLandingCard(
|
||||
response: ProviderUsageResponse?,
|
||||
loaded: Boolean,
|
||||
refreshing: Boolean,
|
||||
preferences: ProviderUsagePreferences,
|
||||
onDisplay: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
) {
|
||||
val providers = response?.providers
|
||||
?.filter { it.available && it.id in preferences.visibleProviders }
|
||||
.orEmpty()
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.settingsPetSurface("settings-card:provider-usage")
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Analytics,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
when (response?.relayEnhanced) {
|
||||
true -> R.string.provider_usage_settings_desc_relay
|
||||
false -> R.string.provider_usage_settings_desc_basic
|
||||
null -> R.string.provider_usage_settings_desc
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !refreshing) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.provider_usage_refresh),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDisplay) {
|
||||
Text(stringResource(R.string.provider_usage_customize))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
when {
|
||||
preferences.landingMode == ProviderUsageLandingMode.Hidden -> {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_hidden_hint),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
!loaded -> {
|
||||
ProviderUsageSkeleton(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
providers.isEmpty() -> {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_not_available_compact),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> providers.forEachIndexed { index, provider ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
ProviderUsageContent(
|
||||
provider = provider,
|
||||
detailed = preferences.landingMode == ProviderUsageLandingMode.Expanded,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RelaySkeletonLine(width = 112.dp, height = 16.dp)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
RelaySkeletonLine(width = 86.dp)
|
||||
RelaySkeletonLine(width = 58.dp)
|
||||
}
|
||||
RelaySkeletonLine(width = 260.dp, height = 6.dp)
|
||||
RelaySkeletonLine(width = 92.dp, height = 10.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(
|
||||
label: String,
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ProviderUsageLandingMode
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferences
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageProvider
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageCredential
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageBalance
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageWindow
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.text.NumberFormat
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private sealed interface UsageLoadState {
|
||||
data object Loading : UsageLoadState
|
||||
data object Unsupported : UsageLoadState
|
||||
data class Loaded(val response: ProviderUsageResponse) : UsageLoadState
|
||||
data object Error : UsageLoadState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UsageLimitsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val preferencesRepository = remember(context) { ProviderUsagePreferencesRepository(context) }
|
||||
val preferences by preferencesRepository.preferences.collectAsState(
|
||||
initial = ProviderUsagePreferences(),
|
||||
)
|
||||
val repository = remember(connectionViewModel) {
|
||||
ProviderUsageRepository(
|
||||
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
|
||||
dashboardClientProvider = {
|
||||
connectionViewModel.activeDashboardUrl()?.let(
|
||||
connectionViewModel::dashboardClientForActive,
|
||||
)
|
||||
},
|
||||
relayHttpClient = connectionViewModel.relayHttpClient,
|
||||
profileProvider = { connectionViewModel.selectedProfile.value?.name },
|
||||
sessionProvider = { chatViewModel.currentSessionId.value },
|
||||
)
|
||||
}
|
||||
var refreshKey by remember { mutableIntStateOf(0) }
|
||||
var state by remember { mutableStateOf<UsageLoadState>(UsageLoadState.Loading) }
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(activeConnection?.id, selectedProfile?.name, currentSessionId, refreshKey) {
|
||||
val hadContent = state is UsageLoadState.Loaded
|
||||
if (!hadContent) state = UsageLoadState.Loading else refreshing = true
|
||||
val next = repository.fetch().fold(
|
||||
onSuccess = { result ->
|
||||
result?.let(UsageLoadState::Loaded) ?: UsageLoadState.Unsupported
|
||||
},
|
||||
onFailure = { UsageLoadState.Error },
|
||||
)
|
||||
if (!hadContent || next is UsageLoadState.Loaded) state = next
|
||||
refreshing = false
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(300_000)
|
||||
refreshKey++
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.provider_usage_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.provider_usage_back),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { refreshKey++ }, enabled = !refreshing) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.provider_usage_refresh),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
text = activeConnection?.label ?: stringResource(R.string.settings_no_connection),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_intro),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
when (val current = state) {
|
||||
UsageLoadState.Loading -> ProviderUsageLoading()
|
||||
UsageLoadState.Unsupported -> ProviderUsageMessage(
|
||||
text = stringResource(R.string.provider_usage_not_available),
|
||||
)
|
||||
UsageLoadState.Error -> ProviderUsageError(onRetry = { refreshKey++ })
|
||||
is UsageLoadState.Loaded -> {
|
||||
ProviderUsageCapabilityNotice(relayEnhanced = current.response.relayEnhanced)
|
||||
val providers = current.response.providers
|
||||
if (providers.none { it.available }) {
|
||||
ProviderUsageMessage(
|
||||
text = stringResource(R.string.provider_usage_none_configured),
|
||||
)
|
||||
}
|
||||
providers.forEach { provider ->
|
||||
ProviderUsageCard(
|
||||
provider = provider,
|
||||
detailed = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderUsageDisplaySettings(
|
||||
preferences = preferences,
|
||||
providers = (state as? UsageLoadState.Loaded)?.response?.providers.orEmpty(),
|
||||
onModeChanged = { mode ->
|
||||
scope.launch { preferencesRepository.setLandingMode(mode) }
|
||||
},
|
||||
onProviderVisibilityChanged = { providerId, visible ->
|
||||
scope.launch {
|
||||
preferencesRepository.setProviderVisible(providerId, visible)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageCapabilityNotice(relayEnhanced: Boolean) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (relayEnhanced) R.string.provider_usage_capability_relay_title
|
||||
else R.string.provider_usage_capability_basic_title,
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (relayEnhanced) R.string.provider_usage_capability_relay_body
|
||||
else R.string.provider_usage_capability_basic_body,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageLoading() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(2) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
RelaySkeletonLine(width = 112.dp, height = 18.dp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
RelaySkeletonLine(width = 92.dp)
|
||||
RelaySkeletonLine(width = 62.dp)
|
||||
}
|
||||
RelaySkeletonLine(width = 280.dp, height = 6.dp)
|
||||
RelaySkeletonLine(width = 98.dp, height = 10.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageMessage(text: String) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageError(onRetry: () -> Unit) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_error),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.provider_usage_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProviderUsageCard(
|
||||
provider: ProviderUsageProvider,
|
||||
detailed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
ProviderUsageContent(
|
||||
provider = provider,
|
||||
detailed = detailed,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProviderUsageContent(
|
||||
provider: ProviderUsageProvider,
|
||||
detailed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = provider.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
provider.plan?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!provider.available) {
|
||||
Text(
|
||||
text = providerUnavailableText(provider),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
if (provider.balances.isNotEmpty()) {
|
||||
ProviderBalanceUsage(provider, detailed)
|
||||
} else if (provider.credentials.isNotEmpty()) {
|
||||
val shownCredentials = if (detailed) {
|
||||
provider.credentials
|
||||
} else {
|
||||
provider.credentials.filter { it.active }.take(1)
|
||||
}
|
||||
if (shownCredentials.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_active_unknown),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
shownCredentials.forEach { credential ->
|
||||
ProviderCredentialUsage(credential, detailed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val windows = if (detailed) provider.windows else provider.windows.take(1)
|
||||
windows.forEach { ProviderUsageWindowRow(it) }
|
||||
}
|
||||
if (detailed && provider.credentials.isEmpty() && provider.balances.isEmpty()) {
|
||||
provider.details.forEach { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderBalanceUsage(
|
||||
provider: ProviderUsageProvider,
|
||||
detailed: Boolean,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val total = provider.balances.firstOrNull { it.id == "total" }
|
||||
?: provider.balances.first()
|
||||
val supporting = provider.balances.filterNot { it.id == total.id }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = formatBalance(total),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = total.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (detailed) {
|
||||
supporting.forEach { balance ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = balance.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatBalance(balance),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
formatRenewal(provider.renewsAt)?.let { renewal ->
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_renews_on, renewal),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (detailed && !provider.actionUrl.isNullOrBlank()) {
|
||||
TextButton(onClick = { uriHandler.openUri(provider.actionUrl) }) {
|
||||
Text(stringResource(R.string.provider_usage_manage_credits))
|
||||
}
|
||||
}
|
||||
if (detailed) {
|
||||
provider.details.forEach { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderCredentialUsage(
|
||||
credential: ProviderUsageCredential,
|
||||
detailed: Boolean,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = credential.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (credential.active) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
credential.active && credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
|
||||
stringResource(R.string.provider_usage_active_available)
|
||||
credential.active && credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
|
||||
stringResource(R.string.provider_usage_active_at_limit)
|
||||
credential.active -> stringResource(R.string.provider_usage_active)
|
||||
credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
|
||||
stringResource(R.string.provider_usage_available)
|
||||
credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
|
||||
stringResource(R.string.provider_usage_at_limit)
|
||||
else -> stringResource(R.string.provider_usage_unavailable_status)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = when (credential.status) {
|
||||
ProviderUsageCredential.STATUS_AT_LIMIT -> MaterialTheme.colorScheme.error
|
||||
ProviderUsageCredential.STATUS_AVAILABLE -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
val windows = if (detailed) credential.windows else credential.windows.take(1)
|
||||
windows.forEach { ProviderUsageWindowRow(it) }
|
||||
if (detailed) {
|
||||
credential.details.forEach { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageWindowRow(window: ProviderUsageWindow) {
|
||||
var now by remember { mutableStateOf(Instant.now()) }
|
||||
LaunchedEffect(window.resetAt) {
|
||||
while (window.resetAt != null) {
|
||||
delay(60_000)
|
||||
now = Instant.now()
|
||||
}
|
||||
}
|
||||
val percent = window.usedPercent?.coerceIn(0.0, 100.0)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(window.label, style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
text = percent?.let { stringResource(R.string.provider_usage_percent, it.toInt()) }
|
||||
?: window.detail.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (percent != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (percent / 100.0).toFloat() },
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp),
|
||||
color = when {
|
||||
percent >= 90 -> MaterialTheme.colorScheme.error
|
||||
percent >= 75 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
}
|
||||
formatReset(window.resetAt, now)?.let { reset ->
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_resets, reset),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (percent != null && !window.detail.isNullOrBlank()) {
|
||||
Text(
|
||||
text = window.detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderUsageDisplaySettings(
|
||||
preferences: ProviderUsagePreferences,
|
||||
providers: List<ProviderUsageProvider>,
|
||||
onModeChanged: (ProviderUsageLandingMode) -> Unit,
|
||||
onProviderVisibilityChanged: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_display_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_display_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val modes = ProviderUsageLandingMode.entries
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
modes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = preferences.landingMode == mode,
|
||||
onClick = { onModeChanged(mode) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, modes.size),
|
||||
) {
|
||||
Text(
|
||||
when (mode) {
|
||||
ProviderUsageLandingMode.Summary -> stringResource(R.string.provider_usage_mode_summary)
|
||||
ProviderUsageLandingMode.Expanded -> stringResource(R.string.provider_usage_mode_expanded)
|
||||
ProviderUsageLandingMode.Hidden -> stringResource(R.string.provider_usage_mode_hidden)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_providers_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.provider_usage_providers_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val rows = if (providers.isEmpty()) {
|
||||
listOf(
|
||||
"openai-codex" to "Codex",
|
||||
"nous" to "Nous",
|
||||
"opencode-go" to "OpenCode Go",
|
||||
)
|
||||
} else {
|
||||
providers.map { it.id to it.displayName }
|
||||
}
|
||||
rows.forEach { (id, label) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(
|
||||
checked = id in preferences.visibleProviders,
|
||||
onCheckedChange = { onProviderVisibilityChanged(id, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun providerUnavailableText(provider: ProviderUsageProvider): String =
|
||||
if (provider.status == ProviderUsageProvider.STATUS_NOT_CONFIGURED) {
|
||||
stringResource(R.string.provider_usage_provider_not_configured)
|
||||
} else {
|
||||
stringResource(R.string.provider_usage_provider_unavailable)
|
||||
}
|
||||
|
||||
private fun formatReset(raw: String?, now: Instant): String? = runCatching {
|
||||
val reset = Instant.parse(raw ?: return null)
|
||||
val duration = Duration.between(now, reset)
|
||||
if (duration.isNegative || duration.isZero) return "now"
|
||||
val days = duration.toDays()
|
||||
val hours = duration.toHours() % 24
|
||||
val minutes = duration.toMinutes() % 60
|
||||
when {
|
||||
days > 0 -> "${days}d ${hours}h"
|
||||
hours > 0 -> "${hours}h ${minutes}m"
|
||||
else -> "${minutes}m"
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun formatBalance(balance: ProviderUsageBalance): String = runCatching {
|
||||
NumberFormat.getCurrencyInstance().apply {
|
||||
currency = Currency.getInstance(balance.currency)
|
||||
}.format(balance.amount)
|
||||
}.getOrElse { "${balance.amount} ${balance.currency}" }
|
||||
|
||||
private fun formatRenewal(raw: String?): String? = runCatching {
|
||||
val instant = Instant.parse(raw ?: return null)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(Locale.getDefault())
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(instant)
|
||||
}.getOrNull()
|
||||
@@ -41,6 +41,14 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SessionActivityFreshness
|
||||
import com.hermesandroid.relay.data.SessionActivityOwner
|
||||
import com.hermesandroid.relay.data.SessionActivityPhase
|
||||
import com.hermesandroid.relay.data.SessionActivityRegistry
|
||||
import com.hermesandroid.relay.data.SessionActivityScope
|
||||
import com.hermesandroid.relay.data.SessionActivityUpdate
|
||||
import com.hermesandroid.relay.data.SessionLiveRuntime
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -57,6 +65,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
|
||||
@@ -257,6 +268,57 @@ internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwabl
|
||||
"unauthorized" in message || "forbidden" in message
|
||||
}
|
||||
|
||||
internal data class ResolvedGatewayActiveSessions(
|
||||
val runtimes: List<SessionLiveRuntime>,
|
||||
val ambiguous: Boolean,
|
||||
val ambiguousForCurrent: Boolean,
|
||||
)
|
||||
|
||||
/** Resolve process-wide runtime rows without ever inventing a profile owner. */
|
||||
internal fun resolveGatewayActiveSessions(
|
||||
sessions: List<GatewayActiveSession>,
|
||||
directory: Set<SessionActivityOwner>,
|
||||
currentOwner: SessionActivityOwner?,
|
||||
currentRuntimeId: String? = null,
|
||||
knownOwnersByRuntime: Map<String, SessionActivityOwner> = emptyMap(),
|
||||
): ResolvedGatewayActiveSessions {
|
||||
var ambiguous = false
|
||||
var ambiguousForCurrent = false
|
||||
val runtimes = sessions.map { row ->
|
||||
val explicitProfile = row.profile?.trim()?.takeIf(String::isNotEmpty)
|
||||
?.let(AgentDisplay::profileSessionKey)
|
||||
val candidates = directory.filter { owner ->
|
||||
owner.storedSessionId == row.storedSessionId &&
|
||||
(explicitProfile == null || owner.profile.equals(explicitProfile, ignoreCase = true))
|
||||
}
|
||||
val owner = when {
|
||||
knownOwnersByRuntime[row.runtimeSessionId]
|
||||
?.takeIf { it.storedSessionId == row.storedSessionId } != null ->
|
||||
knownOwnersByRuntime.getValue(row.runtimeSessionId)
|
||||
explicitProfile == null && currentOwner != null && currentRuntimeId != null &&
|
||||
currentRuntimeId == row.runtimeSessionId &&
|
||||
currentOwner.storedSessionId == row.storedSessionId -> currentOwner
|
||||
explicitProfile != null && candidates.size == 1 -> candidates.single()
|
||||
else -> null
|
||||
}
|
||||
if (owner == null) {
|
||||
ambiguous = true
|
||||
if (currentOwner?.storedSessionId == row.storedSessionId) ambiguousForCurrent = true
|
||||
}
|
||||
SessionLiveRuntime(
|
||||
owner = owner,
|
||||
runtimeId = row.runtimeSessionId,
|
||||
status = when (row.status) {
|
||||
GatewayActiveSessionStatus.Idle -> SessionLiveStatus.Idle
|
||||
GatewayActiveSessionStatus.Starting -> SessionLiveStatus.Starting
|
||||
GatewayActiveSessionStatus.Working -> SessionLiveStatus.Working
|
||||
GatewayActiveSessionStatus.Waiting -> SessionLiveStatus.Waiting
|
||||
},
|
||||
)
|
||||
}
|
||||
return ResolvedGatewayActiveSessions(runtimes, ambiguous, ambiguousForCurrent)
|
||||
}
|
||||
|
||||
sealed interface VoiceMessageSubmissionResult {
|
||||
data class Submitted(val userUiKey: String) : VoiceMessageSubmissionResult
|
||||
data class Rejected(val reason: String) : VoiceMessageSubmissionResult
|
||||
@@ -320,27 +382,126 @@ class ChatViewModel : ViewModel() {
|
||||
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
|
||||
_backgroundSessionActivityStates.asStateFlow()
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val contextKey = activeProfileContextKey
|
||||
_backgroundSessionActivityStates.value = if (contextKey == null) {
|
||||
private val sessionActivityRegistry = MutableStateFlow(SessionActivityRegistry())
|
||||
private val sessionActivityGeneration = AtomicLong(0L)
|
||||
private val sessionActivityPollMutex = Mutex()
|
||||
private var sessionActivityPollJob: Job? = null
|
||||
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
|
||||
private var lastProjectedProcessIds: Set<String> = emptySet()
|
||||
private var lastProjectedProcessOwner: SessionActivityOwner? = null
|
||||
private var lastLocalActivityOwner: SessionActivityOwner? = null
|
||||
private var lastLocalStreaming = false
|
||||
private var lastSessionActivityScope: SessionActivityScope? = null
|
||||
private val _sessionDirectoryRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val sessionDirectoryRefreshRequests: SharedFlow<Unit> =
|
||||
_sessionDirectoryRefreshRequests.asSharedFlow()
|
||||
|
||||
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
|
||||
val raw = contextKey?.trim().orEmpty()
|
||||
val separator = raw.lastIndexOf("::")
|
||||
if (separator <= 0 || separator >= raw.lastIndex) return null
|
||||
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
|
||||
}
|
||||
|
||||
private fun activityOwner(
|
||||
sessionId: String?,
|
||||
contextKey: String? = activeProfileContextKey,
|
||||
): SessionActivityOwner? {
|
||||
val scope = activityScope(contextKey) ?: return null
|
||||
val storedId = sessionId?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return SessionActivityOwner.of(scope.connectionId, scope.profile, storedId)
|
||||
}
|
||||
|
||||
private fun reduceSessionActivity(update: SessionActivityUpdate) {
|
||||
sessionActivityRegistry.update { it.reduce(update) }
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun reduceSessionActivities(updates: Iterable<SessionActivityUpdate>) {
|
||||
sessionActivityRegistry.update { current ->
|
||||
updates.fold(current) { state, update -> state.reduce(update) }
|
||||
}
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun activateSessionActivityScope() {
|
||||
val scope = activityScope() ?: return
|
||||
if (scope == lastSessionActivityScope) return
|
||||
clearProjectedBackgroundProcesses()
|
||||
lastSessionActivityScope = scope
|
||||
val generation = sessionActivityGeneration.incrementAndGet()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
lastLocalActivityOwner = null
|
||||
lastLocalStreaming = false
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.BeginGeneration(
|
||||
scope = scope,
|
||||
generation = generation,
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun publishSessionActivityProjection() {
|
||||
val activeConnectionId = activityScope()?.connectionId
|
||||
_backgroundSessionActivityStates.value = if (activeConnectionId == null) {
|
||||
emptyMap()
|
||||
} else {
|
||||
backgroundTurnCheckpoints.keys
|
||||
.asSequence()
|
||||
.filter { it.contextKey == contextKey }
|
||||
.associate { key ->
|
||||
key.sessionId to if (
|
||||
key in backgroundNeedsInputKeys ||
|
||||
backgroundPendingInteractions.containsKey(key)
|
||||
) {
|
||||
SessionActivityState.NeedsInput
|
||||
} else {
|
||||
SessionActivityState.Working
|
||||
}
|
||||
sessionActivityRegistry.value.presentationStates(System.currentTimeMillis())
|
||||
.filterKeys { it.connectionId == activeConnectionId }
|
||||
.mapKeys { (owner, _) ->
|
||||
val displayProfile = owner.profile.takeUnless {
|
||||
it == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
} ?: "default"
|
||||
"$displayProfile:${owner.storedSessionId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
backgroundTurnCheckpoints.forEach { (key, checkpoint) ->
|
||||
val owner = activityOwner(key.sessionId, key.contextKey) ?: return@forEach
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = checkpoint.liveSessionId,
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
if (key in backgroundNeedsInputKeys || backgroundPendingInteractions.containsKey(key)) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
|
||||
confirmed = backgroundPendingInteractions.containsKey(key),
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
} else if (sessionActivityRegistry.value.record(owner)
|
||||
?.pendingInputs
|
||||
?.containsKey("checkpoint:${key.contextKey}:${key.sessionId}") == true
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
|
||||
confirmed = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
|
||||
|
||||
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
|
||||
@@ -396,6 +557,7 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private var firstTokenNotified = false
|
||||
private var toolHistoryJob: Job? = null
|
||||
private var gatewayComposerSettlementJob: Job? = null
|
||||
private var backgroundProcessSessionJob: Job? = null
|
||||
private var connectionSwitchJob: Job? = null
|
||||
private var sessionRefreshJob: Job? = null
|
||||
@@ -444,6 +606,7 @@ class ChatViewModel : ViewModel() {
|
||||
// === END PHASE3-status ===
|
||||
const val MEDIA_TAP_TO_DOWNLOAD = "Tap to download"
|
||||
private const val MEDIA_FETCH_TIMEOUT_MS = 120_000L
|
||||
private val WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX = Regex("""^[A-Za-z]:[\\/].+""")
|
||||
|
||||
/** Upper bound on the rolling tool-call history flow. */
|
||||
const val TOOL_CALL_HISTORY_LIMIT = 10
|
||||
@@ -1892,10 +2055,329 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayProcessController.dismiss(processId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the known profile/session directory used to attribute process-wide
|
||||
* `session.active_list` rows. A row is projected only when ownership is exact.
|
||||
*/
|
||||
fun updateSessionActivityDirectory(
|
||||
rows: Collection<Pair<String, String>>,
|
||||
) {
|
||||
val scope = activityScope() ?: return
|
||||
sessionActivityDirectory = rows.mapNotNullTo(mutableSetOf()) { (profile, sessionId) ->
|
||||
runCatching {
|
||||
SessionActivityOwner.of(
|
||||
scope.connectionId,
|
||||
AgentDisplay.profileSessionKey(profile),
|
||||
sessionId,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
sessionActivityDirectory.map { owner ->
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation, now)
|
||||
},
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun updateCurrentProfileActivityDirectory(sessions: Collection<ChatSession>) {
|
||||
val scope = activityScope() ?: return
|
||||
sessionActivityDirectory = sessionActivityDirectory
|
||||
.filterNotTo(mutableSetOf()) {
|
||||
it.connectionId == scope.connectionId && it.profile == scope.profile
|
||||
}
|
||||
.apply {
|
||||
sessions.forEach { row ->
|
||||
add(SessionActivityOwner.of(scope.connectionId, scope.profile, row.sessionId))
|
||||
}
|
||||
}
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
sessionActivityDirectory
|
||||
.filter { it.connectionId == scope.connectionId && it.profile == scope.profile }
|
||||
.map { owner -> SessionActivityUpdate.ObserveOwner(owner, generation, now) },
|
||||
)
|
||||
}
|
||||
|
||||
fun setSessionActivityDrawerOpen(open: Boolean) {
|
||||
if (open) requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
/** Local UI edges are immediate evidence, then the active-list poll confirms them. */
|
||||
fun updateCurrentSessionActivity(isStreaming: Boolean, needsInput: Boolean) {
|
||||
val owner = activityOwner(chatHandler?.currentSessionId?.value) ?: return
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
val previousAskId = "current-pending-input"
|
||||
lastLocalActivityOwner?.takeIf { it != owner }?.let { previousOwner ->
|
||||
if (sessionActivityRegistry.value.record(previousOwner)
|
||||
?.pendingInputs
|
||||
?.containsKey(previousAskId) == true
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
previousOwner,
|
||||
previousAskId,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val pendingWasOpen = sessionActivityRegistry.value.record(owner)
|
||||
?.pendingInputs
|
||||
?.containsKey(previousAskId) == true
|
||||
if (needsInput || pendingWasOpen) {
|
||||
reduceSessionActivity(
|
||||
if (needsInput) {
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner, previousAskId, generation = generation, observedAtMillis = now,
|
||||
)
|
||||
} else {
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner, previousAskId, generation = generation, observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val streamingEdge = lastLocalActivityOwner == owner && lastLocalStreaming != isStreaming
|
||||
val alreadyStarting = sessionActivityRegistry.value.record(owner)
|
||||
?.phase(now) == SessionActivityPhase.Starting
|
||||
if ((isStreaming && !alreadyStarting) ||
|
||||
(lastLocalActivityOwner == owner && lastLocalStreaming)
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = null,
|
||||
status = if (isStreaming) SessionLiveStatus.Working else SessionLiveStatus.Idle,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
lastLocalActivityOwner = owner
|
||||
lastLocalStreaming = isStreaming
|
||||
if (streamingEdge) _sessionDirectoryRefreshRequests.tryEmit(Unit)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun markSessionActivityStarting(sessionId: String?) {
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.LocalSend(
|
||||
owner = owner,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun settleSessionActivity(sessionId: String?, runtimeId: String? = null) {
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = runtimeId,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
_sessionDirectoryRefreshRequests.tryEmit(Unit)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
fun requestSessionActivityRefresh() {
|
||||
val client = gatewayClient ?: return
|
||||
if (streamingEndpoint != "gateway" || !chatVisible) return
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = viewModelScope.launch {
|
||||
pollSessionActivity(client)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollSessionActivity(client: GatewayChatClient) {
|
||||
sessionActivityPollMutex.withLock {
|
||||
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val currentScope = activityScope() ?: return
|
||||
val currentOwner = activityOwner(chatHandler?.currentSessionId?.value)
|
||||
val directory = buildSet {
|
||||
addAll(sessionActivityDirectory.filter { it.connectionId == currentScope.connectionId })
|
||||
currentOwner?.let { add(it) }
|
||||
chatHandler?.sessions?.value.orEmpty().forEach { row ->
|
||||
add(SessionActivityOwner.of(
|
||||
currentScope.connectionId,
|
||||
currentScope.profile,
|
||||
row.sessionId,
|
||||
))
|
||||
}
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
when (val result = client.listActiveSessions()) {
|
||||
is GatewayActiveSessionsResult.Success -> {
|
||||
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
|
||||
val resolved = resolveGatewayActiveSessions(
|
||||
sessions = result.sessions,
|
||||
directory = directory,
|
||||
currentOwner = currentOwner,
|
||||
currentRuntimeId = currentOwner?.let {
|
||||
client.currentLiveSessionId(it.storedSessionId)
|
||||
},
|
||||
knownOwnersByRuntime = result.sessions.mapNotNull { row ->
|
||||
client.knownSessionOwner(row.runtimeSessionId)?.let { known ->
|
||||
val knownProfile = when {
|
||||
!known.profile.isNullOrBlank() ->
|
||||
AgentDisplay.profileSessionKey(known.profile)
|
||||
currentOwner != null &&
|
||||
row.runtimeSessionId == client.currentLiveSessionId(
|
||||
currentOwner.storedSessionId,
|
||||
) &&
|
||||
known.storedSessionId == currentOwner.storedSessionId ->
|
||||
currentOwner.profile
|
||||
else -> directory.singleOrNull { owner ->
|
||||
owner.storedSessionId == known.storedSessionId &&
|
||||
owner.profile in setOf(
|
||||
"default",
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
}?.profile ?: return@let null
|
||||
}
|
||||
row.runtimeSessionId to SessionActivityOwner.of(
|
||||
currentScope.connectionId,
|
||||
knownProfile,
|
||||
known.storedSessionId,
|
||||
)
|
||||
}
|
||||
}.toMap(),
|
||||
)
|
||||
val scopes = directory.mapTo(mutableSetOf()) {
|
||||
SessionActivityScope.of(it.connectionId, it.profile)
|
||||
}
|
||||
sessionActivityRegistry.value.records.keys
|
||||
.filterTo(mutableSetOf()) { it.connectionId == currentScope.connectionId }
|
||||
.mapTo(scopes) { SessionActivityScope.of(it.connectionId, it.profile) }
|
||||
resolved.runtimes.mapNotNullTo(scopes) { runtime ->
|
||||
runtime.owner?.let { SessionActivityScope.of(it.connectionId, it.profile) }
|
||||
}
|
||||
if (scopes.isEmpty()) scopes += currentScope
|
||||
reduceSessionActivities(
|
||||
scopes.map { scope ->
|
||||
SessionActivityUpdate.ActiveList(
|
||||
scope = scope,
|
||||
runtimes = resolved.runtimes.filter { it.owner?.let { owner ->
|
||||
owner.connectionId == scope.connectionId && owner.profile == scope.profile
|
||||
} == true },
|
||||
isCompleteForScope = !resolved.ambiguous,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
GatewayActiveSessionsResult.Unsupported,
|
||||
is GatewayActiveSessionsResult.TransientFailure -> {
|
||||
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
|
||||
val scopes = directory.mapTo(mutableSetOf()) {
|
||||
SessionActivityScope.of(it.connectionId, it.profile)
|
||||
}.apply { add(currentScope) }
|
||||
reduceSessionActivities(
|
||||
scopes.map { scope ->
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation, now)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
projectCurrentBackgroundProcesses(generation, now)
|
||||
}
|
||||
|
||||
if (gatewayClient !== client || !chatVisible) return
|
||||
val hasConfirmedLiveWork = sessionActivityRegistry.value.records.values.any { record ->
|
||||
record.freshness == SessionActivityFreshness.Confirmed &&
|
||||
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
|
||||
}
|
||||
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
|
||||
sessionActivityPollJob = viewModelScope.launch {
|
||||
delay(delayMs)
|
||||
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
|
||||
}
|
||||
}
|
||||
|
||||
private fun projectCurrentBackgroundProcesses(generation: Long, now: Long) {
|
||||
val sessionId = chatHandler?.currentSessionId?.value ?: return
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
val previousOwner = lastProjectedProcessOwner
|
||||
if (previousOwner != null && previousOwner != owner) {
|
||||
reduceSessionActivities(
|
||||
lastProjectedProcessIds.map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = previousOwner,
|
||||
processId = processId,
|
||||
running = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
lastProjectedProcessIds = emptySet()
|
||||
}
|
||||
lastProjectedProcessOwner = owner
|
||||
if (!gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)) {
|
||||
lastProjectedProcessIds = emptySet()
|
||||
return
|
||||
}
|
||||
val activeIds = backgroundProcesses.value.filter { it.isRunning }.mapTo(mutableSetOf()) { it.id }
|
||||
reduceSessionActivities(
|
||||
(lastProjectedProcessIds + activeIds).map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = processId,
|
||||
running = processId in activeIds,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
lastProjectedProcessIds = activeIds
|
||||
}
|
||||
|
||||
private fun clearProjectedBackgroundProcesses() {
|
||||
val owner = lastProjectedProcessOwner
|
||||
if (owner != null && lastProjectedProcessIds.isNotEmpty()) {
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
lastProjectedProcessIds.map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = processId,
|
||||
running = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
lastProjectedProcessIds = emptySet()
|
||||
lastProjectedProcessOwner = null
|
||||
}
|
||||
|
||||
fun updateGatewayClient(client: GatewayChatClient?) {
|
||||
val previousClient = gatewayClient
|
||||
val changed = previousClient !== client
|
||||
if (changed) {
|
||||
clearProjectedBackgroundProcesses()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
sessionActivityGeneration.incrementAndGet()
|
||||
sessionActivityDirectory = emptySet()
|
||||
lastLocalActivityOwner = null
|
||||
lastLocalStreaming = false
|
||||
lastSessionActivityScope = null
|
||||
gatewayVisibleReattachJob?.cancel()
|
||||
gatewayVisibleReattachJob = null
|
||||
previousClient?.setUnsolicitedTurnProvider(null)
|
||||
@@ -1914,6 +2396,7 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = chatHandler?.currentSessionId?.value,
|
||||
scopeKey = activeProfileContextKey,
|
||||
)
|
||||
activateSessionActivityScope()
|
||||
}
|
||||
// Bind each gateway session.create/resume to the currently-selected
|
||||
// profile (pulled live) — the upstream gateway builds the agent from it.
|
||||
@@ -2061,6 +2544,7 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
prewarmGateway()
|
||||
}
|
||||
if (changed && client != null) requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
/** Remove the detached sibling's recovery snapshot after server completion. */
|
||||
@@ -2081,7 +2565,20 @@ class ChatViewModel : ViewModel() {
|
||||
backgroundPendingInteractions.remove(key)
|
||||
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
|
||||
}
|
||||
reduceSessionActivities(
|
||||
matching.mapNotNull { key ->
|
||||
activityOwner(key.sessionId, key.contextKey)?.let { owner ->
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = completion.liveSessionId,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
publishBackgroundSessionActivity()
|
||||
requestSessionActivityRefresh()
|
||||
chatTurnCheckpointStore?.let { store ->
|
||||
viewModelScope.launch {
|
||||
checkpointMutex.withLock {
|
||||
@@ -2527,7 +3024,13 @@ class ChatViewModel : ViewModel() {
|
||||
fun setChatVisible(visible: Boolean) {
|
||||
val changed = chatVisible != visible
|
||||
chatVisible = visible
|
||||
if (visible && changed) prewarmGateway()
|
||||
if (visible && changed) {
|
||||
prewarmGateway()
|
||||
requestSessionActivityRefresh()
|
||||
} else if (!visible) {
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
}
|
||||
}
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -2575,6 +3078,12 @@ class ChatViewModel : ViewModel() {
|
||||
/** Server slash-command catalog (`commands.catalog`) — 4th allCommands source. */
|
||||
private val _serverCommands = MutableStateFlow<List<SlashCommand>>(emptyList())
|
||||
val serverCommands: StateFlow<List<SlashCommand>> = _serverCommands.asStateFlow()
|
||||
@Volatile
|
||||
private var canonicalBotChatMode = false
|
||||
|
||||
fun setCanonicalBotChatMode(enabled: Boolean) {
|
||||
canonicalBotChatMode = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the in-flight turn is actually running on the gateway
|
||||
@@ -3069,6 +3578,16 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
lastSurfacedCredentialWarning = null
|
||||
gatewayStateSyncJob = viewModelScope.launch {
|
||||
launch {
|
||||
backgroundProcesses.collect {
|
||||
if (gatewayClient !== client) return@collect
|
||||
projectCurrentBackgroundProcesses(
|
||||
generation = sessionActivityGeneration.get(),
|
||||
now = System.currentTimeMillis(),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverPersonality.collect { value ->
|
||||
if (gatewayClient !== client || value == null) return@collect
|
||||
@@ -3396,6 +3915,8 @@ class ChatViewModel : ViewModel() {
|
||||
if (this.chatHandler !== chatHandler) {
|
||||
checkpointStatusJob?.cancel()
|
||||
checkpointStatusJob = null
|
||||
gatewayComposerSettlementJob?.cancel()
|
||||
gatewayComposerSettlementJob = null
|
||||
}
|
||||
this.chatHandler = chatHandler
|
||||
ensureCheckpointObservers()
|
||||
@@ -3447,6 +3968,26 @@ class ChatViewModel : ViewModel() {
|
||||
scheduleCheckpointWrite()
|
||||
}
|
||||
}
|
||||
gatewayComposerSettlementJob?.cancel()
|
||||
gatewayComposerSettlementJob = viewModelScope.launch {
|
||||
chatHandler.messages.collect { messages ->
|
||||
val storedSessionId = chatHandler.currentSessionId.value ?: return@collect
|
||||
val client = gatewayClient ?: return@collect
|
||||
if (
|
||||
streamingEndpoint == "gateway" &&
|
||||
chatHandler.isStreaming.value &&
|
||||
messages.none { it.isStreaming || it.isThinkingStreaming } &&
|
||||
!client.hasActiveTurnForSession(storedSessionId)
|
||||
) {
|
||||
// A terminal bubble with no matching live or detached
|
||||
// Gateway owner is an orphaned handler-wide busy bit. Clear
|
||||
// it without disturbing a different session's active turn.
|
||||
chatHandler.clearStreamingStatus()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3530,6 +4071,15 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
|
||||
fun initializeGatewayOnly(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
if (chatTurnCheckpointStore == null) {
|
||||
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
|
||||
}
|
||||
ensureCheckpointObservers()
|
||||
}
|
||||
|
||||
/** JVM-test seam; production is wired to the app-wide relay DataStore. */
|
||||
internal fun setChatTurnCheckpointStore(store: ChatTurnCheckpointStore?) {
|
||||
chatTurnCheckpointStore = store
|
||||
@@ -3745,6 +4295,7 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
activateSessionActivityScope()
|
||||
handler.activeAgentName = currentAgentDisplayName()
|
||||
if (
|
||||
previousBinding.contextKey == contextKey &&
|
||||
@@ -4012,6 +4563,8 @@ class ChatViewModel : ViewModel() {
|
||||
currentSessionProfileName() == profileName
|
||||
) {
|
||||
handler.updateSessions(sessions)
|
||||
updateCurrentProfileActivityDirectory(handler.sessions.value)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -5303,6 +5856,14 @@ class ChatViewModel : ViewModel() {
|
||||
return true
|
||||
}
|
||||
|
||||
if (shouldCompactCanonicalBotChat(normalizedName, canonicalBotChatMode)) {
|
||||
handler.addSystemNotice("Bot Chat stays in one conversation — compacting its context instead.")
|
||||
viewModelScope.launch {
|
||||
runServerCompressCommand(gateway, handler, focusTopic = null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
mobileBlockedSlashNotice(normalizedName)?.let { notice ->
|
||||
handler.addSystemNotice(notice)
|
||||
return true
|
||||
@@ -5935,6 +6496,13 @@ class ChatViewModel : ViewModel() {
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
activeStreamIsGateway = false
|
||||
// Navigation owns the visible composer even when the live handle has
|
||||
// already ended or could not be detached. Do not wait for a late
|
||||
// cancel callback to clear a handler-wide busy bit after the new
|
||||
// transcript has replaced its streaming bubble.
|
||||
handler.clearStreamingStatus()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
}
|
||||
|
||||
/** Last-chance synchronous flush before the ViewModel scope is cancelled. */
|
||||
@@ -6372,6 +6940,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
val completedOwner = activeQueueOwnerRunId
|
||||
handler.onStreamComplete(messageId)
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
|
||||
completedQueueOwnerRuns += completedOwner
|
||||
}
|
||||
@@ -6400,6 +6969,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun finalizeFailedTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
handler.onStreamComplete(messageId)
|
||||
handler.markError(messageId)
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
clearTurnCheckpoint()
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
@@ -6442,6 +7012,7 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
handler.clearStreamingStatus()
|
||||
}
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6945,6 +7516,9 @@ class ChatViewModel : ViewModel() {
|
||||
badges = listOf("Realtime Agent"),
|
||||
)
|
||||
)
|
||||
if (streamingEndpoint == "gateway") {
|
||||
markSessionActivityStarting(handler.currentSessionId.value)
|
||||
}
|
||||
return assistantMessageId
|
||||
}
|
||||
|
||||
@@ -7835,6 +8409,9 @@ class ChatViewModel : ViewModel() {
|
||||
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
|
||||
)
|
||||
)
|
||||
if (streamingEndpoint == "gateway") {
|
||||
markSessionActivityStarting(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
val streamDeltas = StreamDeltaCoalescer(
|
||||
scope = viewModelScope,
|
||||
@@ -8152,6 +8729,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
} else if (
|
||||
dispatchedSseEndpoint == "sessions" &&
|
||||
errorSessionId != null &&
|
||||
@@ -8201,6 +8779,7 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
}
|
||||
} else {
|
||||
AppAnalytics.onStreamError()
|
||||
@@ -8241,6 +8820,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
}
|
||||
}
|
||||
val onPreflightErrorCb = { error: Throwable ->
|
||||
@@ -8272,6 +8852,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
|
||||
@@ -8550,6 +9131,7 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
handler.setSessionId(sid)
|
||||
markSessionActivityStarting(sid)
|
||||
updateTurnCheckpointSession(sid)
|
||||
selectBackgroundProcessSession(sid)
|
||||
gatewayProcessController.sessionReady(sid)
|
||||
@@ -8668,6 +9250,7 @@ class ChatViewModel : ViewModel() {
|
||||
// don't resurrect the turn on SSE.
|
||||
intentionallyCancelled = false
|
||||
activeStream = null
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
} else {
|
||||
// Nothing started server-side — rerun this turn on
|
||||
// the SSE fallback. Callbacks land on the main
|
||||
@@ -8781,6 +9364,11 @@ class ChatViewModel : ViewModel() {
|
||||
if (streamingMsg != null) {
|
||||
handler.markStopped(streamingMsg.id)
|
||||
handler.onStreamComplete(streamingMsg.id)
|
||||
} else {
|
||||
// The terminal bubble can settle before the handler-wide busy
|
||||
// flag (or navigation can already have cleared the transcript).
|
||||
// Stop must still be an unconditional escape hatch.
|
||||
handler.clearStreamingStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8867,11 +9455,10 @@ class ChatViewModel : ViewModel() {
|
||||
* Re-run the fetch for an attachment that's in the "Tap to download"
|
||||
* deferred state. Used by the inbound-media card's CTA on cellular.
|
||||
*
|
||||
* Works for both flavors of inbound attachment: if the stored key starts
|
||||
* with `/` it's an absolute path (bare-media form, use
|
||||
* [RelayHttpClient.fetchMediaByPath]); otherwise it's a relay token
|
||||
* (use [RelayHttpClient.fetchMedia]). `secrets.token_urlsafe` never
|
||||
* produces `/` so the prefix check is unambiguous.
|
||||
* Works for both flavors of inbound attachment: POSIX paths start with `/`
|
||||
* and Windows paths match `C:\...`; both use
|
||||
* [RelayHttpClient.fetchMediaByPath]. Everything else is an opaque relay
|
||||
* token and uses [RelayHttpClient.fetchMedia].
|
||||
*/
|
||||
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
|
||||
val handler = chatHandler ?: return
|
||||
@@ -8904,7 +9491,10 @@ class ChatViewModel : ViewModel() {
|
||||
settings,
|
||||
expectedRole = expectedRole,
|
||||
) {
|
||||
if (fetchKey.startsWith("/")) {
|
||||
if (
|
||||
fetchKey.startsWith("/") ||
|
||||
WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX.matches(fetchKey)
|
||||
) {
|
||||
relay.fetchMediaByPath(fetchKey)
|
||||
} else {
|
||||
relay.fetchMedia(fetchKey)
|
||||
@@ -8999,9 +9589,9 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
content = "",
|
||||
state = AttachmentState.LOADING,
|
||||
// Reuse relayToken as a generic inbound-fetch key. Paths always
|
||||
// start with `/`, real tokens never do — downstream helpers
|
||||
// that need to distinguish can check the prefix.
|
||||
// Reuse relayToken as a generic inbound-fetch key. Downstream
|
||||
// helpers distinguish POSIX or Windows absolute paths from opaque
|
||||
// relay tokens.
|
||||
relayToken = originalPath,
|
||||
fileName = originalPath.substringAfterLast('/').substringAfterLast('\\').ifBlank { null }
|
||||
)
|
||||
@@ -9635,6 +10225,9 @@ private fun isUnsupportedMobileCommand(name: String, pair: JsonArray): Boolean {
|
||||
return cliOnly && gatewayGate.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun shouldCompactCanonicalBotChat(commandName: String, canonicalBotChatMode: Boolean): Boolean =
|
||||
canonicalBotChatMode && commandName.lowercase() in setOf("new", "reset")
|
||||
|
||||
private fun normalizeSlashCommandName(rawName: String): String? {
|
||||
val normalized = rawName
|
||||
.trim()
|
||||
|
||||
@@ -56,6 +56,8 @@ import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.computeConnectionSecurity
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfilePresentation
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
@@ -119,6 +121,7 @@ import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.MediaCacheWriter
|
||||
import com.hermesandroid.relay.viewmodel.connection.PairingController
|
||||
import com.hermesandroid.relay.viewmodel.connection.BotModeController
|
||||
import com.hermesandroid.relay.viewmodel.connection.ProfileController
|
||||
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -800,6 +803,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
tokenStoreKeyProvider = { cid ->
|
||||
connectionStore.connections.value.firstOrNull { it.id == cid }?.tokenStoreKey
|
||||
},
|
||||
trustedDashboardUrlProvider = { cid ->
|
||||
if (connectionStore.activeConnectionId.value == cid) {
|
||||
activeDashboardUrl()
|
||||
} else {
|
||||
connectionStore.connections.value.firstOrNull { it.id == cid }
|
||||
?.resolvedDashboardUrl
|
||||
?.takeIf(String::isNotBlank)
|
||||
}
|
||||
},
|
||||
pinnedClientProvider = { url, base ->
|
||||
pluginProxyClientForUrl(url, base, includeRelaySessionHeader = false)
|
||||
},
|
||||
@@ -827,6 +839,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
gatewayClientProvider = { upstreamTransport.activeGatewayChatClient() },
|
||||
)
|
||||
|
||||
private val botModeController = BotModeController(
|
||||
scope = viewModelScope,
|
||||
connections = connectionStore.connections,
|
||||
activeConnectionId = connectionStore.activeConnectionId,
|
||||
dashboardUrlProvider = { connection ->
|
||||
if (connectionStore.activeConnectionId.value == connection.id) {
|
||||
activeDashboardUrl().orEmpty()
|
||||
} else {
|
||||
connection.resolvedDashboardUrl
|
||||
}
|
||||
},
|
||||
dashboardClientFactory = upstreamTransport::dashboardClientFor,
|
||||
gatewayLeaseFactory = upstreamTransport::acquireGatewayRoute,
|
||||
)
|
||||
|
||||
// --- Relay connection state ---
|
||||
val relayConnectionState: StateFlow<ConnectionState> = connectionManager.connectionState
|
||||
|
||||
@@ -1624,6 +1651,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// down, calling profileController.* in their original order.
|
||||
|
||||
val agentProfiles: StateFlow<List<Profile>> get() = profileController.agentProfiles
|
||||
val botModeState: StateFlow<BotModeState> get() = botModeController.state
|
||||
|
||||
fun refreshBotMode() = botModeController.refresh()
|
||||
|
||||
suspend fun ensureCanonicalBotChat(route: com.hermesandroid.relay.data.BotGatewayRoute): Result<BotChatTarget> =
|
||||
botModeController.ensureCanonicalBotChat(route)
|
||||
|
||||
suspend fun createBot(
|
||||
connectionId: String,
|
||||
name: String,
|
||||
title: String,
|
||||
description: String,
|
||||
): Result<String> = botModeController.createBot(connectionId, name, title, description)
|
||||
|
||||
fun acquireBotGateway(
|
||||
route: com.hermesandroid.relay.data.BotGatewayRoute,
|
||||
): Result<com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController.RouteGatewayLease> =
|
||||
botModeController.acquireGateway(route)
|
||||
|
||||
fun botDashboardClient(
|
||||
route: com.hermesandroid.relay.data.BotGatewayRoute,
|
||||
): Result<DashboardApiClient> = botModeController.dashboardClient(route)
|
||||
|
||||
/**
|
||||
* Session namespace after resolving the Server-default UI sentinel through
|
||||
@@ -1765,6 +1814,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/** A local icon path for a specific profile identity on the active connection. */
|
||||
fun profileIconFlow(profileName: String?) = profileController.profileIconFlow(profileName)
|
||||
fun profileIconFlow(connectionId: String, profileName: String) =
|
||||
profileController.profileIconFlow(connectionId, profileName)
|
||||
|
||||
val hostProfileIconImportState: StateFlow<ProfileController.HostIconImportState>
|
||||
get() = profileController.hostIconImportState
|
||||
@@ -3529,7 +3580,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
scrubConnectionArtifacts(removed, removedDeviceId)
|
||||
upstreamTransport.disposeConnectionRouteClients(connectionId)
|
||||
connectionStore.removeConnection(connectionId)
|
||||
botModeController.connectionRemoved(connectionId)
|
||||
// Clear the persisted profile selection for the removed connection
|
||||
// AFTER the switch-away above has finished. Ordering matters: if
|
||||
// we cleared first, any in-flight hydration from the just-swapped
|
||||
@@ -7296,6 +7349,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionManager.shutdown()
|
||||
_apiClient.value?.shutdown()
|
||||
profileChatApiClient?.shutdown()
|
||||
upstreamTransport.disposeAllRouteClients()
|
||||
tailscaleDetector.shutdown()
|
||||
// Release the cached VirtualDisplay + ImageReader + HandlerThread
|
||||
// built by ScreenCapture on the first /screenshot call. Without
|
||||
|
||||
@@ -110,6 +110,10 @@ internal class GatewayProcessController(
|
||||
resetForSession(sessionId, scopeKey)
|
||||
}
|
||||
|
||||
/** True only when the published process snapshot belongs to this exact owner. */
|
||||
fun ownsSnapshot(sessionId: String, scopeKey: String?): Boolean =
|
||||
selectedSessionId == sessionId && selectedScopeKey == scopeKey && readySessionId == sessionId
|
||||
|
||||
/**
|
||||
* Admit process RPCs for [sessionId] after the gateway has created/resumed
|
||||
* that chat's live session. Stale ready callbacks are ignored.
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotGatewayRosterStatus
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.GatewayProfileAuthChoice
|
||||
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
|
||||
import com.hermesandroid.relay.data.GatewayProfilePatch
|
||||
import com.hermesandroid.relay.data.GatewayProfileSection
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal data class BotModeGatewaySnapshot(
|
||||
val connection: Connection,
|
||||
val dashboardUrl: String,
|
||||
val installId: String?,
|
||||
val roster: BotModeRoster,
|
||||
val stale: Boolean,
|
||||
val error: String?,
|
||||
)
|
||||
|
||||
class BotModeController(
|
||||
private val scope: CoroutineScope,
|
||||
private val connections: StateFlow<List<Connection>>,
|
||||
private val activeConnectionId: StateFlow<String?>,
|
||||
private val dashboardUrlProvider: (Connection) -> String,
|
||||
private val dashboardClientFactory: (connectionId: String, dashboardUrl: String) -> DashboardApiClient,
|
||||
private val gatewayLeaseFactory: (
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
profileName: String,
|
||||
retain: Boolean,
|
||||
) -> UpstreamTransportController.RouteGatewayLease,
|
||||
) {
|
||||
private val refreshMutex = Mutex()
|
||||
private val refreshGeneration = AtomicLong(0L)
|
||||
private val snapshots = linkedMapOf<String, BotModeGatewaySnapshot>()
|
||||
private val _state = MutableStateFlow(BotModeState())
|
||||
val state: StateFlow<BotModeState> = _state.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { refreshNow() }
|
||||
}
|
||||
|
||||
suspend fun refreshNow() {
|
||||
refreshMutex.withLock {
|
||||
val generation = refreshGeneration.incrementAndGet()
|
||||
val fleet = connections.value.toList()
|
||||
val liveIds = fleet.mapTo(linkedSetOf(), Connection::id)
|
||||
snapshots.keys.retainAll(liveIds)
|
||||
_state.value = aggregateForTest(
|
||||
fleet = fleet,
|
||||
snapshots = snapshots,
|
||||
loading = fleet.isNotEmpty(),
|
||||
)
|
||||
if (fleet.isEmpty()) {
|
||||
_state.value = BotModeState(error = "Connect to Hermes to use Bot Mode")
|
||||
return
|
||||
}
|
||||
|
||||
val limiter = Semaphore(3)
|
||||
val results = coroutineScope {
|
||||
fleet.map { connection ->
|
||||
async {
|
||||
limiter.withPermit { loadConnection(connection) }
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
if (refreshGeneration.get() != generation) return
|
||||
val currentIds = connections.value.mapTo(linkedSetOf(), Connection::id)
|
||||
results.filter { it.connection.id in currentIds }.forEach { result ->
|
||||
snapshots[result.connection.id] = result
|
||||
}
|
||||
snapshots.keys.retainAll(currentIds)
|
||||
_state.value = aggregateForTest(
|
||||
fleet = connections.value,
|
||||
snapshots = snapshots,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadConnection(connection: Connection): BotModeGatewaySnapshot {
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
val prior = snapshots[connection.id]
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return prior?.copy(
|
||||
connection = connection,
|
||||
stale = true,
|
||||
error = "Gateway is not configured",
|
||||
) ?: BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = null,
|
||||
roster = BotModeRoster(),
|
||||
stale = true,
|
||||
error = "Gateway is not configured",
|
||||
)
|
||||
}
|
||||
val statusClient = dashboardClientFactory(connection.id, dashboardUrl)
|
||||
val installId = try {
|
||||
statusClient.getStatus().getOrNull()?.installId?.trim()?.takeIf(String::isNotEmpty)
|
||||
} finally {
|
||||
statusClient.shutdown()
|
||||
}
|
||||
val rosterResult = gatewayLeaseFactory(connection.id, dashboardUrl, "default", false).use { lease ->
|
||||
lease.client.listBotModeRoster()
|
||||
}
|
||||
return rosterResult.fold(
|
||||
onSuccess = { roster ->
|
||||
BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = installId ?: prior?.installId,
|
||||
roster = roster,
|
||||
stale = false,
|
||||
error = null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
prior?.copy(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = installId ?: prior.installId,
|
||||
stale = true,
|
||||
error = error.message ?: "Gateway unavailable",
|
||||
) ?: BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = dashboardUrl,
|
||||
installId = installId,
|
||||
roster = BotModeRoster(),
|
||||
stale = true,
|
||||
error = error.message ?: "Gateway unavailable",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun ensureCanonicalBotChat(route: BotGatewayRoute): Result<BotChatTarget> {
|
||||
val connection = connectionFor(route)
|
||||
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
|
||||
}
|
||||
return gatewayLeaseFactory(connection.id, dashboardUrl, route.profileName, false).use { lease ->
|
||||
lease.client.ensureCanonicalBotChat(route.profileName)
|
||||
}.mapCatching { target ->
|
||||
check(connectionFor(route) != null) { "The Bot's gateway was removed while opening Bot Chat" }
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
fun acquireGateway(route: BotGatewayRoute): Result<UpstreamTransportController.RouteGatewayLease> {
|
||||
val connection = connectionFor(route)
|
||||
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
|
||||
}
|
||||
return Result.success(gatewayLeaseFactory(connection.id, dashboardUrl, route.profileName, true))
|
||||
}
|
||||
|
||||
fun dashboardClient(route: BotGatewayRoute): Result<DashboardApiClient> {
|
||||
val connection = connectionFor(route)
|
||||
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
|
||||
}
|
||||
return Result.success(dashboardClientFactory(connection.id, dashboardUrl))
|
||||
}
|
||||
|
||||
suspend fun createBot(
|
||||
connectionId: String,
|
||||
name: String,
|
||||
title: String,
|
||||
description: String,
|
||||
): Result<String> {
|
||||
val connection = connections.value.firstOrNull { it.id == connectionId }
|
||||
?: return Result.failure(IllegalStateException("The target gateway was removed"))
|
||||
val dashboardUrl = dashboardUrlProvider(connection).trim()
|
||||
if (dashboardUrl.isBlank()) {
|
||||
return Result.failure(IllegalStateException("The target gateway is not configured"))
|
||||
}
|
||||
val lease = gatewayLeaseFactory(connection.id, dashboardUrl, "default", false)
|
||||
val client = lease.client
|
||||
val cleanTitle = title.trim().ifBlank { name.trim() }.take(128)
|
||||
val result = try {
|
||||
client.createProfile(
|
||||
GatewayProfileCreateRequest(
|
||||
name = name.trim(),
|
||||
description = description.trim().takeIf(String::isNotBlank),
|
||||
cloneFrom = "default",
|
||||
authChoice = GatewayProfileAuthChoice.Shared,
|
||||
),
|
||||
).mapCatching { created ->
|
||||
check(connections.value.any { it.id == connectionId }) {
|
||||
"The target gateway was removed while creating the Bot"
|
||||
}
|
||||
val configured = client.configureProfile(
|
||||
created.name,
|
||||
GatewayProfilePatch(
|
||||
uiMeta = buildJsonObject {
|
||||
put("hermes-bots", buildJsonObject {
|
||||
put("title", cleanTitle)
|
||||
put("created", System.currentTimeMillis())
|
||||
})
|
||||
},
|
||||
),
|
||||
).getOrThrow()
|
||||
check(GatewayProfileSection.UiMeta in configured.applied) {
|
||||
"The profile was created, but Bot Mode metadata was not saved"
|
||||
}
|
||||
created.name
|
||||
}
|
||||
} finally {
|
||||
lease.close()
|
||||
}
|
||||
if (result.isSuccess) refreshNow()
|
||||
return result
|
||||
}
|
||||
|
||||
fun connectionRemoved(connectionId: String) {
|
||||
snapshots.remove(connectionId)
|
||||
refreshGeneration.incrementAndGet()
|
||||
_state.value = aggregateForTest(connections.value, snapshots, loading = false)
|
||||
}
|
||||
|
||||
private fun connectionFor(route: BotGatewayRoute): Connection? =
|
||||
connections.value.firstOrNull { it.id == route.connectionId }
|
||||
|
||||
internal fun aggregateForTest(
|
||||
fleet: List<Connection>,
|
||||
snapshots: Map<String, BotModeGatewaySnapshot>,
|
||||
loading: Boolean,
|
||||
): BotModeState {
|
||||
val order = fleet.mapIndexed { index, connection -> connection.id to index }.toMap()
|
||||
val activeId = activeConnectionId.value
|
||||
val routed = snapshots.values.flatMap { snapshot ->
|
||||
snapshot.roster.bots.map { bot ->
|
||||
bot.copy(
|
||||
route = BotGatewayRoute(
|
||||
key = BotGatewayRouteKey(
|
||||
connectionId = snapshot.connection.id,
|
||||
profileName = bot.profile.name,
|
||||
),
|
||||
connectionLabel = snapshot.connection.label,
|
||||
installId = snapshot.installId,
|
||||
),
|
||||
stale = snapshot.stale,
|
||||
)
|
||||
}
|
||||
}
|
||||
val collapsed = routed
|
||||
.groupBy { bot ->
|
||||
val route = checkNotNull(bot.route)
|
||||
"${route.installId ?: "connection:${route.connectionId}"}::${bot.profile.name}"
|
||||
}
|
||||
.values
|
||||
.map { candidates ->
|
||||
candidates.sortedWith(
|
||||
compareByDescending<BotRosterEntry> { it.route?.connectionId == activeId }
|
||||
.thenBy { it.stale }
|
||||
.thenBy { order[it.route?.connectionId] ?: Int.MAX_VALUE },
|
||||
).first()
|
||||
}
|
||||
val duplicateNames = collapsed.groupingBy { it.profile.name }.eachCount()
|
||||
val bots = collapsed.map { bot ->
|
||||
val route = checkNotNull(bot.route)
|
||||
bot.copy(
|
||||
handle = if ((duplicateNames[bot.profile.name] ?: 0) > 1) {
|
||||
"${handleSlug(bot.profile.name)}-${handleSlug(route.connectionLabel)}"
|
||||
} else {
|
||||
handleSlug(bot.profile.name)
|
||||
},
|
||||
)
|
||||
}.sortedByDescending(BotRosterEntry::latestActivityAtMs)
|
||||
|
||||
val groups = snapshots.values
|
||||
.flatMap { snapshot ->
|
||||
snapshot.roster.groups.map { group -> Triple(snapshot, group, group.roomId ?: group.key) }
|
||||
}
|
||||
.groupBy { it.third }
|
||||
.values
|
||||
.map { candidates ->
|
||||
val selected = candidates.maxWithOrNull(
|
||||
compareBy<Triple<BotModeGatewaySnapshot, BotGroupRoom, String>> { it.second.revision }
|
||||
.thenBy { it.second.latestActivityAtMs },
|
||||
) ?: error("group candidate list cannot be empty")
|
||||
selected.second.copy(
|
||||
sourceConnectionIds = candidates.mapTo(linkedSetOf()) { it.first.connection.id },
|
||||
stale = candidates.all { it.first.stale },
|
||||
)
|
||||
}
|
||||
.sortedByDescending(BotGroupRoom::latestActivityAtMs)
|
||||
|
||||
val statuses = fleet.map { connection ->
|
||||
val snapshot = snapshots[connection.id]
|
||||
BotGatewayRosterStatus(
|
||||
connectionId = connection.id,
|
||||
label = connection.label,
|
||||
installId = snapshot?.installId,
|
||||
loading = loading && snapshot == null,
|
||||
stale = snapshot?.stale == true,
|
||||
error = snapshot?.error,
|
||||
botCount = snapshot?.roster?.bots?.size ?: 0,
|
||||
)
|
||||
}
|
||||
val errors = statuses.mapNotNull(BotGatewayRosterStatus::error)
|
||||
return BotModeState(
|
||||
loading = loading,
|
||||
roster = BotModeRoster(
|
||||
bots = bots,
|
||||
groups = groups,
|
||||
botModeProtocolSupported = snapshots.values.any {
|
||||
it.roster.botModeProtocolSupported
|
||||
},
|
||||
),
|
||||
gateways = statuses,
|
||||
error = errors.takeIf { it.size == statuses.size && bots.isEmpty() }
|
||||
?.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSlug(value: String): String = value
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("[^a-z0-9]+"), "-")
|
||||
.trim('-')
|
||||
.take(64)
|
||||
.ifBlank { "bot" }
|
||||
}
|
||||
@@ -388,6 +388,13 @@ class ProfileController(
|
||||
) { server, fallback, override -> preferredProfileIcon(server, fallback, override) }
|
||||
}
|
||||
|
||||
/** Exact profile identity on any saved connection; never consults active state. */
|
||||
fun profileIconFlow(connectionId: String, profileName: String): Flow<String?> = combine(
|
||||
profileIconStore.serverAvatarFlow(connectionId, profileName),
|
||||
profileIconStore.iconFlow(connectionId, profileName),
|
||||
profileIconStore.localOverrideFlow(connectionId, profileName),
|
||||
) { server, local, localOverride -> preferredProfileIcon(server, local, localOverride) }
|
||||
|
||||
data class HostIconImportState(
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
@@ -18,6 +19,7 @@ import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreferen
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -78,6 +80,8 @@ class UpstreamTransportController(
|
||||
* `hermes_dashboard_<id>` file (original behavior).
|
||||
*/
|
||||
private val tokenStoreKeyProvider: (String) -> String? = { null },
|
||||
/** Exact trusted Dashboard base for any saved connection, active or not. */
|
||||
private val trustedDashboardUrlProvider: (String) -> String? = { null },
|
||||
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
|
||||
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
|
||||
{ _, _ -> null },
|
||||
@@ -96,6 +100,25 @@ class UpstreamTransportController(
|
||||
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
|
||||
private var dashboardHttpClientCache:
|
||||
Triple<String, String, okhttp3.OkHttpClient>? = null
|
||||
private data class RouteGatewayEntry(
|
||||
var dashboardUrl: String,
|
||||
var dashboardClient: DashboardApiClient,
|
||||
val client: GatewayChatClient,
|
||||
var activeRequests: Int = 0,
|
||||
var retained: Int = 0,
|
||||
var retired: Boolean = false,
|
||||
)
|
||||
private val routeGatewayClients = mutableMapOf<BotGatewayRouteKey, RouteGatewayEntry>()
|
||||
|
||||
class RouteGatewayLease internal constructor(
|
||||
val client: GatewayChatClient,
|
||||
private val releaseAction: () -> Unit,
|
||||
) : AutoCloseable {
|
||||
private val closed = AtomicBoolean(false)
|
||||
override fun close() {
|
||||
if (closed.compareAndSet(false, true)) releaseAction()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie store for [connectionId] — ONE instance per connection,
|
||||
@@ -137,9 +160,10 @@ class UpstreamTransportController(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
): DashboardBearerAuth? {
|
||||
if (activeConnectionIdProvider() != connectionId) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
val trustedDashboardUrl = trustedDashboardUrlProvider(connectionId)
|
||||
?: (if (activeConnectionIdProvider() == connectionId) dashboardUrlProvider() else null)
|
||||
?: return null
|
||||
return trustedDashboardBearerAuthOrNull(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
@@ -252,6 +276,7 @@ class UpstreamTransportController(
|
||||
if (gatewayClientCache?.first == connectionId) {
|
||||
gatewayClientCache = null
|
||||
}
|
||||
disposeConnectionRouteClients(connectionId)
|
||||
}
|
||||
|
||||
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
|
||||
@@ -333,6 +358,99 @@ class UpstreamTransportController(
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot/agent Gateway client owned by one immutable connection + profile.
|
||||
* It never consults or changes the foreground connection and never shares
|
||||
* live-session state with the standard Chat client's dynamic profile.
|
||||
*/
|
||||
@Synchronized
|
||||
fun acquireGatewayRoute(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
profileName: String,
|
||||
retain: Boolean = false,
|
||||
): RouteGatewayLease {
|
||||
val profile = profileName.trim().ifBlank { "default" }
|
||||
val key = BotGatewayRouteKey(connectionId.trim(), profile)
|
||||
var entry = routeGatewayClients[key]
|
||||
entry?.let { cached ->
|
||||
if (cached.dashboardUrl == dashboardUrl) {
|
||||
if (retain) cached.retained += 1 else cached.activeRequests += 1
|
||||
return routeLease(key, cached, retain)
|
||||
}
|
||||
if (cached.client.hasActiveTurn()) {
|
||||
val replacementDashboard = dashboardClientFor(connectionId, dashboardUrl)
|
||||
val previousDashboard = cached.dashboardClient
|
||||
cached.client.retarget(replacementDashboard)
|
||||
cached.dashboardClient = replacementDashboard
|
||||
cached.dashboardUrl = dashboardUrl
|
||||
previousDashboard.shutdown()
|
||||
if (retain) cached.retained += 1 else cached.activeRequests += 1
|
||||
return routeLease(key, cached, retain)
|
||||
}
|
||||
cached.retired = true
|
||||
routeGatewayClients.remove(key)
|
||||
if (cached.activeRequests == 0 && cached.retained == 0) shutdownRouteEntry(cached)
|
||||
}
|
||||
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
|
||||
entry = RouteGatewayEntry(
|
||||
dashboardUrl = dashboardUrl,
|
||||
dashboardClient = dashboardClient,
|
||||
client = GatewayChatClient(
|
||||
initialDashboardClient = dashboardClient,
|
||||
fixedSessionProfile = profile,
|
||||
).also { it.setKeepAliveInBackground(gatewayKeepAliveProvider()) },
|
||||
)
|
||||
if (retain) entry.retained = 1 else entry.activeRequests = 1
|
||||
routeGatewayClients[key] = entry
|
||||
return routeLease(key, entry, retain)
|
||||
}
|
||||
|
||||
private fun routeLease(
|
||||
key: BotGatewayRouteKey,
|
||||
entry: RouteGatewayEntry,
|
||||
retained: Boolean,
|
||||
): RouteGatewayLease = RouteGatewayLease(entry.client) {
|
||||
releaseGatewayRoute(key, entry, retained)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseGatewayRoute(
|
||||
key: BotGatewayRouteKey,
|
||||
entry: RouteGatewayEntry,
|
||||
retained: Boolean,
|
||||
) {
|
||||
if (retained) entry.retained = (entry.retained - 1).coerceAtLeast(0)
|
||||
else entry.activeRequests = (entry.activeRequests - 1).coerceAtLeast(0)
|
||||
if ((entry.retired || routeGatewayClients[key] !== entry) &&
|
||||
entry.activeRequests == 0 && entry.retained == 0
|
||||
) {
|
||||
shutdownRouteEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shutdownRouteEntry(entry: RouteGatewayEntry) {
|
||||
entry.client.shutdown()
|
||||
entry.dashboardClient.shutdown()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun disposeConnectionRouteClients(connectionId: String) {
|
||||
routeGatewayClients.entries
|
||||
.filter { it.key.connectionId == connectionId }
|
||||
.forEach { (key, entry) ->
|
||||
entry.retired = true
|
||||
shutdownRouteEntry(entry)
|
||||
routeGatewayClients.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun disposeAllRouteClients() {
|
||||
routeGatewayClients.values.forEach(::shutdownRouteEntry)
|
||||
routeGatewayClients.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the keep-alive-in-background flag to the cached gateway client, if
|
||||
* one exists. Driven by the ViewModel's `gatewayKeepAlive` collector.
|
||||
@@ -341,6 +459,9 @@ class UpstreamTransportController(
|
||||
*/
|
||||
fun applyGatewayKeepAlive(enabled: Boolean) {
|
||||
gatewayClientCache?.third?.setKeepAliveInBackground(enabled)
|
||||
synchronized(this) {
|
||||
routeGatewayClients.values.forEach { it.client.setKeepAliveInBackground(enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -838,6 +838,36 @@
|
||||
<string name="detail_tab_advanced">Avançado</string>
|
||||
<string name="detail_tab_security">Segurança</string>
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Modo Bot</string>
|
||||
<string name="bot_mode_drawer_summary">Bots e salas em grupo</string>
|
||||
<string name="bot_mode_search">Pesquisar no Modo Bot</string>
|
||||
<string name="bot_mode_search_hint">Pesquisar Bots e grupos</string>
|
||||
<string name="bot_mode_refresh">Atualizar Modo Bot</string>
|
||||
<string name="bot_mode_new_bot">Novo Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway indisponível</string>
|
||||
<string name="bot_mode_all_gateways">Todos os gateways</string>
|
||||
<string name="bot_mode_offline">Offline</string>
|
||||
<string name="bot_mode_filter_all">Todos</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Grupos</string>
|
||||
<string name="bot_mode_active_now">Ativos agora</string>
|
||||
<string name="bot_mode_opening_chat">Abrindo o Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Iniciar o Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Somente leitura</string>
|
||||
<string name="bot_mode_group_no_messages">Ainda não há mensagens na sala</string>
|
||||
<string name="bot_mode_empty">Ainda não há Bots nem salas em grupo</string>
|
||||
<string name="bot_mode_group_title">Sala em grupo</string>
|
||||
<string name="bot_mode_group_missing">Esta sala em grupo não está mais disponível.</string>
|
||||
<string name="bot_mode_group_read_only_help">Este é o histórico limitado e somente leitura compartilhado pelo Hermes Desktop. Por enquanto, continue com um Bot individual.</string>
|
||||
<string name="bot_mode_bot_name">Nome do perfil</string>
|
||||
<string name="bot_mode_bot_title">Nome do Bot</string>
|
||||
<string name="bot_mode_bot_description">Função e descrição</string>
|
||||
<string name="bot_mode_create_help">O novo Bot começa com o perfil padrão e compartilha o login dele. Você pode ajustar habilidades e modelo em Gerenciar.</string>
|
||||
<string name="bot_mode_create">Criar Bot</string>
|
||||
<string name="bot_mode_back_to_bots">Voltar ao Modo Bot</string>
|
||||
<string name="bot_mode_chat_open_failed">Não foi possível abrir o Bot Chat</string>
|
||||
<string name="bot_mode_created">%1$s criado</string>
|
||||
<string name="bot_mode_create_failed">Não foi possível criar o Bot</string>
|
||||
<string name="drawer_filter_by_source">Filtrar por origem</string>
|
||||
<string name="drawer_show_sources">Mostrar origens</string>
|
||||
<string name="drawer_refresh_sessions">Atualizar sessões</string>
|
||||
@@ -846,6 +876,10 @@
|
||||
<string name="drawer_search_sessions">Pesquisar sessões</string>
|
||||
<string name="drawer_activity_working">Em andamento</string>
|
||||
<string name="drawer_activity_needs_input">Precisa de resposta</string>
|
||||
<string name="drawer_activity_starting">Iniciando</string>
|
||||
<string name="drawer_activity_background_work">Trabalho em segundo plano</string>
|
||||
<string name="drawer_activity_checking">Verificando</string>
|
||||
<string name="drawer_activity_unavailable">Indisponível</string>
|
||||
<string name="drawer_new_thread">Nova Thread</string>
|
||||
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
|
||||
<string name="drawer_loading_sessions">Carregando sessões…</string>
|
||||
@@ -875,7 +909,7 @@
|
||||
<string name="drawer_filter_pinned">Fixadas</string>
|
||||
<string name="drawer_filter_archive">Arquivo</string>
|
||||
<string name="drawer_filter_sessions">Sessões</string>
|
||||
<string name="drawer_timestamp_active">Ativa</string>
|
||||
<string name="drawer_timestamp_updated">Atualizada</string>
|
||||
<string name="drawer_timestamp_started">Iniciada</string>
|
||||
<string name="drawer_just_now">Agora mesmo</string>
|
||||
<!-- P1: BridgeScreen -->
|
||||
@@ -3851,7 +3885,7 @@
|
||||
<string name="appearance_preview_voice">Voz</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137,2 mil</string>
|
||||
<string name="appearance_preview_message_placeholder">Mensagem…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / perfil: padrão</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / perfil: padrão</string>
|
||||
<string name="appearance_preview_live_note">Esta prévia é atualizada imediatamente com a predefinição, o modo, a fonte e a aparência da Sphere.</string>
|
||||
<string name="appearance_customize_theme">Personalizar %1$s</string>
|
||||
<string name="appearance_accent_preset">Cor de destaque predefinida</string>
|
||||
@@ -4106,6 +4140,45 @@
|
||||
<string name="bridge_timed_unlimited_warning">Sem limite de inatividade. O acesso continua após inatividade e reconexão até ser encerrado, a chave mestra ser desligada ou a política mudar. Ideal para um dispositivo dedicado.</string>
|
||||
<string name="bss_screen_access_off_desc">O acesso à tela está desligado. Novo acesso finito usa %1$d minutos ocioso por padrão.</string>
|
||||
<string name="bss_screen_access_unlimited_desc">Pelo menos um recurso de tela permanece ativo até ser desligado explicitamente.</string>
|
||||
<string name="provider_usage_title">Uso e limites</string>
|
||||
<string name="provider_usage_back">Voltar</string>
|
||||
<string name="provider_usage_refresh">Atualizar uso</string>
|
||||
<string name="provider_usage_intro">Limites de conta dos provedores configurados nesta conexão do Hermes.</string>
|
||||
<string name="provider_usage_not_available">Esta conexão Hermes não expõe o uso dos provedores. Atualize o Hermes ou instale/atualize o plugin Relay.</string>
|
||||
<string name="provider_usage_none_configured">Nenhum provedor visível tem dados de uso da conta disponíveis.</string>
|
||||
<string name="provider_usage_loading">Carregando uso dos provedores…</string>
|
||||
<string name="provider_usage_error">Não foi possível carregar o uso dos provedores.</string>
|
||||
<string name="provider_usage_retry">Tentar novamente</string>
|
||||
<string name="provider_usage_percent">%1$d%% usado</string>
|
||||
<string name="provider_usage_resets">Redefine em %1$s</string>
|
||||
<string name="provider_usage_display_title">Exibição nas Configurações</string>
|
||||
<string name="provider_usage_display_desc">Escolha como o uso da conta aparece na tela principal de Configurações.</string>
|
||||
<string name="provider_usage_mode_summary">Resumo</string>
|
||||
<string name="provider_usage_mode_expanded">Expandido</string>
|
||||
<string name="provider_usage_mode_hidden">Oculto</string>
|
||||
<string name="provider_usage_providers_title">Mostrar nas Configurações principais</string>
|
||||
<string name="provider_usage_providers_desc">Escolha quais cartões de provedores aparecem nas Configurações principais. Todos continuam visíveis aqui.</string>
|
||||
<string name="provider_usage_settings_desc">Uso da conta e limites dos provedores</string>
|
||||
<string name="provider_usage_settings_desc_relay">Uso e limites ampliados pelo plugin Relay</string>
|
||||
<string name="provider_usage_settings_desc_basic">Uso básico do Hermes · Relay adiciona pools e mais</string>
|
||||
<string name="provider_usage_customize">Exibição</string>
|
||||
<string name="provider_usage_hidden_hint">Os cartões de uso estão ocultos nas Configurações.</string>
|
||||
<string name="provider_usage_not_available_compact">O uso dos provedores está indisponível. Atualize o Hermes ou o plugin Relay.</string>
|
||||
<string name="provider_usage_provider_not_configured">Não configurado neste host</string>
|
||||
<string name="provider_usage_provider_unavailable">Uso temporariamente indisponível</string>
|
||||
<string name="provider_usage_active_unknown">Esta sessão ainda não tem uma credencial ativa.</string>
|
||||
<string name="provider_usage_active_available">Ativa · Disponível</string>
|
||||
<string name="provider_usage_active_at_limit">Ativa · Limite atingido</string>
|
||||
<string name="provider_usage_active">Ativa</string>
|
||||
<string name="provider_usage_available">Disponível</string>
|
||||
<string name="provider_usage_at_limit">Limite atingido</string>
|
||||
<string name="provider_usage_unavailable_status">Indisponível</string>
|
||||
<string name="provider_usage_renews_on">Renova em %1$s</string>
|
||||
<string name="provider_usage_manage_credits">Gerenciar créditos</string>
|
||||
<string name="provider_usage_capability_relay_title">Ampliado pelo plugin Relay</string>
|
||||
<string name="provider_usage_capability_relay_body">Pools de credenciais, saldos estruturados da Nous e OpenCode Go são fornecidos pelo plugin Relay.</string>
|
||||
<string name="provider_usage_capability_basic_title">Uso básico do Hermes</string>
|
||||
<string name="provider_usage_capability_basic_body">Instale ou atualize o plugin Relay para pools de credenciais, saldos estruturados da Nous e OpenCode Go.</string>
|
||||
<string name="custom_theme_title">Personalizado</string>
|
||||
<string name="custom_theme_entry_summary">Crie e salve seus próprios temas</string>
|
||||
<string name="custom_theme_your_presets">Seus temas</string>
|
||||
|
||||
@@ -884,6 +884,36 @@
|
||||
<string name="detail_tab_security">安全</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Bot 模式</string>
|
||||
<string name="bot_mode_drawer_summary">Bot 和群组房间</string>
|
||||
<string name="bot_mode_search">搜索 Bot 模式</string>
|
||||
<string name="bot_mode_search_hint">搜索 Bot 和群组</string>
|
||||
<string name="bot_mode_refresh">刷新 Bot 模式</string>
|
||||
<string name="bot_mode_new_bot">新建 Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">网关不可用</string>
|
||||
<string name="bot_mode_all_gateways">所有网关</string>
|
||||
<string name="bot_mode_offline">离线</string>
|
||||
<string name="bot_mode_filter_all">全部</string>
|
||||
<string name="bot_mode_filter_bots">Bot</string>
|
||||
<string name="bot_mode_filter_groups">群组</string>
|
||||
<string name="bot_mode_active_now">当前活跃</string>
|
||||
<string name="bot_mode_opening_chat">正在打开 Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">开始 Bot Chat</string>
|
||||
<string name="bot_mode_read_only">只读</string>
|
||||
<string name="bot_mode_group_no_messages">房间中还没有消息</string>
|
||||
<string name="bot_mode_empty">还没有 Bot 或群组房间</string>
|
||||
<string name="bot_mode_group_title">群组房间</string>
|
||||
<string name="bot_mode_group_missing">此群组房间已不可用。</string>
|
||||
<string name="bot_mode_group_read_only_help">这是 Hermes Desktop 共享的有限只读房间历史记录。目前请先与单个 Bot 继续对话。</string>
|
||||
<string name="bot_mode_bot_name">配置文件名称</string>
|
||||
<string name="bot_mode_bot_title">Bot 名称</string>
|
||||
<string name="bot_mode_bot_description">角色和说明</string>
|
||||
<string name="bot_mode_create_help">新 Bot 基于默认配置文件创建并共享其登录。你可以在“管理”中调整技能和模型。</string>
|
||||
<string name="bot_mode_create">创建 Bot</string>
|
||||
<string name="bot_mode_back_to_bots">返回 Bot 模式</string>
|
||||
<string name="bot_mode_chat_open_failed">无法打开 Bot Chat</string>
|
||||
<string name="bot_mode_created">已创建 %1$s</string>
|
||||
<string name="bot_mode_create_failed">无法创建 Bot</string>
|
||||
<string name="drawer_filter_by_source">按来源筛选</string>
|
||||
<string name="drawer_show_sources">显示来源</string>
|
||||
<string name="drawer_refresh_sessions">刷新会话</string>
|
||||
@@ -892,6 +922,10 @@
|
||||
<string name="drawer_search_sessions">搜索会话</string>
|
||||
<string name="drawer_activity_working">正在处理</string>
|
||||
<string name="drawer_activity_needs_input">需要输入</string>
|
||||
<string name="drawer_activity_starting">正在启动</string>
|
||||
<string name="drawer_activity_background_work">后台工作</string>
|
||||
<string name="drawer_activity_checking">正在检查</string>
|
||||
<string name="drawer_activity_unavailable">不可用</string>
|
||||
<string name="drawer_new_thread">新话题</string>
|
||||
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
|
||||
<string name="drawer_loading_sessions">正在加载会话…</string>
|
||||
@@ -921,7 +955,7 @@
|
||||
<string name="drawer_filter_pinned">已固定</string>
|
||||
<string name="drawer_filter_archive">归档</string>
|
||||
<string name="drawer_filter_sessions">会话</string>
|
||||
<string name="drawer_timestamp_active">活跃</string>
|
||||
<string name="drawer_timestamp_updated">更新</string>
|
||||
<string name="drawer_timestamp_started">已开始</string>
|
||||
<string name="drawer_just_now">刚刚</string>
|
||||
|
||||
@@ -3939,7 +3973,7 @@
|
||||
<string name="appearance_preview_voice">语音</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206 个 token · 137.2K</string>
|
||||
<string name="appearance_preview_message_placeholder">消息…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / 配置文件:默认</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / 配置文件:默认</string>
|
||||
<string name="appearance_preview_live_note">更改预设、模式、字体或 Sphere 皮肤后,此预览会立即更新。</string>
|
||||
<string name="appearance_customize_theme">自定义 %1$s</string>
|
||||
<string name="appearance_accent_preset">预设强调色</string>
|
||||
@@ -4191,6 +4225,45 @@
|
||||
<string name="bridge_timed_unlimited_warning">无空闲超时。屏幕访问在空闲和重新连接后仍保持,直到结束访问、关闭主开关或更改策略。适合专用设备。</string>
|
||||
<string name="bss_screen_access_off_desc">屏幕访问已关闭。新的有限访问默认使用 %1$d 分钟空闲限制。</string>
|
||||
<string name="bss_screen_access_unlimited_desc">至少一项屏幕功能会保持有效,直到明确关闭。</string>
|
||||
<string name="provider_usage_title">用量和限额</string>
|
||||
<string name="provider_usage_back">返回</string>
|
||||
<string name="provider_usage_refresh">刷新用量</string>
|
||||
<string name="provider_usage_intro">此 Hermes 连接中已配置提供商的账户限额。</string>
|
||||
<string name="provider_usage_not_available">此 Hermes 连接未提供服务商用量。请更新 Hermes,或安装/更新 Relay 插件。</string>
|
||||
<string name="provider_usage_none_configured">当前显示的提供商均无可用账户用量。</string>
|
||||
<string name="provider_usage_loading">正在加载提供商用量…</string>
|
||||
<string name="provider_usage_error">无法加载提供商用量。</string>
|
||||
<string name="provider_usage_retry">重试</string>
|
||||
<string name="provider_usage_percent">已使用 %1$d%%</string>
|
||||
<string name="provider_usage_resets">%1$s后重置</string>
|
||||
<string name="provider_usage_display_title">设置页显示</string>
|
||||
<string name="provider_usage_display_desc">选择账户用量在主设置屏幕中的显示方式。</string>
|
||||
<string name="provider_usage_mode_summary">摘要</string>
|
||||
<string name="provider_usage_mode_expanded">展开</string>
|
||||
<string name="provider_usage_mode_hidden">隐藏</string>
|
||||
<string name="provider_usage_providers_title">在主设置页显示</string>
|
||||
<string name="provider_usage_providers_desc">选择要在主设置页显示的提供商卡片。此处仍会显示所有提供商。</string>
|
||||
<string name="provider_usage_settings_desc">账户用量和提供商限额</string>
|
||||
<string name="provider_usage_settings_desc_relay">由 Relay 插件增强的用量和限额</string>
|
||||
<string name="provider_usage_settings_desc_basic">Hermes 基础用量 · Relay 可增加凭据池等功能</string>
|
||||
<string name="provider_usage_customize">显示</string>
|
||||
<string name="provider_usage_hidden_hint">设置页已隐藏用量卡片。</string>
|
||||
<string name="provider_usage_not_available_compact">服务商用量不可用。请更新 Hermes 或 Relay 插件。</string>
|
||||
<string name="provider_usage_provider_not_configured">此主机未配置</string>
|
||||
<string name="provider_usage_provider_unavailable">用量暂时不可用</string>
|
||||
<string name="provider_usage_active_unknown">此会话尚无当前凭据。</string>
|
||||
<string name="provider_usage_active_available">当前 · 可用</string>
|
||||
<string name="provider_usage_active_at_limit">当前 · 已达上限</string>
|
||||
<string name="provider_usage_active">当前</string>
|
||||
<string name="provider_usage_available">可用</string>
|
||||
<string name="provider_usage_at_limit">已达上限</string>
|
||||
<string name="provider_usage_unavailable_status">不可用</string>
|
||||
<string name="provider_usage_renews_on">续期日期:%1$s</string>
|
||||
<string name="provider_usage_manage_credits">管理额度</string>
|
||||
<string name="provider_usage_capability_relay_title">已由 Relay 插件增强</string>
|
||||
<string name="provider_usage_capability_relay_body">凭据池、结构化 Nous 余额和 OpenCode Go 由 Relay 插件提供。</string>
|
||||
<string name="provider_usage_capability_basic_title">Hermes 基础用量</string>
|
||||
<string name="provider_usage_capability_basic_body">安装或更新 Relay 插件即可使用凭据池、结构化 Nous 余额和 OpenCode Go。</string>
|
||||
<string name="custom_theme_title">自定义</string>
|
||||
<string name="custom_theme_entry_summary">创建并保存自己的主题</string>
|
||||
<string name="custom_theme_your_presets">你的预设</string>
|
||||
|
||||
@@ -887,6 +887,36 @@
|
||||
<string name="detail_tab_security">Sicherheit</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Bot-Modus</string>
|
||||
<string name="bot_mode_drawer_summary">Bots und Gruppenräume</string>
|
||||
<string name="bot_mode_search">Bot-Modus durchsuchen</string>
|
||||
<string name="bot_mode_search_hint">Bots und Gruppen durchsuchen</string>
|
||||
<string name="bot_mode_refresh">Bot-Modus aktualisieren</string>
|
||||
<string name="bot_mode_new_bot">Neuer Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway nicht verfügbar</string>
|
||||
<string name="bot_mode_all_gateways">Alle Gateways</string>
|
||||
<string name="bot_mode_offline">Offline</string>
|
||||
<string name="bot_mode_filter_all">Alle</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Gruppen</string>
|
||||
<string name="bot_mode_active_now">Jetzt aktiv</string>
|
||||
<string name="bot_mode_opening_chat">Bot-Chat wird geöffnet…</string>
|
||||
<string name="bot_mode_no_messages">Bot-Chat starten</string>
|
||||
<string name="bot_mode_read_only">Schreibgeschützt</string>
|
||||
<string name="bot_mode_group_no_messages">Noch keine Raumnachrichten</string>
|
||||
<string name="bot_mode_empty">Noch keine Bots oder Gruppenräume</string>
|
||||
<string name="bot_mode_group_title">Gruppenraum</string>
|
||||
<string name="bot_mode_group_missing">Dieser Gruppenraum ist nicht mehr verfügbar.</string>
|
||||
<string name="bot_mode_group_read_only_help">Dies ist der begrenzte, schreibgeschützte Raumverlauf aus Hermes Desktop. Fahre vorerst mit einem einzelnen Bot fort.</string>
|
||||
<string name="bot_mode_bot_name">Profilname</string>
|
||||
<string name="bot_mode_bot_title">Bot-Name</string>
|
||||
<string name="bot_mode_bot_description">Rolle und Beschreibung</string>
|
||||
<string name="bot_mode_create_help">Der neue Bot basiert auf dem Standardprofil und teilt dessen Anmeldung. Fähigkeiten und Modell kannst du unter Verwalten anpassen.</string>
|
||||
<string name="bot_mode_create">Bot erstellen</string>
|
||||
<string name="bot_mode_back_to_bots">Zurück zum Bot-Modus</string>
|
||||
<string name="bot_mode_chat_open_failed">Bot-Chat konnte nicht geöffnet werden</string>
|
||||
<string name="bot_mode_created">%1$s erstellt</string>
|
||||
<string name="bot_mode_create_failed">Bot konnte nicht erstellt werden</string>
|
||||
<string name="drawer_filter_by_source">Nach Quelle filtern</string>
|
||||
<string name="drawer_show_sources">Quellen anzeigen</string>
|
||||
<string name="drawer_refresh_sessions">Sitzungen aktualisieren</string>
|
||||
@@ -895,6 +925,10 @@
|
||||
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
|
||||
<string name="drawer_activity_working">Wird bearbeitet</string>
|
||||
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
|
||||
<string name="drawer_activity_starting">Wird gestartet</string>
|
||||
<string name="drawer_activity_background_work">Hintergrundarbeit</string>
|
||||
<string name="drawer_activity_checking">Wird geprüft</string>
|
||||
<string name="drawer_activity_unavailable">Nicht verfügbar</string>
|
||||
<string name="drawer_new_thread">Neuer Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
|
||||
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
|
||||
@@ -924,7 +958,7 @@
|
||||
<string name="drawer_filter_pinned">Angeheftet</string>
|
||||
<string name="drawer_filter_archive">Archiv</string>
|
||||
<string name="drawer_filter_sessions">Sitzungen</string>
|
||||
<string name="drawer_timestamp_active">Aktiv</string>
|
||||
<string name="drawer_timestamp_updated">Aktualisiert</string>
|
||||
<string name="drawer_timestamp_started">Gestartet</string>
|
||||
<string name="drawer_just_now">Gerade eben</string>
|
||||
|
||||
@@ -4011,7 +4045,7 @@
|
||||
<string name="appearance_preview_voice">Sprache</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206 Token · 137,2K</string>
|
||||
<string name="appearance_preview_message_placeholder">Nachricht…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / Profil: Standard</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / Profil: Standard</string>
|
||||
<string name="appearance_preview_live_note">Diese Vorschau wird sofort mit Vorlage, Modus, Schrift und Sphere-Skin aktualisiert.</string>
|
||||
<string name="appearance_customize_theme">%1$s anpassen</string>
|
||||
<string name="appearance_accent_preset">Voreingestellte Akzentfarbe</string>
|
||||
@@ -4266,6 +4300,45 @@
|
||||
<string name="bridge_timed_unlimited_warning">Kein Leerlaufzeitlimit. Bildschirmzugriff bleibt bei Inaktivität und Wiederverbindung aktiv, bis er beendet, der Hauptschalter deaktiviert oder die Richtlinie geändert wird. Für ein dediziertes Gerät.</string>
|
||||
<string name="bss_screen_access_off_desc">Bildschirmzugriff ist aus. Neuer begrenzter Zugriff verwendet standardmäßig %1$d Minuten Leerlauf.</string>
|
||||
<string name="bss_screen_access_unlimited_desc">Mindestens eine Bildschirmfunktion bleibt bis zum ausdrücklichen Ausschalten aktiv.</string>
|
||||
<string name="provider_usage_title">Nutzung & Limits</string>
|
||||
<string name="provider_usage_back">Zurück</string>
|
||||
<string name="provider_usage_refresh">Nutzung aktualisieren</string>
|
||||
<string name="provider_usage_intro">Kontolimits der Anbieter, die für diese Hermes-Verbindung konfiguriert sind.</string>
|
||||
<string name="provider_usage_not_available">Diese Hermes-Verbindung stellt keine Anbieternutzung bereit. Aktualisieren Sie Hermes oder installieren/aktualisieren Sie das Relay-Plugin.</string>
|
||||
<string name="provider_usage_none_configured">Für keinen sichtbaren Anbieter sind Kontonutzungsdaten verfügbar.</string>
|
||||
<string name="provider_usage_loading">Anbieternutzung wird geladen…</string>
|
||||
<string name="provider_usage_error">Anbieternutzung konnte nicht geladen werden.</string>
|
||||
<string name="provider_usage_retry">Erneut versuchen</string>
|
||||
<string name="provider_usage_percent">%1$d%% verwendet</string>
|
||||
<string name="provider_usage_resets">Zurücksetzung in %1$s</string>
|
||||
<string name="provider_usage_display_title">Anzeige in Einstellungen</string>
|
||||
<string name="provider_usage_display_desc">Wählen Sie, wie die Kontonutzung in den Haupteinstellungen erscheint.</string>
|
||||
<string name="provider_usage_mode_summary">Übersicht</string>
|
||||
<string name="provider_usage_mode_expanded">Erweitert</string>
|
||||
<string name="provider_usage_mode_hidden">Ausgeblendet</string>
|
||||
<string name="provider_usage_providers_title">In den Haupteinstellungen anzeigen</string>
|
||||
<string name="provider_usage_providers_desc">Wählen Sie, welche Anbieterkarten in den Haupteinstellungen erscheinen. Hier bleiben alle Anbieter sichtbar.</string>
|
||||
<string name="provider_usage_settings_desc">Kontonutzung und Anbieterlimits</string>
|
||||
<string name="provider_usage_settings_desc_relay">Durch Relay-Plugin erweiterte Nutzung und Limits</string>
|
||||
<string name="provider_usage_settings_desc_basic">Hermes-Basisnutzung · Relay-Plugin ergänzt Pools und mehr</string>
|
||||
<string name="provider_usage_customize">Anzeige</string>
|
||||
<string name="provider_usage_hidden_hint">Nutzungskarten sind in den Einstellungen ausgeblendet.</string>
|
||||
<string name="provider_usage_not_available_compact">Anbieternutzung ist nicht verfügbar. Aktualisieren Sie Hermes oder das Relay-Plugin.</string>
|
||||
<string name="provider_usage_provider_not_configured">Auf diesem Host nicht konfiguriert</string>
|
||||
<string name="provider_usage_provider_unavailable">Nutzung ist vorübergehend nicht verfügbar</string>
|
||||
<string name="provider_usage_active_unknown">Für diese Sitzung gibt es noch keine aktiven Anmeldedaten.</string>
|
||||
<string name="provider_usage_active_available">Aktiv · Verfügbar</string>
|
||||
<string name="provider_usage_active_at_limit">Aktiv · Limit erreicht</string>
|
||||
<string name="provider_usage_active">Aktiv</string>
|
||||
<string name="provider_usage_available">Verfügbar</string>
|
||||
<string name="provider_usage_at_limit">Limit erreicht</string>
|
||||
<string name="provider_usage_unavailable_status">Nicht verfügbar</string>
|
||||
<string name="provider_usage_renews_on">Verlängert sich am %1$s</string>
|
||||
<string name="provider_usage_manage_credits">Guthaben verwalten</string>
|
||||
<string name="provider_usage_capability_relay_title">Durch Relay-Plugin erweitert</string>
|
||||
<string name="provider_usage_capability_relay_body">Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go werden vom Relay-Plugin bereitgestellt.</string>
|
||||
<string name="provider_usage_capability_basic_title">Basisnutzung von Hermes</string>
|
||||
<string name="provider_usage_capability_basic_body">Installieren oder aktualisieren Sie das Relay-Plugin für Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go.</string>
|
||||
<string name="custom_theme_title">Benutzerdefiniert</string>
|
||||
<string name="custom_theme_entry_summary">Eigene Themes erstellen und speichern</string>
|
||||
<string name="custom_theme_your_presets">Deine Presets</string>
|
||||
|
||||
@@ -802,6 +802,36 @@
|
||||
<string name="detail_tab_routes">Rutas</string>
|
||||
<string name="detail_tab_advanced">Avanzado</string>
|
||||
<string name="detail_tab_security">Seguridad</string>
|
||||
<string name="bot_mode_title">Modo Bot</string>
|
||||
<string name="bot_mode_drawer_summary">Bots y salas de grupo</string>
|
||||
<string name="bot_mode_search">Buscar en Modo Bot</string>
|
||||
<string name="bot_mode_search_hint">Buscar Bots y grupos</string>
|
||||
<string name="bot_mode_refresh">Actualizar Modo Bot</string>
|
||||
<string name="bot_mode_new_bot">Nuevo Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway no disponible</string>
|
||||
<string name="bot_mode_all_gateways">Todos los gateways</string>
|
||||
<string name="bot_mode_offline">Sin conexión</string>
|
||||
<string name="bot_mode_filter_all">Todo</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Grupos</string>
|
||||
<string name="bot_mode_active_now">Activos ahora</string>
|
||||
<string name="bot_mode_opening_chat">Abriendo Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Iniciar el Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Solo lectura</string>
|
||||
<string name="bot_mode_group_no_messages">Aún no hay mensajes en la sala</string>
|
||||
<string name="bot_mode_empty">Aún no hay Bots ni salas de grupo</string>
|
||||
<string name="bot_mode_group_title">Sala de grupo</string>
|
||||
<string name="bot_mode_group_missing">Esta sala de grupo ya no está disponible.</string>
|
||||
<string name="bot_mode_group_read_only_help">Este es el historial limitado y de solo lectura compartido por Hermes Desktop. Por ahora, continúa con un Bot individual.</string>
|
||||
<string name="bot_mode_bot_name">Nombre del perfil</string>
|
||||
<string name="bot_mode_bot_title">Nombre del Bot</string>
|
||||
<string name="bot_mode_bot_description">Rol y descripción</string>
|
||||
<string name="bot_mode_create_help">El nuevo Bot parte del perfil predeterminado y comparte su inicio de sesión. Puedes ajustar sus habilidades y modelo en Administrar.</string>
|
||||
<string name="bot_mode_create">Crear Bot</string>
|
||||
<string name="bot_mode_back_to_bots">Volver a Modo Bot</string>
|
||||
<string name="bot_mode_chat_open_failed">No se pudo abrir el Bot Chat</string>
|
||||
<string name="bot_mode_created">Se creó %1$s</string>
|
||||
<string name="bot_mode_create_failed">No se pudo crear el Bot</string>
|
||||
<string name="drawer_filter_by_source">Filtrar por fuente</string>
|
||||
<string name="drawer_show_sources">Mostrar fuentes</string>
|
||||
<string name="drawer_refresh_sessions">Actualizar sesiones</string>
|
||||
@@ -810,6 +840,10 @@
|
||||
<string name="drawer_search_sessions">Buscar sesiones</string>
|
||||
<string name="drawer_activity_working">En curso</string>
|
||||
<string name="drawer_activity_needs_input">Requiere intervención</string>
|
||||
<string name="drawer_activity_starting">Iniciando</string>
|
||||
<string name="drawer_activity_background_work">Trabajo en segundo plano</string>
|
||||
<string name="drawer_activity_checking">Comprobando</string>
|
||||
<string name="drawer_activity_unavailable">No disponible</string>
|
||||
<string name="drawer_new_thread">Nuevo hilo</string>
|
||||
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
|
||||
<string name="drawer_loading_sessions">Cargando sesiones…</string>
|
||||
@@ -839,7 +873,7 @@
|
||||
<string name="drawer_filter_pinned">Fijado</string>
|
||||
<string name="drawer_filter_archive">Archivo</string>
|
||||
<string name="drawer_filter_sessions">Sesiones</string>
|
||||
<string name="drawer_timestamp_active">Activo</string>
|
||||
<string name="drawer_timestamp_updated">Actualizado</string>
|
||||
<string name="drawer_timestamp_started">Comenzó</string>
|
||||
<string name="drawer_just_now">En este momento</string>
|
||||
<string name="bridge_back">Atrás</string>
|
||||
@@ -3696,7 +3730,7 @@
|
||||
<string name="appearance_preview_voice">Voz</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137,2K</string>
|
||||
<string name="appearance_preview_message_placeholder">Mensaje…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / perfil: predeterminado</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / perfil: predeterminado</string>
|
||||
<string name="appearance_preview_live_note">Esta vista previa se actualiza al instante con el ajuste, modo, fuente y aspecto de Sphere.</string>
|
||||
<string name="appearance_customize_theme">Personalizar %1$s</string>
|
||||
<string name="appearance_accent_preset">Color de acento predefinido</string>
|
||||
@@ -3951,6 +3985,45 @@
|
||||
<string name="bridge_timed_unlimited_warning">Sin límite de inactividad. El acceso continúa tras inactividad y reconexión hasta finalizarlo, desactivar el interruptor maestro o cambiar la política. Ideal para un dispositivo dedicado.</string>
|
||||
<string name="bss_screen_access_off_desc">El acceso a pantalla está desactivado. El acceso finito nuevo usa %1$d minutos de inactividad por defecto.</string>
|
||||
<string name="bss_screen_access_unlimited_desc">Al menos una capacidad de pantalla permanece activa hasta desactivarla explícitamente.</string>
|
||||
<string name="provider_usage_title">Uso y límites</string>
|
||||
<string name="provider_usage_back">Atrás</string>
|
||||
<string name="provider_usage_refresh">Actualizar uso</string>
|
||||
<string name="provider_usage_intro">Límites de cuenta de los proveedores configurados en esta conexión de Hermes.</string>
|
||||
<string name="provider_usage_not_available">Esta conexión de Hermes no expone el uso de proveedores. Actualiza Hermes o instala/actualiza el complemento Relay.</string>
|
||||
<string name="provider_usage_none_configured">Ningún proveedor visible tiene datos de uso de cuenta disponibles.</string>
|
||||
<string name="provider_usage_loading">Cargando uso de proveedores…</string>
|
||||
<string name="provider_usage_error">No se pudo cargar el uso de proveedores.</string>
|
||||
<string name="provider_usage_retry">Reintentar</string>
|
||||
<string name="provider_usage_percent">%1$d%% usado</string>
|
||||
<string name="provider_usage_resets">Se restablece en %1$s</string>
|
||||
<string name="provider_usage_display_title">Visualización en Ajustes</string>
|
||||
<string name="provider_usage_display_desc">Elige cómo aparece el uso de cuenta en la pantalla principal de Ajustes.</string>
|
||||
<string name="provider_usage_mode_summary">Resumen</string>
|
||||
<string name="provider_usage_mode_expanded">Ampliado</string>
|
||||
<string name="provider_usage_mode_hidden">Oculto</string>
|
||||
<string name="provider_usage_providers_title">Mostrar en Ajustes principales</string>
|
||||
<string name="provider_usage_providers_desc">Elige qué tarjetas de proveedores aparecen en Ajustes principales. Aquí siempre se muestran todos.</string>
|
||||
<string name="provider_usage_settings_desc">Uso de cuenta y límites de proveedores</string>
|
||||
<string name="provider_usage_settings_desc_relay">Uso y límites ampliados por el complemento Relay</string>
|
||||
<string name="provider_usage_settings_desc_basic">Uso básico de Hermes · Relay añade grupos y más</string>
|
||||
<string name="provider_usage_customize">Visualización</string>
|
||||
<string name="provider_usage_hidden_hint">Las tarjetas de uso están ocultas en Ajustes.</string>
|
||||
<string name="provider_usage_not_available_compact">El uso de proveedores no está disponible. Actualiza Hermes o el complemento Relay.</string>
|
||||
<string name="provider_usage_provider_not_configured">No configurado en este host</string>
|
||||
<string name="provider_usage_provider_unavailable">El uso no está disponible temporalmente</string>
|
||||
<string name="provider_usage_active_unknown">Esta sesión aún no tiene una credencial activa.</string>
|
||||
<string name="provider_usage_active_available">Activa · Disponible</string>
|
||||
<string name="provider_usage_active_at_limit">Activa · Límite alcanzado</string>
|
||||
<string name="provider_usage_active">Activa</string>
|
||||
<string name="provider_usage_available">Disponible</string>
|
||||
<string name="provider_usage_at_limit">Límite alcanzado</string>
|
||||
<string name="provider_usage_unavailable_status">No disponible</string>
|
||||
<string name="provider_usage_renews_on">Se renueva el %1$s</string>
|
||||
<string name="provider_usage_manage_credits">Gestionar créditos</string>
|
||||
<string name="provider_usage_capability_relay_title">Ampliado por el complemento Relay</string>
|
||||
<string name="provider_usage_capability_relay_body">Los grupos de credenciales, los saldos estructurados de Nous y OpenCode Go los proporciona el complemento Relay.</string>
|
||||
<string name="provider_usage_capability_basic_title">Uso básico de Hermes</string>
|
||||
<string name="provider_usage_capability_basic_body">Instala o actualiza el complemento Relay para obtener grupos de credenciales, saldos estructurados de Nous y OpenCode Go.</string>
|
||||
<string name="custom_theme_title">Personalizado</string>
|
||||
<string name="custom_theme_entry_summary">Crea y guarda tus propios temas</string>
|
||||
<string name="custom_theme_your_presets">Tus preajustes</string>
|
||||
|
||||
@@ -900,6 +900,36 @@
|
||||
<string name="detail_overview_summary">Chat、Manage、Voice では標準の Hermes を使用します。Relay は高度な端末機能を追加するオプションの拡張機能です。</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Botモード</string>
|
||||
<string name="bot_mode_drawer_summary">Botとグループルーム</string>
|
||||
<string name="bot_mode_search">Botモードを検索</string>
|
||||
<string name="bot_mode_search_hint">Botとグループを検索</string>
|
||||
<string name="bot_mode_refresh">Botモードを更新</string>
|
||||
<string name="bot_mode_new_bot">新しいBot</string>
|
||||
<string name="bot_mode_gateway_unavailable">ゲートウェイを利用できません</string>
|
||||
<string name="bot_mode_all_gateways">すべてのゲートウェイ</string>
|
||||
<string name="bot_mode_offline">オフライン</string>
|
||||
<string name="bot_mode_filter_all">すべて</string>
|
||||
<string name="bot_mode_filter_bots">Bot</string>
|
||||
<string name="bot_mode_filter_groups">グループ</string>
|
||||
<string name="bot_mode_active_now">現在アクティブ</string>
|
||||
<string name="bot_mode_opening_chat">Bot Chatを開いています…</string>
|
||||
<string name="bot_mode_no_messages">Bot Chatを開始</string>
|
||||
<string name="bot_mode_read_only">読み取り専用</string>
|
||||
<string name="bot_mode_group_no_messages">ルームのメッセージはまだありません</string>
|
||||
<string name="bot_mode_empty">Botまたはグループルームはまだありません</string>
|
||||
<string name="bot_mode_group_title">グループルーム</string>
|
||||
<string name="bot_mode_group_missing">このグループルームは利用できなくなりました。</string>
|
||||
<string name="bot_mode_group_read_only_help">これはHermes Desktopが共有する範囲限定の読み取り専用ルーム履歴です。当面は個別のBotで会話を続けてください。</string>
|
||||
<string name="bot_mode_bot_name">プロファイル名</string>
|
||||
<string name="bot_mode_bot_title">Bot名</string>
|
||||
<string name="bot_mode_bot_description">役割と説明</string>
|
||||
<string name="bot_mode_create_help">新しいBotは既定のプロファイルを基に作成され、サインインを共有します。スキルとモデルは管理画面で調整できます。</string>
|
||||
<string name="bot_mode_create">Botを作成</string>
|
||||
<string name="bot_mode_back_to_bots">Botモードに戻る</string>
|
||||
<string name="bot_mode_chat_open_failed">Bot Chatを開けませんでした</string>
|
||||
<string name="bot_mode_created">%1$sを作成しました</string>
|
||||
<string name="bot_mode_create_failed">Botを作成できませんでした</string>
|
||||
<string name="drawer_filter_by_source">ソースによるフィルター</string>
|
||||
<string name="drawer_show_sources">ソースを表示</string>
|
||||
<string name="drawer_refresh_sessions">セッションを更新する</string>
|
||||
@@ -908,6 +938,10 @@
|
||||
<string name="drawer_search_sessions">セッションを検索</string>
|
||||
<string name="drawer_activity_working">処理中</string>
|
||||
<string name="drawer_activity_needs_input">入力が必要</string>
|
||||
<string name="drawer_activity_starting">開始中</string>
|
||||
<string name="drawer_activity_background_work">バックグラウンド処理</string>
|
||||
<string name="drawer_activity_checking">確認中</string>
|
||||
<string name="drawer_activity_unavailable">利用不可</string>
|
||||
<string name="drawer_new_thread">新しいスレッド</string>
|
||||
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
|
||||
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
|
||||
@@ -937,7 +971,7 @@
|
||||
<string name="drawer_filter_pinned">固定された</string>
|
||||
<string name="drawer_filter_archive">アーカイブ</string>
|
||||
<string name="drawer_filter_sessions">セッション</string>
|
||||
<string name="drawer_timestamp_active">アクティブ</string>
|
||||
<string name="drawer_timestamp_updated">更新</string>
|
||||
<string name="drawer_timestamp_started">開始しました</string>
|
||||
<string name="drawer_just_now">ちょうど今</string>
|
||||
|
||||
@@ -4010,7 +4044,7 @@
|
||||
<string name="appearance_preview_voice">音声</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206トークン · 137.2K</string>
|
||||
<string name="appearance_preview_message_placeholder">メッセージ…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / プロファイル: デフォルト</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / プロファイル: デフォルト</string>
|
||||
<string name="appearance_preview_live_note">このプレビューには、プリセット、モード、フォント、Sphereスキンの変更がすぐに反映されます。</string>
|
||||
<string name="appearance_customize_theme">%1$sをカスタマイズ</string>
|
||||
<string name="appearance_accent_preset">プリセットのアクセント</string>
|
||||
@@ -4264,6 +4298,45 @@
|
||||
<string name="bridge_timed_unlimited_warning">アイドルタイムアウトはありません。終了、マスター無効化、またはポリシー変更まで、非操作時や再接続後も画面アクセスが続きます。専用端末向けです。</string>
|
||||
<string name="bss_screen_access_off_desc">画面アクセスはオフです。新しい有限アクセスの既定アイドル制限は %1$d 分です。</string>
|
||||
<string name="bss_screen_access_unlimited_desc">少なくとも 1 つの画面機能が明示的にオフにするまで有効です。</string>
|
||||
<string name="provider_usage_title">使用量と上限</string>
|
||||
<string name="provider_usage_back">戻る</string>
|
||||
<string name="provider_usage_refresh">使用量を更新</string>
|
||||
<string name="provider_usage_intro">この Hermes 接続に設定されたプロバイダーのアカウント上限です。</string>
|
||||
<string name="provider_usage_not_available">この Hermes 接続はプロバイダー使用量を公開していません。Hermes を更新するか、Relay プラグインをインストール/更新してください。</string>
|
||||
<string name="provider_usage_none_configured">表示中のプロバイダーに利用可能なアカウント使用量がありません。</string>
|
||||
<string name="provider_usage_loading">プロバイダー使用量を読み込み中…</string>
|
||||
<string name="provider_usage_error">プロバイダー使用量を読み込めませんでした。</string>
|
||||
<string name="provider_usage_retry">再試行</string>
|
||||
<string name="provider_usage_percent">%1$d%% 使用済み</string>
|
||||
<string name="provider_usage_resets">%1$s後にリセット</string>
|
||||
<string name="provider_usage_display_title">設定での表示</string>
|
||||
<string name="provider_usage_display_desc">メインの設定画面にアカウント使用量を表示する方法を選びます。</string>
|
||||
<string name="provider_usage_mode_summary">概要</string>
|
||||
<string name="provider_usage_mode_expanded">展開</string>
|
||||
<string name="provider_usage_mode_hidden">非表示</string>
|
||||
<string name="provider_usage_providers_title">メイン設定に表示</string>
|
||||
<string name="provider_usage_providers_desc">メイン設定に表示するプロバイダーカードを選びます。ここではすべて表示されます。</string>
|
||||
<string name="provider_usage_settings_desc">アカウント使用量とプロバイダー上限</string>
|
||||
<string name="provider_usage_settings_desc_relay">Relay プラグインで拡張された使用量と上限</string>
|
||||
<string name="provider_usage_settings_desc_basic">Hermes の基本使用量 · Relay でプールなどを追加</string>
|
||||
<string name="provider_usage_customize">表示</string>
|
||||
<string name="provider_usage_hidden_hint">設定では使用量カードが非表示です。</string>
|
||||
<string name="provider_usage_not_available_compact">プロバイダー使用量を利用できません。Hermes または Relay プラグインを更新してください。</string>
|
||||
<string name="provider_usage_provider_not_configured">このホストでは未設定です</string>
|
||||
<string name="provider_usage_provider_unavailable">使用量は一時的に利用できません</string>
|
||||
<string name="provider_usage_active_unknown">このセッションにはまだ使用中の認証情報がありません。</string>
|
||||
<string name="provider_usage_active_available">使用中 · 利用可能</string>
|
||||
<string name="provider_usage_active_at_limit">使用中 · 上限到達</string>
|
||||
<string name="provider_usage_active">使用中</string>
|
||||
<string name="provider_usage_available">利用可能</string>
|
||||
<string name="provider_usage_at_limit">上限到達</string>
|
||||
<string name="provider_usage_unavailable_status">利用不可</string>
|
||||
<string name="provider_usage_renews_on">%1$s に更新</string>
|
||||
<string name="provider_usage_manage_credits">クレジットを管理</string>
|
||||
<string name="provider_usage_capability_relay_title">Relay プラグインで拡張</string>
|
||||
<string name="provider_usage_capability_relay_body">認証情報プール、構造化された Nous 残高、OpenCode Go は Relay プラグインによって提供されます。</string>
|
||||
<string name="provider_usage_capability_basic_title">Hermes の基本使用量</string>
|
||||
<string name="provider_usage_capability_basic_body">認証情報プール、構造化された Nous 残高、OpenCode Go を利用するには Relay プラグインをインストールまたは更新してください。</string>
|
||||
<string name="custom_theme_title">カスタム</string>
|
||||
<string name="custom_theme_entry_summary">独自のテーマを作成して保存します</string>
|
||||
<string name="custom_theme_your_presets">保存したテーマ</string>
|
||||
|
||||
@@ -910,6 +910,36 @@
|
||||
<string name="active_section_unencrypted_transport">Это соединение использует незашифрованный транспорт</string>
|
||||
<string name="active_section_actions">Действия</string>
|
||||
<string name="active_section_revoke_relay">Отозвать сопряжение Relay</string>
|
||||
<string name="bot_mode_title">Режим ботов</string>
|
||||
<string name="bot_mode_drawer_summary">Боты и групповые комнаты</string>
|
||||
<string name="bot_mode_search">Поиск в режиме ботов</string>
|
||||
<string name="bot_mode_search_hint">Поиск ботов и групп</string>
|
||||
<string name="bot_mode_refresh">Обновить режим ботов</string>
|
||||
<string name="bot_mode_new_bot">Новый бот</string>
|
||||
<string name="bot_mode_gateway_unavailable">Шлюз недоступен</string>
|
||||
<string name="bot_mode_all_gateways">Все шлюзы</string>
|
||||
<string name="bot_mode_offline">Не в сети</string>
|
||||
<string name="bot_mode_filter_all">Все</string>
|
||||
<string name="bot_mode_filter_bots">Боты</string>
|
||||
<string name="bot_mode_filter_groups">Группы</string>
|
||||
<string name="bot_mode_active_now">Сейчас активны</string>
|
||||
<string name="bot_mode_opening_chat">Открывается Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Начать Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Только чтение</string>
|
||||
<string name="bot_mode_group_no_messages">В комнате пока нет сообщений</string>
|
||||
<string name="bot_mode_empty">Пока нет ботов или групповых комнат</string>
|
||||
<string name="bot_mode_group_title">Групповая комната</string>
|
||||
<string name="bot_mode_group_missing">Эта групповая комната больше недоступна.</string>
|
||||
<string name="bot_mode_group_read_only_help">Это ограниченная история комнаты только для чтения, предоставленная Hermes Desktop. Пока продолжайте общение с отдельным ботом.</string>
|
||||
<string name="bot_mode_bot_name">Имя профиля</string>
|
||||
<string name="bot_mode_bot_title">Имя бота</string>
|
||||
<string name="bot_mode_bot_description">Роль и описание</string>
|
||||
<string name="bot_mode_create_help">Новый бот создаётся на основе профиля по умолчанию и использует его вход. Навыки и модель можно настроить в разделе управления.</string>
|
||||
<string name="bot_mode_create">Создать бота</string>
|
||||
<string name="bot_mode_back_to_bots">Назад в режим ботов</string>
|
||||
<string name="bot_mode_chat_open_failed">Не удалось открыть Bot Chat</string>
|
||||
<string name="bot_mode_created">%1$s создан</string>
|
||||
<string name="bot_mode_create_failed">Не удалось создать бота</string>
|
||||
<string name="drawer_filter_by_source">Фильтр по источнику</string>
|
||||
<string name="drawer_show_sources">Показать источники</string>
|
||||
<string name="drawer_refresh_sessions">Обновить сессии</string>
|
||||
@@ -918,6 +948,10 @@
|
||||
<string name="drawer_search_sessions">Поиск сессий</string>
|
||||
<string name="drawer_activity_working">Выполняется</string>
|
||||
<string name="drawer_activity_needs_input">Требуется ввод</string>
|
||||
<string name="drawer_activity_starting">Запуск</string>
|
||||
<string name="drawer_activity_background_work">Фоновая работа</string>
|
||||
<string name="drawer_activity_checking">Проверка</string>
|
||||
<string name="drawer_activity_unavailable">Недоступно</string>
|
||||
<string name="drawer_new_thread">Новая ветка</string>
|
||||
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
|
||||
<string name="drawer_loading_sessions">Загрузка сессий…</string>
|
||||
@@ -947,7 +981,7 @@
|
||||
<string name="drawer_filter_pinned">Закрепленные</string>
|
||||
<string name="drawer_filter_archive">Архив</string>
|
||||
<string name="drawer_filter_sessions">Сессии</string>
|
||||
<string name="drawer_timestamp_active">Активен</string>
|
||||
<string name="drawer_timestamp_updated">Обновлено</string>
|
||||
<string name="drawer_timestamp_started">Начат</string>
|
||||
<string name="drawer_just_now">Только что</string>
|
||||
<string name="bridge_back">Назад</string>
|
||||
@@ -3732,7 +3766,7 @@
|
||||
<string name="appearance_preview_voice">Голос</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206 токенов · 137,2 тыс.</string>
|
||||
<string name="appearance_preview_message_placeholder">Сообщение…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / профиль: по умолчанию</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / профиль: по умолчанию</string>
|
||||
<string name="appearance_preview_live_note">Предпросмотр сразу обновляется при изменении шаблона, режима, шрифта и оформления Sphere.</string>
|
||||
<string name="appearance_customize_theme">Настроить %1$s</string>
|
||||
<string name="appearance_accent_preset">Предустановленный акцент</string>
|
||||
@@ -3993,6 +4027,45 @@
|
||||
<string name="bridge_timed_unlimited_warning">Без тайм-аута. Доступ сохраняется при бездействии и переподключении, пока не завершен, не выключен главный переключатель или не изменена политика. Для выделенного устройства.</string>
|
||||
<string name="bss_screen_access_off_desc">Доступ к экрану выключен. Новый ограниченный доступ по умолчанию использует %1$d минут бездействия.</string>
|
||||
<string name="bss_screen_access_unlimited_desc">Хотя бы одна экранная возможность активна до явного отключения.</string>
|
||||
<string name="provider_usage_title">Использование и лимиты</string>
|
||||
<string name="provider_usage_back">Назад</string>
|
||||
<string name="provider_usage_refresh">Обновить использование</string>
|
||||
<string name="provider_usage_intro">Лимиты учётных записей поставщиков, настроенных для этого подключения Hermes.</string>
|
||||
<string name="provider_usage_not_available">Это подключение Hermes не предоставляет данные поставщиков. Обновите Hermes или установите/обновите плагин Relay.</string>
|
||||
<string name="provider_usage_none_configured">Ни у одного видимого поставщика нет доступных данных об использовании.</string>
|
||||
<string name="provider_usage_loading">Загрузка данных поставщиков…</string>
|
||||
<string name="provider_usage_error">Не удалось загрузить данные поставщиков.</string>
|
||||
<string name="provider_usage_retry">Повторить</string>
|
||||
<string name="provider_usage_percent">Использовано %1$d%%</string>
|
||||
<string name="provider_usage_resets">Сброс через %1$s</string>
|
||||
<string name="provider_usage_display_title">Отображение в настройках</string>
|
||||
<string name="provider_usage_display_desc">Выберите, как использование учётной записи отображается на главном экране настроек.</string>
|
||||
<string name="provider_usage_mode_summary">Сводка</string>
|
||||
<string name="provider_usage_mode_expanded">Развёрнуто</string>
|
||||
<string name="provider_usage_mode_hidden">Скрыто</string>
|
||||
<string name="provider_usage_providers_title">Показывать в основных настройках</string>
|
||||
<string name="provider_usage_providers_desc">Выберите карточки поставщиков для главного экрана настроек. Здесь всегда видны все поставщики.</string>
|
||||
<string name="provider_usage_settings_desc">Использование учётной записи и лимиты поставщиков</string>
|
||||
<string name="provider_usage_settings_desc_relay">Расширенные данные и лимиты от плагина Relay</string>
|
||||
<string name="provider_usage_settings_desc_basic">Базовые данные Hermes · Relay добавляет пулы и другое</string>
|
||||
<string name="provider_usage_customize">Отображение</string>
|
||||
<string name="provider_usage_hidden_hint">Карточки использования скрыты в настройках.</string>
|
||||
<string name="provider_usage_not_available_compact">Данные поставщиков недоступны. Обновите Hermes или плагин Relay.</string>
|
||||
<string name="provider_usage_provider_not_configured">Не настроено на этом хосте</string>
|
||||
<string name="provider_usage_provider_unavailable">Данные временно недоступны</string>
|
||||
<string name="provider_usage_active_unknown">Для этого сеанса ещё нет активных учётных данных.</string>
|
||||
<string name="provider_usage_active_available">Активно · Доступно</string>
|
||||
<string name="provider_usage_active_at_limit">Активно · Лимит исчерпан</string>
|
||||
<string name="provider_usage_active">Активно</string>
|
||||
<string name="provider_usage_available">Доступно</string>
|
||||
<string name="provider_usage_at_limit">Лимит исчерпан</string>
|
||||
<string name="provider_usage_unavailable_status">Недоступно</string>
|
||||
<string name="provider_usage_renews_on">Продление: %1$s</string>
|
||||
<string name="provider_usage_manage_credits">Управление кредитами</string>
|
||||
<string name="provider_usage_capability_relay_title">Расширено плагином Relay</string>
|
||||
<string name="provider_usage_capability_relay_body">Пулы учётных данных, структурированные балансы Nous и OpenCode Go предоставляются плагином Relay.</string>
|
||||
<string name="provider_usage_capability_basic_title">Базовые данные Hermes</string>
|
||||
<string name="provider_usage_capability_basic_body">Установите или обновите плагин Relay для пулов учётных данных, структурированных балансов Nous и OpenCode Go.</string>
|
||||
<string name="custom_theme_title">Своя тема</string>
|
||||
<string name="custom_theme_entry_summary">Создавайте и сохраняйте собственные темы</string>
|
||||
<string name="custom_theme_your_presets">Ваши темы</string>
|
||||
|
||||
@@ -1002,6 +1002,36 @@
|
||||
<string name="active_section_revoke_relay">Revoke Relay pairing</string>
|
||||
|
||||
<!-- P0: SessionDrawer -->
|
||||
<string name="bot_mode_title">Bot Mode</string>
|
||||
<string name="bot_mode_drawer_summary">Bots and group rooms</string>
|
||||
<string name="bot_mode_search">Search Bot Mode</string>
|
||||
<string name="bot_mode_search_hint">Search Bots and groups</string>
|
||||
<string name="bot_mode_refresh">Refresh Bot Mode</string>
|
||||
<string name="bot_mode_new_bot">New Bot</string>
|
||||
<string name="bot_mode_gateway_unavailable">Gateway unavailable</string>
|
||||
<string name="bot_mode_all_gateways">All gateways</string>
|
||||
<string name="bot_mode_offline">Offline</string>
|
||||
<string name="bot_mode_filter_all">All</string>
|
||||
<string name="bot_mode_filter_bots">Bots</string>
|
||||
<string name="bot_mode_filter_groups">Groups</string>
|
||||
<string name="bot_mode_active_now">Active now</string>
|
||||
<string name="bot_mode_opening_chat">Opening Bot Chat…</string>
|
||||
<string name="bot_mode_no_messages">Start the Bot Chat</string>
|
||||
<string name="bot_mode_read_only">Read only</string>
|
||||
<string name="bot_mode_group_no_messages">No room messages yet</string>
|
||||
<string name="bot_mode_empty">No Bots or group rooms yet</string>
|
||||
<string name="bot_mode_group_title">Group room</string>
|
||||
<string name="bot_mode_group_missing">This group room is no longer available.</string>
|
||||
<string name="bot_mode_group_read_only_help">This is the bounded read-only room history shared by Hermes Desktop. Continue with an individual Bot for now.</string>
|
||||
<string name="bot_mode_bot_name">Profile name</string>
|
||||
<string name="bot_mode_bot_title">Bot name</string>
|
||||
<string name="bot_mode_bot_description">Role and description</string>
|
||||
<string name="bot_mode_create_help">The new Bot starts from the default profile and shares its sign-in. You can refine its skills and model in Manage.</string>
|
||||
<string name="bot_mode_create">Create Bot</string>
|
||||
<string name="bot_mode_back_to_bots">Back to Bot Mode</string>
|
||||
<string name="bot_mode_chat_open_failed">Bot Chat could not open</string>
|
||||
<string name="bot_mode_created">%1$s created</string>
|
||||
<string name="bot_mode_create_failed">Bot could not be created</string>
|
||||
<string name="drawer_filter_by_source">Filter by source</string>
|
||||
<string name="drawer_show_sources">Show sources</string>
|
||||
<string name="drawer_refresh_sessions">Refresh sessions</string>
|
||||
@@ -1010,6 +1040,10 @@
|
||||
<string name="drawer_search_sessions">Search sessions</string>
|
||||
<string name="drawer_activity_working">Working</string>
|
||||
<string name="drawer_activity_needs_input">Needs input</string>
|
||||
<string name="drawer_activity_starting">Starting</string>
|
||||
<string name="drawer_activity_background_work">Background work</string>
|
||||
<string name="drawer_activity_checking">Checking</string>
|
||||
<string name="drawer_activity_unavailable">Unavailable</string>
|
||||
<string name="drawer_new_thread">New Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
|
||||
<string name="drawer_loading_sessions">Loading sessions…</string>
|
||||
@@ -1039,7 +1073,7 @@
|
||||
<string name="drawer_filter_pinned">Pinned</string>
|
||||
<string name="drawer_filter_archive">Archive</string>
|
||||
<string name="drawer_filter_sessions">Sessions</string>
|
||||
<string name="drawer_timestamp_active">Active</string>
|
||||
<string name="drawer_timestamp_updated">Updated</string>
|
||||
<string name="drawer_timestamp_started">Started</string>
|
||||
<string name="drawer_just_now">Just now</string>
|
||||
|
||||
@@ -1276,7 +1310,7 @@
|
||||
<string name="appearance_preview_voice">Voice</string>
|
||||
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137.2K</string>
|
||||
<string name="appearance_preview_message_placeholder">Message…</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / profile: default</string>
|
||||
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / profile: default</string>
|
||||
<string name="appearance_preview_live_note">This preview updates immediately with your preset, mode, font, and sphere skin.</string>
|
||||
<string name="appearance_back">Back</string>
|
||||
<string name="appearance_remove_pet_title">Remove pet?</string>
|
||||
@@ -4309,4 +4343,43 @@
|
||||
<string name="bridge_timed_allow">Allow access</string>
|
||||
<string name="bridge_timed_end_now">End now</string>
|
||||
<string name="bridge_timed_ended_snackbar">Screen access ended. Permanent grants are still available.</string>
|
||||
<string name="provider_usage_title">Usage & limits</string>
|
||||
<string name="provider_usage_back">Back</string>
|
||||
<string name="provider_usage_refresh">Refresh usage</string>
|
||||
<string name="provider_usage_intro">Account limits from providers configured on this Hermes connection.</string>
|
||||
<string name="provider_usage_not_available">This Hermes connection does not expose provider usage. Update Hermes or install/update the Relay plugin to enable it.</string>
|
||||
<string name="provider_usage_none_configured">No visible provider has account usage available.</string>
|
||||
<string name="provider_usage_loading">Loading provider usage…</string>
|
||||
<string name="provider_usage_error">Couldn\'t load provider usage.</string>
|
||||
<string name="provider_usage_retry">Retry</string>
|
||||
<string name="provider_usage_percent">%1$d%% used</string>
|
||||
<string name="provider_usage_resets">Resets in %1$s</string>
|
||||
<string name="provider_usage_display_title">Settings display</string>
|
||||
<string name="provider_usage_display_desc">Choose how account usage appears on the main Settings screen.</string>
|
||||
<string name="provider_usage_mode_summary">Summary</string>
|
||||
<string name="provider_usage_mode_expanded">Expanded</string>
|
||||
<string name="provider_usage_mode_hidden">Hidden</string>
|
||||
<string name="provider_usage_providers_title">Show on main Settings</string>
|
||||
<string name="provider_usage_providers_desc">Choose which provider cards appear on the main Settings page. All providers remain visible here.</string>
|
||||
<string name="provider_usage_settings_desc">Account usage and provider limits</string>
|
||||
<string name="provider_usage_settings_desc_relay">Relay plugin enhanced usage and limits</string>
|
||||
<string name="provider_usage_settings_desc_basic">Basic Hermes usage · Relay plugin adds pools and more</string>
|
||||
<string name="provider_usage_customize">Display</string>
|
||||
<string name="provider_usage_hidden_hint">Usage cards are hidden on Settings.</string>
|
||||
<string name="provider_usage_not_available_compact">Provider usage is unavailable. Update Hermes or install/update the Relay plugin.</string>
|
||||
<string name="provider_usage_provider_not_configured">Not configured on this host</string>
|
||||
<string name="provider_usage_provider_unavailable">Usage is temporarily unavailable</string>
|
||||
<string name="provider_usage_active_unknown">No active credential yet for this session.</string>
|
||||
<string name="provider_usage_active_available">Active · Available</string>
|
||||
<string name="provider_usage_active_at_limit">Active · At limit</string>
|
||||
<string name="provider_usage_active">Active</string>
|
||||
<string name="provider_usage_available">Available</string>
|
||||
<string name="provider_usage_at_limit">At limit</string>
|
||||
<string name="provider_usage_unavailable_status">Unavailable</string>
|
||||
<string name="provider_usage_renews_on">Renews %1$s</string>
|
||||
<string name="provider_usage_manage_credits">Manage credits</string>
|
||||
<string name="provider_usage_capability_relay_title">Relay plugin enhanced</string>
|
||||
<string name="provider_usage_capability_relay_body">Credential pools, structured Nous balances, and OpenCode Go are provided by the Relay plugin.</string>
|
||||
<string name="provider_usage_capability_basic_title">Basic usage from Hermes</string>
|
||||
<string name="provider_usage_capability_basic_body">Install or update the Relay plugin for credential pools, structured Nous balances, and OpenCode Go.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class ProviderUsagePreferencesTest {
|
||||
@get:Rule
|
||||
val tempFolder = TemporaryFolder()
|
||||
|
||||
private lateinit var file: File
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var repository: ProviderUsagePreferencesRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
file = tempFolder.newFile("provider_usage.preferences_pb").also { it.delete() }
|
||||
scope = CoroutineScope(Dispatchers.IO + Job())
|
||||
repository = ProviderUsagePreferencesRepository(
|
||||
PreferenceDataStoreFactory.create(scope = scope, produceFile = { file }),
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsToSummaryWithSupportedProvidersVisible() = runTest {
|
||||
val preferences = repository.preferences.first()
|
||||
assertEquals(ProviderUsageLandingMode.Summary, preferences.landingMode)
|
||||
assertEquals(
|
||||
setOf("openai-codex", "nous", "opencode-go"),
|
||||
preferences.visibleProviders,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsDisplayMode() = runTest {
|
||||
repository.setLandingMode(ProviderUsageLandingMode.Expanded)
|
||||
|
||||
val preferences = repository.preferences.first()
|
||||
assertEquals(ProviderUsageLandingMode.Expanded, preferences.landingMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsIndependentProviderVisibility() = runTest {
|
||||
repository.setProviderVisible("nous", false)
|
||||
|
||||
val preferences = repository.preferences.first()
|
||||
assertFalse("nous" in preferences.visibleProviders)
|
||||
assertTrue("openai-codex" in preferences.visibleProviders)
|
||||
assertTrue("opencode-go" in preferences.visibleProviders)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityRegistryTest {
|
||||
private val owner = owner("connection-a", "Default", "session-a")
|
||||
private val scope = SessionActivityScope.of("connection-a", "default")
|
||||
|
||||
@Test
|
||||
fun `directory owner is checking until status is unavailable or confirms idle`() {
|
||||
val checking = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
|
||||
val unavailable = checking.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
|
||||
|
||||
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
|
||||
assertNull(confirmedIdle.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory observation cannot downgrade confirmed live evidence`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `owner normalizes profile without collapsing connection ownership`() {
|
||||
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
|
||||
val otherConnection = owner("connection-b", "default", "session-a")
|
||||
assertEquals(2, setOf(owner, otherConnection).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact pending input outranks live working and answer restores it`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
|
||||
|
||||
state = state.reduce(closeInput(owner, "request-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired pending input no longer overrides live state`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
|
||||
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkpoint is revalidating until successful snapshot settles it`() {
|
||||
var state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
|
||||
state = state.reduce(activeList(scope, generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful snapshot absence settles only the same profile`() {
|
||||
val otherProfile = owner("connection-a", "work", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(activeList(scope, generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same ids cannot alias across profiles or connections`() {
|
||||
val profileB = owner("connection-a", "work", "session-a")
|
||||
val connectionB = owner("connection-b", "default", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
listOf(owner, profileB, connectionB).forEach {
|
||||
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
|
||||
}
|
||||
|
||||
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
|
||||
assertEquals(
|
||||
profileB,
|
||||
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
|
||||
)
|
||||
assertEquals(
|
||||
connectionB,
|
||||
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unscoped active row cannot mark duplicate stored ids as working`() {
|
||||
val profileB = owner("connection-a", "work", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
1,
|
||||
false,
|
||||
SessionLiveRuntime(
|
||||
owner = null,
|
||||
runtimeId = "ambiguous-runtime",
|
||||
status = SessionLiveStatus.Working,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
|
||||
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial snapshot applies resolved row without settling absent owner`() {
|
||||
val absentOwner = owner("connection-a", "default", "session-b")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
1,
|
||||
false,
|
||||
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
|
||||
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
|
||||
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
|
||||
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new generation rejects late terminal event and invalidates old alias`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
|
||||
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
|
||||
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
assertNull(state.ownerForRuntime(scope, "runtime-old"))
|
||||
|
||||
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed or unsupported status refresh is unavailable rather than idle`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Working,
|
||||
source = SessionActivityEvidenceSource.ActiveList,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(
|
||||
scope = scope,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active-list failure does not override exact live session event`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(
|
||||
scope = scope,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presentation keeps starting background and revalidation distinct from working`() {
|
||||
val starting = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
|
||||
|
||||
val background = starting
|
||||
.reduce(process(owner, "process-a", running = true, generation = 1))
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
|
||||
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
|
||||
|
||||
val checking = starting.reduce(
|
||||
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal turn with running process projects background work separately`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(process(owner, "process-a", running = true, generation = 1))
|
||||
.reduce(terminal(owner, "runtime-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
|
||||
|
||||
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal settles pending input and removes its runtime alias`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
|
||||
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertNull(state.ownerForRuntime(scope, "runtime-a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoritative live state is not overwritten by restored checkpoint`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
|
||||
.reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 30,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored needs-input checkpoint stays checking until live confirmation`() {
|
||||
val state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.NeedsInput,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthetic checkpoint input does not confirm restored working state`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:session-a",
|
||||
confirmed = false,
|
||||
generation = 1,
|
||||
observedAtMillis = 11,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:session-a",
|
||||
confirmed = true,
|
||||
generation = 1,
|
||||
observedAtMillis = 12,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoritative idle active-list row clears stale pending input`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
generation = 1,
|
||||
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime-only update requires alias in current scoped generation`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.RuntimeState(
|
||||
scope = scope,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Waiting,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
|
||||
|
||||
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
|
||||
.reduce(
|
||||
SessionActivityUpdate.RuntimeState(
|
||||
scope = scope,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Idle,
|
||||
generation = 2,
|
||||
observedAtMillis = 40,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
private fun owner(connection: String, profile: String, session: String) =
|
||||
SessionActivityOwner.of(connection, profile, session)
|
||||
|
||||
private fun live(
|
||||
owner: SessionActivityOwner,
|
||||
runtime: String,
|
||||
status: SessionLiveStatus,
|
||||
generation: Long,
|
||||
) = SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = runtime,
|
||||
status = status,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun activeList(
|
||||
scope: SessionActivityScope,
|
||||
generation: Long,
|
||||
complete: Boolean = true,
|
||||
vararg runtimes: SessionLiveRuntime,
|
||||
) = SessionActivityUpdate.ActiveList(
|
||||
scope = scope,
|
||||
runtimes = runtimes.toList(),
|
||||
isCompleteForScope = complete,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
|
||||
private fun openInput(
|
||||
owner: SessionActivityOwner,
|
||||
request: String,
|
||||
generation: Long,
|
||||
expiresAt: Long? = null,
|
||||
) = SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = request,
|
||||
expiresAtMillis = expiresAt,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner = owner,
|
||||
requestId = request,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
|
||||
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = id,
|
||||
running = running,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = runtime,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
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.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class RelayHttpClientProviderUsageTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesProviderNeutralPayloadAndAuthenticates() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""
|
||||
{
|
||||
"schema_version": 2,
|
||||
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
|
||||
"providers": [
|
||||
{
|
||||
"id": "openai-codex",
|
||||
"display_name": "Codex",
|
||||
"status": "available",
|
||||
"plan": "Plus",
|
||||
"active_credential_id": "abc123",
|
||||
"active_credential_state": "known",
|
||||
"credentials": [{
|
||||
"id": "abc123",
|
||||
"label": "Work",
|
||||
"active": true,
|
||||
"status": "available",
|
||||
"windows": []
|
||||
}],
|
||||
"windows": [{
|
||||
"id": "session",
|
||||
"label": "Session",
|
||||
"used_percent": 42.5,
|
||||
"reset_at": "2026-08-22T00:00:00Z"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val response = client(token = "paired-token")
|
||||
.fetchProviderUsage(profile = "victor", sessionId = "session-42")
|
||||
.getOrThrow()!!
|
||||
val request = server.takeRequest()
|
||||
|
||||
assertEquals("/usage/providers?profile=victor&session_id=session-42", request.path)
|
||||
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
|
||||
assertEquals("Codex", response.providers.single().displayName)
|
||||
assertEquals(42.5, response.providers.single().windows.single().usedPercent!!, 0.001)
|
||||
assertEquals("Work", response.providers.single().credentials.single().label)
|
||||
assertTrue(response.providers.single().credentials.single().active)
|
||||
assertTrue(response.relayEnhanced)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedHostIsNullSuccess() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
val response = client(token = "paired-token").fetchProviderUsage()
|
||||
assertTrue(response.isSuccess)
|
||||
assertNull(response.getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpairedIsUnsupportedAndDoesNotHitServer() = runTest {
|
||||
val response = client(token = null).fetchProviderUsage()
|
||||
assertTrue(response.isSuccess)
|
||||
assertNull(response.getOrNull())
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
private fun client(token: String?) = RelayHttpClient(
|
||||
okHttpClient = OkHttpClient(),
|
||||
relayUrlProvider = { server.url("/").toString() },
|
||||
sessionTokenProvider = { token },
|
||||
)
|
||||
}
|
||||
@@ -76,6 +76,7 @@ class DashboardApiClientTest {
|
||||
"""
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"install_id": " install-a ",
|
||||
"auth_required": true,
|
||||
"auth_providers": ["basic", "nous"]
|
||||
}
|
||||
@@ -92,6 +93,7 @@ class DashboardApiClientTest {
|
||||
assertEquals(listOf("basic", "nous"), status.authProviders)
|
||||
assertEquals("basic", status.authProviderDetails.first().name)
|
||||
assertEquals("0.16.0", status.version)
|
||||
assertEquals("install-a", status.installId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,6 +126,44 @@ class DashboardApiClientTest {
|
||||
assertEquals(listOf("default", "worker"), status.gateways.single().servedProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getProviderUsage_carriesSessionAndParsesCredentialPool() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
"""
|
||||
{
|
||||
"schema_version": 2,
|
||||
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
|
||||
"providers": [{
|
||||
"id": "openai-codex",
|
||||
"display_name": "Codex",
|
||||
"status": "available",
|
||||
"active_credential_state": "known",
|
||||
"credentials": [{
|
||||
"id": "abc123",
|
||||
"label": "bailey",
|
||||
"active": true,
|
||||
"status": "available"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val usage = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
.getProviderUsage(profile = "victor", sessionId = "session/42")
|
||||
.getOrThrow()!!
|
||||
val request = server.takeRequest().requestUrl!!
|
||||
|
||||
assertEquals("/api/plugins/hermes-relay/provider-usage", request.encodedPath)
|
||||
assertEquals("victor", request.queryParameter("profile"))
|
||||
assertEquals("session/42", request.queryParameter("session_id"))
|
||||
assertEquals("bailey", usage.providers.single().credentials.single().label)
|
||||
assertTrue(usage.providers.single().credentials.single().active)
|
||||
assertTrue(usage.relayEnhanced)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
|
||||
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
|
||||
@@ -289,6 +329,7 @@ class DashboardApiClientTest {
|
||||
val wsUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
baseUrl = "https://example.com/hermes/",
|
||||
ticket = "abc/123",
|
||||
profile = "research bot",
|
||||
)
|
||||
val landingPath = DashboardApiClient.authLandingPath("https://example.com/hermes/")
|
||||
|
||||
@@ -297,7 +338,7 @@ class DashboardApiClientTest {
|
||||
authUrl,
|
||||
)
|
||||
assertEquals(
|
||||
"wss://example.com/hermes/api/ws?ticket=abc%2F123",
|
||||
"wss://example.com/hermes/api/ws?ticket=abc%2F123&profile=research%20bot",
|
||||
wsUrl,
|
||||
)
|
||||
assertEquals("/hermes/", landingPath)
|
||||
|
||||
@@ -190,6 +190,16 @@ class GatewayClientHarness(
|
||||
})))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var sessionListPayload: JsonObject = buildJsonObject {
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var activeSessionListPayload: JsonObject = buildJsonObject {
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profileCreatePayload: JsonObject = buildJsonObject {
|
||||
put("ok", true)
|
||||
@@ -310,6 +320,9 @@ class GatewayClientHarness(
|
||||
"session.activate" -> recoveryPayload(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
|
||||
)
|
||||
"session.list" -> sessionListPayload
|
||||
"session.active_list" -> activeSessionListPayload
|
||||
"session.title" -> buildJsonObject { put("ok", true) }
|
||||
"prompt.submit" -> promptSubmitPayload
|
||||
"session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
"process.list" -> buildJsonObject {
|
||||
@@ -891,6 +904,165 @@ class GatewayChatClientTest {
|
||||
assertEquals(false, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bot roster parses canonical chats activity and read only room projection`() = runBlocking {
|
||||
harness.profilesListPayload = buildJsonObject {
|
||||
put("bot_mode_protocol", true)
|
||||
put("profiles", JsonArray(listOf(buildJsonObject {
|
||||
put("name", "default")
|
||||
put("display_name", "Hermes")
|
||||
put("model", "gpt-5.6")
|
||||
put("is_default", true)
|
||||
put("canonical_session", buildJsonObject {
|
||||
put("id", "bot-root")
|
||||
put("resolved_id", "bot-tip")
|
||||
put("root_title", "Bot Chat")
|
||||
put("preview", "Release plan ready")
|
||||
put("last_active", 1_777_000_000)
|
||||
put("message_count", 8)
|
||||
})
|
||||
put("worker_session", buildJsonObject {
|
||||
put("id", "worker-1")
|
||||
put("title", "Build")
|
||||
put("last_active", 1_777_000_030)
|
||||
})
|
||||
put("ui_meta", buildJsonObject {
|
||||
put("hermes-bots", buildJsonObject { put("title", "Lucy") })
|
||||
put("hermes-bots-groups", buildJsonObject {
|
||||
put("version", 3)
|
||||
put("rooms", buildJsonObject {
|
||||
put("id:launch", buildJsonObject {
|
||||
put("name", "Launch Council")
|
||||
put("roomId", "launch")
|
||||
put("revision", 4)
|
||||
put("members", JsonArray(listOf(buildJsonObject {
|
||||
put("name", "default")
|
||||
put("handle", "hermes")
|
||||
})))
|
||||
put("log", JsonArray(listOf(buildJsonObject {
|
||||
put("id", "message-1")
|
||||
put("from", buildJsonObject {
|
||||
put("kind", "member")
|
||||
put("name", "Lucy")
|
||||
})
|
||||
put("text", "Rollout is clear")
|
||||
put("at", 1_777_000_020)
|
||||
})))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
val roster = client.listBotModeRoster().getOrThrow()
|
||||
|
||||
assertTrue(roster.botModeProtocolSupported)
|
||||
assertEquals("Lucy", roster.bots.single().displayName)
|
||||
assertEquals("bot-tip", roster.bots.single().canonicalSession?.resolvedId)
|
||||
assertEquals(1_777_000_030_000L, roster.bots.single().workerSession?.lastActiveAtMs)
|
||||
assertEquals("Launch Council", roster.groups.single().name)
|
||||
assertEquals("Rollout is clear", roster.groups.single().latestMessage?.text)
|
||||
assertEquals(true, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot chat adopts exact title registry without creating`() = runBlocking {
|
||||
harness.sessionListPayload = buildJsonObject {
|
||||
put("sessions", JsonArray(listOf(buildJsonObject {
|
||||
put("id", "bot-root")
|
||||
put("resolved_id", "bot-tip")
|
||||
put("root_title", "Bot Chat")
|
||||
})))
|
||||
}
|
||||
|
||||
val target = client.ensureCanonicalBotChat("operator").getOrThrow()
|
||||
|
||||
assertEquals("bot-root", target.storedSessionId)
|
||||
assertEquals("bot-tip", target.resolvedSessionId)
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.create" })
|
||||
val lookup = harness.awaitRpc("session.list")
|
||||
assertEquals("operator", (lookup["profile"] as JsonPrimitive).content)
|
||||
assertEquals("Bot Chat", (lookup["title"] as JsonPrimitive).content)
|
||||
assertEquals(true, (lookup["include_hidden"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot chat creates hidden row only after authoritative empty lookup`() = runBlocking {
|
||||
harness.createdSessionProfileName = "operator"
|
||||
|
||||
val target = client.ensureCanonicalBotChat("operator").getOrThrow()
|
||||
|
||||
assertEquals("20260612_120000_abc123", target.storedSessionId)
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("operator", (create["profile"] as JsonPrimitive).content)
|
||||
assertEquals("Bot Chat", (create["title"] as JsonPrimitive).content)
|
||||
assertEquals(true, (create["hidden"] as JsonPrimitive).booleanOrNull)
|
||||
val title = harness.awaitRpc("session.title")
|
||||
assertEquals("live-1", (title["session_id"] as JsonPrimitive).content)
|
||||
assertEquals("Bot Chat", (title["title"] as JsonPrimitive).content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot chat lookup failure never creates replacement`() = runBlocking {
|
||||
harness.rpcErrors["session.list"] = 5006 to "profile db unavailable"
|
||||
|
||||
assertTrue(client.ensureCanonicalBotChat("operator").isFailure)
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.create" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed route profile rides websocket URL and canonical RPC`() = runBlocking {
|
||||
val routeClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "research bot",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
)
|
||||
try {
|
||||
routeClient.ensureCanonicalBotChat("research bot").getOrThrow()
|
||||
val requests = List(2) { harness.server.takeRequest(5, TimeUnit.SECONDS) }
|
||||
assertTrue(requests.filterNotNull().any {
|
||||
it.path?.contains("profile=research%20bot") == true
|
||||
})
|
||||
assertEquals(
|
||||
"research bot",
|
||||
(harness.awaitRpc("session.list")["profile"] as JsonPrimitive).content,
|
||||
)
|
||||
} finally {
|
||||
routeClient.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical bot lookup on remote route never reaches active gateway`() = runBlocking {
|
||||
val remoteHarness = GatewayClientHarness()
|
||||
val remoteScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val remoteClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = remoteHarness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "default",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = remoteScope,
|
||||
)
|
||||
try {
|
||||
remoteClient.ensureCanonicalBotChat("default").getOrThrow()
|
||||
assertTrue(remoteHarness.rpcLog.any { it.first == "session.list" })
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.list" })
|
||||
} finally {
|
||||
remoteClient.shutdown()
|
||||
remoteScope.cancel()
|
||||
remoteHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile list drops oversized ui meta without dropping profile`() = runBlocking {
|
||||
harness.profilesListPayload = buildJsonObject {
|
||||
@@ -1348,6 +1520,112 @@ class GatewayChatClientTest {
|
||||
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list parses every authoritative upstream state`() = runBlocking {
|
||||
harness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
|
||||
add(buildJsonObject {
|
||||
put("id", "runtime-$index")
|
||||
put("session_key", "stored-$index")
|
||||
put("status", status)
|
||||
put("last_active", 1_774_000_000.25 + index)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val result = client.listActiveSessions()
|
||||
val rows = (result as GatewayActiveSessionsResult.Success).sessions
|
||||
|
||||
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayActiveSessionStatus.Idle,
|
||||
GatewayActiveSessionStatus.Starting,
|
||||
GatewayActiveSessionStatus.Working,
|
||||
GatewayActiveSessionStatus.Waiting,
|
||||
),
|
||||
rows.map(GatewayActiveSession::status),
|
||||
)
|
||||
assertEquals("runtime-2", rows[2].runtimeSessionId)
|
||||
assertEquals("stored-2", rows[2].storedSessionId)
|
||||
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
|
||||
assertTrue(rows.all { it.profile == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
|
||||
val result = client.listActiveSessions()
|
||||
|
||||
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
|
||||
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
|
||||
val routeHarness = GatewayClientHarness()
|
||||
routeHarness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("id", "runtime-operator")
|
||||
put("session_key", "stored-shared")
|
||||
put("status", "working")
|
||||
put("last_active", 1_774_000_000.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val routeClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "operator",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = routeScope,
|
||||
)
|
||||
try {
|
||||
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
|
||||
val params = routeHarness.awaitRpc("session.active_list")
|
||||
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
|
||||
|
||||
assertFalse(params.containsKey("profile"))
|
||||
assertNull(result.sessions.single().profile)
|
||||
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
|
||||
} finally {
|
||||
routeClient.shutdown()
|
||||
routeScope.cancel()
|
||||
routeHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
|
||||
harness.methodNotFound += "session.active_list"
|
||||
|
||||
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
|
||||
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
|
||||
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session transient error stays distinct from unsupported`() = runBlocking {
|
||||
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
|
||||
|
||||
val failed = client.listActiveSessions()
|
||||
|
||||
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
|
||||
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
|
||||
harness.rpcErrors.remove("session.active_list")
|
||||
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
|
||||
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
@@ -3861,6 +4139,10 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
assertTrue(client.backgroundActiveTurn())
|
||||
assertEquals(
|
||||
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
|
||||
client.knownSessionOwner("live-1"),
|
||||
)
|
||||
client.clearSession()
|
||||
client.sessionProfileProvider = { "writer" }
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.network.usage
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProviderUsageModelsTest {
|
||||
@Test
|
||||
fun completeRelayCapabilitySetIsEnhanced() {
|
||||
assertTrue(
|
||||
ProviderUsageResponse(
|
||||
capabilities = ProviderUsageResponse.RELAY_ENHANCED_CAPABILITIES,
|
||||
).relayEnhanced,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOrPartialCapabilitiesRemainBasic() {
|
||||
assertFalse(ProviderUsageResponse().relayEnhanced)
|
||||
assertFalse(
|
||||
ProviderUsageResponse(
|
||||
capabilities = setOf("credential_pools", "structured_balances"),
|
||||
).relayEnhanced,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.BotSessionSummary
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.components.LocalSphereSkin
|
||||
import com.hermesandroid.relay.ui.components.SphereRegistry
|
||||
import com.hermesandroid.relay.ui.screens.BotModeContent
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import java.io.File
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w390dp-h844dp-432dpi")
|
||||
class BotModeScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun approvedBotModeHome() {
|
||||
val output = File("build/ui-evidence/bot-mode-home.png")
|
||||
output.parentFile?.mkdirs()
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
|
||||
BotModeContent(
|
||||
state = fixtureState(),
|
||||
connections = listOf(
|
||||
fixtureConnection("hermes", "Hermes"),
|
||||
fixtureConnection("lab", "Lab server"),
|
||||
),
|
||||
activeConnection = fixtureConnection("hermes", "Hermes"),
|
||||
onBack = {},
|
||||
onRefresh = {},
|
||||
onSelectGateway = {},
|
||||
onOpenBot = {},
|
||||
onOpenGroup = {},
|
||||
onNewBot = {},
|
||||
nowMs = NOW,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage(output.absolutePath)
|
||||
}
|
||||
|
||||
private fun fixtureState() = BotModeState(
|
||||
roster = BotModeRoster(
|
||||
bots = listOf(
|
||||
bot("hermes", "Hermes", "default", "Lucy", "Drafted a rollout plan for the new flow.", NOW - 60_000L),
|
||||
bot("lab", "Lab server", "researcher", "Researcher", "Here are the latest findings.", NOW - 18 * 60_000L),
|
||||
bot("hermes", "Hermes", "builder", "Builder", "Build complete. 3 tests added.", NOW - 43 * 60_000L),
|
||||
),
|
||||
groups = listOf(
|
||||
room("id:launch", "Launch Council", "Maya", "Please review the deck when you can.", NOW - 86_400_000L),
|
||||
room("id:home", "Home Lab", "Alex", "Benchmarked the new model.", NOW - 3 * 86_400_000L),
|
||||
),
|
||||
botModeProtocolSupported = true,
|
||||
),
|
||||
)
|
||||
|
||||
private fun bot(
|
||||
connectionId: String,
|
||||
connectionLabel: String,
|
||||
name: String,
|
||||
title: String,
|
||||
preview: String,
|
||||
activeAt: Long,
|
||||
) = BotRosterEntry(
|
||||
profile = Profile(name = name, model = "gpt-5.6", description = title),
|
||||
displayName = title,
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey(connectionId, name),
|
||||
connectionLabel = connectionLabel,
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
id = "$name-bot-chat",
|
||||
preview = preview,
|
||||
lastActiveAtMs = activeAt,
|
||||
messageCount = 8,
|
||||
),
|
||||
workerSession = BotSessionSummary(
|
||||
id = "$name-worker",
|
||||
lastActiveAtMs = NOW - 30_000L,
|
||||
),
|
||||
)
|
||||
|
||||
private fun room(key: String, name: String, sender: String, text: String, at: Long) = BotGroupRoom(
|
||||
key = key,
|
||||
roomId = key.substringAfter(':'),
|
||||
name = name,
|
||||
messages = listOf(
|
||||
BotGroupMessage(
|
||||
id = "$key-message",
|
||||
senderName = sender,
|
||||
senderKind = "member",
|
||||
text = text,
|
||||
atMs = at,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun fixtureConnection(id: String, label: String) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "https://example.invalid",
|
||||
tokenStoreKey = "test-$id",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val NOW = 1_777_000_000_000L
|
||||
}
|
||||
}
|
||||
@@ -61,12 +61,14 @@ import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.DashboardConnectionStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PetBehaviorPreferences
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.ui.components.ChatInputBar
|
||||
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
|
||||
import com.hermesandroid.relay.ui.components.ChatInputTrailing
|
||||
import com.hermesandroid.relay.ui.components.ContextMeterBar
|
||||
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
@@ -137,9 +139,14 @@ class StoreScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
|
||||
private fun capture(
|
||||
name: String,
|
||||
themeId: String = "hermes-relay",
|
||||
themePreference: String = "dark",
|
||||
body: @Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
|
||||
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
|
||||
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
|
||||
// it recolors to the active theme. The preview/test fallback is Classic,
|
||||
// which mismatches the app and reads poorly on light themes.
|
||||
@@ -183,6 +190,10 @@ class StoreScreenshotTest {
|
||||
}
|
||||
@Test fun s11_voice_conversation() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
// RelayRefresh is a process-wide compatibility façade. Seed it before
|
||||
// the first composition so this animation-pinned scene cannot inherit
|
||||
// the palette left by a light-theme gallery test that ran earlier.
|
||||
RelayRefresh.activePalette = AppThemes.byId("hermes-relay").paletteFor(dark = true)
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
|
||||
@@ -246,6 +257,14 @@ class StoreScreenshotTest {
|
||||
}
|
||||
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
|
||||
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
|
||||
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
|
||||
SessionActivityStatesScene()
|
||||
}
|
||||
@Test fun s12_session_activity_states_light() = capture(
|
||||
"12_session_activity_states_light",
|
||||
"nous-blue",
|
||||
themePreference = "light",
|
||||
) { SessionActivityStatesScene() }
|
||||
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
|
||||
// Real Appearance screen scrolled to the new Font picker — proves the
|
||||
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
|
||||
@@ -280,7 +299,9 @@ class StoreScreenshotTest {
|
||||
// The earlier frame scrolled past the pet controls and showed only the
|
||||
// sphere-skin grid. Frame the independently selected floating companion,
|
||||
// its real PetAvatar preview, Petdex/import actions, and tuning controls.
|
||||
compose.onNodeWithText("Browse Petdex").performScrollTo()
|
||||
compose.onNodeWithText(
|
||||
"Scales the pet art, touch target, and safe routing footprint together. Larger pets may skip narrow perches.",
|
||||
).performScrollTo()
|
||||
compose.onRoot().captureRoboImage("build/store-shots/08_appearance.png")
|
||||
}
|
||||
|
||||
@@ -361,8 +382,7 @@ private fun BlendChatScene() = StoreCockpit(contextUsage = 0.06f) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlendThread() {
|
||||
val thread = MockChat.blendThread
|
||||
private fun BlendThread(thread: List<ChatMessage> = MockChat.blendThread) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(start = 18.dp, top = 14.dp, end = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom)
|
||||
@@ -405,18 +425,40 @@ private fun StoreCockpit(
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(Modifier.size(34.dp), shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)) {
|
||||
Image(painterResource(R.drawable.splash_icon), contentDescription = null, modifier = Modifier.padding(3.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Keep the Hermes logo as the public default agent identity,
|
||||
// while matching ChatScreen's current live-status treatment.
|
||||
Box(Modifier.size(40.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.splash_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(3.dp),
|
||||
)
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = true,
|
||||
isConnecting = false,
|
||||
modifier = Modifier.size(10.dp).align(Alignment.BottomEnd),
|
||||
size = 10.dp,
|
||||
)
|
||||
}
|
||||
Column(Modifier.padding(start = 10.dp)) {
|
||||
Column {
|
||||
Text("Hermes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
RelayChromeIconButton(Icons.Filled.Bolt, "Approvals off", onClick = {}, tint = RelayRefresh.Amber, borderColor = RelayRefresh.Amber.copy(alpha = 0.5f), modifier = Modifier.padding(end = 4.dp))
|
||||
// The production bolt appears only when approval bypass is
|
||||
// active. The public marketing fixture uses the safe default.
|
||||
RelayChromeIconButton(Icons.Filled.Code, "Terminal", onClick = {}, modifier = Modifier.padding(end = 4.dp))
|
||||
RelayChromeIconButton(Icons.Filled.Tune, "Settings", onClick = {}, modifier = Modifier.padding(end = 4.dp))
|
||||
RelayChromeIconButton(Icons.Filled.MoreVert, "More", onClick = {}, modifier = Modifier.padding(end = 4.dp))
|
||||
@@ -534,7 +576,10 @@ private fun VoiceConversationScene() = StoreCockpit(
|
||||
contextUsage = 0.05f,
|
||||
conversationVoiceState = marketingVoiceUiState,
|
||||
) {
|
||||
BlendThread()
|
||||
// The voice dock occupies more vertical space than the standard composer.
|
||||
// Use its shorter one-line lead-in so the frame starts near the context
|
||||
// meter while the final user reply remains fully visible.
|
||||
BlendThread(MockChat.voiceBlendThread)
|
||||
}
|
||||
|
||||
/** The real sphere renderer pinned to one frame for pixel-identical marketing output. */
|
||||
@@ -687,9 +732,18 @@ private object MockChat {
|
||||
),
|
||||
)
|
||||
|
||||
// A grouped thread for the "Blend" capture: user → two-message assistant
|
||||
// group (avatar once, code block in the first) → user follow-up.
|
||||
// A grouped thread for the "Blend" capture. The short opening exchange
|
||||
// intentionally fills the production conversation viewport so the first
|
||||
// turn begins directly below the context meter instead of leaving a large,
|
||||
// misleading empty band in the canonical marketing frame.
|
||||
val blendThread = listOf(
|
||||
ChatMessage(
|
||||
id = "ba0",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "I’ll keep cancellation and offline behavior intact. Show me the retry path.",
|
||||
timestamp = 0L,
|
||||
agentName = "Hermes",
|
||||
),
|
||||
ChatMessage(
|
||||
id = "bu1",
|
||||
role = MessageRole.USER,
|
||||
@@ -728,6 +782,15 @@ private object MockChat {
|
||||
timestamp = 0L,
|
||||
),
|
||||
)
|
||||
|
||||
val voiceBlendThread = listOf(
|
||||
ChatMessage(
|
||||
id = "voice-lead",
|
||||
role = MessageRole.USER,
|
||||
content = "Can you review this?",
|
||||
timestamp = 0L,
|
||||
),
|
||||
) + blendThread.drop(1)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -756,6 +819,41 @@ private fun SessionsScene() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionActivityStatesScene() {
|
||||
val states = SessionActivityState.entries
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
|
||||
SessionDrawerContent(
|
||||
sessions = states.mapIndexed { index, state ->
|
||||
ChatSession(
|
||||
sessionId = "activity-$index",
|
||||
title = when (state) {
|
||||
SessionActivityState.Starting -> "Launching the agent"
|
||||
SessionActivityState.Working -> "Reviewing the release"
|
||||
SessionActivityState.NeedsInput -> "Approval required"
|
||||
SessionActivityState.BackgroundWork -> "Build still running"
|
||||
SessionActivityState.Checking -> "Reconnecting to Hermes"
|
||||
SessionActivityState.Unavailable -> "Offline session"
|
||||
},
|
||||
model = "gpt-5.6-sol",
|
||||
)
|
||||
},
|
||||
currentSessionId = null,
|
||||
activeProfileName = "default",
|
||||
scopeTitle = "Hermes",
|
||||
scopeSubtitle = "Live session status",
|
||||
activityStates = states.mapIndexed { index, state ->
|
||||
"default:activity-$index" to state
|
||||
}.toMap(),
|
||||
animationEnabled = false,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionsScene() = ConnectionsSettingsScreen(
|
||||
connections = marketingConnections,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MorphingSphereMotionPolicyTest {
|
||||
@Test
|
||||
fun `visible idle sphere uses lightweight ambient motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.AmbientLayer,
|
||||
sphereMotionMode(
|
||||
state = SphereState.Idle,
|
||||
voiceMode = false,
|
||||
motionVisible = true,
|
||||
fixedTime = null,
|
||||
fixedColorPhase = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden or paused idle sphere is still`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, false, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible active and voice states keep procedural motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Thinking, false, true, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Idle, true, true, null, null),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPolicyTest {
|
||||
@@ -19,6 +21,127 @@ class SessionDrawerPolicyTest {
|
||||
|
||||
assertEquals("alpha:same", sessionRowKey(alpha))
|
||||
assertEquals("beta:same", sessionRowKey(beta))
|
||||
assertEquals("default:same", sessionRowKey(row("default", "same")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rest recent activity alone does not mark a session working`() {
|
||||
val recentlyActive = row("default", "recent", recentlyActive = true)
|
||||
|
||||
assertEquals(
|
||||
SessionDrawerStatus.Idle,
|
||||
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate session ids cannot leak activity across profiles`() {
|
||||
val alpha = row("alpha", "same")
|
||||
val beta = row("beta", "same")
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(alpha, beta),
|
||||
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
|
||||
allowBareSessionIds = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
|
||||
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
|
||||
assertFalse(sessionRowKey(beta) in scoped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all profiles ignores ambiguous bare session activity`() {
|
||||
val alpha = row("alpha", "same")
|
||||
val beta = row("beta", "same")
|
||||
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(alpha, beta),
|
||||
activityStates = mapOf("same" to SessionActivityState.Working),
|
||||
allowBareSessionIds = false,
|
||||
)
|
||||
|
||||
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected profile may scope legacy bare session activity`() {
|
||||
val row = row("work", "session")
|
||||
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(row),
|
||||
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
|
||||
allowBareSessionIds = true,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
|
||||
scoped,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status filter and grouping use the same authoritative state`() {
|
||||
val restOnly = row("default", "rest-only", recentlyActive = true)
|
||||
val working = row("default", "working")
|
||||
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
|
||||
|
||||
val filtered = filterAndSortSessionRows(
|
||||
rows = listOf(restOnly, working),
|
||||
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
|
||||
activityStates = states,
|
||||
)
|
||||
val grouped = groupSessionRows(
|
||||
rows = listOf(restOnly, working),
|
||||
grouping = SessionDrawerGrouping.Status,
|
||||
activityStates = states,
|
||||
)
|
||||
|
||||
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
|
||||
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expanded live phases retain distinct drawer statuses and labels`() {
|
||||
val phases = listOf(
|
||||
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
|
||||
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
|
||||
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
|
||||
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
|
||||
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
|
||||
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
|
||||
)
|
||||
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
|
||||
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
|
||||
|
||||
assertEquals(
|
||||
phases.map { it.second.first },
|
||||
rows.map { sessionDrawerStatus(it, states) },
|
||||
)
|
||||
assertEquals(
|
||||
phases.map { it.second.second },
|
||||
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
R.string.drawer_activity_needs_input,
|
||||
R.string.drawer_activity_starting,
|
||||
R.string.drawer_activity_working,
|
||||
R.string.drawer_activity_background_work,
|
||||
R.string.drawer_activity_checking,
|
||||
R.string.drawer_activity_unavailable,
|
||||
),
|
||||
phases.map { sessionActivityLabelResource(it.first) },
|
||||
)
|
||||
phases.forEachIndexed { index, phase ->
|
||||
assertEquals(
|
||||
listOf("session-$index"),
|
||||
filterAndSortSessionRows(
|
||||
rows = rows,
|
||||
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
|
||||
activityStates = states,
|
||||
).map { it.session.sessionId },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -113,6 +236,7 @@ class SessionDrawerPolicyTest {
|
||||
outputTokens: Int = 0,
|
||||
cost: Double? = null,
|
||||
updatedAt: Long = 0L,
|
||||
recentlyActive: Boolean = false,
|
||||
) = ProfileSessionRow(
|
||||
profile = profile,
|
||||
session = ChatSession(
|
||||
@@ -126,6 +250,7 @@ class SessionDrawerPolicyTest {
|
||||
outputTokens = outputTokens,
|
||||
actualCostUsd = cost,
|
||||
lastActivityAt = updatedAt,
|
||||
recentlyActive = recentlyActive,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -359,6 +360,66 @@ class SessionDrawerTest {
|
||||
compose.onNodeWithText("Filters").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer renders every authoritative activity phase distinctly`() {
|
||||
val states = SessionActivityState.entries
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = states.mapIndexed { index, _ ->
|
||||
ChatSession("session-$index", "Session $index", null)
|
||||
},
|
||||
currentSessionId = null,
|
||||
activeProfileName = "default",
|
||||
activityStates = states.mapIndexed { index, state ->
|
||||
"default:session-$index" to state
|
||||
}.toMap(),
|
||||
animationEnabled = false,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"Starting",
|
||||
"Working",
|
||||
"Needs input",
|
||||
"Background work",
|
||||
"Checking",
|
||||
"Unavailable",
|
||||
).forEach { label ->
|
||||
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rest recency alone renders no working badge`() {
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = listOf(
|
||||
ChatSession(
|
||||
"recent",
|
||||
"Recently updated",
|
||||
null,
|
||||
recentlyActive = true,
|
||||
),
|
||||
),
|
||||
currentSessionId = null,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Working").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all profiles customization can override a profile identity color`() {
|
||||
var changed: Pair<String, String?>? = null
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SessionDrawerTimestampTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun `distinct activity is labeled updated`() {
|
||||
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Updated Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relative timestamp changes when the drawer clock advances`() {
|
||||
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Updated Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
assertEquals(
|
||||
"Updated 2m ago",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session without later activity keeps started label`() {
|
||||
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Started Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
|
||||
sessionId = "session",
|
||||
title = "Session",
|
||||
model = null,
|
||||
startedAt = startedAt,
|
||||
lastActivityAt = lastActivityAt,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.BotSessionSummary
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w390dp-h844dp-432dpi")
|
||||
class BotModeScreenTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `tabs filter bots and read only groups without changing workspace`() {
|
||||
render()
|
||||
|
||||
compose.onNodeWithText("Lucy").assertExists()
|
||||
compose.onNodeWithText("Launch Council").assertExists()
|
||||
|
||||
compose.onNodeWithText("Groups").performClick()
|
||||
|
||||
compose.onNodeWithText("Lucy").assertDoesNotExist()
|
||||
compose.onNodeWithText("Launch Council").assertExists()
|
||||
compose.onNodeWithText("Read only").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bot and group rows dispatch their exact owners`() {
|
||||
var botOwner: String? = null
|
||||
var groupOwner: String? = null
|
||||
render(
|
||||
onOpenBot = { botOwner = it.profile.name },
|
||||
onOpenGroup = { groupOwner = it.key },
|
||||
)
|
||||
|
||||
compose.onNodeWithText("Lucy").performClick()
|
||||
compose.onNodeWithText("Launch Council").performClick()
|
||||
|
||||
assertEquals("default", botOwner)
|
||||
assertEquals("id:launch", groupOwner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gateway scope filters union without switching active connection`() {
|
||||
render(selectedGatewayId = "lab")
|
||||
|
||||
compose.onNodeWithText("Researcher").assertExists()
|
||||
compose.onNodeWithText("Lucy").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun render(
|
||||
onOpenBot: (BotRosterEntry) -> Unit = {},
|
||||
onOpenGroup: (BotGroupRoom) -> Unit = {},
|
||||
selectedGatewayId: String? = null,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
BotModeContent(
|
||||
state = state(),
|
||||
connections = listOf(connection(), labConnection()),
|
||||
activeConnection = connection(),
|
||||
selectedGatewayId = selectedGatewayId,
|
||||
onBack = {},
|
||||
onRefresh = {},
|
||||
onSelectGateway = {},
|
||||
onOpenBot = onOpenBot,
|
||||
onOpenGroup = onOpenGroup,
|
||||
onNewBot = {},
|
||||
nowMs = NOW + 200_000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun state() = BotModeState(
|
||||
roster = BotModeRoster(
|
||||
bots = listOf(
|
||||
BotRosterEntry(
|
||||
profile = Profile(name = "default", model = "gpt-5.6", description = "Operator"),
|
||||
displayName = "Lucy",
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey("home", "default"),
|
||||
connectionLabel = "Hermes",
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
id = "bot-root",
|
||||
preview = "Drafted a rollout plan",
|
||||
lastActiveAtMs = NOW - 10_000L,
|
||||
),
|
||||
),
|
||||
BotRosterEntry(
|
||||
profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
|
||||
displayName = "Researcher",
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
|
||||
connectionLabel = "Lab server",
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
id = "researcher-root",
|
||||
preview = "Findings ready",
|
||||
lastActiveAtMs = NOW - 30_000L,
|
||||
),
|
||||
),
|
||||
),
|
||||
groups = listOf(
|
||||
BotGroupRoom(
|
||||
key = "id:launch",
|
||||
roomId = "launch",
|
||||
name = "Launch Council",
|
||||
messages = listOf(
|
||||
BotGroupMessage(
|
||||
id = "message-1",
|
||||
senderName = "Lucy",
|
||||
senderKind = "member",
|
||||
text = "Rollout is clear",
|
||||
atMs = NOW - 20_000L,
|
||||
),
|
||||
),
|
||||
sourceConnectionIds = setOf("home", "lab"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun connection() = Connection(
|
||||
id = "home",
|
||||
label = "Hermes",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "https://example.invalid",
|
||||
tokenStoreKey = "test",
|
||||
)
|
||||
|
||||
private fun labConnection() = Connection(
|
||||
id = "lab",
|
||||
label = "Lab server",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "https://lab.invalid",
|
||||
tokenStoreKey = "test-lab",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val NOW = 1_777_000_000_000L
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityStateTest {
|
||||
@Test
|
||||
fun `multiple background turns remain visible beside current working turn`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf(
|
||||
"session-a" to SessionActivityState.Working,
|
||||
"session-b" to SessionActivityState.NeedsInput,
|
||||
),
|
||||
currentSessionId = "session-c",
|
||||
isStreaming = true,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Working, resolved["session-a"])
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
|
||||
assertEquals(SessionActivityState.Working, resolved["session-c"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needs input takes precedence over current working state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = emptyMap(),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = true,
|
||||
needsInput = true,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected idle session does not retain a stale background state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf("session-a" to SessionActivityState.Working),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = false,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertFalse(resolved.containsKey("session-a"))
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,15 @@ class ChatViewModelCommandCatalogTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalBotChat_rewritesOnlyConversationForkCommands() {
|
||||
assertTrue(shouldCompactCanonicalBotChat("new", canonicalBotChatMode = true))
|
||||
assertTrue(shouldCompactCanonicalBotChat("RESET", canonicalBotChatMode = true))
|
||||
assertFalse(shouldCompactCanonicalBotChat("new", canonicalBotChatMode = false))
|
||||
assertFalse(shouldCompactCanonicalBotChat("compact", canonicalBotChatMode = true))
|
||||
assertFalse(shouldCompactCanonicalBotChat("title", canonicalBotChatMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCommandsCatalog_ranksOnlySkillSlotsAndBoundsMetadata() {
|
||||
val catalog = buildJsonObject {
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
@@ -23,6 +24,7 @@ import com.hermesandroid.relay.network.upstream.GatewayClientHarness
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -291,6 +293,78 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeListWorkingThenDisappearanceSettlesDespiteRestRecency() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
)
|
||||
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Working
|
||||
}
|
||||
|
||||
gatewayHarness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray { })
|
||||
}
|
||||
viewModel.requestSessionActivityRefresh()
|
||||
gatewayHarness.awaitRpcCount("session.active_list", 2)
|
||||
awaitCondition {
|
||||
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeListWaitingProjectsNeedsInput() {
|
||||
bindActivityTestDirectory()
|
||||
gatewayHarness.activeSessionListPayload = activeSessionPayload("waiting")
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.NeedsInput
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
)
|
||||
gatewayHarness.methodNotFound += "session.active_list"
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedAdmissionCannotLeaveStartingStatusStale() {
|
||||
bindActivityTestDirectory()
|
||||
gatewayClient.clearSession()
|
||||
gatewayHarness.rpcErrors["session.resume"] = 4090 to "stored session is unavailable"
|
||||
|
||||
viewModel.sendMessage("This admission should fail")
|
||||
gatewayHarness.awaitRpc("session.resume")
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
assertTrue(
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] !=
|
||||
SessionActivityState.Starting,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
DiagnosticsLog.clear()
|
||||
@@ -1003,6 +1077,72 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(handler.messages.value.any { "Stopped" in it.badges })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
|
||||
// Exercise Stop's route-independent fallback without the Gateway
|
||||
// orphan-state observer settling this synthetic state first.
|
||||
viewModel.streamingEndpoint = "sessions"
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
|
||||
viewModel.cancelStream()
|
||||
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertNull(handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedGatewayBubbleAutomaticallySettlesComposerWithoutInput() {
|
||||
handler.onTextDelta("completed-answer", "Finished answer")
|
||||
handler.onTurnComplete("completed-answer")
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertFalse(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "prompt.submit" })
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedBubbleKeepsComposerBusyWhileGatewaySessionStillOwnsTheRun() {
|
||||
viewModel.sendMessage("Continue through another assistant turn")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID) }
|
||||
val assistantId = handler.messages.value.single { it.role == MessageRole.ASSISTANT }.id
|
||||
|
||||
handler.onTextDelta(assistantId, "First assistant message")
|
||||
handler.onTurnComplete(assistantId)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertTrue(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
|
||||
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Final assistant message") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(gatewayClient.hasActiveTurn())
|
||||
|
||||
viewModel.createNewChat()
|
||||
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertTrue(handler.messages.value.isEmpty())
|
||||
assertNull(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopenedChatRestoresRichStateAndReattachesLiveGatewayTurn() {
|
||||
val now = System.currentTimeMillis()
|
||||
@@ -2310,6 +2450,27 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
|
||||
}
|
||||
|
||||
private fun bindActivityTestDirectory() {
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
viewModel.updateSessionActivityDirectory(
|
||||
rows = listOf("default" to STORED_SESSION_ID),
|
||||
)
|
||||
}
|
||||
|
||||
private fun activeSessionPayload(status: String) = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("id", "live-resumed")
|
||||
put("session_key", STORED_SESSION_ID)
|
||||
put("status", status)
|
||||
put("last_active", 1.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private fun persistedAnswerHistory(
|
||||
answer: String = BACKGROUND_ANSWER,
|
||||
id: String = "persisted-background-answer",
|
||||
|
||||
@@ -103,6 +103,54 @@ class ChatViewModelMediaStateTest {
|
||||
assertEquals("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg", loaded.cachedUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantWindowsPathCellularRetryUsesByPathEndpoint() {
|
||||
val windowsPath =
|
||||
"C:\\Users\\Example\\AppData\\Local\\Temp\\Sovereign Intelligence copy.md"
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "assistant-file-1",
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("MEDIA:$windowsPath"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val deferred = awaitMessage {
|
||||
it.attachments.singleOrNull()?.errorMessage == "Tap to download"
|
||||
}
|
||||
assertEquals(AttachmentState.FAILED, deferred.attachments.single().state)
|
||||
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "text/markdown")
|
||||
.setHeader(
|
||||
"Content-Disposition",
|
||||
"inline; filename=\"Sovereign Intelligence copy.md\"",
|
||||
)
|
||||
.setBody("# copy")
|
||||
)
|
||||
viewModel.cellularNetworkOverride = false
|
||||
viewModel.manualFetchAttachment("assistant-file-1", 0)
|
||||
|
||||
val loaded = awaitMessage {
|
||||
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
|
||||
}.attachments.single()
|
||||
assertEquals("text/markdown", loaded.contentType)
|
||||
assertEquals("Sovereign Intelligence copy.md", loaded.fileName)
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/media/by-path", request.requestUrl?.encodedPath)
|
||||
assertEquals(
|
||||
"path=C%3A%5CUsers%5CExample%5CAppData%5CLocal%5CTemp%5C" +
|
||||
"Sovereign%20Intelligence%20copy.md",
|
||||
request.requestUrl?.encodedQuery,
|
||||
)
|
||||
assertEquals(windowsPath, request.requestUrl?.queryParameter("path"))
|
||||
}
|
||||
|
||||
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
|
||||
val deadline = System.nanoTime() + 5_000_000_000L
|
||||
while (System.nanoTime() < deadline) {
|
||||
|
||||
@@ -318,10 +318,14 @@ class GatewayProcessControllerTest {
|
||||
controller.bind(source, "same-id", scopeKey = "profile-a")
|
||||
controller.sessionReady("same-id")
|
||||
runCurrent()
|
||||
assertTrue(controller.ownsSnapshot("same-id", "profile-a"))
|
||||
|
||||
controller.selectSession("same-id", scopeKey = "profile-b")
|
||||
assertFalse(controller.ownsSnapshot("same-id", "profile-a"))
|
||||
assertFalse(controller.ownsSnapshot("same-id", "profile-b"))
|
||||
controller.sessionReady("same-id")
|
||||
runCurrent()
|
||||
assertTrue(controller.ownsSnapshot("same-id", "profile-b"))
|
||||
oldResult.complete(Result.success(listOf(process(id = "old-profile"))))
|
||||
runCurrent()
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityOwner
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityResolutionTest {
|
||||
private val alpha = SessionActivityOwner.of("connection", "alpha", "same")
|
||||
private val beta = SessionActivityOwner.of("connection", "beta", "same")
|
||||
|
||||
@Test
|
||||
fun `duplicate stored ids stay unresolved without exact runtime binding`() {
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(active("runtime-beta", GatewayActiveSessionStatus.Working)),
|
||||
directory = setOf(alpha, beta),
|
||||
currentOwner = alpha,
|
||||
)
|
||||
|
||||
assertNull(result.runtimes.single().owner)
|
||||
assertTrue(result.ambiguous)
|
||||
assertTrue(result.ambiguousForCurrent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact foreground runtime binding resolves duplicate stored id`() {
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(active("runtime-alpha", GatewayActiveSessionStatus.Waiting)),
|
||||
directory = setOf(alpha, beta),
|
||||
currentOwner = alpha,
|
||||
currentRuntimeId = "runtime-alpha",
|
||||
)
|
||||
|
||||
assertEquals(alpha, result.runtimes.single().owner)
|
||||
assertEquals(SessionLiveStatus.Waiting, result.runtimes.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unscoped stored id stays unresolved even when bounded directory looks unique`() {
|
||||
val unique = SessionActivityOwner.of("connection", "beta", "unique")
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = "runtime",
|
||||
storedSessionId = "unique",
|
||||
status = GatewayActiveSessionStatus.Starting,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
),
|
||||
),
|
||||
directory = setOf(alpha, unique),
|
||||
currentOwner = alpha,
|
||||
)
|
||||
|
||||
assertNull(result.runtimes.single().owner)
|
||||
assertTrue(result.ambiguous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client known detached runtime resolves exact profile owner`() {
|
||||
val unique = SessionActivityOwner.of("connection", "beta", "unique")
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = "runtime",
|
||||
storedSessionId = "unique",
|
||||
status = GatewayActiveSessionStatus.Starting,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
),
|
||||
),
|
||||
directory = setOf(alpha, unique),
|
||||
currentOwner = alpha,
|
||||
knownOwnersByRuntime = mapOf("runtime" to unique),
|
||||
)
|
||||
|
||||
assertEquals(unique, result.runtimes.single().owner)
|
||||
assertEquals(SessionLiveStatus.Starting, result.runtimes.single().status)
|
||||
}
|
||||
|
||||
private fun active(runtimeId: String, status: GatewayActiveSessionStatus) =
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = "same",
|
||||
status = status,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayClientHarness
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BotModeControllerTest {
|
||||
@Test
|
||||
fun refreshAggregatesTwoGatewaysPreservesOfflineCacheAndNeverSwitchesActive() = runBlocking {
|
||||
val aHarness = GatewayClientHarness()
|
||||
val bHarness = GatewayClientHarness()
|
||||
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val clients = mapOf(
|
||||
"a" to routeClient(aHarness, routeScope),
|
||||
"b" to routeClient(bHarness, routeScope),
|
||||
)
|
||||
aHarness.profilesListPayload = rosterPayload("default")
|
||||
bHarness.profilesListPayload = rosterPayload("researcher")
|
||||
val connections = MutableStateFlow(
|
||||
listOf(
|
||||
connection("a", "Laptop", aHarness),
|
||||
connection("b", "Home Lab", bHarness),
|
||||
),
|
||||
)
|
||||
val active = MutableStateFlow<String?>("a")
|
||||
val controller = BotModeController(
|
||||
scope = this,
|
||||
connections = connections,
|
||||
activeConnectionId = active,
|
||||
dashboardUrlProvider = Connection::resolvedDashboardUrl,
|
||||
dashboardClientFactory = { _, url -> DashboardApiClient(url, OkHttpClient()) },
|
||||
gatewayLeaseFactory = { connectionId, _, _, _ ->
|
||||
UpstreamTransportController.RouteGatewayLease(checkNotNull(clients[connectionId])) {}
|
||||
},
|
||||
)
|
||||
try {
|
||||
controller.refreshNow()
|
||||
assertEquals(setOf("default", "researcher"), controller.state.value.roster.bots.map { it.profile.name }.toSet())
|
||||
assertEquals("a", active.value)
|
||||
|
||||
bHarness.rpcErrors["profiles.list"] = 5006 to "offline"
|
||||
controller.refreshNow()
|
||||
assertTrue(
|
||||
controller.state.value.roster.bots
|
||||
.first { it.profile.name == "researcher" }
|
||||
.stale,
|
||||
)
|
||||
assertEquals("a", active.value)
|
||||
|
||||
connections.value = connections.value.filterNot { it.id == "b" }
|
||||
controller.connectionRemoved("b")
|
||||
assertEquals(listOf("default"), controller.state.value.roster.bots.map { it.profile.name })
|
||||
} finally {
|
||||
clients.values.forEach(GatewayChatClient::shutdown)
|
||||
routeScope.cancel()
|
||||
aHarness.shutdown()
|
||||
bHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeClient(harness: GatewayClientHarness, scope: CoroutineScope) = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
harness.server.url("/").toString().trimEnd('/'),
|
||||
OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "default",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
private fun rosterPayload(profile: String) = buildJsonObject {
|
||||
put("profiles", JsonArray(listOf(buildJsonObject {
|
||||
put("name", profile)
|
||||
put("model", "gpt")
|
||||
put("is_default", profile == "default")
|
||||
})))
|
||||
put("bot_mode_protocol", true)
|
||||
}
|
||||
|
||||
private fun connection(id: String, label: String, harness: GatewayClientHarness) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
tokenStoreKey = "token-$id",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BotModeRosterAggregationTest {
|
||||
private val a = connection("a", "Laptop")
|
||||
private val b = connection("b", "Home Lab")
|
||||
private val connections = MutableStateFlow(listOf(a, b))
|
||||
private val active = MutableStateFlow<String?>("a")
|
||||
private val controller = BotModeController(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
|
||||
connections = connections,
|
||||
activeConnectionId = active,
|
||||
dashboardUrlProvider = Connection::resolvedDashboardUrl,
|
||||
dashboardClientFactory = { _, url -> DashboardApiClient(url) },
|
||||
gatewayLeaseFactory = { _, _, _, _ -> error("not used by aggregation tests") },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun sameInstallCollapsesBeforeHandleCountingAndActiveRouteWins() {
|
||||
val state = aggregate(
|
||||
snapshot(a, "install-1", bots = listOf(bot("default"))),
|
||||
snapshot(b, "install-1", bots = listOf(bot("default"))),
|
||||
)
|
||||
|
||||
assertEquals(1, state.roster.bots.size)
|
||||
assertEquals("a", state.roster.bots.single().route?.connectionId)
|
||||
assertEquals("default", state.roster.bots.single().handle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameNameOnDifferentInstallsUsesSourceQualifiedHandles() {
|
||||
val state = aggregate(
|
||||
snapshot(a, "install-a", bots = listOf(bot("default"), bot("unique"))),
|
||||
snapshot(b, "install-b", bots = listOf(bot("default"))),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf("default-laptop", "default-home-lab", "unique"),
|
||||
state.roster.bots.mapTo(linkedSetOf(), BotRosterEntry::handle),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingInstallIdsNeverCollapseAndOfflineRowsStayStale() {
|
||||
val state = aggregate(
|
||||
snapshot(a, null, bots = listOf(bot("worker")), stale = true),
|
||||
snapshot(b, null, bots = listOf(bot("worker"))),
|
||||
)
|
||||
|
||||
assertEquals(2, state.roster.bots.size)
|
||||
assertTrue(state.roster.bots.first { it.route?.connectionId == "a" }.stale)
|
||||
assertFalse(state.roster.bots.first { it.route?.connectionId == "b" }.stale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupProjectionDeduplicatesByRoomIdentityAndKeepsAllSources() {
|
||||
val old = room(revision = 1, text = "old")
|
||||
val fresh = room(revision = 2, text = "fresh")
|
||||
val state = aggregate(
|
||||
snapshot(a, "install-a", groups = listOf(old)),
|
||||
snapshot(b, "install-b", groups = listOf(fresh)),
|
||||
)
|
||||
|
||||
assertEquals(1, state.roster.groups.size)
|
||||
assertEquals("fresh", state.roster.groups.single().latestMessage?.text)
|
||||
assertEquals(setOf("a", "b"), state.roster.groups.single().sourceConnectionIds)
|
||||
}
|
||||
|
||||
private fun aggregate(vararg snapshots: BotModeGatewaySnapshot) = controller.aggregateForTest(
|
||||
fleet = connections.value,
|
||||
snapshots = snapshots.associateBy { it.connection.id },
|
||||
loading = false,
|
||||
)
|
||||
|
||||
private fun snapshot(
|
||||
connection: Connection,
|
||||
installId: String?,
|
||||
bots: List<BotRosterEntry> = emptyList(),
|
||||
groups: List<BotGroupRoom> = emptyList(),
|
||||
stale: Boolean = false,
|
||||
) = BotModeGatewaySnapshot(
|
||||
connection = connection,
|
||||
dashboardUrl = connection.resolvedDashboardUrl,
|
||||
installId = installId,
|
||||
roster = BotModeRoster(bots = bots, groups = groups, botModeProtocolSupported = true),
|
||||
stale = stale,
|
||||
error = if (stale) "offline" else null,
|
||||
)
|
||||
|
||||
private fun bot(name: String) = BotRosterEntry(
|
||||
profile = Profile(name = name, model = "gpt"),
|
||||
displayName = name,
|
||||
)
|
||||
|
||||
private fun room(revision: Long, text: String) = BotGroupRoom(
|
||||
key = "id:room-1",
|
||||
roomId = "room-1",
|
||||
name = "Council",
|
||||
revision = revision,
|
||||
messages = listOf(
|
||||
BotGroupMessage(
|
||||
senderName = "Bot",
|
||||
senderKind = "member",
|
||||
text = text,
|
||||
atMs = revision,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun connection(id: String, label: String) = Connection(
|
||||
id = id,
|
||||
label = label,
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
dashboardUrl = "http://$id.invalid",
|
||||
tokenStoreKey = "token-$id",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class UpstreamTransportControllerRoutePoolTest {
|
||||
private val controller = UpstreamTransportController(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
activeConnectionIdProvider = { "a" },
|
||||
dashboardUrlProvider = { "http://a.invalid" },
|
||||
gatewayKeepAliveProvider = { false },
|
||||
trustedDashboardUrlProvider = { id -> "http://$id.invalid" },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun exactConnectionAndProfileOwnOneClient() {
|
||||
val first = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
|
||||
val repeated = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
|
||||
val sibling = controller.acquireGatewayRoute("a", "http://a.invalid", "writer")
|
||||
val remoteTwin = controller.acquireGatewayRoute("b", "http://b.invalid", "default")
|
||||
|
||||
assertSame(first.client, repeated.client)
|
||||
assertNotSame(first.client, sibling.client)
|
||||
assertNotSame(first.client, remoteTwin.client)
|
||||
|
||||
first.close()
|
||||
repeated.close()
|
||||
sibling.close()
|
||||
remoteTwin.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionRemovalDisposesOnlyThatConnectionsRoutes() {
|
||||
val a = controller.acquireGatewayRoute("a", "http://a.invalid", "default", retain = true)
|
||||
val b = controller.acquireGatewayRoute("b", "http://b.invalid", "default", retain = true)
|
||||
val oldA = a.client
|
||||
val oldB = b.client
|
||||
|
||||
controller.disposeConnectionRouteClients("a")
|
||||
a.close()
|
||||
val newA = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
|
||||
val sameB = controller.acquireGatewayRoute("b", "http://b.invalid", "default")
|
||||
|
||||
assertNotSame(oldA, newA.client)
|
||||
assertSame(oldB, sameB.client)
|
||||
|
||||
b.close()
|
||||
newA.close()
|
||||
sameB.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleLeaseCannotEvictRouteReplacement() {
|
||||
val old = controller.acquireGatewayRoute("a", "http://a.invalid", "default", retain = true)
|
||||
val replacement = controller.acquireGatewayRoute(
|
||||
"a",
|
||||
"http://a.invalid/alternate",
|
||||
"default",
|
||||
)
|
||||
assertNotSame(old.client, replacement.client)
|
||||
|
||||
old.close()
|
||||
val repeated = controller.acquireGatewayRoute(
|
||||
"a",
|
||||
"http://a.invalid/alternate",
|
||||
"default",
|
||||
)
|
||||
assertSame(replacement.client, repeated.client)
|
||||
|
||||
replacement.close()
|
||||
repeated.close()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -55,6 +55,54 @@ No open P0, P1, or P2 findings.
|
||||
|
||||
final result: passed
|
||||
|
||||
# Android Bot Mode messenger workspace
|
||||
|
||||
**Comparison target**
|
||||
|
||||
- Source visual truth: `docs/mockups/bot-mode/bot-mode-home-approved.png`
|
||||
- Rendered implementation: `app/build/ui-evidence/bot-mode-home.png`
|
||||
- Combined evidence: `app/build/ui-evidence/bot-mode-comparison.png`
|
||||
- Viewport: Android Compose at `390dp x 844dp`, Robolectric qualifier `w390dp-h844dp-432dpi`
|
||||
- Pixels and normalization: source `853 x 1844`; implementation `1053 x 2278` at Android density `2.7`. The combined evidence downsamples the implementation to `853 x 1844` and places it beside the source at equal visible bounds.
|
||||
- State: dark Hermes Relay theme, active Hermes gateway, All filter, three active Bots, three Bot conversations, and two read-only group rooms.
|
||||
|
||||
**Findings**
|
||||
|
||||
- No actionable P0, P1, or P2 differences remain.
|
||||
- Fonts and typography: the implementation uses the app's established Material typography and optical weights. The title, selector labels, active roster, conversation titles, metadata, and one-line previews preserve the source hierarchy without clipped primary labels.
|
||||
- Spacing and layout rhythm: the implementation preserves the full-screen messenger composition, compact gateway selector, equal-width filters, horizontal active roster, divided chronological list, and one bottom-end primary action. Native touch targets make the selector and filters slightly taller than the generated source; all five rows remain visible at the target viewport.
|
||||
- Colors and visual tokens: the first capture overused the strong electric `primaryContainer`. The final capture uses the softer theme-scoped relay/periwinkle tokens for selected and identity surfaces, graphite containers, semantic green presence, and restrained hairlines.
|
||||
- Image quality and asset fidelity: production rows render the real cached upstream profile avatar when present. The deterministic fixture exercises the app's real initial fallback instead of shipping the mock's illustrative anime/crystal/robot images as product assets. Group rows use the app's Material group/lock icons; no placeholder bitmap or custom-drawn icon was introduced.
|
||||
- Copy and content: Bot Mode, All gateways, All/Bots/Groups, Active now, source-qualified rows, previews, and Read only match the selected workflow. Relative timestamps follow the app's existing locale-aware convention rather than the mock's fixed clock values.
|
||||
- Interaction: gateway selection, All/Bots/Groups filtering, Bot owner dispatch, group owner dispatch, search, New Bot, canonical Bot Chat open, read-only room detail, drawer entry, and Back to Bot Mode are wired. The fixture does not invent unread dots because current upstream supplies activity but no canonical mobile read-state contract.
|
||||
|
||||
**Comparison history**
|
||||
|
||||
1. Initial capture found P2 accent-weight drift: the selected tab, FAB, and fallback avatars used saturated electric fills; control borders were stronger than the source; an extra refresh action crowded the app bar.
|
||||
2. Replaced large fills with the softer theme-scoped relay/electric-muted tokens, reduced control-border opacity and vertical padding, and removed the extra refresh action while retaining automatic load and error-state retry.
|
||||
3. Re-rendered at the same `390dp x 844dp` state and normalized both artifacts into `bot-mode-comparison.png`. The corrected surface has no remaining P0/P1/P2 mismatch.
|
||||
4. After multi-gateway aggregation landed, re-rendered the final All gateways state with per-row source-qualified handles. The gateway scope now matches the approved mock literally while preserving readable row density; no new P0/P1/P2 mismatch appeared.
|
||||
|
||||
**Open Questions**
|
||||
|
||||
- None blocking. A physical-device pass may refine P3 density at the user's font/display scale, but the deterministic supported viewport is complete.
|
||||
|
||||
**Implementation Checklist**
|
||||
|
||||
- [x] Keep Bot Mode outside the ordinary session taxonomy.
|
||||
- [x] Match the approved list hierarchy and native return path.
|
||||
- [x] Render a truthful all-gateway union without changing the foreground connection.
|
||||
- [x] Render upstream group projection as visibly read-only.
|
||||
- [x] Verify the primary filters and owner-routing interactions.
|
||||
- [x] Capture and compare the actual Compose surface.
|
||||
|
||||
**Follow-up Polish**
|
||||
|
||||
- P3: physical-device review can tune handle truncation for unusually long gateway labels.
|
||||
- P3: add canonical read-state dots only if upstream publishes a durable read-state contract.
|
||||
|
||||
final result: passed
|
||||
|
||||
## Assistant overlay
|
||||
|
||||
- Reference: `C:\Users\Bailey\.codex\generated_images\019fb003-68ea-7052-85c7-3745fa7e838e\call_jPwpDr9QfaCDA6k2c01StBYe.png`
|
||||
|
||||
@@ -120,6 +120,9 @@ $env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/C
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Prebuilt Linux CLI assets support both x64 and arm64; the installer selects the
|
||||
matching binary from the machine architecture.
|
||||
|
||||
Windows downloads and verifies `hermes-relay-windows-x64-setup.exe`. The installer
|
||||
places the CLI and management UI together, adds `~/.hermes/bin` to the user PATH, and
|
||||
lets the UI start at sign-in. CLI-only installs download the same prebuilt
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hermes-relay": "bin/hermes-relay.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -28,9 +28,10 @@
|
||||
"version": "npm run sync:version",
|
||||
"build": "npm run gen:version && tsc -p tsconfig.build.json",
|
||||
"build:watch": "tsc -p tsconfig.build.json --watch",
|
||||
"build:bin": "npm run build:bin:win && npm run build:bin:linux && npm run build:bin:mac-x64 && npm run build:bin:mac-arm",
|
||||
"build:bin": "npm run build:bin:win && npm run build:bin:linux && npm run build:bin:linux-arm && npm run build:bin:mac-x64 && npm run build:bin:mac-arm",
|
||||
"build:bin:win": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-windows-x64 src/bunWindowsEntry.ts --outfile dist/bin/hermes-relay-win-x64",
|
||||
"build:bin:linux": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-linux-x64 src/cli.ts --outfile dist/bin/hermes-relay-linux-x64",
|
||||
"build:bin:linux-arm": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-linux-arm64 src/cli.ts --outfile dist/bin/hermes-relay-linux-arm64",
|
||||
"build:bin:mac-x64": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-x64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-x64",
|
||||
"build:bin:mac-arm": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-arm64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-arm64",
|
||||
"build:sums": "cd dist/bin && sha256sum hermes-relay-* > SHA256SUMS.txt",
|
||||
|
||||
@@ -120,7 +120,13 @@ $resolvedVersion = $version
|
||||
if ($version -eq 'latest') {
|
||||
Say "-> resolving latest desktop-v* release..."
|
||||
try {
|
||||
$releases = Invoke-RestMethod -UseBasicParsing "https://api.github.com/repos/$repo/releases"
|
||||
$releases = @()
|
||||
$page = 1
|
||||
do {
|
||||
$releasePage = @(Invoke-RestMethod -UseBasicParsing "https://api.github.com/repos/$repo/releases?per_page=100&page=$page")
|
||||
$releases += $releasePage
|
||||
$page += 1
|
||||
} while ($releasePage.Count -eq 100)
|
||||
# Don't trust the API's first-element ordering — GitHub orders by the
|
||||
# release row's created_at which shifts when the row is touched (re-tag,
|
||||
# edit). Sort by parsed version components explicitly so a touched
|
||||
@@ -209,10 +215,6 @@ if ($surface -eq 'tray') {
|
||||
if ($expected -ne $actual) { Die "checksum mismatch (expected $expected, got $actual) - refusing to install" }
|
||||
Say ' ok'
|
||||
|
||||
if (Get-Command Unblock-File -ErrorAction SilentlyContinue) {
|
||||
Unblock-File $installer -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$installerArgs = @()
|
||||
if ($env:HERMES_RELAY_TRAY_SILENT -eq '1') {
|
||||
$installerArgs += '/S'
|
||||
@@ -220,7 +222,8 @@ if ($surface -eq 'tray') {
|
||||
# NSIS requires /D to be the final argument. Keeping the existing registered
|
||||
# install directory avoids splitting an upgrade across two PATH locations.
|
||||
$installerArgs += "/D=$dir"
|
||||
Say '-> launching installer...'
|
||||
Say '-> launching unsigned preview installer...'
|
||||
Say ' Windows may show a SmartScreen publisher warning; review it before continuing.'
|
||||
$proc = Start-Process -FilePath $installer -ArgumentList $installerArgs -Wait -PassThru
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Die "tray installer exited with code $($proc.ExitCode)"
|
||||
|
||||
@@ -90,9 +90,10 @@ os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m)"
|
||||
case "$os-$arch" in
|
||||
linux-x86_64) asset="hermes-relay-linux-x64" ;;
|
||||
linux-aarch64|linux-arm64) asset="hermes-relay-linux-arm64" ;;
|
||||
darwin-x86_64) asset="hermes-relay-darwin-x64" ;;
|
||||
darwin-arm64) asset="hermes-relay-darwin-arm64" ;;
|
||||
*) die "unsupported platform: $os/$arch (published binaries: linux-x64, darwin-x64/arm64; Windows uses install.ps1)" ;;
|
||||
*) die "unsupported platform: $os/$arch (published binaries: linux-x64/arm64, darwin-x64/arm64; Windows uses install.ps1)" ;;
|
||||
esac
|
||||
|
||||
# Resolve "latest" to a concrete tag. GitHub's /releases/latest/download/ URL
|
||||
@@ -103,16 +104,24 @@ esac
|
||||
resolved_version="$VERSION"
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
say "-> resolving latest desktop-v* release..."
|
||||
api_body=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) \
|
||||
|| die "could not query GitHub Releases API"
|
||||
release_tags=""
|
||||
page=1
|
||||
while :; do
|
||||
api_body=$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=100&page=$page" 2>/dev/null) \
|
||||
|| die "could not query GitHub Releases API page $page"
|
||||
page_tags=$(printf '%s\n' "$api_body" \
|
||||
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
||||
| sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/' || true)
|
||||
page_count=$(printf '%s\n' "$page_tags" | awk 'NF { count++ } END { print count + 0 }')
|
||||
release_tags=$(printf '%s\n%s\n' "$release_tags" "$page_tags")
|
||||
[ "$page_count" -eq 100 ] || break
|
||||
page=$((page + 1))
|
||||
done
|
||||
# Extract every CLI-track tag_name and pick the SemVer-max. Don't trust the
|
||||
# API's first-element ordering — GitHub orders by the release row's created_at
|
||||
# which shifts when the row is edited or re-tagged. Each "tag_name": entry is
|
||||
# on its own line in GitHub's JSON output, so line-oriented tooling is
|
||||
# sufficient and avoids a jq dependency.
|
||||
release_tags=$(printf '%s\n' "$api_body" \
|
||||
| grep -E '"tag_name": *"(cli-v|desktop-v)' \
|
||||
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)
|
||||
# which shifts when the row is edited or re-tagged. Extract only tag_name
|
||||
# fields from each page so this stays independent of JSON formatting and
|
||||
# avoids a jq dependency.
|
||||
resolved_version=$(printf '%s\n' "$release_tags" \
|
||||
| awk '/^desktop-v/ { v=$0; sub(/^desktop-v/, "", v); print v "\t" $0 }' \
|
||||
| sort -V \
|
||||
|
||||
@@ -29,8 +29,8 @@ import { pipeline } from 'node:stream/promises'
|
||||
import { VERSION } from './version.js'
|
||||
|
||||
const DEFAULT_REPO = 'Codename-11/hermes-relay'
|
||||
const RELEASES_API = (repo: string): string =>
|
||||
`https://api.github.com/repos/${repo}/releases`
|
||||
const RELEASES_API = (repo: string, page: number): string =>
|
||||
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string
|
||||
@@ -176,21 +176,24 @@ interface GhRelease {
|
||||
}
|
||||
|
||||
async function fetchReleases(repo: string): Promise<GhRelease[]> {
|
||||
const url = RELEASES_API(repo)
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': `hermes-relay-cli/${VERSION}`
|
||||
const releases: GhRelease[] = []
|
||||
for (let page = 1; ; page += 1) {
|
||||
const res = await fetch(RELEASES_API(repo, page), {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': `hermes-relay-cli/${VERSION}`
|
||||
}
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub Releases API ${res.status} ${res.statusText}`)
|
||||
}
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub Releases API ${res.status} ${res.statusText}`)
|
||||
const data = (await res.json()) as GhRelease[]
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('unexpected Releases API response (not an array)')
|
||||
}
|
||||
releases.push(...data)
|
||||
if (data.length < 100) return releases
|
||||
}
|
||||
const data = (await res.json()) as GhRelease[]
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('unexpected Releases API response (not an array)')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function checkForUpdate(opts: { repo?: string; assetName?: string } = {}): Promise<UpdateInfo | null> {
|
||||
@@ -396,10 +399,19 @@ export async function finalizePendingUpdate(): Promise<void> {
|
||||
// binary, nothing to swap.
|
||||
return
|
||||
}
|
||||
await finalizePendingUpdateAt(target)
|
||||
}
|
||||
|
||||
/** Path-level implementation used by the Windows startup hook and tests. */
|
||||
export async function finalizePendingUpdateAt(target: string): Promise<void> {
|
||||
const base = target.slice(0, -'.exe'.length)
|
||||
const newPath = `${base}.new.exe`
|
||||
const oldPath = `${base}.old.exe`
|
||||
|
||||
// Reap a backup from the prior cooperative swap even when no new update is
|
||||
// staged. The prior process can keep the renamed image locked until exit.
|
||||
try { await unlink(oldPath) } catch { /* absent or still locked */ }
|
||||
|
||||
try {
|
||||
await stat(newPath)
|
||||
} catch {
|
||||
@@ -407,10 +419,6 @@ export async function finalizePendingUpdate(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Reap any leftover .old.exe from a previous swap. Windows lets us delete
|
||||
// it once the file handle from the prior run is released.
|
||||
try { await unlink(oldPath) } catch { /* either absent or still locked */ }
|
||||
|
||||
// Move running binary → .old.exe. This is allowed on Windows even for
|
||||
// the actively-executing image (the OS keeps the handle; the file just
|
||||
// gets a new name).
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Regenerated from package.json by gen:version script. Do not edit by hand.
|
||||
export const VERSION = "0.4.0-beta.4" as const
|
||||
export const VERSION = "0.4.0-beta.5" as const
|
||||
|
||||
@@ -118,8 +118,32 @@ test('tray update helper preserves the current install directory and cleans its
|
||||
|
||||
test('POSIX installer only advertises artifacts produced by the release workflow', async () => {
|
||||
const script = await readFile(new URL('../scripts/install.sh', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(script, /hermes-relay-linux-arm64/)
|
||||
const packageJson = await readFile(new URL('../package.json', import.meta.url), 'utf8')
|
||||
const workflow = await readFile(new URL('../../.github/workflows/release-cli.yml', import.meta.url), 'utf8')
|
||||
assert.match(script, /linux-aarch64\|linux-arm64\) asset="hermes-relay-linux-arm64"/)
|
||||
assert.match(script, /hermes-relay-linux-x64/)
|
||||
assert.match(packageJson, /--target=bun-linux-arm64[^\n]+hermes-relay-linux-arm64/)
|
||||
assert.match(workflow, /npm run build:bin:linux-arm/)
|
||||
assert.match(workflow, /release-assets\/cli-binaries\/hermes-relay-linux-arm64/)
|
||||
assert.doesNotMatch(workflow, /hermes-relay-linux-x64 "\$cmd" 2>&1 \|\| true/)
|
||||
assert.match(workflow, /if \[ "\$exit_code" -ne 0 \]/)
|
||||
assert.match(workflow, /Smoke exact macOS CLI release asset/)
|
||||
assert.match(workflow, /release-assets\/\$native_asset" --version/)
|
||||
})
|
||||
|
||||
test('bootstrap installers paginate release discovery beyond the first API page', async () => {
|
||||
const posix = await readFile(new URL('../scripts/install.sh', import.meta.url), 'utf8')
|
||||
const powershell = await readFile(new URL('../scripts/install.ps1', import.meta.url), 'utf8')
|
||||
assert.match(posix, /releases\?per_page=100&page=\$page/)
|
||||
assert.match(posix, /page=\$\(\(page \+ 1\)\)/)
|
||||
assert.match(powershell, /releases\?per_page=100&page=\$page/)
|
||||
assert.match(powershell, /while \(\$releasePage\.Count -eq 100\)/)
|
||||
})
|
||||
|
||||
test('PowerShell tray bootstrap keeps unsigned publisher warnings visible', async () => {
|
||||
const script = await readFile(new URL('../scripts/install.ps1', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(script, /Unblock-File \$installer/)
|
||||
assert.match(script, /Windows may show a SmartScreen publisher warning/)
|
||||
})
|
||||
|
||||
test('PowerShell uninstaller delegates bundle cleanup to the NSIS uninstaller', async () => {
|
||||
|
||||