Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f61f2aeb | ||
|
|
d554c1819a | ||
|
|
0ce7c6c6b3 | ||
|
|
46e8f90c39 | ||
|
|
2d28f171e0 | ||
|
|
fdd8301796 | ||
|
|
b0d662b802 | ||
|
|
63ca0a1428 | ||
|
|
8196856d76 | ||
|
|
605ff00cc0 | ||
|
|
c9b1e1b04e | ||
|
|
a4466e4ca0 | ||
|
|
e13e381e3a | ||
|
|
19d33910d0 | ||
|
|
41480a3254 | ||
|
|
50f151edba | ||
|
|
0f108fe971 | ||
|
|
c8d6119e1a | ||
|
|
b2b7a2572b | ||
|
|
1ccf87401a | ||
|
|
50b1a17895 | ||
|
|
d536923c55 | ||
|
|
14a2e01ac5 | ||
|
|
a0d03f6947 | ||
|
|
478eeac3f7 | ||
|
|
799159457a | ||
|
|
f90650bdc9 | ||
|
|
6a41ec154c | ||
|
|
53f4c8187b | ||
|
|
675252090c | ||
|
|
e94db467e8 | ||
|
|
65dae79c8c | ||
|
|
e5d0334c8b | ||
|
|
9cc7b25fd1 | ||
|
|
2ce352a320 | ||
|
|
a6957268b9 | ||
|
|
8f42b96be1 | ||
|
|
8a9c058ddb | ||
|
|
5ef2c40f81 | ||
|
|
6b32fe7ddd | ||
|
|
02f38322fa | ||
|
|
f40b7abaf0 | ||
|
|
5fcbe5ff63 | ||
|
|
6ce504b1c9 | ||
|
|
31ebef825f | ||
|
|
68abbf156d | ||
|
|
73a8af7c8f | ||
|
|
2db00d4c59 | ||
|
|
847444d115 | ||
|
|
798e8eef55 | ||
|
|
35f791be50 | ||
|
|
e727979897 | ||
|
|
91025b733f | ||
|
|
34da86a96a | ||
|
|
30f9f51e2a | ||
|
|
713529f79f | ||
|
|
16d1728306 | ||
|
|
7a813995ba | ||
|
|
c86b2224ce | ||
|
|
11a782bc44 | ||
|
|
884a17ded7 | ||
|
|
745904d468 | ||
|
|
4258322acc | ||
|
|
2c544b8ab0 | ||
|
|
5122ee69f6 | ||
|
|
512199448e | ||
|
|
7a7b10f08a | ||
|
|
f7845291e7 | ||
|
|
689219405b | ||
|
|
f4e9de425a | ||
|
|
d4cdb96bab | ||
|
|
c5f35145b4 | ||
|
|
3283c9b601 | ||
|
|
44f030f93b | ||
|
|
abce00f49e | ||
|
|
123f1d1263 | ||
|
|
61c4337807 | ||
|
|
6e1338004c | ||
|
|
348152a7cb | ||
|
|
3ec34502ec | ||
|
|
d30de8656d | ||
|
|
9b9b8c7c06 | ||
|
|
93b82aa538 | ||
|
|
7f2049fa0a | ||
|
|
92444a7039 | ||
|
|
a02d7e10df | ||
|
|
8a3935ffa1 | ||
|
|
e48935929a | ||
|
|
1e82347e7d | ||
|
|
75ed3c4226 | ||
|
|
bade61ac34 | ||
|
|
f88559f856 |
@@ -0,0 +1,79 @@
|
||||
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
|
||||
#
|
||||
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
|
||||
# intentionally small: check out a clean upstream hermes-agent beside Relay and
|
||||
# run the desktop typed-stream/renderer tests that protect the gateway event
|
||||
# contract. A later expansion can boot the upstream gateway with a mock provider
|
||||
# once that harness is stable enough for CI.
|
||||
|
||||
name: CI - Desktop Upstream Baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check"
|
||||
required: false
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "30 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-upstream-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop-baseline:
|
||||
name: Desktop typed gateway baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}"
|
||||
else
|
||||
REF="main"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout vanilla upstream
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Assert upstream checkout is vanilla
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
|
||||
fi
|
||||
git -C _upstream status --short --untracked-files=no
|
||||
|
||||
- name: Run desktop gateway baseline contract
|
||||
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Run desktop gateway baseline tests
|
||||
working-directory: desktop
|
||||
env:
|
||||
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
|
||||
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
|
||||
@@ -6,11 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
|
||||
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
|
||||
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
|
||||
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-24 — Realtime background-task delivery continuity
|
||||
|
||||
Chat stream completion now preserves an otherwise empty assistant row when it
|
||||
owns a promoted background task. This keeps the task identity available after
|
||||
the provider's initial spoken handoff, so later progress, completion, and
|
||||
forced-summary events update and settle the initiating row even when a newer
|
||||
persistent Voice command has started. Existing empty-response cleanup remains
|
||||
unchanged for assistant rows without background work.
|
||||
|
||||
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
|
||||
|
||||
The shared Android network security configuration now accepts CA certificates
|
||||
that the device owner deliberately installed in Android's user credential store,
|
||||
in addition to system roots. Because the policy is attached to the common
|
||||
application manifest, it covers both product flavors and every platform-backed
|
||||
Hermes transport: endpoint probes, Dashboard requests and authentication
|
||||
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
|
||||
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
|
||||
checks remain active, and Relay's independent certificate pinner is not
|
||||
overridden.
|
||||
|
||||
An app-wide policy is required for arbitrary operator-supplied server names and
|
||||
for consistent WebView behavior. A runtime opt-in would require parallel custom
|
||||
trust implementations for each client and could not safely reconfigure WebView;
|
||||
per-connection CA import would add private trust-material lifecycle without
|
||||
covering all transports. The tradeoff is that Android disables public
|
||||
Certificate Transparency verification when user trust anchors are enabled.
|
||||
User documentation records the deliberate-installation boundary and a device or
|
||||
emulator validation procedure with positive chain and negative hostname checks.
|
||||
JVM coverage locks the accepted anchor sources and rejects pin overrides or
|
||||
debug-only trust additions.
|
||||
|
||||
## 2026-07-23 — Bounded Android code-highlighting ranges
|
||||
|
||||
Android Markdown code rendering now validates every syntax-highlighting span
|
||||
before applying it to Compose text. The bundled highlighting dependency can
|
||||
emit a reversed multiline-comment range when malformed or incomplete code
|
||||
contains a closing delimiter before its opening delimiter; the Markdown
|
||||
renderer previously passed that range directly to `AnnotatedString` and
|
||||
crashed. Both fenced and indented code use the guarded renderer, valid spans
|
||||
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
|
||||
coverage reproduces the dependency output and verifies the safe conversion.
|
||||
|
||||
## 2026-07-20 — Image generation placeholder during turns
|
||||
|
||||
Android chat now specializes the generic tool lifecycle for active
|
||||
`image_generate` calls. While the tool is pending, the message shows a
|
||||
theme-aware procedural diffusion canvas with a polite live-region announcement
|
||||
instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** July 15, 2026
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
|
||||
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
|
||||
|
||||
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
|
||||
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
|
||||
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
|
||||
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
|
||||
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
+15
-10
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.4.9
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
|
||||
**Release Date:** July 19, 2026
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.4.9-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,19 +12,24 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes the Hermes dashboard the standard connection path and refreshes setup, connection management, and profile identity throughout Android.
|
||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
||||
|
||||
## Changed
|
||||
## Added
|
||||
|
||||
- Connect through the Hermes dashboard for Chat, sessions, Manage, and voice with one sign-in. The API server remains an automatic fallback or headless compatibility path, and Relay remains optional for power features.
|
||||
- Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, and Relay paths with clearer status, route, startup, Advanced, and Security controls.
|
||||
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
|
||||
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
|
||||
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Server default now shows Hermes' pinned active profile consistently across Chat, sessions, agent details, settings, voice, diagnostics, and profile inspection.
|
||||
- Successful local discovery adds useful hostname identity without replacing a custom connection label.
|
||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
||||
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
|
||||
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
|
||||
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
|
||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.4.9** (versionCode **32**).
|
||||
- App version: **1.5.0** (versionCode **33**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -36,6 +36,55 @@ removal.
|
||||
|
||||
---
|
||||
|
||||
## Upstream impact certification follow-ups (2026-07-19)
|
||||
|
||||
The client/plugin implementation batch for queued recovery,
|
||||
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
|
||||
trust, and retained bootstrap async safety is implemented. The following gates
|
||||
intentionally remain outside that code batch:
|
||||
|
||||
- **Image-generation lifecycle while tool progress is hidden.** The upstream
|
||||
TUI gateway suppresses every `tool.start` / `tool.complete` event when
|
||||
`display.tool_progress` is off, so a client cannot distinguish an active
|
||||
`image_generate` turn from generic model work. Propose a narrow upstream
|
||||
exception that always emits the lifecycle for `image_generate` while leaving
|
||||
unrelated tool diagnostics hidden. Android already treats that lifecycle as
|
||||
presentation state rather than a generic tool card and keeps the diffusion
|
||||
canvas visible when its local tool display is off.
|
||||
- Run `docs/upstream-compatibility-certification.md` against an approved test
|
||||
gateway with real provider calls and an Android device. Include concurrent
|
||||
model/image routing, turn isolation off/on, queued reconnect, same-profile
|
||||
background-completion ownership, compression lineage, and the explicitly
|
||||
approved restart case. Static upstream fixtures are necessary but do not
|
||||
prove device or restart behavior.
|
||||
- Upstream the atomic one-turn model arm/submit contract proposed in
|
||||
`docs/upstream-contributions.md`. Until then, document the narrow race where a
|
||||
disconnect or Stop after `/model --once` succeeds but before prompt submission
|
||||
can leave the override armed for a later prompt.
|
||||
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
|
||||
exposes a reset on the active gateway session or an authoritative reset event.
|
||||
`slash.exec` runs the command in a separate worker today, and the mirrored
|
||||
slash side effects do not reset the active TUI session's agent. Relay must not
|
||||
clear local model, reasoning, or Fast pins from a successful command response
|
||||
that did not mutate the agent those controls describe.
|
||||
- Keep profile-scoped cron execution attempts blocked on the public upstream API
|
||||
proposed in `docs/upstream-contributions.md`. The first-class interim
|
||||
assistant event is no longer blocked: Relay Android and desktop consume
|
||||
upstream `message.interim` / `response_previewed`.
|
||||
- Keep Standard voice labeled host-global until upstream exposes a stable
|
||||
profile/per-request audio contract; do not emulate it through Relay on the
|
||||
vanilla path.
|
||||
- Keep provider exclusion/disable filtering out of Android Manage until the
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
checkout and the desktop typed gateway renderer/tests.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||
|
||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||
@@ -1070,7 +1119,7 @@ Things to look into:
|
||||
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
|
||||
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
@@ -326,8 +326,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
|
||||
|
||||
/** Debug-build-only deterministic host for design QA screenshots. */
|
||||
class VoiceSettingsDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSettingsDesignQaScene() {
|
||||
val provider = remember {
|
||||
RealtimeProviderInfo(
|
||||
id = "xai_tts",
|
||||
name = "xAI Grok TTS",
|
||||
status = "ready",
|
||||
models = listOf("grok-tts", "grok-tts-fast"),
|
||||
voices = listOf("eve", "ara", "sal", "rex", "leo"),
|
||||
model_labels = mapOf("grok-tts" to "Grok TTS"),
|
||||
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
|
||||
recommended_voices = listOf("eve", "ara"),
|
||||
supports_tts = true,
|
||||
)
|
||||
}
|
||||
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
|
||||
var selectedVoice by remember { mutableStateOf("eve") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val allVoices = remember {
|
||||
listOf(
|
||||
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
|
||||
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
|
||||
VoiceChoice("sal", "Sal", "Calm · grounded"),
|
||||
VoiceChoice("rex", "Rex", "Direct · confident"),
|
||||
VoiceChoice("leo", "Leo", "Bright · conversational"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
VoiceSettingsTabs(selectedSection) { selectedSection = it }
|
||||
VoiceProviderGroupCard(
|
||||
provider = provider,
|
||||
providerValue = provider.id,
|
||||
enabled = true,
|
||||
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
|
||||
onEnabledChange = {},
|
||||
onProviderChange = {},
|
||||
controlsEnabled = true,
|
||||
)
|
||||
ModelAndVoiceGroupCard(
|
||||
modelValue = "grok-tts",
|
||||
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
|
||||
voices = previewVoiceChoices(allVoices, selectedVoice),
|
||||
allVoices = allVoices,
|
||||
selectedVoice = selectedVoice,
|
||||
previewState = VoicePreviewUiState(
|
||||
selectionKey = "voice:eve",
|
||||
isPlaying = true,
|
||||
amplitude = 0.42f,
|
||||
),
|
||||
onModelChange = {},
|
||||
onVoiceChange = { selectedVoice = it },
|
||||
onPreviewVoice = {},
|
||||
enabled = true,
|
||||
)
|
||||
LanguageQualityCard(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
language = "English",
|
||||
languages = listOf(VoiceChoice("en", "English")),
|
||||
onLanguageChange = {},
|
||||
sampleRate = "24000",
|
||||
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
|
||||
onSampleRateChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice. Setup and connection details now explain nearby, remote, Tailscale, custom-port, optional Relay, route, and security choices. Server default also displays Hermes' pinned active profile consistently across the app.
|
||||
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
|
||||
@@ -1 +1 @@
|
||||
现在可通过 Hermes 控制面板一次登录使用聊天、会话、管理和语音。设置与连接详情会清楚说明附近设备、远程访问、Tailscale、自定义端口、可选 Relay、路由和安全选项。“服务器默认”也会在整个应用中一致显示 Hermes 当前固定的活跃配置文件。
|
||||
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Voice that keeps pace",
|
||||
"date": "2026-07-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A clearer voice studio",
|
||||
"bullets": [
|
||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Natural streaming speech",
|
||||
"bullets": [
|
||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More upstream-aware controls",
|
||||
"bullets": [
|
||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.9",
|
||||
"title": "Clearer Hermes connections",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
v1.4.9 - Clearer Hermes connections
|
||||
v1.5.0 - Voice that keeps pace
|
||||
|
||||
* Connect through the Hermes dashboard with one sign-in; API fallback and optional Relay remain available.
|
||||
* Onboarding and connection details now explain nearby, remote, Tailscale, custom-port, route, and security choices.
|
||||
* Server default consistently displays Hermes' pinned active profile, and discovered servers show useful host identity.
|
||||
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
|
||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
|
||||
@@ -13,6 +13,8 @@ data class DashboardConnectionStatus(
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val gatewayMode: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -112,6 +114,8 @@ data class Connection(
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
const val DEFAULT_API_PORT: Int = 8642
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
@@ -187,6 +191,29 @@ data class Connection(
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
|
||||
fun deriveDefaultApiUrl(
|
||||
dashboardUrl: String,
|
||||
apiPort: Int = DEFAULT_API_PORT,
|
||||
): String? {
|
||||
val trimmed = dashboardUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$apiPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
@@ -196,7 +223,7 @@ data class Connection(
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
relayPort: Int = DEFAULT_RELAY_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
@@ -135,6 +135,91 @@ class RelayHttpClient(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivitySnapshot(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val profile: String,
|
||||
val activities: List<ImageActivity> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivity(
|
||||
@SerialName("call_id") val callId: String,
|
||||
@SerialName("tool_name") val toolName: String,
|
||||
val state: String,
|
||||
@SerialName("started_at") val startedAt: Double,
|
||||
@SerialName("completed_at") val completedAt: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Poll the optional Relay image lifecycle bridge. A null success means the
|
||||
* connected Relay predates the endpoint; callers should stop polling and
|
||||
* continue using native Gateway events without surfacing an error.
|
||||
*/
|
||||
suspend fun fetchImageActivity(
|
||||
profile: String,
|
||||
sessionId: String,
|
||||
sinceEpochSeconds: Double,
|
||||
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = try {
|
||||
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("profile", profile)
|
||||
.addQueryParameter("session_id", sessionId)
|
||||
.addQueryParameter("since", sinceEpochSeconds.toString())
|
||||
.build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val activityClient = okHttpClient.newBuilder()
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
try {
|
||||
activityClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Image activity request failed (HTTP ${response.code})")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Empty response body"))
|
||||
}
|
||||
Result.success(
|
||||
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
|
||||
* [Result] — success carries a [FetchedMedia], failure wraps the
|
||||
@@ -615,6 +700,14 @@ class RelayHttpClient(
|
||||
val capabilities: List<String> = emptyList(),
|
||||
val profiles: List<RelayProfileInfo> = emptyList(),
|
||||
val health: String = "unknown",
|
||||
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayHeartbeat(
|
||||
val status: String = "missing",
|
||||
val supported: Boolean = false,
|
||||
@SerialName("age_seconds") val ageSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||
|
||||
@@ -834,6 +834,11 @@ class RelayVoiceClient(
|
||||
suspend fun runVoiceOutput(
|
||||
text: String,
|
||||
renderMode: String? = "verbatim",
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
onEvent: (RealtimeVoiceEvent) -> Unit,
|
||||
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
|
||||
@@ -844,7 +849,15 @@ class RelayVoiceClient(
|
||||
return@withContext Result.failure(missingAuthError())
|
||||
}
|
||||
|
||||
val sessionResult = createVoiceOutputSession(httpBase, token)
|
||||
val sessionResult = createVoiceOutputSession(
|
||||
httpBase = httpBase,
|
||||
token = token,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
|
||||
}
|
||||
@@ -1261,8 +1274,10 @@ class RelayVoiceClient(
|
||||
inputSampleRate: Int = 16_000,
|
||||
chatSessionId: String? = null,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1290,8 +1305,10 @@ class RelayVoiceClient(
|
||||
token = token,
|
||||
chatSessionId = chatSessionId,
|
||||
conversationContext = conversationContext,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -2658,17 +2675,25 @@ class RelayVoiceClient(
|
||||
token: String,
|
||||
chatSessionId: String?,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
): Result<RealtimeSessionResponse> {
|
||||
val body = buildJsonObject {
|
||||
putProfile()
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("provider", JsonPrimitive(it))
|
||||
}
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("model", JsonPrimitive(it))
|
||||
}
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("voice", JsonPrimitive(it))
|
||||
}
|
||||
sampleRate?.takeIf { it > 0 }?.let {
|
||||
put("sample_rate", JsonPrimitive(it))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2726,8 +2751,23 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildJsonObject { putProfile() }.toString()
|
||||
private fun createVoiceOutputSession(
|
||||
httpBase: String,
|
||||
token: String,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildVoiceOutputSessionPayload(
|
||||
profile = currentProfileName(),
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("$httpBase/voice/output/session")
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
@@ -2970,6 +3010,22 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildVoiceOutputSessionPayload(
|
||||
profile: String?,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): String = buildJsonObject {
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
|
||||
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
|
||||
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Wire shape of `GET /voice/config`. Providers are returned as nested
|
||||
* objects describing the currently-active STT and TTS backend. Extra
|
||||
|
||||
@@ -59,8 +59,8 @@ data class RouteProbeOutcome(
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** Dashboard-first routes use `GET
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `HEAD
|
||||
* ${api.url}/health`. Relay-only routes use `HEAD ${relay.httpUrl}/health`.
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `GET
|
||||
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
|
||||
* Each request has a 4-second
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
@@ -110,7 +110,6 @@ class EndpointResolver(
|
||||
val baseUrl: String,
|
||||
val requestUrl: String,
|
||||
val path: String,
|
||||
val useHead: Boolean,
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
@@ -326,7 +325,11 @@ class EndpointResolver(
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "*/*")
|
||||
val request = if (target.useHead) requestBuilder.head().build() else requestBuilder.get().build()
|
||||
// Hermes API's aiohttp health route accepts GET but returns 405 to
|
||||
// HEAD. That response proves connectivity while the old probe marked
|
||||
// the route unreachable. Health payloads are tiny, so follow the
|
||||
// endpoint's actual public contract on every surface.
|
||||
val request = requestBuilder.get().build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
@@ -407,7 +410,6 @@ class EndpointResolver(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/api/status",
|
||||
path = "/api/status",
|
||||
useHead = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -416,7 +418,6 @@ class EndpointResolver(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/health",
|
||||
path = "/health",
|
||||
useHead = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -436,7 +437,6 @@ class EndpointResolver(
|
||||
baseUrl = relayUrl,
|
||||
requestUrl = "$httpBase/health",
|
||||
path = "/health",
|
||||
useHead = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import java.net.URI
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical API base for a selected Hermes profile.
|
||||
*
|
||||
* A dedicated profile API URL remains authoritative when advertised. When
|
||||
* the dashboard has positively identified a shared multiplex gateway and
|
||||
* the selected non-default profile is in its served-profile list, route
|
||||
* through the upstream `/p/<profile>` mirror on the connection's root API
|
||||
* origin. Older/single-profile servers and incomplete topology snapshots
|
||||
* deliberately keep the root URL.
|
||||
*/
|
||||
fun resolveChatBase(
|
||||
profileApiUrl: String?,
|
||||
baseApiUrl: String?,
|
||||
selectedProfileName: String?,
|
||||
gatewayMode: String?,
|
||||
servedProfiles: Collection<String>,
|
||||
): String? {
|
||||
val base = normalize(baseApiUrl)
|
||||
val dedicated = resolveForConnection(profileApiUrl, base)
|
||||
if (dedicated != null) return dedicated
|
||||
|
||||
val profile = selectedProfileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
|
||||
?: return base
|
||||
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
|
||||
servedProfiles.any { it.equals(profile, ignoreCase = false) }
|
||||
if (!isKnownMultiplexProfile) return base
|
||||
|
||||
val root = base?.toHttpUrlOrNull() ?: return base
|
||||
return root.newBuilder()
|
||||
.addPathSegment("p")
|
||||
.addPathSegment(profile)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
||||
|
||||
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
|
||||
enum class VoiceSpeechStreamStatus {
|
||||
Completed,
|
||||
Fallback,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
data class VoiceSpeechStreamOutcome(
|
||||
val status: VoiceSpeechStreamStatus,
|
||||
val audioStarted: Boolean,
|
||||
val error: Throwable? = null,
|
||||
)
|
||||
|
||||
data class VoiceSpeechStreamCallbacks(
|
||||
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
|
||||
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
|
||||
)
|
||||
|
||||
interface VoiceSpeechStream {
|
||||
fun append(text: String)
|
||||
fun finish()
|
||||
fun stop()
|
||||
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral STT/TTS contract. The routing seam between the Standard
|
||||
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
|
||||
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
/**
|
||||
* Open one provider-backed PCM stream for an assistant reply. A null
|
||||
* success means this route has no streaming surface and the caller should
|
||||
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
|
||||
* calls made before the socket opens and report whether any PCM was emitted
|
||||
* so callers never replay already-heard audio during compatibility fallback.
|
||||
*/
|
||||
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
|
||||
Result.success(null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
|
||||
client.openSpeechStream(callbacks)
|
||||
}
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
|
||||
@@ -1208,10 +1208,14 @@ class ChatHandler {
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
// Upstream injects role:system STEERING markers into the
|
||||
// session history on model/personality change — e.g.
|
||||
// "[System: The active model for this chat has changed to …]"
|
||||
@@ -1227,9 +1231,11 @@ class ChatHandler {
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val displayContent = displayEventContent(displayKind, item.displayMetadata)
|
||||
val rawServerContent = displayContent ?: item.contentText ?: ""
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.timestamp ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
@@ -1241,8 +1247,8 @@ class ChatHandler {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = item.contentText ?: ""
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
@@ -1496,19 +1502,53 @@ class ChatHandler {
|
||||
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
|
||||
* actually render.
|
||||
*/
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
|
||||
when (displayKind) {
|
||||
"model_switch" -> {
|
||||
val model = metadata.stringField("model")
|
||||
?: metadata.stringField("to_model")
|
||||
?: metadata.stringField("target_model")
|
||||
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
|
||||
}
|
||||
"async_delegation_complete" -> {
|
||||
val count = metadata.intField("task_count")
|
||||
?: metadata.intField("tasks")
|
||||
?: metadata.intField("count")
|
||||
when (count) {
|
||||
null -> "Background work completed"
|
||||
1 -> "1 background task completed"
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.intField(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
|
||||
primitive.contentOrNull?.toIntOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
|
||||
@@ -1682,7 +1722,10 @@ class ChatHandler {
|
||||
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
|
||||
// local title whenever the server hasn't supplied a non-blank one.
|
||||
val existingById = _sessions.value.associateBy { it.sessionId }
|
||||
val mapped = items.map { item ->
|
||||
// The drawer is always composed, even while closed, and keys rows by
|
||||
// session id. A refresh race or duplicated upstream row must not put
|
||||
// the same key into Compose's LazyColumn.
|
||||
val mapped = items.distinctBy { it.id }.map { item ->
|
||||
val startedAtMs = timestampToMillis(item.startedAt)
|
||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||
@@ -1760,7 +1803,15 @@ class ChatHandler {
|
||||
* Add a newly created session to the list.
|
||||
*/
|
||||
fun addSession(session: ChatSession) {
|
||||
_sessions.update { listOf(session) + it }
|
||||
// Gateway onSessionId and an overlapping REST refresh can both publish
|
||||
// the same freshly-created session. Treat this as an idempotent upsert,
|
||||
// preferring an existing server-enriched row while collapsing any
|
||||
// duplicates that were already present.
|
||||
_sessions.update { current ->
|
||||
val existing = current.firstOrNull { it.sessionId == session.sessionId }
|
||||
listOf(existing ?: session) +
|
||||
current.filterNot { it.sessionId == session.sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE streaming event entry points ---
|
||||
@@ -2867,13 +2918,22 @@ class ChatHandler {
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
msg.thinkingContent.isBlank() &&
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-stream reconciliation: re-scan final content for any annotation
|
||||
@@ -3073,3 +3133,15 @@ internal fun formatPhoneActionResult(
|
||||
append("Status ${result.status}.")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isIntentionalSilenceMarker(content: String): Boolean =
|
||||
when (content.trim().trim('"', '\'', '`').uppercase()) {
|
||||
"NO_REPLY",
|
||||
"[NO_REPLY]",
|
||||
"SILENT",
|
||||
"[SILENT]",
|
||||
"<SILENT>",
|
||||
"(SILENT)",
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
+333
-7
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -51,6 +52,37 @@ data class DashboardStatus(
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
@SerialName("gateway_mode") val gatewayMode: String? = null,
|
||||
val gateways: List<DashboardGatewayTopology> = emptyList(),
|
||||
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardGatewayTopology(
|
||||
val profile: String,
|
||||
val ports: Map<String, Int> = emptyMap(),
|
||||
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealthRollup(
|
||||
val supported: Boolean = false,
|
||||
val overall: String? = null,
|
||||
val components: List<DashboardComponentHealth> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealth(
|
||||
val name: String,
|
||||
val status: String,
|
||||
val message: String? = null,
|
||||
val configured: Int? = null,
|
||||
val connected: Int? = null,
|
||||
val healthy: Boolean? = null,
|
||||
val ok: Boolean? = null,
|
||||
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -92,6 +124,53 @@ data class DashboardChatDisplaySettings(
|
||||
val toolDisplay: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardMcpOAuthFlow(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val status: String,
|
||||
val authorizationUrl: String? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isTerminal: Boolean get() = status == "approved" || status == "error"
|
||||
}
|
||||
|
||||
data class DashboardCustomEndpoint(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val hasApiKey: Boolean = false,
|
||||
val apiKeyPreview: String? = null,
|
||||
val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpoints(
|
||||
val endpoints: List<DashboardCustomEndpoint>,
|
||||
val currentProvider: String? = null,
|
||||
val currentModel: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointDraft(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val makeDefault: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointValidation(
|
||||
val ok: Boolean,
|
||||
val reachable: Boolean,
|
||||
val message: String,
|
||||
val models: List<String>,
|
||||
)
|
||||
|
||||
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
|
||||
data class ElevenLabsVoice(
|
||||
val voiceId: String,
|
||||
@@ -255,6 +334,14 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
|
||||
|
||||
/**
|
||||
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
|
||||
* This adds plugin provider names and readiness metadata. Command-provider
|
||||
* IDs remain sourced from the dynamic config-schema enum.
|
||||
*/
|
||||
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
|
||||
getJsonObject("/api/tools/toolsets/tts/config")
|
||||
|
||||
/**
|
||||
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
|
||||
* writes the WHOLE document, so [config] MUST be the full values tree
|
||||
@@ -466,25 +553,100 @@ class DashboardApiClient(
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
suspend fun setMcpServerEnabled(
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun startMcpOAuth(
|
||||
name: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
|
||||
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
/**
|
||||
* Read-only hosted-OAuth capability probe. New dashboards recognize the
|
||||
* flow-status route and return its canonical expired-flow 404; older
|
||||
* FastAPI routers return the generic route-level 404. No OAuth worker is
|
||||
* started and no provider/browser interaction occurs.
|
||||
*/
|
||||
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
|
||||
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
|
||||
return result.fold(
|
||||
onSuccess = { Result.success(true) },
|
||||
onFailure = { error ->
|
||||
val message = error.message.orEmpty()
|
||||
when {
|
||||
message.contains("OAuth flow not found or expired") -> Result.success(true)
|
||||
message.contains("HTTP 404") -> Result.success(false)
|
||||
else -> Result.failure(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
|
||||
getJsonObject("/api/providers/custom-endpoints")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun saveCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun validateCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpointValidation> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints/validate",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching { root ->
|
||||
DashboardCustomEndpointValidation(
|
||||
ok = root.booleanField("ok") == true,
|
||||
reachable = root.booleanField("reachable") == true,
|
||||
message = root.stringField("message").orEmpty(),
|
||||
models = root.stringList("models"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun activateCustomEndpoint(
|
||||
id: String,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
|
||||
|
||||
suspend fun deleteCustomEndpoint(
|
||||
id: String,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
path = "/api/mcp/catalog/install${profileQuery(profile)}",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
@@ -929,6 +1091,57 @@ class DashboardApiClient(
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
|
||||
val flowId = root.stringField("flow_id")
|
||||
?: throw IOException("MCP OAuth response did not include a flow id")
|
||||
val status = root.stringField("status")
|
||||
?: throw IOException("MCP OAuth response did not include a status")
|
||||
return DashboardMcpOAuthFlow(
|
||||
flowId = flowId,
|
||||
serverName = root.stringField("server_name").orEmpty(),
|
||||
status = status,
|
||||
authorizationUrl = root.stringField("authorization_url"),
|
||||
error = root.stringField("error"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
|
||||
val current = root["current"] as? JsonObject
|
||||
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("id") ?: return@mapNotNull null
|
||||
DashboardCustomEndpoint(
|
||||
id = id,
|
||||
name = obj.stringField("name") ?: id,
|
||||
baseUrl = obj.stringField("base_url").orEmpty(),
|
||||
model = obj.stringField("model").orEmpty(),
|
||||
models = obj.stringList("models"),
|
||||
contextLength = obj.intField("context_length"),
|
||||
discoverModels = obj.booleanField("discover_models") != false,
|
||||
hasApiKey = obj.booleanField("has_api_key") == true,
|
||||
apiKeyPreview = obj.stringField("api_key_preview"),
|
||||
isCurrent = obj.booleanField("is_current") == true,
|
||||
)
|
||||
}
|
||||
return DashboardCustomEndpoints(
|
||||
endpoints = endpoints,
|
||||
currentProvider = current?.stringField("provider"),
|
||||
currentModel = current?.stringField("model"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
|
||||
buildJsonObject {
|
||||
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
@@ -947,6 +1160,20 @@ class DashboardApiClient(
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
|
||||
}
|
||||
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val profile = obj.stringField("profile") ?: return@mapNotNull null
|
||||
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
|
||||
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
|
||||
}.toMap()
|
||||
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
|
||||
}
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
@@ -955,6 +1182,45 @@ class DashboardApiClient(
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
profiles = profiles,
|
||||
gatewayMode = root.stringField("gateway_mode"),
|
||||
gateways = gateways,
|
||||
componentHealth = parseComponentHealth(root),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
|
||||
val rawComponents = root["components"] as? JsonObject
|
||||
?: return DashboardComponentHealthRollup()
|
||||
val components = rawComponents.mapNotNull { (name, element) ->
|
||||
val component = element as? JsonObject ?: return@mapNotNull null
|
||||
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
DashboardComponentHealth(
|
||||
name = safeName,
|
||||
status = component.stringField("status")
|
||||
?: component.stringField("state")
|
||||
?: component.stringField("health")
|
||||
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
|
||||
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
|
||||
?: "unknown",
|
||||
message = component.stringField("message")
|
||||
?: component.stringField("summary")
|
||||
?: component.stringField("error")
|
||||
?: component.stringField("reason"),
|
||||
configured = component.intField("configured"),
|
||||
connected = component.intField("connected"),
|
||||
healthy = component.booleanField("healthy"),
|
||||
ok = component.booleanField("ok"),
|
||||
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
|
||||
)
|
||||
}.sortedBy { it.name }
|
||||
return DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = root.stringField("overall")
|
||||
?: root.stringField("status")
|
||||
?: root.stringField("health"),
|
||||
components = components,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1203,6 +1469,61 @@ class DashboardCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy only Hermes' authenticated dashboard session cookies to another host
|
||||
* that belongs to the same saved Connection. Dashboard cookies are host-only
|
||||
* by design, while a Connection may reach one server through LAN and
|
||||
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
|
||||
* explicit sign-out clears every mirrored host together.
|
||||
*
|
||||
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
|
||||
* copied. Secure cookies also remain Secure; this helper never downgrades them
|
||||
* for an HTTP route.
|
||||
*/
|
||||
fun mirrorDashboardSessionCookies(
|
||||
store: DashboardCookieStore,
|
||||
targetUrl: String,
|
||||
trustedHosts: Set<String>,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
|
||||
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
if (targetHost !in allowedHosts) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
val all = store.load()
|
||||
val live = all.filterNot { it.isExpired(now) }
|
||||
val existingTargetKeys = live.asSequence()
|
||||
.filter { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
|
||||
.toSet()
|
||||
val mirrored = live.asSequence()
|
||||
.filter { it.isDashboardSessionCookie() }
|
||||
.filter { it.domain.lowercase() in allowedHosts }
|
||||
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.groupBy { "${it.name.lowercase()}|${it.path}" }
|
||||
.values
|
||||
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
|
||||
.map { it.copy(domain = targetHost, hostOnly = true) }
|
||||
.filterNot { it.key in existingTargetKeys }
|
||||
.toList()
|
||||
|
||||
if (mirrored.isNotEmpty() || live.size != all.size) {
|
||||
store.save(live + mirrored)
|
||||
}
|
||||
return mirrored.size
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
|
||||
val bareName = name
|
||||
.removePrefix("__Host-")
|
||||
.removePrefix("__Secure-")
|
||||
return bareName == "hermes_session" ||
|
||||
bareName == "hermes_session_at" ||
|
||||
bareName == "hermes_session_rt" ||
|
||||
bareName == "hermes_session_provider"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
@@ -1353,3 +1674,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun JsonObject?.stringList(name: String): List<String> =
|
||||
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
|
||||
* `category` field so it is robust to upstream's category-merging.
|
||||
*/
|
||||
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
|
||||
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
|
||||
fields.filter {
|
||||
it.key.startsWith("tts.") ||
|
||||
it.key.startsWith("stt.") ||
|
||||
it.key.startsWith("voice.")
|
||||
}
|
||||
|
||||
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
|
||||
data class TtsToolsetProvider(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String? = null,
|
||||
val isActive: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
|
||||
*
|
||||
* Unlike the config-schema enum, this payload adds readiness metadata and
|
||||
* reliably discovered plugin providers. Command providers remain schema-owned.
|
||||
* Older upstream builds may omit `tts_provider`;
|
||||
* those rows are ignored because their picker label is not a stable config ID.
|
||||
*/
|
||||
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
|
||||
val providers = root["providers"] as? JsonArray ?: return emptyList()
|
||||
return providers.mapNotNull { element ->
|
||||
val provider = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = provider.configString("tts_provider")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return@mapNotNull null
|
||||
TtsToolsetProvider(
|
||||
id = id,
|
||||
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
|
||||
status = provider.configString("status"),
|
||||
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
|
||||
/** Read the value at a dot-path from the nested config values tree, or null. */
|
||||
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
||||
|
||||
+348
-35
@@ -1,6 +1,8 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -24,6 +27,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -82,6 +86,8 @@ class GatewayChatClient(
|
||||
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
|
||||
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
|
||||
) {
|
||||
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
|
||||
private val sessionSource = "webui"
|
||||
companion object {
|
||||
private const val TAG = "GatewayChatClient"
|
||||
|
||||
@@ -128,6 +134,7 @@ class GatewayChatClient(
|
||||
* not enough.
|
||||
*/
|
||||
private const val ATTACH_RPC_TIMEOUT_MS = 60_000L
|
||||
private const val COMPRESS_RPC_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** Upstream image byte-upload RPC (underscore — `image.attach_bytes`, content_base64). */
|
||||
private const val ATTACH_METHOD_UPSTREAM = "image.attach_bytes"
|
||||
@@ -289,6 +296,10 @@ class GatewayChatClient(
|
||||
private val _serverContext = MutableStateFlow<Pair<Int, Int>?>(null)
|
||||
val serverContext: StateFlow<Pair<Int, Int>?> = _serverContext.asStateFlow()
|
||||
|
||||
/** Optional upstream project identity for the active session. */
|
||||
private val _serverProject = MutableStateFlow<GatewaySessionProject?>(null)
|
||||
val serverProject: StateFlow<GatewaySessionProject?> = _serverProject.asStateFlow()
|
||||
|
||||
/** Serializes connect / session-establish so concurrent sends share one socket. */
|
||||
private val connectMutex = Mutex()
|
||||
|
||||
@@ -528,10 +539,10 @@ class GatewayChatClient(
|
||||
// into the SSE fallback, which would resubmit the same
|
||||
// prompt as a duplicate turn. Recovery belongs to the
|
||||
// stream: the watchdog and mid-turn rejoin own it.
|
||||
if (turn.started || turn.ended) {
|
||||
if (turn.started || turn.ended || turn.transportRecoveryStarted) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"prompt.submit ack failed after turn start " +
|
||||
"prompt.submit ack failed after turn start/rejoin " +
|
||||
"(${submitted.exceptionOrNull()?.message}) — no SSE fallback",
|
||||
)
|
||||
return@launch
|
||||
@@ -720,14 +731,15 @@ class GatewayChatClient(
|
||||
* New Hermes gateways expose `session.activate`, which attaches the new
|
||||
* WebSocket transport to the exact live id saved in the client checkpoint.
|
||||
* If that id has already been reaped (or the method is unavailable), fall
|
||||
* back to `session.resume` by durable session id. Its `running` + `inflight`
|
||||
* fields decide whether a live mapper is installed or history should settle
|
||||
* the turn instead.
|
||||
* back to `session.resume` by durable session id. Its `running`, `inflight`,
|
||||
* and optional `queued` fields decide whether a live mapper is installed or
|
||||
* history should settle the turn instead.
|
||||
*/
|
||||
suspend fun recoverTurn(
|
||||
storedId: String,
|
||||
preferredLiveId: String?,
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
queuedTurnProvider: ((GatewayQueuedTurn) -> GatewayInboundTurnRegistration?)? = null,
|
||||
): Result<GatewaySessionRecovery> = runCatching {
|
||||
require(storedId.isNotBlank()) { "stored session id required" }
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -757,6 +769,7 @@ class GatewayChatClient(
|
||||
boundTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
deferEvents = true,
|
||||
).also { turn ->
|
||||
turn.markRecoveredStarted()
|
||||
activeTurn = turn
|
||||
@@ -770,6 +783,7 @@ class GatewayChatClient(
|
||||
response = activated.getOrNull()
|
||||
if (response == null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
boundTurn.detach()
|
||||
boundTurn = null
|
||||
Log.d(
|
||||
@@ -786,6 +800,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
@@ -807,7 +822,7 @@ class GatewayChatClient(
|
||||
storedSessionId = storedId
|
||||
liveSessionProfile = requestedProfile
|
||||
updateCancelledDrainLiveSession(storedId, recoveredLiveId)
|
||||
(response["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(response)
|
||||
|
||||
val inflight = (response["inflight"] as? JsonObject)?.let { value ->
|
||||
GatewayInflightTurn(
|
||||
@@ -816,6 +831,11 @@ class GatewayChatClient(
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
)
|
||||
}
|
||||
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||
value.stringField("user")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::GatewayQueuedTurn)
|
||||
}
|
||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
||||
|
||||
if (running) {
|
||||
@@ -828,10 +848,47 @@ class GatewayChatClient(
|
||||
activeTurn = turn
|
||||
}
|
||||
}
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
}
|
||||
}
|
||||
boundTurn.releaseDeferredEvents()
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||
// its events flow through the completed checkpoint's mapper.
|
||||
val priorBoundTurn = boundTurn
|
||||
boundTurn = null
|
||||
val registration = queuedTurnProvider?.invoke(queued)
|
||||
if (registration != null) {
|
||||
val queuedTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(registration.callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
)
|
||||
// recoverTurn is resumed on its caller's coroutine context;
|
||||
// ChatViewModel calls it from Main, so this admission runs
|
||||
// atomically with the checkpoint handoff. Posting back
|
||||
// through bindInboundTurn would deadlock waiting on the
|
||||
// same paused Main dispatcher during cold-start recovery.
|
||||
if (registration.onHandle(queuedTurn)) {
|
||||
priorBoundTurn?.redirectDeferredEventsTo(queuedTurn)
|
||||
boundTurn = queuedTurn
|
||||
activeTurn = queuedTurn
|
||||
queuedTurn.armWatchdog()
|
||||
priorBoundTurn?.detach()
|
||||
} else {
|
||||
priorBoundTurn?.discardDeferredEvents()
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
priorBoundTurn?.discardDeferredEvents()
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
if (boundTurn != null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
boundTurn.detach()
|
||||
}
|
||||
boundTurn = null
|
||||
@@ -843,36 +900,109 @@ class GatewayChatClient(
|
||||
running = running,
|
||||
status = response.stringField("status"),
|
||||
inflight = inflight,
|
||||
handle = boundTurn?.takeUnless { it.ended },
|
||||
queued = queued,
|
||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||
?.takeUnless { it.ended },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject [text] into the in-flight turn (`session.steer`). The server
|
||||
* only accepts a steer while a tool batch is running — [SteerResult.Rejected]
|
||||
* means "no batch in flight, queue it instead". [SteerResult.Failed]
|
||||
* covers transport/RPC failure (no live session, socket down, 4010 …) —
|
||||
* callers should fall back to the local queue for both non-queued cases.
|
||||
* Inject [text] into the in-flight turn. Current upstream exposes this as
|
||||
* `session.redirect`, which can redirect an active model turn while keeping
|
||||
* valid work/context. Older gateways only expose `session.steer`; fall back
|
||||
* to that legacy RPC only when the redirect method is absent/unsupported so
|
||||
* old installs still queue the correction instead of dropping it.
|
||||
*/
|
||||
suspend fun steer(text: String): SteerResult {
|
||||
val sid = liveSessionId ?: return SteerResult.Failed
|
||||
val result = rpc(
|
||||
"session.steer",
|
||||
buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("text", text)
|
||||
},
|
||||
)
|
||||
val params = buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("text", text)
|
||||
}
|
||||
val redirect = rpc("session.redirect", params)
|
||||
val result = if (redirect.isLegacyRedirectUnsupported()) {
|
||||
Log.i(TAG, "session.redirect unsupported — falling back to session.steer (session=$storedSessionId)")
|
||||
rpc("session.steer", params)
|
||||
} else {
|
||||
redirect
|
||||
}
|
||||
val outcome = when (result.getOrNull()?.stringField("status")) {
|
||||
"queued" -> SteerResult.Queued
|
||||
"redirected", "queued" -> SteerResult.Queued
|
||||
"rejected" -> SteerResult.Rejected
|
||||
else -> SteerResult.Failed
|
||||
}
|
||||
Log.i(TAG, "Steer → $outcome (session=$storedSessionId)")
|
||||
Log.i(TAG, "Active-turn correction → $outcome (session=$storedSessionId)")
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress the live gateway session through the dedicated upstream RPC.
|
||||
* `/compress` is intentionally not sent through generic slash execution on
|
||||
* modern gateways because `session.compress` returns authoritative transcript,
|
||||
* usage, title/model/session-info, and compute-host isolation metadata.
|
||||
*/
|
||||
suspend fun compressSession(focusTopic: String? = null): Result<GatewayCompressResult> {
|
||||
val sid = liveSessionId
|
||||
?: return Result.failure(GatewayRpcException("no live session"))
|
||||
val params = buildJsonObject {
|
||||
put("session_id", sid)
|
||||
focusTopic?.trim()?.takeIf { it.isNotBlank() }?.let { put("focus_topic", it) }
|
||||
}
|
||||
val direct = rpc("session.compress", params, timeoutMs = COMPRESS_RPC_TIMEOUT_MS)
|
||||
val result = if (direct.exceptionOrNull().isMethodNotFound()) {
|
||||
val legacyCommand = "/compress" + focusTopic
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { " $it" }
|
||||
.orEmpty()
|
||||
slashExec(legacyCommand).map { slashResult ->
|
||||
buildJsonObject {
|
||||
put("status", "legacy")
|
||||
slashResult.stringField("output")?.let { put("output", it) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
direct
|
||||
}
|
||||
return result.map { payload ->
|
||||
applySessionResultInfo(payload)
|
||||
payload.toGatewayCompressResult()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Result<JsonObject>.isLegacyRedirectUnsupported(): Boolean {
|
||||
val error = exceptionOrNull() ?: return false
|
||||
val message = error.message.orEmpty()
|
||||
return error.isMethodNotFound() ||
|
||||
(error as? GatewayRpcException)?.code == 4010 ||
|
||||
message.contains("does not support active-turn redirect", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun JsonObject.toGatewayCompressResult(): GatewayCompressResult =
|
||||
GatewayCompressResult(
|
||||
status = stringField("status") ?: "completed",
|
||||
output = stringField("output"),
|
||||
removed = (this["removed"] as? JsonPrimitive)?.intOrNull,
|
||||
beforeMessages = (this["before_messages"] as? JsonPrimitive)?.intOrNull,
|
||||
afterMessages = (this["after_messages"] as? JsonPrimitive)?.intOrNull,
|
||||
beforeTokens = (this["before_tokens"] as? JsonPrimitive)?.intOrNull,
|
||||
afterTokens = (this["after_tokens"] as? JsonPrimitive)?.intOrNull,
|
||||
usage = GatewayEventMapper.parseGatewayUsage(this["usage"] as? JsonObject),
|
||||
info = this["info"] as? JsonObject,
|
||||
messages = (this["messages"] as? JsonArray)?.let { messages ->
|
||||
runCatching {
|
||||
json.decodeFromJsonElement(
|
||||
ListSerializer(MessageItem.serializer()),
|
||||
messages,
|
||||
)
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "session.compress returned unreadable messages", it)
|
||||
emptyList()
|
||||
}
|
||||
}.orEmpty(),
|
||||
)
|
||||
|
||||
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
|
||||
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> =
|
||||
rpc(
|
||||
@@ -1350,6 +1480,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
@@ -1368,13 +1499,14 @@ class GatewayChatClient(
|
||||
// resume result's embedded `info` (same shape session.info carries),
|
||||
// so a reopened session shows its ACTUAL model immediately instead of
|
||||
// a misleading default until the first turn's async session.info.
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply connection-level session info (model / provider / reasoning effort /
|
||||
* personality / yolo / fast / context usage) into the `_server*` state flows.
|
||||
* personality / yolo / fast / context usage / project) into the `_server*`
|
||||
* state flows.
|
||||
* Shared by the `session.info` event handler and the `session.resume` RPC
|
||||
* result — the resume response embeds the same `info` object, so reopening a
|
||||
* session can paint its real model up front rather than waiting for a turn.
|
||||
@@ -1396,6 +1528,18 @@ class GatewayChatClient(
|
||||
info.stringField("credential_warning")?.takeIf { it.isNotBlank() }
|
||||
(info["yolo"] as? JsonPrimitive)?.booleanOrNull?.let { _serverYolo.value = it }
|
||||
(info["fast"] as? JsonPrimitive)?.booleanOrNull?.let { _serverFast.value = it }
|
||||
_serverProject.value = (info["project"] as? JsonObject)?.let { project ->
|
||||
project.stringField("name")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { name ->
|
||||
GatewaySessionProject(
|
||||
id = project.stringField("id")?.takeIf { it.isNotBlank() },
|
||||
slug = project.stringField("slug")?.takeIf { it.isNotBlank() },
|
||||
name = name,
|
||||
primaryPath = project.stringField("primary_path")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Context usage: require used > 0 — a COLD resume resets counters and
|
||||
// reports 0 until the first turn rebuilds the prompt; painting 0 would
|
||||
// mislead on a session that actually has history.
|
||||
@@ -1408,6 +1552,12 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a session create/resume result without leaking metadata from the prior session. */
|
||||
private fun applySessionResultInfo(result: JsonObject) {
|
||||
_serverProject.value = null
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
}
|
||||
|
||||
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
|
||||
private suspend fun ensureLiveProcessSession(): Result<String> {
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -1495,6 +1645,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", requestedStoredId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
@@ -1505,7 +1656,7 @@ class GatewayChatClient(
|
||||
storedSessionId = requestedStoredId
|
||||
liveSessionProfile = requestedProfile
|
||||
updateCancelledDrainLiveSession(requestedStoredId, live)
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(result)
|
||||
return
|
||||
}
|
||||
Log.w(
|
||||
@@ -1519,6 +1670,7 @@ class GatewayChatClient(
|
||||
"session.create",
|
||||
buildJsonObject {
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
// Bind the in-chat overrides to the new session as its
|
||||
@@ -1877,12 +2029,14 @@ class GatewayChatClient(
|
||||
|
||||
/**
|
||||
* Recover an in-flight turn after a mid-turn socket loss by reconnecting
|
||||
* the SOCKET ONLY and keeping [preservedLiveId] as the live session id.
|
||||
* the socket and rebinding [preservedLiveId] to the new transport.
|
||||
*
|
||||
* A bare reconnect lets the tail — including the final
|
||||
* `message.complete` — keep matching without another RPC. Current upstream
|
||||
* can rebind live sessions too, but older builds cannot, so this remains the
|
||||
* lowest-common-denominator same-client recovery path.
|
||||
* Current upstream detaches a live session from a closed WebSocket; a new
|
||||
* socket receives no tail until `session.activate` attaches that exact live
|
||||
* id. Older gateways do not expose the method, so method-not-found alone
|
||||
* retains the legacy bare-socket behavior. We never call `session.resume`
|
||||
* here: resuming a durable id can create a different live runtime and
|
||||
* orphan the turn already executing server-side.
|
||||
*
|
||||
* Retries with backoff for up to [midTurnRejoinWindowMs] so a multi-second
|
||||
* radio blip doesn't abandon the turn. Events emitted while the socket was
|
||||
@@ -1900,7 +2054,39 @@ class GatewayChatClient(
|
||||
connectCooldownUntil = 0L
|
||||
ensureConnected()
|
||||
}
|
||||
true
|
||||
if (preservedLiveId == null) {
|
||||
true
|
||||
} else {
|
||||
// Bind before activation so a tail event racing the RPC ack
|
||||
// still matches the original active turn.
|
||||
liveSessionId = preservedLiveId
|
||||
val activated = rpc(
|
||||
"session.activate",
|
||||
buildJsonObject { put("session_id", preservedLiveId) },
|
||||
)
|
||||
when {
|
||||
activated.isSuccess -> {
|
||||
activated.getOrNull()?.let(::applySessionResultInfo)
|
||||
true
|
||||
}
|
||||
activated.exceptionOrNull().isMethodNotFound() -> {
|
||||
Log.i(
|
||||
TAG,
|
||||
"session.activate unsupported during mid-turn rejoin — " +
|
||||
"using legacy socket recovery",
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Mid-turn session.activate retry failed: " +
|
||||
activated.exceptionOrNull()?.message,
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Mid-turn reconnect retry failed: ${e.message}")
|
||||
false
|
||||
@@ -1912,7 +2098,8 @@ class GatewayChatClient(
|
||||
if (preservedLiveId != null) liveSessionId = preservedLiveId
|
||||
Log.i(
|
||||
TAG,
|
||||
"Gateway socket rejoined mid-turn (session=$storedSessionId) — kept live session, awaiting tail",
|
||||
"Gateway socket rejoined mid-turn (session=$storedSessionId) — " +
|
||||
"rebound live session, awaiting tail",
|
||||
)
|
||||
// A reconnect that followed a route RETARGET gets a short settle
|
||||
// (the fresh socket won't replay the in-flight turn); a normal
|
||||
@@ -2091,8 +2278,14 @@ class GatewayChatClient(
|
||||
private inner class GatewayTurn(
|
||||
val callbacks: GatewayTurnCallbacks,
|
||||
dedupeAdjacentMessageStarts: Boolean = false,
|
||||
deferEvents: Boolean = false,
|
||||
) : ActiveTurnHandle {
|
||||
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
|
||||
private val deferredEventLock = Any()
|
||||
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
|
||||
private var eventsDeferred = deferEvents
|
||||
private var redirectedTo: GatewayTurn? = null
|
||||
private var queuedSuccessor: Pair<GatewayInboundTurnRegistration, GatewayTurn>? = null
|
||||
|
||||
/** t0 = construction ≈ sendTurn entry (the moment the user sent). */
|
||||
val tracer = TurnLatencyTracer("gateway")
|
||||
@@ -2113,10 +2306,22 @@ class GatewayChatClient(
|
||||
*/
|
||||
fun beginRejoin(): Boolean {
|
||||
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
if (shouldRejoin) reconcileRequired = true
|
||||
if (shouldRejoin) {
|
||||
reconcileRequired = true
|
||||
transportRecoveryStarted = true
|
||||
}
|
||||
return shouldRejoin
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket loss after `prompt.submit` makes server acceptance ambiguous
|
||||
* even when no turn event or RPC ack reached Android. Once recovery has
|
||||
* started, the caller must not resubmit through SSE.
|
||||
*/
|
||||
@Volatile
|
||||
var transportRecoveryStarted = false
|
||||
private set
|
||||
|
||||
private var watchdog: Job? = null
|
||||
|
||||
val ended: Boolean get() = mapper.turnEnded || cancelled
|
||||
@@ -2133,6 +2338,21 @@ class GatewayChatClient(
|
||||
private set
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
val redirect = synchronized(deferredEventLock) {
|
||||
if (eventsDeferred) {
|
||||
deferredEvents += type to payload
|
||||
return
|
||||
}
|
||||
redirectedTo
|
||||
}
|
||||
if (redirect != null) {
|
||||
redirect.onEvent(type, payload)
|
||||
return
|
||||
}
|
||||
processEvent(type, payload)
|
||||
}
|
||||
|
||||
private fun processEvent(type: String, payload: JsonObject?) {
|
||||
if (type != "session.info") started = true
|
||||
tracer.mark("ttfe")
|
||||
if (type == "message.delta" || type == "reasoning.delta" || type == "thinking.delta") {
|
||||
@@ -2152,6 +2372,74 @@ class GatewayChatClient(
|
||||
if (mapper.turnEnded) {
|
||||
disarmWatchdog()
|
||||
tracer.done()
|
||||
handoffQueuedSuccessor()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a queued prompt reported beside an in-flight recovery as a
|
||||
* distinct next turn. Its mapper starts deferred so events that race
|
||||
* the resume acknowledgement cannot paint the completing prior turn.
|
||||
*/
|
||||
fun installQueuedSuccessor(registration: GatewayInboundTurnRegistration) {
|
||||
synchronized(deferredEventLock) {
|
||||
if (queuedSuccessor == null) {
|
||||
queuedSuccessor = registration to GatewayTurn(
|
||||
callbacks = dispatchOn(registration.callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
deferEvents = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handoffQueuedSuccessor() {
|
||||
val successor = synchronized(deferredEventLock) {
|
||||
queuedSuccessor?.also {
|
||||
queuedSuccessor = null
|
||||
redirectedTo = it.second
|
||||
}
|
||||
} ?: return
|
||||
val (registration, turn) = successor
|
||||
|
||||
// Claim socket ownership immediately so the next message.start is
|
||||
// buffered by this exact successor instead of being admitted as a
|
||||
// generic unsolicited turn. UI admission is ordered after the
|
||||
// prior turn's terminal callbacks on the shared dispatcher.
|
||||
activeTurn = turn
|
||||
callbackDispatcher {
|
||||
if (registration.onHandle(turn)) {
|
||||
turn.releaseDeferredEvents()
|
||||
turn.armWatchdog()
|
||||
} else {
|
||||
turn.discardDeferredEvents()
|
||||
turn.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseDeferredEvents() {
|
||||
val pending = synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
deferredEvents.toList().also { deferredEvents.clear() }
|
||||
}
|
||||
pending.forEach { (type, payload) -> onEvent(type, payload) }
|
||||
}
|
||||
|
||||
fun redirectDeferredEventsTo(target: GatewayTurn) {
|
||||
val pending = synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
redirectedTo = target
|
||||
deferredEvents.toList().also { deferredEvents.clear() }
|
||||
}
|
||||
pending.forEach { (type, payload) -> target.onEvent(type, payload) }
|
||||
}
|
||||
|
||||
fun discardDeferredEvents() {
|
||||
synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
redirectedTo = null
|
||||
deferredEvents.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2315,6 +2603,9 @@ class GatewayChatClient(
|
||||
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
|
||||
onStart = { callbackDispatcher { callbacks.onStart() } },
|
||||
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
@@ -2340,12 +2631,12 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
/** Outcome of [GatewayChatClient.steer] — Rejected and Failed both mean "queue locally instead". */
|
||||
/** Outcome of an active-turn correction — Rejected and Failed both mean "queue locally instead". */
|
||||
enum class SteerResult {
|
||||
/** Server accepted — text lands in the next tool batch's last result. */
|
||||
/** Server accepted the active-turn correction. */
|
||||
Queued,
|
||||
|
||||
/** Server reachable but no tool batch in flight to steer. */
|
||||
/** Server reachable but no active turn is available to correct. */
|
||||
Rejected,
|
||||
|
||||
/** Transport/RPC failure (no live session, socket down, unsupported …). */
|
||||
@@ -2377,6 +2668,28 @@ internal class GatewayRpcException(message: String, val code: Int? = null) : Exc
|
||||
|
||||
private const val JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
|
||||
data class GatewayCompressResult(
|
||||
val status: String,
|
||||
val output: String? = null,
|
||||
val removed: Int? = null,
|
||||
val beforeMessages: Int? = null,
|
||||
val afterMessages: Int? = null,
|
||||
val beforeTokens: Int? = null,
|
||||
val afterTokens: Int? = null,
|
||||
val usage: UsageInfo? = null,
|
||||
val info: JsonObject? = null,
|
||||
val messages: List<MessageItem> = emptyList(),
|
||||
) {
|
||||
val effectiveUsage: UsageInfo?
|
||||
get() = usage ?: GatewayEventMapper.parseGatewayUsage(info?.get("usage") as? JsonObject)
|
||||
|
||||
val title: String?
|
||||
get() = info?.stringField("title")?.takeIf { it.isNotBlank() }
|
||||
|
||||
val isAuthoritative: Boolean
|
||||
get() = messages.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
|
||||
|
||||
@@ -35,6 +35,7 @@ class GatewayEventMapper(
|
||||
private var previousEventType: String? = null
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var previewedText: String? = null
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
@@ -95,13 +96,30 @@ class GatewayEventMapper(
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
|
||||
clearActivityStatuses()
|
||||
sawTextDelta = true
|
||||
previewedText = null
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.interim" -> {
|
||||
val text = payload.string("text") ?: payload.string("message")
|
||||
?: payload.string("preview") ?: payload.string("rendered")
|
||||
val alreadyStreamed = payload.boolean("already_streamed") == true
|
||||
if (!text.isNullOrBlank() || alreadyStreamed) {
|
||||
clearActivityStatuses()
|
||||
if (!sawMessageStart) {
|
||||
sawMessageStart = true
|
||||
callbacks.onStart()
|
||||
}
|
||||
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
|
||||
previewedText = text
|
||||
sawTextDelta = alreadyStreamed
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// The upstream background-completion poller currently emits
|
||||
// message.start immediately before _run_prompt_submit(), which
|
||||
@@ -114,6 +132,7 @@ class GatewayEventMapper(
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
previewedText = null
|
||||
callbacks.onStart()
|
||||
}
|
||||
|
||||
@@ -164,7 +183,15 @@ class GatewayEventMapper(
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
||||
val duplicatesPreview = responsePreviewed &&
|
||||
!text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
||||
if (!text.isNullOrEmpty() &&
|
||||
!duplicatesPreview &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
@@ -297,6 +324,13 @@ class GatewayEventMapper(
|
||||
),
|
||||
)
|
||||
|
||||
"clarify.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for the proposed upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> callbacks.onInteractionExpired(
|
||||
|
||||
@@ -97,6 +97,19 @@ data class GatewayInflightTurn(
|
||||
val streaming: Boolean,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
val slug: String?,
|
||||
val name: String,
|
||||
val primaryPath: String?,
|
||||
)
|
||||
|
||||
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||
data class GatewaySessionRecovery(
|
||||
val storedSessionId: String,
|
||||
@@ -104,9 +117,14 @@ data class GatewaySessionRecovery(
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
val inflight: GatewayInflightTurn?,
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
)
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
data class GatewayBackgroundTurnCompletion(
|
||||
@@ -296,7 +314,9 @@ data class GatewayModelOptions(
|
||||
*
|
||||
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
|
||||
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
|
||||
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
|
||||
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
|
||||
* pins priority, `false` explicitly pins normal, and `null` omits the field so
|
||||
* the profile's service tier is inherited.
|
||||
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
|
||||
* accept it as a per-session override, so it is applied post-create instead.
|
||||
*/
|
||||
@@ -328,6 +348,13 @@ class GatewayTurnCallbacks(
|
||||
/** A gateway `message.start` opened an assistant response for this turn. */
|
||||
val onStart: () -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
/**
|
||||
* Gateway `message.interim` sealed an attempted assistant message before
|
||||
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
|
||||
* has not arrived through `message.delta` and should be rendered before
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -159,6 +160,146 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ToolsetInfo(
|
||||
val name: String,
|
||||
val label: String = "",
|
||||
val description: String = "",
|
||||
val enabled: Boolean = false,
|
||||
val configured: Boolean = false,
|
||||
val tools: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
|
||||
|
||||
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
|
||||
json.decodeFromString<ToolsetListResponse>(body).data
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
|
||||
data class ApiModelOption(
|
||||
val id: String,
|
||||
val root: String? = null,
|
||||
val parent: String? = null,
|
||||
) {
|
||||
/** Secondary picker copy for a configured route alias. */
|
||||
val routeDetail: String?
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
return data.mapNotNull { row ->
|
||||
val obj = row as? JsonObject ?: return@mapNotNull null
|
||||
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
ApiModelOption(
|
||||
id = id,
|
||||
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
|
||||
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
|
||||
|
||||
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
|
||||
internal fun streamHttpFailureMessage(
|
||||
code: Int,
|
||||
reason: String,
|
||||
retryAfter: String?,
|
||||
body: String?,
|
||||
json: Json,
|
||||
): String {
|
||||
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
|
||||
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
|
||||
}
|
||||
val errorObj = error as? JsonObject
|
||||
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
|
||||
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
|
||||
?: (error as? JsonPrimitive)?.contentOrNull
|
||||
return buildString {
|
||||
append("API error ").append(code).append(": ")
|
||||
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
|
||||
append(detail?.takeIf { it.isNotBlank() } ?: reason)
|
||||
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
|
||||
append(" (Retry-After: ").append(it).append("s)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayDrainRetryDelayMillis(
|
||||
httpCode: Int?,
|
||||
retryAfter: String?,
|
||||
errorMessage: String,
|
||||
receivedEvent: Boolean,
|
||||
retryAlreadyScheduled: Boolean,
|
||||
): Long? {
|
||||
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
|
||||
!errorMessage.startsWith("API error 503: gateway_draining:")
|
||||
) return null
|
||||
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
|
||||
return seconds * 1_000L
|
||||
}
|
||||
|
||||
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
|
||||
private class RetryingEventSource(
|
||||
private val originalRequest: Request,
|
||||
private val handler: Handler,
|
||||
) : EventSource {
|
||||
private val lock = Any()
|
||||
private var active: EventSource? = null
|
||||
private var retryRunnable: Runnable? = null
|
||||
private var cancelled = false
|
||||
|
||||
override fun request(): Request = originalRequest
|
||||
|
||||
fun attach(source: EventSource) {
|
||||
synchronized(lock) {
|
||||
if (cancelled) source.cancel() else active = source
|
||||
}
|
||||
}
|
||||
|
||||
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
|
||||
val task = Runnable {
|
||||
synchronized(lock) {
|
||||
retryRunnable = null
|
||||
if (cancelled) return@Runnable
|
||||
// Keep creation under the same lock as cancel(): once Stop or
|
||||
// a session switch wins, no delayed POST can start afterward.
|
||||
active = create()
|
||||
}
|
||||
}
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
retryRunnable = task
|
||||
handler.postDelayed(task, delayMillis)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
val source: EventSource?
|
||||
val task: Runnable?
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
source = active
|
||||
active = null
|
||||
task = retryRunnable
|
||||
retryRunnable = null
|
||||
}
|
||||
task?.let(handler::removeCallbacks)
|
||||
source?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -199,8 +340,13 @@ class HermesApiClient(
|
||||
|
||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
response != null && !response.isSuccessful -> streamHttpFailureMessage(
|
||||
code = response.code,
|
||||
reason = response.message,
|
||||
retryAfter = response.header("Retry-After"),
|
||||
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
@@ -445,6 +591,24 @@ class HermesApiClient(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
|
||||
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/toolsets").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(IOException("HTTP ${response.code}"))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseToolsetListBody(json, body)
|
||||
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
@@ -453,17 +617,13 @@ class HermesApiClient(
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
parseModelOptionsBody(json, body).orEmpty()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
@@ -471,6 +631,9 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -623,6 +786,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
@@ -636,6 +802,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -799,9 +966,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -814,7 +992,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
@@ -876,6 +1055,9 @@ class HermesApiClient(
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
@@ -886,6 +1068,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -941,9 +1124,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -956,7 +1150,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
@@ -1070,6 +1265,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
@@ -1080,6 +1278,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -1246,9 +1445,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -1261,7 +1471,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- Capability detection ---
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
|
||||
* remain in memory only; OAuth codes, callback state, and tokens never enter
|
||||
* the Android client. The dashboard owns the complete PKCE/callback exchange.
|
||||
*/
|
||||
class McpOAuthFlowCoordinator(
|
||||
private val client: DashboardApiClient,
|
||||
private val pollDelayMillis: Long = 1_000,
|
||||
private val maxPolls: Int = 900,
|
||||
private val maxConsecutiveFailures: Int = 3,
|
||||
private val sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
) {
|
||||
suspend fun start(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
|
||||
if (started.status == "error") {
|
||||
throw IOException(started.error ?: "MCP OAuth failed to start")
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
var failures = 0
|
||||
repeat(maxPolls.coerceAtLeast(1)) {
|
||||
val current = client.getMcpOAuthFlow(flowId)
|
||||
if (current.isFailure) {
|
||||
failures += 1
|
||||
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
|
||||
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
|
||||
}
|
||||
} else {
|
||||
failures = 0
|
||||
val flow = current.getOrThrow()
|
||||
when (flow.status) {
|
||||
"approved" -> return@runCatching flow
|
||||
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
|
||||
}
|
||||
}
|
||||
sleep(pollDelayMillis.coerceAtLeast(0))
|
||||
}
|
||||
throw IOException("MCP OAuth authorization timed out")
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
suspend fun complete(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
openAuthorization: (String) -> Boolean,
|
||||
): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
val started = start(serverName, profile).getOrThrow()
|
||||
if (started.status == "approved") return@runCatching started
|
||||
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
|
||||
if (!openAuthorization(authorizationUrl)) {
|
||||
throw IOException("No browser is available to complete MCP OAuth")
|
||||
}
|
||||
resume(started.flowId).getOrThrow()
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
|
||||
val url = flow.authorizationUrl
|
||||
?: throw IOException("MCP OAuth server did not provide an authorization URL")
|
||||
if (url.toHttpUrlOrNull()?.scheme != "https") {
|
||||
throw IOException("MCP OAuth authorization URL must use HTTPS")
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
-3
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
|
||||
// upstream ever adds profile-aware TTS. Until then, standard voice remains
|
||||
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
|
||||
private val profileProvider: () -> String? = { null },
|
||||
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -57,9 +67,7 @@ class StandardHermesVoiceClient(
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
standardHermesDashboardAudioClient(okHttpClient)
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
@@ -148,6 +156,42 @@ class StandardHermesVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
baseUrl = baseUrl,
|
||||
ticket = ticket,
|
||||
path = "/api/audio/speak-stream",
|
||||
) ?: throw IOException("Could not build Hermes speech stream URL")
|
||||
val request = Request.Builder().url(websocketUrl).build()
|
||||
StandardHermesSpeechStream(
|
||||
request = request,
|
||||
callbacks = callbacks,
|
||||
json = json,
|
||||
socketFactory = webSocketFactory ?: callClient::newWebSocket,
|
||||
).also { it.connect() }
|
||||
.let { Result.success<VoiceSpeechStream?>(it) }
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -242,3 +286,209 @@ class StandardHermesVoiceClient(
|
||||
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
private class StandardHermesSpeechStream(
|
||||
private val request: Request,
|
||||
private val callbacks: VoiceSpeechStreamCallbacks,
|
||||
private val json: Json,
|
||||
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
) : VoiceSpeechStream {
|
||||
private val lock = Any()
|
||||
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
|
||||
private val pendingFrames = ArrayDeque<String>()
|
||||
private var socket: WebSocket? = null
|
||||
private var opened = false
|
||||
private var stopped = false
|
||||
private var finished = false
|
||||
private var audioStarted = false
|
||||
private var sampleRate = DEFAULT_SAMPLE_RATE
|
||||
private var oddByteCarry: Byte? = null
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
val frames = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) {
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
socket = webSocket
|
||||
opened = true
|
||||
pendingFrames.toList().also { pendingFrames.clear() }
|
||||
}
|
||||
frames.forEach(webSocket::send)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
|
||||
when (root.stringField("type")) {
|
||||
"start" -> {
|
||||
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: DEFAULT_SAMPLE_RATE
|
||||
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: 1
|
||||
if (channels != 1) {
|
||||
settle(
|
||||
status = VoiceSpeechStreamStatus.Fallback,
|
||||
error = IOException("Hermes speech stream returned $channels channels; mono required"),
|
||||
)
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
synchronized(lock) { sampleRate = nextRate }
|
||||
callbacks.onStart(nextRate, channels)
|
||||
}
|
||||
"fallback" -> {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) {
|
||||
VoiceSpeechStreamStatus.Completed
|
||||
} else {
|
||||
VoiceSpeechStreamStatus.Fallback
|
||||
},
|
||||
)
|
||||
webSocket.close(1000, "fallback")
|
||||
}
|
||||
"end" -> {
|
||||
settle(VoiceSpeechStreamStatus.Completed)
|
||||
webSocket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
val delivery = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
var incoming = bytes.toByteArray()
|
||||
oddByteCarry?.let { carry ->
|
||||
incoming = byteArrayOf(carry) + incoming
|
||||
oddByteCarry = null
|
||||
}
|
||||
val usable = incoming.size - (incoming.size % 2)
|
||||
if (usable < incoming.size) oddByteCarry = incoming.last()
|
||||
if (usable == 0) return
|
||||
audioStarted = true
|
||||
incoming.copyOf(usable) to sampleRate
|
||||
}
|
||||
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
|
||||
.onFailure { error ->
|
||||
settle(VoiceSpeechStreamStatus.Failed, error)
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
settleForDisconnect(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
val created = socketFactory(request, listener)
|
||||
synchronized(lock) {
|
||||
if (socket == null) socket = created
|
||||
if (stopped) created.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun append(text: String) {
|
||||
if (text.isEmpty()) return
|
||||
sendFrame(buildJsonObject { put("text", text) })
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
synchronized(lock) {
|
||||
if (finished || stopped || outcome.isCompleted) return
|
||||
finished = true
|
||||
}
|
||||
sendFrame(buildJsonObject { put("done", true) })
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val current = synchronized(lock) {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
socket
|
||||
}
|
||||
if (current != null) {
|
||||
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
|
||||
current.cancel()
|
||||
}
|
||||
settle(VoiceSpeechStreamStatus.Stopped)
|
||||
}
|
||||
|
||||
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
|
||||
|
||||
private fun sendFrame(frame: JsonObject) {
|
||||
val encoded = json.encodeToString(JsonObject.serializer(), frame)
|
||||
val current = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
if (!opened) {
|
||||
pendingFrames.addLast(encoded)
|
||||
return
|
||||
}
|
||||
socket
|
||||
}
|
||||
if (current?.send(encoded) != true) {
|
||||
settleForDisconnect(IOException("Hermes speech stream send failed"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleForDisconnect(error: Throwable) {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
|
||||
val result = synchronized(lock) {
|
||||
if (outcome.isCompleted) return
|
||||
VoiceSpeechStreamOutcome(
|
||||
status = status,
|
||||
audioStarted = audioStarted,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
outcome.complete(result)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_SAMPLE_RATE = 24_000
|
||||
}
|
||||
}
|
||||
|
||||
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
|
||||
|
||||
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
|
||||
requestedSeconds.coerceIn(
|
||||
minimumValue = 180L,
|
||||
maximumValue = 600L,
|
||||
)
|
||||
|
||||
internal fun standardHermesDashboardAudioClient(
|
||||
baseClient: OkHttpClient,
|
||||
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
|
||||
): OkHttpClient {
|
||||
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
|
||||
return baseClient.newBuilder()
|
||||
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ data class MessageItem(
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null,
|
||||
@SerialName("display_kind") val displayKind: String? = null,
|
||||
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
|
||||
// Reasoning persisted with the assistant message (upstream serializes
|
||||
// both names; reasoning is the canonical one). Restored into
|
||||
// ChatMessage.thinkingContent so the Thought-process block survives a
|
||||
|
||||
@@ -769,6 +769,9 @@ fun RelayApp() {
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
chatViewModel.setIsolatedProfileApiProvider {
|
||||
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
|
||||
}
|
||||
chatViewModel.setSessionProfileNameProvider {
|
||||
connectionViewModel.effectiveSessionProfileName.value
|
||||
}
|
||||
@@ -1445,7 +1448,8 @@ fun RelayApp() {
|
||||
// the previous run). The pre-warm fills cold keys and refreshes
|
||||
// stale (disk-hydrated) ones, then mirrors results back to disk.
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
|
||||
val effectiveManageProfile by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl, effectiveManageProfile) {
|
||||
val connection = activeConnection ?: return@LaunchedEffect
|
||||
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
|
||||
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
|
||||
@@ -1462,6 +1466,7 @@ fun RelayApp() {
|
||||
cookieStore = cookieStore,
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
cacheDir = hydrateContext.cacheDir,
|
||||
context = hydrateContext,
|
||||
)
|
||||
|
||||
+7
-2
@@ -2098,6 +2098,9 @@ fun ActiveCardRoutesSection(
|
||||
outcomeFor = { candidate ->
|
||||
routeProbeOutcomes[connectionViewModel.routeOutcomeKey(candidate)]
|
||||
},
|
||||
dashboardAuthenticated = connection.dashboardLastStatus?.authenticated,
|
||||
dashboardSignInRequired = connection.dashboardLastStatus?.authRequired == true &&
|
||||
connection.dashboardLastStatus.authenticated != true,
|
||||
preferredRole = preferredRole,
|
||||
manualOverrideRole = manualOverrideRole,
|
||||
onUseNow = { candidate -> connectionViewModel.useRouteNow(candidate.role) },
|
||||
@@ -2127,10 +2130,12 @@ fun ActiveCardRoutesSection(
|
||||
if (routeEditorOpen) {
|
||||
RouteEditorDialog(
|
||||
original = routeEditorOriginal,
|
||||
onSave = { role, apiUrl, onResult ->
|
||||
relayEnabled = connection.relayUrl.isNotBlank() ||
|
||||
endpoints.any { it.relay != null },
|
||||
onSave = { role, dashboardUrl, onResult ->
|
||||
connectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl,
|
||||
original = routeEditorOriginal,
|
||||
onResult = onResult,
|
||||
)
|
||||
|
||||
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
|
||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||
if (!content.contains("![")) return content to emptyList()
|
||||
val images = mutableListOf<ChatInlineImage>()
|
||||
var inlineDataImages = 0
|
||||
var inlineDataBytes = 0L
|
||||
var inlineOverflowNoticeAdded = false
|
||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
|
||||
val alt = m.groupValues[2].trim()
|
||||
val src = normalizeImageSrc(m.groupValues[3].trim())
|
||||
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
|
||||
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
|
||||
val exceedsInlineBudget = isInlineData && (
|
||||
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
|
||||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
|
||||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
|
||||
)
|
||||
if (exceedsInlineBudget) {
|
||||
return@replace if (inlineOverflowNoticeAdded) {
|
||||
""
|
||||
} else {
|
||||
inlineOverflowNoticeAdded = true
|
||||
"\n\n_Additional inline images omitted for memory safety._\n\n"
|
||||
}
|
||||
}
|
||||
images += ChatInlineImage(
|
||||
alt = alt,
|
||||
src = normalizeImageSrc(m.groupValues[3].trim()),
|
||||
src = src,
|
||||
sensitive = spoilerWrapped || isSensitiveAltText(alt),
|
||||
)
|
||||
if (inlineDataSize != null) {
|
||||
inlineDataImages += 1
|
||||
inlineDataBytes += inlineDataSize
|
||||
}
|
||||
""
|
||||
}
|
||||
if (images.isEmpty()) return content to emptyList()
|
||||
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
|
||||
return s.startsWith("http://") || s.startsWith("https://")
|
||||
}
|
||||
|
||||
private fun ChatInlineImage.isInlineDataImage(): Boolean =
|
||||
src.startsWith("data:image/", ignoreCase = true)
|
||||
|
||||
/**
|
||||
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
|
||||
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
|
||||
@@ -232,6 +258,7 @@ fun ChatInlineImages(
|
||||
images.forEach { image ->
|
||||
when {
|
||||
image.isRemote() -> RemoteChatImage(image, maxWidth)
|
||||
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
|
||||
// A server-local file the agent referenced — fetch it through
|
||||
// /media/by-path and render inline; on failure the notice shows
|
||||
// the ACTUAL reason (for debugging) instead of a generic message.
|
||||
@@ -253,6 +280,94 @@ fun ChatInlineImages(
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface DataUrlImagePhase {
|
||||
data object Loading : DataUrlImagePhase
|
||||
data class Loaded(
|
||||
val bitmap: ImageBitmap,
|
||||
val mime: String,
|
||||
) : DataUrlImagePhase
|
||||
data object Rejected : DataUrlImagePhase
|
||||
}
|
||||
|
||||
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
|
||||
@Composable
|
||||
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
|
||||
var viewerOpen by remember(image.src) { mutableStateOf(false) }
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(image.src) { mutableStateOf(false) }
|
||||
LaunchedEffect(image.src) {
|
||||
phase = withInlineImageDecodeLock {
|
||||
val decoded = decodeInlineImageDataUrl(image.src)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
|
||||
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bitmap = BitmapFactory.decodeByteArray(
|
||||
decoded.bytes,
|
||||
0,
|
||||
decoded.bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
|
||||
}
|
||||
}
|
||||
when (val current = phase) {
|
||||
DataUrlImagePhase.Loading -> Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
|
||||
image.copy(src = "inline image"),
|
||||
reason = "Unsupported or oversized inline image.",
|
||||
)
|
||||
is DataUrlImagePhase.Loaded -> {
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Bitmap(
|
||||
bitmap = current.bitmap,
|
||||
displayName = image.alt.ifBlank { "image" },
|
||||
mime = current.mime,
|
||||
// Decode the already-retained data URL only when the
|
||||
// user requests Save/Share; don't keep a second 5 MiB
|
||||
// byte array beside every thumbnail.
|
||||
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
sensitive = image.sensitive,
|
||||
initiallyRevealed = revealed,
|
||||
)
|
||||
}
|
||||
InlineImageColumn(image, maxWidth) {
|
||||
BlurredMedia(
|
||||
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
|
||||
onReveal = { revealed = true },
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = current.bitmap,
|
||||
contentDescription = image.alt.ifBlank { "Generated image" },
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
|
||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||
* !isStreaming -> VOICE // GraphicEq, primary
|
||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
||||
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||
* ```
|
||||
*/
|
||||
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
|
||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||
* reflows the bar, the overline doesn't.
|
||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
||||
* (steer/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* (correct/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* is STEER, muted otherwise. Null collapses the row.
|
||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||
@@ -191,7 +191,7 @@ fun ChatInputBar(
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Caption row — steer/queue hinting, single line, no buttons.
|
||||
// Caption row — correct/queue hinting, single line, no buttons.
|
||||
AnimatedVisibility(visible = caption != null) {
|
||||
Text(
|
||||
text = caption ?: lastCaption.orEmpty(),
|
||||
|
||||
@@ -84,6 +84,7 @@ import com.hermesandroid.relay.data.ProfilePresence
|
||||
import com.hermesandroid.relay.data.ProfilePresenceResolver
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
@@ -671,7 +672,7 @@ fun AgentInfoSheet(
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
@@ -714,9 +715,12 @@ fun AgentInfoSheet(
|
||||
// Connection summary state.
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
@@ -1289,11 +1293,13 @@ fun AgentInfoSheet(
|
||||
// SSE fallback model list — /v1/models plus the configured profiles'
|
||||
// models (used only when the gateway model.options groups aren't
|
||||
// available, e.g. on an SSE transport).
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
||||
.distinct()
|
||||
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
|
||||
(apiModelOptions +
|
||||
agentProfiles.mapNotNull { profile ->
|
||||
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
|
||||
} +
|
||||
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
// Always show the Model picker — choosing a model is always possible
|
||||
// (Server default at minimum). While the provider/model list is still
|
||||
@@ -1362,14 +1368,14 @@ fun AgentInfoSheet(
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
primary = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model)
|
||||
toast(modelToast.format(model))
|
||||
if (selectedModelOverride != model.id) {
|
||||
chatViewModel.selectApiModel(model.id)
|
||||
toast(modelToast.format(model.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1563,11 +1569,12 @@ fun AgentInfoSheet(
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint),
|
||||
)
|
||||
val routeLabel = if (sessionTransport.isGateway) {
|
||||
val dashboardUrl = activeConnection?.resolvedDashboardUrl.orEmpty()
|
||||
activeConnection?.routeCandidates
|
||||
?.firstOrNull { it.dashboard?.url?.trimEnd('/') == dashboardUrl.trimEnd('/') }
|
||||
?.firstOrNull {
|
||||
it.dashboard?.url?.trimEnd('/') == effectiveDashboardUrl.trimEnd('/')
|
||||
}
|
||||
?.displayLabel()
|
||||
?: dashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
|
||||
?: effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
|
||||
com.hermesandroid.relay.data.Connection.endpointCandidateFromDashboardUrl(
|
||||
role = com.hermesandroid.relay.data.Connection.inferRouteRole(url),
|
||||
priority = 0,
|
||||
@@ -1577,7 +1584,7 @@ fun AgentInfoSheet(
|
||||
} else {
|
||||
activeEndpoint?.displayLabel()
|
||||
?: com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(apiServerUrl)
|
||||
.extractDefaultLabel(effectiveApiServerUrl)
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val relayConnected = relayConnectionState == ConnectionState.Connected
|
||||
@@ -1711,8 +1718,8 @@ fun AgentInfoSheet(
|
||||
routeLabel = routeLabel,
|
||||
relayConnectionState = relayConnectionState,
|
||||
capabilities = sessionCaps,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
apiServerUrl = effectiveApiServerUrl,
|
||||
relayUrl = effectiveRelayUrl,
|
||||
streamingEndpoint = streamingEndpoint,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
serverCapabilities = serverCapabilities,
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -47,6 +50,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
@@ -61,6 +65,7 @@ import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
@@ -75,9 +80,9 @@ import kotlinx.coroutines.launch
|
||||
* on app start, tried first on every resolve; cleared by
|
||||
* [onClearPreferred].
|
||||
*
|
||||
* Verbose by design: per-route visibility is retained instead of collapsing
|
||||
* into a master row. The card itself is wrapped in a
|
||||
* [SettingsExpandableCard] by the caller for page layout hygiene.
|
||||
* Each route stays visible, while its Dashboard/API/Relay topology uses
|
||||
* progressive disclosure: the active route opens by default and fallback
|
||||
* routes keep a compact surface-and-port summary.
|
||||
*
|
||||
* Not visible on legacy installs: when [endpoints] is empty we render a
|
||||
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
||||
@@ -109,6 +114,9 @@ fun EndpointsCard(
|
||||
* resolver's cache-key scheme.
|
||||
*/
|
||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
||||
/** Auth state applies only to the currently active Dashboard route. */
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
/**
|
||||
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||
@@ -177,6 +185,13 @@ fun EndpointsCard(
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
isProbing = isProbing,
|
||||
outcome = outcomeFor(candidate),
|
||||
dashboardAuthenticated = dashboardAuthenticated.takeIf { activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority()
|
||||
},
|
||||
dashboardSignInRequired = dashboardSignInRequired && activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
|
||||
onUseNow = { onUseNow(candidate) },
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onClearPrefer = onClearPreferred,
|
||||
@@ -221,6 +236,8 @@ private fun EndpointRow(
|
||||
isPreferred: Boolean,
|
||||
isProbing: Boolean = false,
|
||||
outcome: RouteProbeOutcome? = null,
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
onUseNow: () -> Unit,
|
||||
onPrefer: () -> Unit,
|
||||
onClearPrefer: () -> Unit,
|
||||
@@ -232,6 +249,7 @@ private fun EndpointRow(
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
|
||||
|
||||
@@ -280,17 +298,6 @@ private fun EndpointRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Full URL, scheme included: http vs https decides whether
|
||||
// the health probe TLS-handshakes, so two rows that both
|
||||
// read "host:8642" can behave completely differently. The
|
||||
// scheme must be visible to be debuggable.
|
||||
Text(
|
||||
text = candidate.primaryRouteUrl().orEmpty() +
|
||||
(candidate.relay?.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = stringResource(R.string.endpoints_checking),
|
||||
@@ -309,6 +316,47 @@ private fun EndpointRow(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
val dashboardSurfaceUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val dashboardLabel = stringResource(R.string.active_section_dashboard)
|
||||
val apiLabel = stringResource(R.string.active_section_api_server)
|
||||
val relayLabel = stringResource(R.string.active_section_relay)
|
||||
val surfaceSummary = listOfNotNull(
|
||||
dashboardSurfaceUrl?.let { "$dashboardLabel ${displayPort(it)}" },
|
||||
candidate.api?.url?.let { "$apiLabel ${displayPort(it)}" },
|
||||
candidate.relay?.url?.let { "$relayLabel ${displayPort(it)}" },
|
||||
).joinToString(" · ")
|
||||
if (surfaceSummary.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { detailsExpanded = !detailsExpanded }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = surfaceSummary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (detailsExpanded) {
|
||||
Icons.Filled.ExpandLess
|
||||
} else {
|
||||
Icons.Filled.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
@@ -394,6 +442,15 @@ private fun EndpointRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (detailsExpanded) {
|
||||
RouteSurfaceMap(
|
||||
candidate = candidate,
|
||||
dashboardAuthenticated = dashboardAuthenticated,
|
||||
dashboardSignInRequired = dashboardSignInRequired,
|
||||
modifier = Modifier.padding(start = 26.dp, end = 4.dp, top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove && onRemove != null) {
|
||||
@@ -438,6 +495,106 @@ private fun EndpointRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteSurfaceMap(
|
||||
candidate: EndpointCandidate,
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dashboardUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val apiUrl = candidate.api?.url
|
||||
val relayUrl = candidate.relay?.url
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_dashboard),
|
||||
url = dashboardUrl,
|
||||
status = when {
|
||||
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
|
||||
dashboardAuthenticated == true -> stringResource(R.string.active_section_signed_in)
|
||||
dashboardUrl != null -> stringResource(R.string.active_section_configured)
|
||||
else -> stringResource(R.string.active_section_not_configured)
|
||||
},
|
||||
warning = dashboardSignInRequired,
|
||||
)
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_api_server),
|
||||
url = apiUrl,
|
||||
status = stringResource(
|
||||
if (apiUrl != null) R.string.active_section_configured
|
||||
else R.string.active_section_not_configured,
|
||||
),
|
||||
)
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
url = relayUrl,
|
||||
status = stringResource(
|
||||
if (relayUrl != null) R.string.active_section_configured
|
||||
else R.string.active_section_not_configured,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteSurfaceRow(
|
||||
label: String,
|
||||
url: String?,
|
||||
status: String,
|
||||
warning: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = url ?: "—",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (warning) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayPort(url: String): String {
|
||||
val uri = runCatching { URI(url) }.getOrNull()
|
||||
val port = uri?.port?.takeIf { it > 0 } ?: when (uri?.scheme?.lowercase()) {
|
||||
"https", "wss" -> 443
|
||||
else -> 80
|
||||
}
|
||||
return ":$port"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveChip(label: String) {
|
||||
Row(
|
||||
@@ -533,19 +690,21 @@ private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||
*
|
||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
||||
* The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
|
||||
* fallback uses `:8642`, and an already-enabled Relay uses `:8767`.
|
||||
* Routes that need custom per-surface hosts or ports still come from a QR.
|
||||
*
|
||||
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||
* URL, keeps the stored priority).
|
||||
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
|
||||
* @param onSave invoked with (role, dashboardUrl, resultCallback); the callback
|
||||
* receives a user-facing error string to render inline, or null on
|
||||
* success (the dialog then closes itself).
|
||||
*/
|
||||
@Composable
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
relayEnabled: Boolean = false,
|
||||
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -618,12 +777,20 @@ fun RouteEditorDialog(
|
||||
// Live preview of what will actually be saved — scheme and
|
||||
// port defaults applied — so "what, which port, http or
|
||||
// https?" is answered before Save, not after a failed probe.
|
||||
val previewCandidate = remember(url, effectiveRole) {
|
||||
val previewCandidate = remember(url, effectiveRole, relayEnabled) {
|
||||
url.takeIf { it.isNotBlank() }?.let {
|
||||
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
|
||||
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
dashboardUrl = Connection.normalizeDashboardUrlInput(it),
|
||||
dashboardUrl = dashboardUrl,
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = if (relayEnabled) {
|
||||
apiUrl?.let(Connection::deriveDefaultRelayUrl)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -654,6 +821,9 @@ fun RouteEditorDialog(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
previewCandidate?.let { candidate ->
|
||||
RouteSurfaceMap(candidate = candidate)
|
||||
}
|
||||
if (selectedRole == "tailscale") {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_tailscale_setup_hint),
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val IMAGE_GENERATION_TOOL = "image_generate"
|
||||
private const val GRID_COLUMNS = 42
|
||||
private const val GRID_ROWS = 24
|
||||
|
||||
internal fun ToolCall.showsImageGenerationPlaceholder(): Boolean =
|
||||
!isComplete && name.trim().lowercase() == IMAGE_GENERATION_TOOL
|
||||
|
||||
/**
|
||||
* Keep the image canvas alive across the short tool-complete → media-arrival
|
||||
* handoff. Once a result surface exists it can crossfade into the same bubble;
|
||||
* a failed or fully-finished turn never leaves a stale canvas behind.
|
||||
*/
|
||||
internal fun shouldShowImageGenerationPlaceholder(
|
||||
toolCalls: List<ToolCall>,
|
||||
isStreaming: Boolean,
|
||||
hasMediaResult: Boolean,
|
||||
): Boolean {
|
||||
val imageCalls = toolCalls.filter {
|
||||
it.name.trim().lowercase() == IMAGE_GENERATION_TOOL
|
||||
}
|
||||
if (imageCalls.any { !it.isComplete }) return true
|
||||
return !hasMediaResult &&
|
||||
isStreaming &&
|
||||
imageCalls.any { it.isComplete && it.success != false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Image generation is a user-visible result lifecycle, not generic tool
|
||||
* diagnostics. Keep its active canvas visible even when upstream
|
||||
* `display.tool_progress` hides ordinary tool cards.
|
||||
*/
|
||||
internal fun ToolCall.isVisibleForToolDisplay(toolDisplay: String): Boolean =
|
||||
toolDisplay != "off" || showsImageGenerationPlaceholder()
|
||||
|
||||
/**
|
||||
* Theme-aware latent diffusion preview for an active Hermes image-generation
|
||||
* tool. It specializes the generic tool lifecycle already emitted by vanilla
|
||||
* Hermes; no Relay-only protocol or server patch is required.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageGenerationPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(R.string.image_generation_rendering)
|
||||
val transition = rememberInfiniteTransition(label = "imageGenerationDiffusion")
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 4_800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "imageGenerationDiffusionPhase",
|
||||
)
|
||||
val background = MaterialTheme.colorScheme.surfaceVariant
|
||||
val foreground = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val tertiary = MaterialTheme.colorScheme.tertiary
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(background)
|
||||
.semantics {
|
||||
contentDescription = description
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
},
|
||||
) {
|
||||
DiffusionCanvas(
|
||||
phase = phase,
|
||||
background = background,
|
||||
foreground = foreground,
|
||||
primary = primary,
|
||||
tertiary = tertiary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffusionCanvas(
|
||||
phase: Float,
|
||||
background: Color,
|
||||
foreground: Color,
|
||||
primary: Color,
|
||||
tertiary: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
drawRect(background)
|
||||
val cellWidth = size.width / GRID_COLUMNS
|
||||
val cellHeight = size.height / GRID_ROWS
|
||||
val denoise = diffusionDenoise(phase)
|
||||
val time = phase * 9f
|
||||
|
||||
repeat(GRID_ROWS) { row ->
|
||||
repeat(GRID_COLUMNS) { column ->
|
||||
val signal = diffusionSignal(column, row, time, denoise)
|
||||
if (signal < 0.2f) return@repeat
|
||||
|
||||
val x = column * cellWidth + cellWidth * 0.5f
|
||||
val y = row * cellHeight + cellHeight * 0.5f
|
||||
val radius = (cellWidth.coerceAtMost(cellHeight) * (0.14f + signal * 0.24f))
|
||||
.coerceAtLeast(0.7.dp.toPx())
|
||||
val warmMix = ((signal - 0.35f) / 0.65f).coerceIn(0f, 1f)
|
||||
val base = lerpColor(foreground, primary, warmMix)
|
||||
val color = lerpColor(base, tertiary, hash01(column + 17, row - 11) * 0.32f)
|
||||
|
||||
drawRoundRect(
|
||||
color = color.copy(alpha = (0.08f + signal * 0.76f).coerceAtMost(0.84f)),
|
||||
topLeft = Offset(x - radius, y - radius),
|
||||
size = Size(radius * 2f, radius * 2f),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius * 0.45f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffusionDenoise(phase: Float): Float {
|
||||
val normalized = phase - floor(phase)
|
||||
return if (normalized < 0.82f) {
|
||||
smoothstep(0.02f, 0.82f, normalized)
|
||||
} else {
|
||||
1f - smoothstep(0.82f, 1f, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffusionSignal(column: Int, row: Int, time: Float, denoise: Float): Float {
|
||||
val nx = (column + 0.5f) / GRID_COLUMNS - 0.52f
|
||||
val ny = (row + 0.5f) / GRID_ROWS - 0.5f
|
||||
val radius = kotlin.math.sqrt(nx * nx * 1.35f + ny * ny)
|
||||
val bloom = (1f - radius * 2.35f).coerceIn(0f, 1f)
|
||||
val ring = (1f - abs(radius - (0.23f + sin(time * 0.44f) * 0.025f)) * 15f)
|
||||
.coerceIn(0f, 1f)
|
||||
val latent = (bloom * 0.75f + ring * 0.5f).coerceIn(0f, 1f)
|
||||
val staticNoise = hash01(column + floor(time * 3f).toInt() * 19, row - floor(time * 3f).toInt() * 11)
|
||||
val livingNoise = hash01(column + floor(time * 7f).toInt(), row + floor(time * 5f).toInt())
|
||||
return (
|
||||
staticNoise * (1f - denoise) +
|
||||
latent * denoise +
|
||||
(livingNoise - 0.5f) * (0.42f - denoise * 0.2f)
|
||||
).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun hash01(x: Int, y: Int): Float {
|
||||
val value = sin(x * 127.1 + y * 311.7) * 43_758.5453
|
||||
return (value - floor(value)).toFloat()
|
||||
}
|
||||
|
||||
private fun smoothstep(edge0: Float, edge1: Float, value: Float): Float {
|
||||
val t = ((value - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
|
||||
return t * t * (3f - 2f * t)
|
||||
}
|
||||
|
||||
private fun lerpColor(from: Color, to: Color, amount: Float): Color = Color(
|
||||
red = from.red + (to.red - from.red) * amount,
|
||||
green = from.green + (to.green - from.green) * amount,
|
||||
blue = from.blue + (to.blue - from.blue) * amount,
|
||||
alpha = from.alpha + (to.alpha - from.alpha) * amount,
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import java.util.Base64
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
|
||||
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
|
||||
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
|
||||
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
|
||||
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
|
||||
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
private const val MAX_HEADER_CHARS = 64
|
||||
private val inlineImageDecodeMutex = Mutex()
|
||||
private val DATA_IMAGE_HEADER = Regex(
|
||||
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
internal data class DecodedInlineImageData(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Strict decoder for upstream API-server inline images.
|
||||
*
|
||||
* Only the five raster formats upstream emits for this path are accepted. The
|
||||
* encoded input is bounded before allocation, strict base64 is required, and
|
||||
* the decoded magic bytes must agree with the declared MIME type.
|
||||
*/
|
||||
internal fun decodeInlineImageDataUrl(
|
||||
source: String,
|
||||
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
|
||||
): DecodedInlineImageData? {
|
||||
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
|
||||
val comma = source.indexOf(',')
|
||||
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
|
||||
?.groupValues?.get(1)?.lowercase() ?: return null
|
||||
val encoded = source.substring(comma + 1)
|
||||
if (encoded.isEmpty()) return null
|
||||
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
|
||||
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
|
||||
if (encoded.length.toLong() > maxEncoded) return null
|
||||
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
|
||||
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
|
||||
return DecodedInlineImageData(bytes, mime)
|
||||
}
|
||||
|
||||
/** Conservative decoded-size estimate that does not allocate a byte array. */
|
||||
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
|
||||
if (!source.startsWith("data:image/", ignoreCase = true)) return null
|
||||
val comma = source.indexOf(',')
|
||||
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||
val encodedLength = source.length.toLong() - comma - 1L
|
||||
if (encodedLength <= 0) return null
|
||||
val padding = when {
|
||||
source.endsWith("==") -> 2L
|
||||
source.endsWith('=') -> 1L
|
||||
else -> 0L
|
||||
}
|
||||
return ((encodedLength + 3L) / 4L) * 3L - padding
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
|
||||
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
|
||||
* allocations from overlapping.
|
||||
*/
|
||||
internal suspend fun <T> withInlineImageDecodeLock(
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
block: () -> T,
|
||||
): T = withContext(dispatcher) {
|
||||
inlineImageDecodeMutex.withLock { block() }
|
||||
}
|
||||
|
||||
internal suspend fun decodeInlineImageDataUrlOffMain(
|
||||
source: String,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
|
||||
decodeInlineImageDataUrl(source)
|
||||
}
|
||||
|
||||
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
|
||||
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
)
|
||||
"image/jpeg" -> bytes.size >= 3 &&
|
||||
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
|
||||
"image/webp" -> bytes.size >= 12 &&
|
||||
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
|
||||
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
|
||||
"image/gif" -> bytes.size >= 6 &&
|
||||
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
|
||||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
|
||||
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
|
||||
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
|
||||
*/
|
||||
internal fun inlineImageSampleSize(
|
||||
width: Int,
|
||||
height: Int,
|
||||
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
|
||||
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
|
||||
): Int? {
|
||||
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
|
||||
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
|
||||
) return null
|
||||
var sample = 1
|
||||
while (true) {
|
||||
val sampledWidth = (width + sample - 1L) / sample
|
||||
val sampledHeight = (height + sample - 1L) / sample
|
||||
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
|
||||
sampledWidth <= maxPixels / sampledHeight
|
||||
) return sample
|
||||
if (sample >= 1 shl 15) return null
|
||||
sample *= 2
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTable
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||
@@ -152,7 +150,7 @@ fun MarkdownContent(
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
SafeMarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
@@ -160,7 +158,7 @@ fun MarkdownContent(
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownHighlightedCodeFence(
|
||||
SafeMarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -205,6 +206,11 @@ fun MessageBubble(
|
||||
extractChatInlineImages(message.content)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -309,6 +315,7 @@ fun MessageBubble(
|
||||
val showBubble = isUser || isSystem ||
|
||||
message.content.isNotBlank() ||
|
||||
message.isStreaming ||
|
||||
showImageGeneration ||
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
@@ -478,32 +485,41 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments — two or more loaded images collapse into one
|
||||
// grid + swipe-across gallery. Every other item stays on the
|
||||
// unified InboundAttachmentCard path, and layout items retain
|
||||
// their original ChatMessage.attachments indices so retry /
|
||||
// manual-fetch callbacks cannot drift after grouping.
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = remember(message.attachments) {
|
||||
attachmentLayoutItems(message.attachments)
|
||||
}
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
// Image generation owns the bubble's progress slot. It replaces
|
||||
// the generic first-token dots, remains mounted through the
|
||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
||||
// attachment renderer in this same Surface.
|
||||
Crossfade(
|
||||
targetState = showImageGeneration,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "imageGenerationToResult",
|
||||
) { generating ->
|
||||
if (generating) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationPlaceholder()
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,7 +530,11 @@ fun MessageBubble(
|
||||
// signal, so the pulsing dots stop (Messenger/Telegram drop the
|
||||
// typing bubble the moment content appears) instead of throbbing
|
||||
// under the text for the whole turn.
|
||||
if (message.isStreaming && message.content.isBlank()) {
|
||||
if (
|
||||
message.isStreaming &&
|
||||
message.content.isBlank() &&
|
||||
!showImageGeneration
|
||||
) {
|
||||
// After a few seconds with no content yet, escalate the bare
|
||||
// dots to a labeled "Still working…" so a slow first token
|
||||
// never reads as a hang on the SSE / sessions paths.
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBackground
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
|
||||
@Composable
|
||||
internal fun SafeMarkdownHighlightedCodeFence(
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
style: TextStyle = LocalMarkdownTypography.current.code,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean = false,
|
||||
) {
|
||||
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
|
||||
SafeMarkdownHighlightedCode(
|
||||
code = code,
|
||||
language = language,
|
||||
style = codeStyle,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SafeMarkdownHighlightedCodeBlock(
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
style: TextStyle = LocalMarkdownTypography.current.code,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean = false,
|
||||
) {
|
||||
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
|
||||
SafeMarkdownHighlightedCode(
|
||||
code = code,
|
||||
language = language,
|
||||
style = codeStyle,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafeMarkdownHighlightedCode(
|
||||
code: String,
|
||||
language: String?,
|
||||
style: TextStyle,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean,
|
||||
) {
|
||||
val codeHighlights by produceState(
|
||||
initialValue = AnnotatedString(code),
|
||||
key1 = code,
|
||||
key2 = language,
|
||||
key3 = highlightsBuilder,
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
buildSafeHighlightedAnnotatedString(code, language, highlightsBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
val codeBackgroundCornerSize = LocalMarkdownDimens.current.codeBackgroundCornerSize
|
||||
MarkdownCodeBackground(
|
||||
color = LocalMarkdownColors.current.codeBackground,
|
||||
shape = RoundedCornerShape(codeBackgroundCornerSize),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
showHeader = showHeader,
|
||||
language = language,
|
||||
code = code,
|
||||
) {
|
||||
MarkdownBasicText(
|
||||
text = codeHighlights,
|
||||
style = style,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(LocalMarkdownPadding.current.codeBlock),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Highlights ranges into Compose spans without trusting dependency
|
||||
* offsets. Highlights 1.1.0 can return a reversed multiline-comment range
|
||||
* when a multiline-comment closing delimiter precedes its opening delimiter.
|
||||
* Streaming can expose that shape before a code block is complete.
|
||||
*/
|
||||
internal fun buildSafeHighlightedAnnotatedString(
|
||||
code: String,
|
||||
language: String?,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
): AnnotatedString {
|
||||
val syntaxLanguage = language?.let(SyntaxLanguage::getByName)
|
||||
val highlights = highlightsBuilder
|
||||
.code(code)
|
||||
.let { if (syntaxLanguage != null) it.language(syntaxLanguage) else it }
|
||||
.build()
|
||||
.getHighlights()
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
highlights.forEach { highlight ->
|
||||
val start = highlight.location.start.coerceIn(0, code.length)
|
||||
val end = highlight.location.end.coerceIn(0, code.length)
|
||||
if (start >= end) return@forEach
|
||||
|
||||
val style = when (highlight) {
|
||||
is ColorHighlight -> SpanStyle(color = Color(highlight.rgb).copy(alpha = 1f))
|
||||
is BoldHighlight -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
}
|
||||
addStyle(style = style, start = start, end = end)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,15 @@ data class SourceBadge(val label: String, val color: Color)
|
||||
* The app's own chats — no badge (they're "your" conversations from this app /
|
||||
* desktop / API, not a distinct gateway lane).
|
||||
*/
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "")
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "webui", "")
|
||||
|
||||
/**
|
||||
* Map a session `source` to a drawer badge, or null for the app's own chats and
|
||||
* the phone **Thread** (which renders its own thread-spool chip). External
|
||||
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
|
||||
* small colored chip so the drawer reads like the desktop's per-channel tags.
|
||||
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron,
|
||||
* webhook, phone.
|
||||
* Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
|
||||
* cron, webhook, phone.
|
||||
*/
|
||||
fun sourceBadge(source: String?): SourceBadge? {
|
||||
val s = source?.trim()?.lowercase() ?: return null
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
@@ -140,6 +141,8 @@ fun VoiceWaveform(
|
||||
amplitude: Float,
|
||||
state: VoiceState,
|
||||
outputAudioActive: Boolean = false,
|
||||
height: Dp = 56.dp,
|
||||
compactBars: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// No downstream smoothing. The VoiceViewModel already runs an
|
||||
@@ -154,6 +157,7 @@ fun VoiceWaveform(
|
||||
// doesn't look abrupt.
|
||||
val dim = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val compactColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val targetPrimary = when (state) {
|
||||
VoiceState.Idle -> dim.copy(alpha = 0.3f)
|
||||
@@ -198,16 +202,38 @@ fun VoiceWaveform(
|
||||
)
|
||||
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
val canvasModifier = if (compactBars) {
|
||||
modifier.height(height)
|
||||
} else {
|
||||
modifier.fillMaxWidth().height(height)
|
||||
}
|
||||
Canvas(modifier = canvasModifier) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
if (width <= 0f || height <= 0f) return@Canvas
|
||||
|
||||
val centerY = height / 2f
|
||||
|
||||
if (compactBars) {
|
||||
val barCount = 10
|
||||
val gap = 3.dp.toPx()
|
||||
val barWidth = 2.5.dp.toPx()
|
||||
val totalWidth = barWidth * barCount + gap * (barCount - 1)
|
||||
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
|
||||
repeat(barCount) { index ->
|
||||
val wave = ((sin(phases[0] + index * 0.82f) + 1f) * 0.5f)
|
||||
val activeHeight = height * (0.22f + wave * (0.28f + displayAmplitude * 0.5f))
|
||||
val x = startX + index * (barWidth + gap)
|
||||
drawRoundRect(
|
||||
color = compactColor.copy(alpha = if (index < 5) 0.96f else 0.22f),
|
||||
topLeft = Offset(x, centerY - activeHeight / 2f),
|
||||
size = Size(barWidth, activeHeight),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
|
||||
)
|
||||
}
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
val peakPixels = height * PEAK_FRACTION
|
||||
val strokePx = STROKE_WIDTH_DP.dp.toPx()
|
||||
val centerX = width / 2f
|
||||
|
||||
@@ -115,6 +115,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
@@ -195,8 +196,11 @@ import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
|
||||
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
|
||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||
import com.hermesandroid.relay.ui.components.SlashCommand
|
||||
import com.hermesandroid.relay.ui.components.StreamingDots
|
||||
import com.hermesandroid.relay.ui.components.SubagentLane
|
||||
import com.hermesandroid.relay.ui.components.ToolProgressCard
|
||||
import com.hermesandroid.relay.ui.components.isVisibleForToolDisplay
|
||||
import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
@@ -223,6 +227,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||
|
||||
internal fun resolveChatHeaderSubtitle(
|
||||
isStreaming: Boolean,
|
||||
statusText: String,
|
||||
projectName: String?,
|
||||
personalityName: String?,
|
||||
modelName: String?,
|
||||
): String = if (isStreaming) {
|
||||
statusText
|
||||
} else {
|
||||
listOfNotNull(
|
||||
projectName?.takeIf { it.isNotBlank() },
|
||||
personalityName?.takeIf { it.isNotBlank() },
|
||||
modelName?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" \u00B7 ").ifBlank { statusText }
|
||||
}
|
||||
|
||||
/**
|
||||
* A same-author run breaks into a new visual group once the gap to the
|
||||
* neighboring message exceeds this — so a conversation resumed after a pause
|
||||
@@ -524,6 +545,7 @@ fun ChatScreen(
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
@@ -551,11 +573,12 @@ fun ChatScreen(
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
|
||||
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
@@ -587,7 +610,7 @@ fun ChatScreen(
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
// Whether the NEXT turn would ride the gateway transport — gates the
|
||||
// "Edit & resend" menu entry (conversation rewind needs the gateway).
|
||||
// Per-turn steerability comes from [steerableTurn], which also covers
|
||||
// Per-turn correction availability comes from [steerableTurn], which also covers
|
||||
// the preflight-SSE-fallback window.
|
||||
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
|
||||
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
@@ -1583,12 +1606,16 @@ fun ChatScreen(
|
||||
// yet (server config still loading), fall back to the plain
|
||||
// connection status \u2014 never the literal "None"/"Default"
|
||||
// personality label.
|
||||
val subtitleText = when {
|
||||
!headerChatReady -> statusText
|
||||
else -> listOfNotNull(
|
||||
nonDefaultPersonality,
|
||||
modelName?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" \u00B7 ").ifBlank { statusText }
|
||||
val subtitleText = if (!headerChatReady) {
|
||||
statusText
|
||||
} else {
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = isStreaming,
|
||||
statusText = statusText,
|
||||
projectName = gatewayProjectName,
|
||||
personalityName = nonDefaultPersonality,
|
||||
modelName = modelName,
|
||||
)
|
||||
}
|
||||
val subtitleColor = if (headerChatReady) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -1721,6 +1748,10 @@ fun ChatScreen(
|
||||
transitionSpec = { loadedContentTransform() },
|
||||
label = "chatHeaderSubtitle",
|
||||
) { line ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1728,6 +1759,13 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1929,6 +1967,9 @@ fun ChatScreen(
|
||||
chatReady = chatReady,
|
||||
isLoadingHistory = isLoadingHistory,
|
||||
isLoadingSessions = isLoadingSessions,
|
||||
gatewayAvailability = chatGatewayAvailability,
|
||||
dashboardRouteMovedHint = dashboardRouteMovedHint,
|
||||
onNavigateToManage = onNavigateToManage,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -2315,7 +2356,7 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Steered sends live inside a server-side tool
|
||||
// Legacy steered sends live inside a server-side tool
|
||||
// result, not a user message — flag the local
|
||||
// bubble so the scrollback explains itself.
|
||||
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
|
||||
@@ -2332,12 +2373,21 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (toolDisplay != "off" && !hasBackgroundTask) {
|
||||
if (!hasBackgroundTask) {
|
||||
// Subagent children (taskIndex != null) group
|
||||
// into lanes after the top-level tool cards;
|
||||
// the null group renders exactly as before.
|
||||
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
|
||||
laneGroups[null]?.forEach { toolCall ->
|
||||
// Image generation renders inside MessageBubble
|
||||
// so the progress canvas and final media share
|
||||
// one stable Surface and transition in place.
|
||||
if (toolCall.showsImageGenerationPlaceholder()) {
|
||||
return@forEach
|
||||
}
|
||||
if (!toolCall.isVisibleForToolDisplay(toolDisplay)) {
|
||||
return@forEach
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
when (toolDisplay) {
|
||||
"compact" -> CompactToolCall(toolCall = toolCall)
|
||||
@@ -2347,12 +2397,14 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = laneGroups.getValue(taskIndex),
|
||||
)
|
||||
if (toolDisplay != "off") {
|
||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = laneGroups.getValue(taskIndex),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2680,7 +2732,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Input bar — pill field with ONE trailing slot morphing
|
||||
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker,
|
||||
// Send / Voice / Stop / Correct / Queue. "+" taps the file picker,
|
||||
// long-press opens the CommandPalette (the dedicated "/" button
|
||||
// is gone — typing "/" still surfaces InlineAutocomplete).
|
||||
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
@@ -2707,13 +2759,16 @@ fun ChatScreen(
|
||||
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
|
||||
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
|
||||
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
||||
.distinct()
|
||||
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
|
||||
(apiModelOptions +
|
||||
agentProfiles.mapNotNull { profile ->
|
||||
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
|
||||
} +
|
||||
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
val currentModelForInput = AgentDisplay.displayModelName(selectedModelOverride)
|
||||
val currentModelForInput = apiModelOptions.firstOrNull { it.id == selectedModelOverride }?.id
|
||||
?: AgentDisplay.displayModelName(selectedModelOverride)
|
||||
?: AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||
?: AgentDisplay.displayModelName(effectiveProfile?.model)
|
||||
?: AgentDisplay.displayModelName(serverModelName)
|
||||
@@ -2785,9 +2840,10 @@ fun ChatScreen(
|
||||
sseModelOptions.forEach { model ->
|
||||
add(
|
||||
ChatInputPickerOption(
|
||||
label = model,
|
||||
value = model,
|
||||
selected = selectedModelOverride == model,
|
||||
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
value = model.id,
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2932,7 +2988,11 @@ fun ChatScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modelControl = modelControl,
|
||||
onModelOptionSelected = { option ->
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
|
||||
option.value?.let(chatViewModel::selectApiModel)
|
||||
} else {
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
}
|
||||
},
|
||||
effortControl = effortControl,
|
||||
onEffortOptionSelected = { option ->
|
||||
@@ -2949,7 +3009,11 @@ fun ChatScreen(
|
||||
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
|
||||
onSelect = { option ->
|
||||
showModelSheet = false
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
|
||||
option.value?.let(chatViewModel::selectApiModel)
|
||||
} else {
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
}
|
||||
},
|
||||
onDismiss = { showModelSheet = false },
|
||||
)
|
||||
@@ -3226,6 +3290,9 @@ private fun ChatColdStartLoadingState(
|
||||
chatReady: Boolean,
|
||||
isLoadingHistory: Boolean,
|
||||
isLoadingSessions: Boolean,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
dashboardRouteMovedHint: String?,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -3288,14 +3355,51 @@ private fun ChatColdStartLoadingState(
|
||||
)
|
||||
}
|
||||
|
||||
ChatLoadingCommandPanel(
|
||||
commands = commands,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
)
|
||||
val dashboardSignInRequired =
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
|
||||
if (dashboardSignInRequired) {
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_required_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = dashboardRouteMovedHint?.let { route ->
|
||||
stringResource(R.string.dashboard_signin_route_hint, route)
|
||||
} ?: stringResource(R.string.chat_settings_gateway_needs_signin_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = onNavigateToManage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.voice_settings_sign_in_via_manage))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatLoadingCommandPanel(
|
||||
commands = commands,
|
||||
onNavigateToConnections = onNavigateToConnections,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
|
||||
EnableMcp,
|
||||
DisableMcp,
|
||||
TestMcp,
|
||||
AuthenticateMcp,
|
||||
RemoveMcp,
|
||||
InstallMcpCatalog,
|
||||
ViewProfileSoul,
|
||||
ActivateProfile,
|
||||
DeleteProfile,
|
||||
EditCustomEndpoint,
|
||||
ValidateCustomEndpoint,
|
||||
ActivateCustomEndpoint,
|
||||
DeleteCustomEndpoint,
|
||||
|
||||
// Input-backed kinds — intercepted before runAction and routed to a
|
||||
// text-input or model-picker dialog instead of firing immediately.
|
||||
|
||||
+538
-17
@@ -2,6 +2,8 @@
|
||||
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
@@ -96,9 +98,12 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCustomEndpointDraft
|
||||
import com.hermesandroid.relay.network.upstream.McpOAuthFlowCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
@@ -114,7 +119,10 @@ import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import com.hermesandroid.relay.ui.theme.relayPanel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.DashboardManageOAuthViewModel
|
||||
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
@@ -123,6 +131,7 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -136,6 +145,7 @@ private enum class DashboardManagementSection(val path: String) {
|
||||
Cron("/api/cron/jobs"),
|
||||
Mcp("/api/mcp/servers"),
|
||||
Catalog("/api/mcp/catalog"),
|
||||
CustomEndpoints("/api/providers/custom-endpoints"),
|
||||
Profiles("/api/profiles"),
|
||||
Models("/api/model/info"),
|
||||
Keys("/api/env"),
|
||||
@@ -147,6 +157,7 @@ private enum class DashboardManagementSection(val path: String) {
|
||||
Cron -> stringResource(R.string.dashboard_tab_cron)
|
||||
Mcp -> stringResource(R.string.dashboard_tab_mcp)
|
||||
Catalog -> stringResource(R.string.dashboard_tab_catalog)
|
||||
CustomEndpoints -> stringResource(R.string.dashboard_tab_custom_endpoints)
|
||||
Profiles -> stringResource(R.string.dashboard_tab_profiles)
|
||||
Models -> stringResource(R.string.dashboard_tab_models)
|
||||
Keys -> stringResource(R.string.dashboard_tab_keys)
|
||||
@@ -160,6 +171,7 @@ private enum class DashboardManagementSection(val path: String) {
|
||||
Cron -> stringResource(R.string.dashboard_tab_cron_lower)
|
||||
Mcp -> stringResource(R.string.dashboard_tab_mcp_lower)
|
||||
Catalog -> stringResource(R.string.dashboard_tab_catalog_lower)
|
||||
CustomEndpoints -> stringResource(R.string.dashboard_tab_custom_endpoints_lower)
|
||||
Profiles -> stringResource(R.string.dashboard_tab_profiles_lower)
|
||||
Models -> stringResource(R.string.dashboard_tab_models_lower)
|
||||
Keys -> stringResource(R.string.dashboard_tab_keys_lower)
|
||||
@@ -169,6 +181,55 @@ private enum class DashboardManagementSection(val path: String) {
|
||||
|
||||
private val managementSections: List<DashboardManagementSection> = DashboardManagementSection.entries
|
||||
|
||||
internal fun dashboardSectionRequestPath(path: String, profile: String?): String {
|
||||
if (profile.isNullOrBlank() || path !in setOf("/api/mcp/servers", "/api/mcp/catalog")) return path
|
||||
val encoded = java.net.URLEncoder.encode(profile, Charsets.UTF_8.name()).replace("+", "%20")
|
||||
return "$path?profile=$encoded"
|
||||
}
|
||||
|
||||
internal fun resolveMcpOAuthDialogItem(
|
||||
requested: DashboardSummaryItem?,
|
||||
pending: PendingMcpOAuth?,
|
||||
): DashboardSummaryItem? = pending?.let { flow ->
|
||||
DashboardSummaryItem(
|
||||
id = flow.serverName,
|
||||
title = flow.serverName,
|
||||
profile = flow.profile,
|
||||
)
|
||||
} ?: requested
|
||||
|
||||
internal fun canStartMcpOAuth(capabilitySupported: Boolean, pending: PendingMcpOAuth?): Boolean =
|
||||
capabilitySupported && pending == null
|
||||
|
||||
/** Stable, non-secret owner identity for a dashboard-hosted OAuth flow. */
|
||||
internal fun mcpOAuthRouteIdentity(connectionId: String, dashboardUrl: String): String? {
|
||||
if (connectionId.isBlank()) return null
|
||||
val url = dashboardUrl.trim().toHttpUrlOrNull() ?: return null
|
||||
val normalized = url.newBuilder()
|
||||
.username("")
|
||||
.password("")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
return "$connectionId|$normalized"
|
||||
}
|
||||
|
||||
internal fun canResumeMcpOAuth(pending: PendingMcpOAuth?, currentRouteIdentity: String?): Boolean =
|
||||
pending != null && currentRouteIdentity != null && pending.routeIdentity == currentRouteIdentity
|
||||
|
||||
internal fun scopeDashboardManageItems(
|
||||
sectionPath: String,
|
||||
profile: String?,
|
||||
items: List<DashboardSummaryItem>,
|
||||
): List<DashboardSummaryItem> =
|
||||
if (sectionPath == "/api/mcp/servers" || sectionPath == "/api/mcp/catalog") {
|
||||
items.map { it.copy(profile = profile) }
|
||||
} else {
|
||||
items
|
||||
}
|
||||
|
||||
private sealed interface DashboardPayloadState {
|
||||
data object Idle : DashboardPayloadState
|
||||
data object Loading : DashboardPayloadState
|
||||
@@ -287,6 +348,7 @@ private enum class DashboardSectionAction {
|
||||
CreateProfile,
|
||||
BrowseSkillsHub,
|
||||
UpdateSkillsHub,
|
||||
AddCustomEndpoint,
|
||||
}
|
||||
|
||||
/** Editor session for a profile's SOUL.md — content is the FULL file from GET. */
|
||||
@@ -322,6 +384,7 @@ fun DashboardManagementScreen(
|
||||
onNavigateToBridge: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
oauthViewModel: DashboardManageOAuthViewModel = viewModel(),
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -331,6 +394,10 @@ fun DashboardManagementScreen(
|
||||
// dashboard off the connection's persisted URL — drives the target line
|
||||
// and the per-host sign-in explanation below.
|
||||
val dashboardRouteHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
val effectiveProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val pendingMcpOAuth by oauthViewModel.pending.collectAsState()
|
||||
val unsupportedOAuthRoutes by oauthViewModel.unsupportedRoutes.collectAsState()
|
||||
val supportedOAuthRoutes by oauthViewModel.supportedRoutes.collectAsState()
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var showingDetail by remember { mutableStateOf(false) }
|
||||
var reloadNonce by remember { mutableStateOf(0) }
|
||||
@@ -349,11 +416,23 @@ fun DashboardManagementScreen(
|
||||
var expensiveModelConfirm by remember { mutableStateOf<ExpensiveModelConfirm?>(null) }
|
||||
var showSkillsHub by remember { mutableStateOf(false) }
|
||||
var soulEditor by remember { mutableStateOf<SoulEditorState?>(null) }
|
||||
var oauthMcpItem by remember { mutableStateOf<DashboardSummaryItem?>(null) }
|
||||
var oauthDialogHidden by remember(pendingMcpOAuth?.flowId) { mutableStateOf(false) }
|
||||
var customEndpointEditor by remember { mutableStateOf<DashboardSummaryItem?>(null) }
|
||||
var showCustomEndpointEditor by remember { mutableStateOf(false) }
|
||||
|
||||
val section = managementSections[selectedTab]
|
||||
val connectionId = activeConnection?.id ?: "default"
|
||||
val currentMcpOAuthRouteIdentity = mcpOAuthRouteIdentity(connectionId, dashboardUrl)
|
||||
val oauthRouteKey = "${currentMcpOAuthRouteIdentity.orEmpty()}|${effectiveProfileName.orEmpty()}"
|
||||
val mcpOAuthSupported = oauthRouteKey in supportedOAuthRoutes
|
||||
val mcpOAuthStartAllowed = canStartMcpOAuth(mcpOAuthSupported, pendingMcpOAuth)
|
||||
fun payloadKeyFor(targetSection: DashboardManagementSection): String =
|
||||
dashboardPayloadKey(connectionId, dashboardUrl, targetSection.path)
|
||||
dashboardPayloadKey(
|
||||
connectionId,
|
||||
dashboardUrl,
|
||||
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
|
||||
)
|
||||
|
||||
val payloadKey = payloadKeyFor(section)
|
||||
val payloadState = payloadStates[payloadKey] ?: DashboardPayloadState.Idle
|
||||
@@ -436,6 +515,7 @@ fun DashboardManagementScreen(
|
||||
val nextState = fetchDashboardSectionState(
|
||||
clientFactory = clientFactory,
|
||||
targetSection = targetSection,
|
||||
effectiveProfileName = effectiveProfileName,
|
||||
preamble = preamble,
|
||||
context = context,
|
||||
recordStatus = { status: DashboardStatus?, session: DashboardAuthSession?, gatewayTicketAvailable: Boolean? ->
|
||||
@@ -711,7 +791,7 @@ fun DashboardManagementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dashboardUrl, selectedTab, reloadNonce, activeConnection?.id) {
|
||||
LaunchedEffect(dashboardUrl, selectedTab, reloadNonce, activeConnection?.id, effectiveProfileName) {
|
||||
val forceCurrent = forceReloadKey == payloadKey
|
||||
loadDashboardSection(
|
||||
targetSection = section,
|
||||
@@ -724,7 +804,7 @@ fun DashboardManagementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dashboardUrl, activeConnection?.id, payloadState) {
|
||||
LaunchedEffect(dashboardUrl, activeConnection?.id, payloadState, effectiveProfileName) {
|
||||
val loadedState = payloadState as? DashboardPayloadState.Loaded ?: return@LaunchedEffect
|
||||
if (dashboardUrl.isBlank()) return@LaunchedEffect
|
||||
if (loadedState.status?.authRequired == true && loadedState.session?.authenticated != true) {
|
||||
@@ -759,6 +839,27 @@ fun DashboardManagementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(oauthRouteKey, payloadState) {
|
||||
val loadedState = payloadState as? DashboardPayloadState.Loaded ?: return@LaunchedEffect
|
||||
if (dashboardUrl.isBlank() || oauthRouteKey in supportedOAuthRoutes || oauthRouteKey in unsupportedOAuthRoutes) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (loadedState.status?.authRequired == true && loadedState.session?.authenticated != true) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val capability = try {
|
||||
withDashboardClient(clientFactory) { client -> client.supportsHostedMcpOAuth() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
capability.onSuccess { supported ->
|
||||
if (supported) oauthViewModel.markSupported(oauthRouteKey)
|
||||
else oauthViewModel.markUnsupported(oauthRouteKey)
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction?.let { pending ->
|
||||
val isActivateProfile = pending.action.kind == DashboardActionKind.ActivateProfile
|
||||
val actionLabel = dashboardActionLabel(pending.action)
|
||||
@@ -959,6 +1060,58 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val oauthDialogItem = resolveMcpOAuthDialogItem(oauthMcpItem, pendingMcpOAuth)
|
||||
if (oauthDialogItem != null &&
|
||||
!oauthDialogHidden &&
|
||||
(pendingMcpOAuth == null || canResumeMcpOAuth(pendingMcpOAuth, currentMcpOAuthRouteIdentity))
|
||||
) {
|
||||
McpOAuthDialog(
|
||||
item = oauthDialogItem,
|
||||
effectiveProfileName = effectiveProfileName,
|
||||
pending = pendingMcpOAuth,
|
||||
currentRouteIdentity = currentMcpOAuthRouteIdentity,
|
||||
clientFactory = clientFactory,
|
||||
onPending = { flow ->
|
||||
oauthViewModel.remember(flow.flowId, flow.serverName, flow.profile, flow.routeIdentity)
|
||||
},
|
||||
onApproved = {
|
||||
oauthViewModel.clear()
|
||||
oauthMcpItem = null
|
||||
forceReloadKey = payloadKey
|
||||
reloadNonce += 1
|
||||
actionMessage = context.getString(R.string.dashboard_mcp_oauth_approved)
|
||||
},
|
||||
onFlowFailed = { failure ->
|
||||
oauthViewModel.clear()
|
||||
oauthMcpItem = null
|
||||
oauthDialogHidden = true
|
||||
actionMessage = failure
|
||||
},
|
||||
onDismiss = {
|
||||
oauthMcpItem = null
|
||||
oauthDialogHidden = true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showCustomEndpointEditor) {
|
||||
CustomEndpointDialog(
|
||||
existing = customEndpointEditor,
|
||||
clientFactory = clientFactory,
|
||||
onSaved = {
|
||||
showCustomEndpointEditor = false
|
||||
customEndpointEditor = null
|
||||
forceReloadKey = payloadKey
|
||||
reloadNonce += 1
|
||||
actionMessage = context.getString(R.string.dashboard_custom_endpoint_saved)
|
||||
},
|
||||
onDismiss = {
|
||||
showCustomEndpointEditor = false
|
||||
customEndpointEditor = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmClearDashboardSession) {
|
||||
val clearedMsg = stringResource(R.string.dashboard_session_cleared)
|
||||
AlertDialog(
|
||||
@@ -1139,11 +1292,23 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
DashboardActionKind.EditProfileSoul ->
|
||||
openSoulEditor(item)
|
||||
DashboardActionKind.AuthenticateMcp ->
|
||||
if (mcpOAuthStartAllowed) {
|
||||
oauthDialogHidden = false
|
||||
oauthMcpItem = item.copy(profile = effectiveProfileName)
|
||||
}
|
||||
DashboardActionKind.EditCustomEndpoint,
|
||||
DashboardActionKind.ValidateCustomEndpoint -> {
|
||||
customEndpointEditor = item
|
||||
showCustomEndpointEditor = true
|
||||
}
|
||||
// Always confirm: this flips the
|
||||
// server's persistent active agent for
|
||||
// every client, unlike the ephemeral
|
||||
// per-conversation switch in chat.
|
||||
DashboardActionKind.ActivateProfile ->
|
||||
DashboardActionKind.ActivateProfile,
|
||||
DashboardActionKind.ActivateCustomEndpoint,
|
||||
DashboardActionKind.DeleteCustomEndpoint ->
|
||||
pendingAction = PendingDashboardAction(item, action)
|
||||
else -> if (action.destructive) {
|
||||
pendingAction = PendingDashboardAction(item, action)
|
||||
@@ -1162,8 +1327,13 @@ fun DashboardManagementScreen(
|
||||
showSkillsHub = true
|
||||
DashboardSectionAction.UpdateSkillsHub ->
|
||||
runUpdateSkillsHub()
|
||||
DashboardSectionAction.AddCustomEndpoint -> {
|
||||
customEndpointEditor = null
|
||||
showCustomEndpointEditor = true
|
||||
}
|
||||
}
|
||||
},
|
||||
mcpOAuthSupported = mcpOAuthStartAllowed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1233,6 +1403,11 @@ private fun manageTileSpec(section: DashboardManagementSection): ManageTileSpec
|
||||
title = stringResource(R.string.dashboard_tile_catalog_title),
|
||||
subtitle = stringResource(R.string.dashboard_tile_catalog_sub),
|
||||
)
|
||||
DashboardManagementSection.CustomEndpoints -> ManageTileSpec(
|
||||
icon = Icons.Filled.Link,
|
||||
title = stringResource(R.string.dashboard_tile_custom_endpoints_title),
|
||||
subtitle = stringResource(R.string.dashboard_tile_custom_endpoints_sub),
|
||||
)
|
||||
DashboardManagementSection.Models -> ManageTileSpec(
|
||||
icon = Icons.Filled.Tune,
|
||||
title = stringResource(R.string.dashboard_tile_models_title),
|
||||
@@ -1340,6 +1515,39 @@ private fun ManageOverviewBody(
|
||||
onClearSession = onClearSession,
|
||||
)
|
||||
}
|
||||
if (status?.nousSessionValid == "terminal") {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_nous_terminal_warning),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status?.gatewayMode != null || status?.profiles?.isNotEmpty() == true) {
|
||||
item {
|
||||
val mode = status.gatewayMode ?: "unknown"
|
||||
val profiles = status.profiles.joinToString().ifBlank { "—" }
|
||||
val ports = status.gateways.flatMap { gateway ->
|
||||
gateway.ports.map { (platform, port) -> "${gateway.profile}/$platform:$port" }
|
||||
}.joinToString().ifBlank { "—" }
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_gateway_topology, mode, profiles, ports),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
val signInStatus = status
|
||||
if (signInStatus?.authRequired == true && authenticated != true) {
|
||||
item {
|
||||
@@ -1406,6 +1614,14 @@ private fun ManageOverviewBody(
|
||||
onClick = { onSelectSection(DashboardManagementSection.Models) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Link,
|
||||
title = stringResource(R.string.dashboard_tile_custom_endpoints_title),
|
||||
subtitle = stringResource(R.string.dashboard_tile_custom_endpoints_sub),
|
||||
onClick = { onSelectSection(DashboardManagementSection.CustomEndpoints) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Key,
|
||||
@@ -1622,6 +1838,7 @@ private suspend fun fetchDashboardPreamble(client: DashboardApiClient): Dashboar
|
||||
private suspend fun fetchDashboardSectionState(
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
targetSection: DashboardManagementSection,
|
||||
effectiveProfileName: String? = null,
|
||||
preamble: DashboardPreamble? = null,
|
||||
recordStatus: (DashboardStatus?, DashboardAuthSession?, Boolean?) -> Unit = { _, _, _ -> },
|
||||
context: android.content.Context,
|
||||
@@ -1629,6 +1846,7 @@ private suspend fun fetchDashboardSectionState(
|
||||
fetchDashboardSectionStateWith(
|
||||
client = client,
|
||||
targetSection = targetSection,
|
||||
effectiveProfileName = effectiveProfileName,
|
||||
preamble = preamble,
|
||||
recordStatus = recordStatus,
|
||||
context = context,
|
||||
@@ -1639,6 +1857,7 @@ private suspend fun fetchDashboardSectionState(
|
||||
private suspend fun fetchDashboardSectionStateWith(
|
||||
client: DashboardApiClient,
|
||||
targetSection: DashboardManagementSection,
|
||||
effectiveProfileName: String? = null,
|
||||
preamble: DashboardPreamble? = null,
|
||||
recordStatus: (DashboardStatus?, DashboardAuthSession?, Boolean?) -> Unit = { _, _, _ -> },
|
||||
context: android.content.Context,
|
||||
@@ -1651,13 +1870,19 @@ private suspend fun fetchDashboardSectionStateWith(
|
||||
status = resolved.status,
|
||||
)
|
||||
} else {
|
||||
val result = client.getJsonElement(targetSection.path)
|
||||
val result = client.getJsonElement(
|
||||
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { root ->
|
||||
DashboardPayloadState.Loaded(
|
||||
status = resolved.status,
|
||||
session = resolved.session,
|
||||
items = summarize(targetSection, root),
|
||||
items = scopeDashboardManageItems(
|
||||
targetSection.path,
|
||||
effectiveProfileName,
|
||||
summarize(targetSection, root),
|
||||
),
|
||||
rawSummary = summarizeRoot(root),
|
||||
fetchedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -1694,13 +1919,18 @@ internal suspend fun prewarmDashboardManage(
|
||||
cookieStore: DashboardCookieStore,
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
effectiveProfileName: String? = null,
|
||||
/** When non-null, the sweep's results are mirrored to the disk cache. */
|
||||
cacheDir: java.io.File? = null,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
if (dashboardUrl.isBlank()) return
|
||||
fun needsWarm(targetSection: DashboardManagementSection): Boolean {
|
||||
val key = dashboardPayloadKey(connectionId, dashboardUrl, targetSection.path)
|
||||
val key = dashboardPayloadKey(
|
||||
connectionId,
|
||||
dashboardUrl,
|
||||
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
|
||||
)
|
||||
return when (val existing = DashboardPayloadCache.states[key]) {
|
||||
is DashboardPayloadState.Loading -> false
|
||||
is DashboardPayloadState.Loaded ->
|
||||
@@ -1736,13 +1966,14 @@ internal suspend fun prewarmDashboardManage(
|
||||
val key = dashboardPayloadKey(
|
||||
connectionId,
|
||||
dashboardUrl,
|
||||
targetSection.path,
|
||||
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
|
||||
)
|
||||
if (!needsWarm(targetSection)) return@launch
|
||||
val state = try {
|
||||
fetchDashboardSectionStateWith(
|
||||
client = client,
|
||||
targetSection = targetSection,
|
||||
effectiveProfileName = effectiveProfileName,
|
||||
preamble = preamble,
|
||||
context = context,
|
||||
)
|
||||
@@ -1942,12 +2173,14 @@ private fun LoadedBody(
|
||||
actionMessage: String?,
|
||||
onAction: (DashboardSummaryItem, DashboardItemAction) -> Unit,
|
||||
onSectionAction: (DashboardSectionAction) -> Unit = {},
|
||||
mcpOAuthSupported: Boolean = true,
|
||||
) {
|
||||
// Pre-resolve action labels outside LazyColumn's non-Composable lambda
|
||||
val actionLabelChangeMainModel = stringResource(R.string.dashboard_section_action_change_main_model)
|
||||
val actionLabelNewProfile = stringResource(R.string.dashboard_section_action_new_profile)
|
||||
val actionLabelBrowseHub = stringResource(R.string.dashboard_section_action_browse_hub)
|
||||
val actionLabelUpdateInstalled = stringResource(R.string.dashboard_section_action_update_installed)
|
||||
val actionLabelAddEndpoint = stringResource(R.string.dashboard_custom_endpoint_add)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
@@ -1972,6 +2205,9 @@ private fun LoadedBody(
|
||||
DashboardSectionAction.BrowseSkillsHub to actionLabelBrowseHub,
|
||||
DashboardSectionAction.UpdateSkillsHub to actionLabelUpdateInstalled,
|
||||
)
|
||||
DashboardManagementSection.CustomEndpoints -> listOf(
|
||||
DashboardSectionAction.AddCustomEndpoint to actionLabelAddEndpoint,
|
||||
)
|
||||
else -> emptyList()
|
||||
}
|
||||
if (sectionActions.isNotEmpty()) {
|
||||
@@ -2015,10 +2251,13 @@ private fun LoadedBody(
|
||||
}
|
||||
} else {
|
||||
items(state.items) { item ->
|
||||
val renderedItem = if (!mcpOAuthSupported) {
|
||||
item.copy(actions = item.actions.filterNot { it.kind == DashboardActionKind.AuthenticateMcp })
|
||||
} else item
|
||||
DashboardSummaryCard(
|
||||
item = item,
|
||||
item = renderedItem,
|
||||
actionInFlight = actionInFlight,
|
||||
onAction = { action -> onAction(item, action) },
|
||||
onAction = { action -> onAction(renderedItem, action) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2049,6 +2288,7 @@ private fun dashboardActionLabel(kind: DashboardActionKind): String = when (kind
|
||||
DashboardActionKind.EnableMcp -> stringResource(R.string.dashboard_action_enable)
|
||||
DashboardActionKind.DisableMcp -> stringResource(R.string.dashboard_action_disable)
|
||||
DashboardActionKind.TestMcp -> stringResource(R.string.dashboard_action_test)
|
||||
DashboardActionKind.AuthenticateMcp -> stringResource(R.string.dashboard_action_authenticate)
|
||||
DashboardActionKind.RemoveMcp -> stringResource(R.string.dashboard_action_remove)
|
||||
DashboardActionKind.ViewProfileSoul -> stringResource(R.string.dashboard_action_soul)
|
||||
DashboardActionKind.EditProfileSoul -> stringResource(R.string.dashboard_action_edit_soul)
|
||||
@@ -2058,6 +2298,10 @@ private fun dashboardActionLabel(kind: DashboardActionKind): String = when (kind
|
||||
DashboardActionKind.EnableSkill -> stringResource(R.string.dashboard_action_enable)
|
||||
DashboardActionKind.DisableSkill -> stringResource(R.string.dashboard_action_disable)
|
||||
DashboardActionKind.DeleteProfile -> stringResource(R.string.dashboard_action_delete)
|
||||
DashboardActionKind.EditCustomEndpoint -> stringResource(R.string.dashboard_action_edit)
|
||||
DashboardActionKind.ValidateCustomEndpoint -> stringResource(R.string.dashboard_action_validate)
|
||||
DashboardActionKind.ActivateCustomEndpoint -> stringResource(R.string.dashboard_action_use)
|
||||
DashboardActionKind.DeleteCustomEndpoint -> stringResource(R.string.dashboard_action_delete)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2078,6 +2322,7 @@ private fun dashboardActionLabel(context: android.content.Context, kind: Dashboa
|
||||
DashboardActionKind.EnableMcp -> context.getString(R.string.dashboard_action_enable)
|
||||
DashboardActionKind.DisableMcp -> context.getString(R.string.dashboard_action_disable)
|
||||
DashboardActionKind.TestMcp -> context.getString(R.string.dashboard_action_test)
|
||||
DashboardActionKind.AuthenticateMcp -> context.getString(R.string.dashboard_action_authenticate)
|
||||
DashboardActionKind.RemoveMcp -> context.getString(R.string.dashboard_action_remove)
|
||||
DashboardActionKind.ViewProfileSoul -> context.getString(R.string.dashboard_action_soul)
|
||||
DashboardActionKind.EditProfileSoul -> context.getString(R.string.dashboard_action_edit_soul)
|
||||
@@ -2087,6 +2332,10 @@ private fun dashboardActionLabel(context: android.content.Context, kind: Dashboa
|
||||
DashboardActionKind.EnableSkill -> context.getString(R.string.dashboard_action_enable)
|
||||
DashboardActionKind.DisableSkill -> context.getString(R.string.dashboard_action_disable)
|
||||
DashboardActionKind.DeleteProfile -> context.getString(R.string.dashboard_action_delete)
|
||||
DashboardActionKind.EditCustomEndpoint -> context.getString(R.string.dashboard_action_edit)
|
||||
DashboardActionKind.ValidateCustomEndpoint -> context.getString(R.string.dashboard_action_validate)
|
||||
DashboardActionKind.ActivateCustomEndpoint -> context.getString(R.string.dashboard_action_use)
|
||||
DashboardActionKind.DeleteCustomEndpoint -> context.getString(R.string.dashboard_action_delete)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -2564,6 +2813,10 @@ internal data class ModelProviderOption(
|
||||
*/
|
||||
internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
|
||||
val providers = root["providers"] as? JsonArray ?: return emptyList()
|
||||
val excludedProviders = (root["excluded_providers"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank) }
|
||||
.map { it.lowercase() }
|
||||
.toSet()
|
||||
return providers.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("slug")
|
||||
@@ -2571,6 +2824,10 @@ internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
|
||||
?: obj.stringField("provider")
|
||||
?: obj.stringField("name")
|
||||
?: return@mapNotNull null
|
||||
if (id.lowercase() in excludedProviders) return@mapNotNull null
|
||||
if (obj.booleanField("enabled") == false || obj.booleanField("excluded") == true) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val models = (obj["models"] as? JsonArray)?.mapNotNull { modelElement ->
|
||||
when (modelElement) {
|
||||
is JsonPrimitive -> modelElement.contentOrNull
|
||||
@@ -3067,6 +3324,228 @@ private fun SkillsHubDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun McpOAuthDialog(
|
||||
item: DashboardSummaryItem,
|
||||
effectiveProfileName: String?,
|
||||
pending: PendingMcpOAuth?,
|
||||
currentRouteIdentity: String?,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onPending: (PendingMcpOAuth) -> Unit,
|
||||
onApproved: () -> Unit,
|
||||
onFlowFailed: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var running by remember(item, pending?.flowId) { mutableStateOf(pending != null) }
|
||||
var message by remember(item) { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(pending?.flowId, currentRouteIdentity) {
|
||||
val flow = pending ?: return@LaunchedEffect
|
||||
if (!canResumeMcpOAuth(flow, currentRouteIdentity)) return@LaunchedEffect
|
||||
running = true
|
||||
val result = try {
|
||||
withDashboardClient(clientFactory) { client ->
|
||||
McpOAuthFlowCoordinator(client).resume(flow.flowId)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { onApproved() },
|
||||
onFailure = { error ->
|
||||
running = false
|
||||
onFlowFailed(error.message ?: context.getString(R.string.dashboard_mcp_oauth_failed))
|
||||
},
|
||||
)
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.dashboard_mcp_oauth_title, item.title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.dashboard_mcp_oauth_body))
|
||||
message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (running) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !running,
|
||||
onClick = {
|
||||
running = true
|
||||
message = null
|
||||
scope.launch {
|
||||
val result = try {
|
||||
withDashboardClient(clientFactory) { client ->
|
||||
McpOAuthFlowCoordinator(client).start(
|
||||
serverName = item.id.ifBlank { item.title },
|
||||
profile = effectiveProfileName,
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { started ->
|
||||
if (started.status == "approved") {
|
||||
onApproved()
|
||||
return@fold
|
||||
}
|
||||
val authorizationUrl = McpOAuthFlowCoordinator.validatedAuthorizationUrl(started)
|
||||
authorizationUrl.fold(
|
||||
onSuccess = authorization@{ url ->
|
||||
val serverName = started.serverName.ifBlank { item.id.ifBlank { item.title } }
|
||||
val routeIdentity = currentRouteIdentity
|
||||
if (routeIdentity == null) {
|
||||
running = false
|
||||
message = context.getString(R.string.dashboard_mcp_oauth_failed)
|
||||
return@authorization
|
||||
}
|
||||
onPending(
|
||||
PendingMcpOAuth(
|
||||
started.flowId,
|
||||
serverName,
|
||||
effectiveProfileName,
|
||||
routeIdentity,
|
||||
),
|
||||
)
|
||||
val opened = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}.isSuccess
|
||||
if (!opened) {
|
||||
onFlowFailed(context.getString(R.string.dashboard_mcp_oauth_no_browser))
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
running = false
|
||||
message = error.message ?: context.getString(R.string.dashboard_mcp_oauth_failed)
|
||||
},
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
running = false
|
||||
message = error.message ?: context.getString(R.string.dashboard_mcp_oauth_failed)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.dashboard_action_authenticate)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dashboard_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomEndpointDialog(
|
||||
existing: DashboardSummaryItem?,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onSaved: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(existing) { mutableStateOf(existing?.title.orEmpty()) }
|
||||
var baseUrl by remember(existing) { mutableStateOf(existing?.subtitle.orEmpty()) }
|
||||
var model by remember(existing) {
|
||||
mutableStateOf(existing?.meta?.substringAfter("model=", "")?.substringBefore(" · ").orEmpty())
|
||||
}
|
||||
var apiKey by remember(existing) { mutableStateOf("") }
|
||||
var contextLength by remember(existing) {
|
||||
mutableStateOf(existing?.meta?.substringAfter("context=", "")?.substringBefore(" · ").orEmpty())
|
||||
}
|
||||
var discoverModels by remember(existing) {
|
||||
mutableStateOf(existing?.meta?.contains("discover=off") != true)
|
||||
}
|
||||
var busy by remember(existing) { mutableStateOf(false) }
|
||||
var message by remember(existing) { mutableStateOf<String?>(null) }
|
||||
|
||||
fun draft() = DashboardCustomEndpointDraft(
|
||||
id = existing?.id,
|
||||
name = name.trim(),
|
||||
baseUrl = baseUrl.trim(),
|
||||
model = model.trim(),
|
||||
apiKey = apiKey.takeIf { it.isNotBlank() },
|
||||
contextLength = contextLength.toIntOrNull(),
|
||||
discoverModels = discoverModels,
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onDismiss() },
|
||||
title = { Text(stringResource(if (existing == null) R.string.dashboard_custom_endpoint_add else R.string.dashboard_custom_endpoint_edit)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
|
||||
OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) }, enabled = !busy)
|
||||
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_key)) },
|
||||
supportingText = { Text(stringResource(R.string.dashboard_custom_endpoint_key_help)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(contextLength, { contextLength = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_context)) }, enabled = !busy)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(discoverModels, { discoverModels = it }, enabled = !busy)
|
||||
Text(stringResource(R.string.dashboard_custom_endpoint_discover))
|
||||
}
|
||||
message?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
if (busy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row {
|
||||
TextButton(
|
||||
enabled = !busy && baseUrl.isNotBlank(),
|
||||
onClick = {
|
||||
busy = true
|
||||
scope.launch {
|
||||
val result = withDashboardClient(clientFactory) { it.validateCustomEndpoint(draft()) }
|
||||
busy = false
|
||||
message = result.fold(
|
||||
onSuccess = { validation ->
|
||||
validation.message.ifBlank {
|
||||
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
|
||||
}
|
||||
},
|
||||
onFailure = { it.message ?: context.getString(R.string.dashboard_custom_endpoint_validate_failed) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.dashboard_action_validate)) }
|
||||
TextButton(
|
||||
enabled = !busy && name.isNotBlank() && baseUrl.isNotBlank() && model.isNotBlank(),
|
||||
onClick = {
|
||||
busy = true
|
||||
scope.launch {
|
||||
val result = withDashboardClient(clientFactory) { it.saveCustomEndpoint(draft()) }
|
||||
result.fold(onSuccess = { onSaved() }, onFailure = {
|
||||
busy = false
|
||||
message = it.message ?: context.getString(R.string.dashboard_custom_endpoint_save_failed)
|
||||
})
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.dashboard_save)) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.dashboard_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-file SOUL.md editor. The dashboard GET returns the complete file (no
|
||||
* truncation), so saving the edited buffer back is a lossless round-trip.
|
||||
@@ -3151,6 +3630,7 @@ private fun summarize(
|
||||
DashboardManagementSection.Catalog -> root.arrayItems("entries", "catalog", "items")
|
||||
?.mapIndexed { index, item -> summarizeObjectItem(item, "Catalog ${index + 1}") }
|
||||
?: emptyList()
|
||||
DashboardManagementSection.CustomEndpoints -> summarizeCustomEndpoints(root)
|
||||
DashboardManagementSection.Profiles -> {
|
||||
root.arrayItems("profiles", "items")
|
||||
?.mapIndexed { index, item -> summarizeObjectItem(item, "Profile ${index + 1}") }
|
||||
@@ -3252,7 +3732,7 @@ private fun summarizeObjectItem(
|
||||
)
|
||||
}
|
||||
|
||||
private fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
|
||||
internal fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
|
||||
val hasSchedule = obj["schedule"] != null || obj["cron"] != null || obj.stringField("next_run") != null
|
||||
val hasTransport = obj.stringField("transport") != null ||
|
||||
obj.stringField("command") != null ||
|
||||
@@ -3290,6 +3770,9 @@ private fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
|
||||
} else {
|
||||
add(DashboardItemAction("Disable", DashboardActionKind.DisableMcp))
|
||||
}
|
||||
if (obj.stringField("auth") == "oauth") {
|
||||
add(DashboardItemAction("Authenticate", DashboardActionKind.AuthenticateMcp))
|
||||
}
|
||||
add(DashboardItemAction("Test", DashboardActionKind.TestMcp))
|
||||
add(DashboardItemAction("Remove", DashboardActionKind.RemoveMcp, destructive = true))
|
||||
}
|
||||
@@ -3329,6 +3812,31 @@ private fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryItem> =
|
||||
root.arrayItems("endpoints")?.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("id") ?: return@mapNotNull null
|
||||
val model = obj.stringField("model").orEmpty()
|
||||
DashboardSummaryItem(
|
||||
id = id,
|
||||
title = obj.stringField("name") ?: id,
|
||||
subtitle = obj.stringField("base_url"),
|
||||
meta = listOfNotNull(
|
||||
model.takeIf { it.isNotBlank() }?.let { "model=$it" },
|
||||
obj.booleanField("is_current")?.takeIf { it }?.let { "active" },
|
||||
obj.booleanField("has_api_key")?.takeIf { it }?.let { "credential set" },
|
||||
obj.intField("context_length")?.let { "context=$it" },
|
||||
obj.booleanField("discover_models")?.takeIf { !it }?.let { "discover=off" },
|
||||
).joinToString(" · ").takeIf { it.isNotBlank() },
|
||||
actions = listOf(
|
||||
DashboardItemAction("Edit", DashboardActionKind.EditCustomEndpoint),
|
||||
DashboardItemAction("Validate", DashboardActionKind.ValidateCustomEndpoint),
|
||||
DashboardItemAction("Use", DashboardActionKind.ActivateCustomEndpoint, destructive = true),
|
||||
DashboardItemAction("Delete", DashboardActionKind.DeleteCustomEndpoint, destructive = true),
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
private fun summarizeRoot(root: JsonElement): String {
|
||||
return when (root) {
|
||||
is JsonObject -> {
|
||||
@@ -3382,7 +3890,8 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
|
||||
)
|
||||
DashboardActionKind.DeleteCron,
|
||||
DashboardActionKind.RemoveMcp,
|
||||
DashboardActionKind.DeleteProfile -> null
|
||||
DashboardActionKind.DeleteProfile,
|
||||
DashboardActionKind.DeleteCustomEndpoint -> null
|
||||
DashboardActionKind.InstallMcpCatalog -> copy(
|
||||
meta = appendMeta(meta, "installed"),
|
||||
actions = emptyList(),
|
||||
@@ -3394,6 +3903,7 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
|
||||
DashboardActionKind.ViewCronRuns,
|
||||
DashboardActionKind.TriggerCron,
|
||||
DashboardActionKind.TestMcp,
|
||||
DashboardActionKind.AuthenticateMcp,
|
||||
DashboardActionKind.ViewProfileSoul,
|
||||
DashboardActionKind.ActivateProfile,
|
||||
DashboardActionKind.SetEnvKey,
|
||||
@@ -3401,6 +3911,9 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
|
||||
DashboardActionKind.EditProfileDescription,
|
||||
DashboardActionKind.SetProfileModel,
|
||||
DashboardActionKind.EditProfileSoul -> this
|
||||
DashboardActionKind.EditCustomEndpoint,
|
||||
DashboardActionKind.ValidateCustomEndpoint,
|
||||
DashboardActionKind.ActivateCustomEndpoint -> this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3513,14 +4026,19 @@ private suspend fun DashboardApiClient.runDashboardAction(
|
||||
DashboardActionKind.ResumeCron -> resumeCronJob(id, profile = item.profile)
|
||||
DashboardActionKind.TriggerCron -> triggerCronJob(id, profile = item.profile)
|
||||
DashboardActionKind.DeleteCron -> deleteCronJob(id, profile = item.profile)
|
||||
DashboardActionKind.EnableMcp -> setMcpServerEnabled(id, enabled = true)
|
||||
DashboardActionKind.DisableMcp -> setMcpServerEnabled(id, enabled = false)
|
||||
DashboardActionKind.TestMcp -> testMcpServer(id)
|
||||
DashboardActionKind.RemoveMcp -> removeMcpServer(id)
|
||||
DashboardActionKind.InstallMcpCatalog -> installMcpCatalogEntry(id)
|
||||
DashboardActionKind.EnableMcp -> setMcpServerEnabled(id, enabled = true, profile = item.profile)
|
||||
DashboardActionKind.DisableMcp -> setMcpServerEnabled(id, enabled = false, profile = item.profile)
|
||||
DashboardActionKind.TestMcp -> testMcpServer(id, profile = item.profile)
|
||||
DashboardActionKind.AuthenticateMcp ->
|
||||
Result.failure(IllegalStateException("Authenticate requires hosted OAuth flow"))
|
||||
DashboardActionKind.RemoveMcp -> removeMcpServer(id, profile = item.profile)
|
||||
DashboardActionKind.InstallMcpCatalog -> installMcpCatalogEntry(id, profile = item.profile)
|
||||
DashboardActionKind.ViewProfileSoul -> getProfileSoul(id)
|
||||
DashboardActionKind.ActivateProfile -> setActiveProfile(id)
|
||||
DashboardActionKind.DeleteProfile -> deleteProfile(id)
|
||||
DashboardActionKind.ActivateCustomEndpoint -> activateCustomEndpoint(id)
|
||||
DashboardActionKind.DeleteCustomEndpoint ->
|
||||
deleteCustomEndpoint(id).map { JsonObject(emptyMap()) }
|
||||
DashboardActionKind.RevealEnvKey -> revealEnvVar(id)
|
||||
DashboardActionKind.ClearEnvKey -> deleteEnvVar(id)
|
||||
// Input-backed kinds are intercepted at the onAction layer and routed
|
||||
@@ -3530,6 +4048,9 @@ private suspend fun DashboardApiClient.runDashboardAction(
|
||||
DashboardActionKind.SetProfileModel,
|
||||
DashboardActionKind.EditProfileSoul ->
|
||||
Result.failure(IllegalStateException("${action.label} requires input"))
|
||||
DashboardActionKind.EditCustomEndpoint,
|
||||
DashboardActionKind.ValidateCustomEndpoint ->
|
||||
Result.failure(IllegalStateException("${action.label} requires input"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ fun DiagnosticsScreen(
|
||||
val relayInfo by connectionViewModel.relayInfo.collectAsState()
|
||||
val effectiveSessionProfileName by
|
||||
connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val toolsets by connectionViewModel.toolsetInventory.collectAsState()
|
||||
val checkedAt by connectionViewModel.diagnosticsCheckedAt.collectAsState()
|
||||
val refreshing by connectionViewModel.diagnosticsRefreshing.collectAsState()
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
@@ -182,6 +183,18 @@ fun DiagnosticsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
info.gatewayHeartbeat?.let { heartbeat ->
|
||||
val detail = heartbeat.ageSeconds?.let { " · ${it}s" }.orEmpty()
|
||||
Text(
|
||||
text = stringResource(R.string.diag_gateway_heartbeat, heartbeat.status, detail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (heartbeat.status in setOf("stale", "malformed", "pid_mismatch", "start_mismatch")) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
val profileKey = effectiveSessionProfileName ?: "(default)"
|
||||
val profileState = info.profiles.firstOrNull { it.name == profileKey }?.relayState
|
||||
?: stringResource(R.string.diag_check_not_checked)
|
||||
@@ -191,6 +204,22 @@ fun DiagnosticsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
toolsets?.let { inventory ->
|
||||
val enabled = inventory.count { it.enabled }
|
||||
val relayVisible = inventory.any { item ->
|
||||
item.tools.any { it.startsWith("relay_") || it.startsWith("android_") }
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.diag_toolsets_inventory,
|
||||
enabled,
|
||||
inventory.size,
|
||||
if (relayVisible) stringResource(R.string.diag_yes) else stringResource(R.string.diag_no),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
StatusCheckTimeline(
|
||||
checks = checks,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -227,6 +227,18 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
// Upstream rejects new API work with this stable code while an intentional
|
||||
// shutdown/external drain is in progress. Keep it distinct from provider
|
||||
// 503s: the server is healthy and will accept work after the drain clears.
|
||||
if (msg.startsWith("api error 503: gateway_draining:")) {
|
||||
return HumanError(
|
||||
title = "Hermes is restarting",
|
||||
body = "Hermes is draining active work. Wait a moment, then retry this message.",
|
||||
retryable = true,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_retry) ?: "Retry",
|
||||
)
|
||||
}
|
||||
|
||||
// Typed exceptions are checked first because an IOException message scan
|
||||
// would otherwise swallow SSL/timeout/connect errors whose messages
|
||||
// happen to contain HTTP-ish substrings. Only fall through to the
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayBackgroundTurnCompletion
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayCompressResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.GatewayInboundTurnRegistration
|
||||
import com.hermesandroid.relay.network.upstream.GatewayModelProvider
|
||||
@@ -57,6 +58,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAttachment
|
||||
import com.hermesandroid.relay.network.upstream.GatewayRpcException
|
||||
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.relay.ProactiveMessage
|
||||
import com.hermesandroid.relay.network.relay.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
|
||||
@@ -105,6 +107,7 @@ import kotlinx.serialization.json.contentOrNull
|
||||
import okhttp3.sse.EventSource
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -197,6 +200,12 @@ class ChatViewModel : ViewModel() {
|
||||
val startedAt: Long,
|
||||
)
|
||||
|
||||
private data class QueuedRecoveryHandoff(
|
||||
val checkpoint: ChatTurnCheckpoint,
|
||||
val completedHistory: List<MessageItem>,
|
||||
val queuedUserText: String,
|
||||
)
|
||||
|
||||
/** Test seam for the recovery poll cadence — production uses the defaults. */
|
||||
internal var recoveryTimingOverride: ChatStreamRecovery.Timing? = null
|
||||
|
||||
@@ -213,6 +222,7 @@ class ChatViewModel : ViewModel() {
|
||||
private var backgroundProcessSessionJob: Job? = null
|
||||
private var connectionSwitchJob: Job? = null
|
||||
private var sessionRefreshJob: Job? = null
|
||||
private var imageActivityJob: Job? = null
|
||||
private val historyLoadGeneration = AtomicInteger(0)
|
||||
private val sessionRefreshGeneration = AtomicInteger(0)
|
||||
private val realtimeAgentUserMessages = mutableMapOf<String, String>()
|
||||
@@ -465,6 +475,8 @@ class ChatViewModel : ViewModel() {
|
||||
/** Flat model ids from `GET /v1/models` (SSE fallback source; gateway uses [modelProviders]). */
|
||||
private val _availableModels = MutableStateFlow<List<String>>(emptyList())
|
||||
val availableModels: StateFlow<List<String>> = _availableModels.asStateFlow()
|
||||
private val _apiModelOptions = MutableStateFlow<List<ApiModelOption>>(emptyList())
|
||||
val apiModelOptions: StateFlow<List<ApiModelOption>> = _apiModelOptions.asStateFlow()
|
||||
|
||||
/**
|
||||
* Curated provider→model groups from the gateway `model.options` RPC — the
|
||||
@@ -600,7 +612,11 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun fetchModels() {
|
||||
val client = apiClient ?: return
|
||||
viewModelScope.launch { _availableModels.value = client.getModels() }
|
||||
viewModelScope.launch {
|
||||
val options = client.getModelOptions()
|
||||
_apiModelOptions.value = options
|
||||
_availableModels.value = options.map { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -720,6 +736,13 @@ class ChatViewModel : ViewModel() {
|
||||
refreshActiveAgentName()
|
||||
}
|
||||
|
||||
/** Select a `/v1/models` request id verbatim, including route aliases. */
|
||||
fun selectApiModel(modelId: String) {
|
||||
_selectedModelOverride.value = modelId.trim().takeIf { it.isNotEmpty() }
|
||||
_selectedProviderOverride.value = null
|
||||
refreshActiveAgentName()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the gateway reasoning effort for this session through `config.set`.
|
||||
* The chip owns optimistic UI; failures are surfaced in chat.
|
||||
@@ -1085,10 +1108,11 @@ class ChatViewModel : ViewModel() {
|
||||
val model = _selectedModelOverride.value?.takeIf { it.isNotBlank() }
|
||||
val provider = _selectedProviderOverride.value?.takeIf { it.isNotBlank() }
|
||||
val effort = _selectedReasoningEffort.value?.takeIf { it.isNotBlank() }
|
||||
// Only pin fast when explicitly ON (upstream treats fast=false the
|
||||
// same as omitting → profile default tier), so a null/false leaves
|
||||
// the profile's own service tier intact.
|
||||
val fast = _fastEnabled.value?.takeIf { it }
|
||||
// Contract v4 distinguishes all three states: null omits the field
|
||||
// and inherits the profile tier, true pins priority, and false pins
|
||||
// normal. Do not filter false here or a user's explicit Fast-off
|
||||
// pick would silently inherit a priority-by-default profile.
|
||||
val fast = _fastEnabled.value
|
||||
if (model != null || effort != null || fast != null) {
|
||||
GatewaySessionModel(
|
||||
model = model,
|
||||
@@ -1195,6 +1219,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun createGatewayInboundTurnRegistration(
|
||||
client: GatewayChatClient,
|
||||
storedSessionId: String,
|
||||
queuedRecovery: QueuedRecoveryHandoff? = null,
|
||||
): GatewayInboundTurnRegistration? {
|
||||
val handler = chatHandler ?: return null
|
||||
fun matchesAdmissionContext(): Boolean =
|
||||
@@ -1204,6 +1229,7 @@ class ChatViewModel : ViewModel() {
|
||||
handler.currentSessionId.value == storedSessionId
|
||||
|
||||
val messageId = "gateway-inbound-${UUID.randomUUID()}"
|
||||
val queuedUserMessageId = "gateway-queued-user-${UUID.randomUUID()}"
|
||||
var baselineAssistantCount = 0
|
||||
var started = false
|
||||
var accepted = false
|
||||
@@ -1356,7 +1382,26 @@ class ChatViewModel : ViewModel() {
|
||||
return GatewayInboundTurnRegistration(
|
||||
callbacks = callbacks,
|
||||
onHandle = { handle ->
|
||||
if (matchesAdmissionContext() && activeStream == null && !handler.isStreaming.value) {
|
||||
val replaceRecoveredCheckpoint = queuedRecovery != null && handler.isStreaming.value
|
||||
val canReplaceRecoveredCheckpoint = queuedRecovery != null && activeStream == null
|
||||
if (matchesAdmissionContext() && activeStream == null &&
|
||||
(!handler.isStreaming.value || canReplaceRecoveredCheckpoint)
|
||||
) {
|
||||
queuedRecovery?.takeIf { replaceRecoveredCheckpoint }?.let { handoff ->
|
||||
if (handoff.completedHistory.isNotEmpty()) {
|
||||
handler.loadMessageHistory(handoff.completedHistory)
|
||||
}
|
||||
val durableAssistantCount = handoff.completedHistory.count {
|
||||
it.role.equals("assistant", ignoreCase = true)
|
||||
}
|
||||
if (durableAssistantCount <= handoff.checkpoint.baselineAssistantCount) {
|
||||
// Persistence can trail the resume snapshot. Preserve
|
||||
// the prior checkpoint as a completed partial turn
|
||||
// instead of deleting it or appending the next answer.
|
||||
handler.restoreInFlightTurn(handoff.checkpoint)
|
||||
}
|
||||
finalizeTurnSideEffects(handler, handoff.checkpoint.assistant.id)
|
||||
}
|
||||
// Admission and its transcript baseline are both captured
|
||||
// on the callback/main dispatcher. Reading activeStream or
|
||||
// messages on the WebSocket thread can observe a local
|
||||
@@ -1369,6 +1414,45 @@ class ChatViewModel : ViewModel() {
|
||||
activeStream = handle
|
||||
activeStreamIsGateway = true
|
||||
_steerableTurn.value = true
|
||||
queuedRecovery?.let { handoff ->
|
||||
val now = System.currentTimeMillis()
|
||||
handler.activeAgentName = currentAgentDisplayName()
|
||||
handler.addUserMessage(
|
||||
ChatMessage(
|
||||
id = queuedUserMessageId,
|
||||
role = MessageRole.USER,
|
||||
content = handoff.queuedUserText,
|
||||
timestamp = now,
|
||||
),
|
||||
)
|
||||
handler.setLastSentMessage(handoff.queuedUserText)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = now + 1L,
|
||||
isStreaming = true,
|
||||
agentName = handler.activeAgentName,
|
||||
),
|
||||
)
|
||||
started = true
|
||||
beginTurnCheckpoint(
|
||||
handler = handler,
|
||||
sessionId = storedSessionId,
|
||||
transport = "gateway",
|
||||
userMessageId = queuedUserMessageId,
|
||||
userText = handoff.queuedUserText,
|
||||
assistantMessageId = messageId,
|
||||
assistantTimestamp = now + 1L,
|
||||
)
|
||||
activeTurnCheckpointSeed?.liveSessionId =
|
||||
client.currentLiveSessionId(storedSessionId)
|
||||
handler.setTurnStatus(
|
||||
"Reconnected — queued: “${queuedPromptPreview(handoff.queuedUserText)}”",
|
||||
)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -1575,14 +1659,14 @@ class ChatViewModel : ViewModel() {
|
||||
/**
|
||||
* True while the in-flight turn is actually running on the gateway
|
||||
* transport (not an SSE fallback) — the only state in which
|
||||
* `session.steer` can land. Drives the STEER trailing slot.
|
||||
* `session.redirect` can land. Drives the correction trailing slot.
|
||||
*/
|
||||
private val _steerableTurn = MutableStateFlow(false)
|
||||
val steerableTurn: StateFlow<Boolean> = _steerableTurn.asStateFlow()
|
||||
|
||||
/**
|
||||
* One-line caption feedback after a steer attempt fell back to the
|
||||
* queue ("Queued — delivers after this turn"). Cleared at turn end.
|
||||
* One-line caption feedback after an active-turn correction fell back to the
|
||||
* queue ("Queued — sends after this turn"). Cleared at turn end.
|
||||
*/
|
||||
private val _steerNotice = MutableStateFlow<String?>(null)
|
||||
val steerNotice: StateFlow<String?> = _steerNotice.asStateFlow()
|
||||
@@ -1677,6 +1761,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private var selectedProfileProvider: () -> Profile? = { null }
|
||||
private var isolatedProfileApiProvider: () -> Boolean = {
|
||||
selectedProfileProvider()?.hasIsolatedApi == true
|
||||
}
|
||||
private var sessionProfileNameProvider: () -> String? = {
|
||||
AgentDisplay.profileRequestName(selectedProfileProvider()?.name)
|
||||
}
|
||||
@@ -1708,6 +1795,10 @@ class ChatViewModel : ViewModel() {
|
||||
refreshActiveAgentName()
|
||||
}
|
||||
|
||||
fun setIsolatedProfileApiProvider(provider: () -> Boolean) {
|
||||
isolatedProfileApiProvider = provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the single effective namespace for Gateway session.create/resume.
|
||||
* This is separate from [selectedProfileProvider] because a null selection
|
||||
@@ -1878,6 +1969,9 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private var lastSurfacedCredentialWarning: String? = null
|
||||
|
||||
private val _gatewayProjectName = MutableStateFlow<String?>(null)
|
||||
val gatewayProjectName: StateFlow<String?> = _gatewayProjectName.asStateFlow()
|
||||
|
||||
/**
|
||||
* Track the active client's `session.info`-derived state (personality, model,
|
||||
* provider, reasoning effort, credential warning, YOLO, fast) so a change made
|
||||
@@ -1960,6 +2054,12 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverProject.collect { project ->
|
||||
if (gatewayClient !== client) return@collect
|
||||
_gatewayProjectName.value = project?.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2545,6 +2645,7 @@ class ChatViewModel : ViewModel() {
|
||||
_defaultPersonality.value = ""
|
||||
personalityPrompts = emptyMap()
|
||||
_availableModels.value = emptyList()
|
||||
_apiModelOptions.value = emptyList()
|
||||
_serverCommands.value = emptyList()
|
||||
_modelProviders.value = emptyList()
|
||||
_gatewayCurrentModel.value = ""
|
||||
@@ -2669,7 +2770,7 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
viewModelScope.launch {
|
||||
val selectedProfile = selectedProfileProvider()
|
||||
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
|
||||
val useIsolatedProfileApi = isolatedProfileApiProvider()
|
||||
client.createSessionResult(
|
||||
profileName = if (useIsolatedProfileApi) null else selectedProfile?.name,
|
||||
model = if (useIsolatedProfileApi) {
|
||||
@@ -2961,7 +3062,7 @@ class ChatViewModel : ViewModel() {
|
||||
// slash.exec / command.dispatch instead of becoming a prompt.
|
||||
if (activeStream == null && maybeHandleServerSlashCommand(text.trim())) return
|
||||
|
||||
// Mid-turn: steer on the gateway transport, queue everywhere else.
|
||||
// Mid-turn: redirect on the gateway transport, queue everywhere else.
|
||||
if (activeStream != null) {
|
||||
if (_steerableTurn.value && streamingEndpoint == "gateway") {
|
||||
steerActiveTurn(text.trim())
|
||||
@@ -2975,12 +3076,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject [text] into the in-flight gateway turn via `session.steer`.
|
||||
* Accepted steers land in the next tool batch's last result — the
|
||||
* server records them inside a tool result, NOT as a user message, so
|
||||
* we add a local `steer-` bubble that [ChatHandler.loadMessageHistory]
|
||||
* preserves. Rejected/failed steers fall back to the existing queue
|
||||
* with a one-line caption so the send never silently vanishes.
|
||||
* Correct the in-flight gateway turn via `session.redirect`, falling back
|
||||
* to legacy `session.steer` only on older gateways. Current upstream records
|
||||
* a redirect as durable session state; the local echo is ordinary so the
|
||||
* post-turn history reconcile can replace it with the server-owned row.
|
||||
*/
|
||||
private fun steerActiveTurn(text: String) {
|
||||
val handler = chatHandler ?: return
|
||||
@@ -2994,23 +3093,19 @@ class ChatViewModel : ViewModel() {
|
||||
SteerResult.Queued -> {
|
||||
handler.addUserMessage(
|
||||
ChatMessage(
|
||||
id = "steer-${UUID.randomUUID()}",
|
||||
id = "redirect-${UUID.randomUUID()}",
|
||||
role = MessageRole.USER,
|
||||
content = text,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
// Steered text lands in a server-side tool result,
|
||||
// never as a user message, so this echo has no server
|
||||
// row — keep it across the post-turn reload.
|
||||
clientOnly = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
SteerResult.Rejected, SteerResult.Failed -> {
|
||||
if (activeStream != null) {
|
||||
_queuedMessages.update { it + text }
|
||||
_steerNotice.value = "Queued — delivers after this turn"
|
||||
_steerNotice.value = "Queued — sends after this turn"
|
||||
} else {
|
||||
// Turn ended while the steer RPC was in flight —
|
||||
// Turn ended while the correction RPC was in flight —
|
||||
// send it as a normal next-turn prompt instead.
|
||||
val client = apiClient
|
||||
if (gatewayClient != null || client != null) {
|
||||
@@ -3500,6 +3595,14 @@ class ChatViewModel : ViewModel() {
|
||||
return true
|
||||
}
|
||||
|
||||
if (normalizedName == "compress" || normalizedName == "compact") {
|
||||
val focusTopic = text.substringAfter(' ', "").trim().takeIf { it.isNotBlank() }
|
||||
viewModelScope.launch {
|
||||
runServerCompressCommand(gateway, handler, focusTopic)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val commands = currentOrRefreshedServerCommands(gateway, handler) ?: return@launch
|
||||
val known = commands.any {
|
||||
@@ -3530,6 +3633,68 @@ class ChatViewModel : ViewModel() {
|
||||
return parsed
|
||||
}
|
||||
|
||||
private suspend fun runServerCompressCommand(
|
||||
gateway: GatewayChatClient,
|
||||
handler: ChatHandler,
|
||||
focusTopic: String?,
|
||||
) {
|
||||
val invokedSessionId = handler.currentSessionId.value
|
||||
handler.addSystemNotice("Compressing conversation context…")
|
||||
gateway.compressSession(focusTopic).fold(
|
||||
onSuccess = { result ->
|
||||
if (
|
||||
invokedSessionId != null &&
|
||||
handler.currentSessionId.value == invokedSessionId &&
|
||||
result.isAuthoritative
|
||||
) {
|
||||
handler.loadMessageHistory(result.messages)
|
||||
}
|
||||
result.effectiveUsage?.let(::applyCompressUsage)
|
||||
invokedSessionId?.let { sessionId ->
|
||||
result.title?.let { title -> handler.renameSessionLocal(sessionId, title) }
|
||||
}
|
||||
val summary = when (result.status.lowercase()) {
|
||||
"aborted" -> "Compression aborted."
|
||||
"noop", "no_op" -> "Nothing to compress."
|
||||
"legacy" -> result.output ?: "Compression command sent through legacy slash support."
|
||||
else -> result.output ?: compressionSummary(result)
|
||||
}
|
||||
if (summary.isNotBlank()) handler.addSystemNotice(summary)
|
||||
refreshSessions()
|
||||
},
|
||||
onFailure = { e ->
|
||||
handler.addSystemNotice("/compress failed: ${e.message ?: "unknown error"}")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun compressionSummary(result: GatewayCompressResult): String {
|
||||
val before = result.beforeMessages
|
||||
val after = result.afterMessages
|
||||
val removed = result.removed
|
||||
return when {
|
||||
before != null && after != null ->
|
||||
"Context compressed — messages $before → $after."
|
||||
removed != null && removed > 0 ->
|
||||
"Context compressed — removed $removed messages."
|
||||
else -> "Context compressed."
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCompressUsage(usage: UsageInfo) {
|
||||
val ctxMax = usage.contextMax
|
||||
if (ctxMax != null && ctxMax > 0) {
|
||||
usage.contextUsed?.let { used ->
|
||||
_contextUsage.value = (used.toFloat() / ctxMax).coerceIn(0f, 1f)
|
||||
_contextWindow.value = ContextWindowUsage(usedTokens = used, maxTokens = ctxMax)
|
||||
return
|
||||
}
|
||||
usage.contextPercent?.let { percent ->
|
||||
_contextUsage.value = (percent / 100f).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream dispatch order: `slash.exec` first; error 4018 (pending-input
|
||||
* / skill / blocked command) falls through to `command.dispatch`, whose
|
||||
@@ -4001,29 +4166,60 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
|
||||
if (checkpoint.transport == "gateway" && client != null) {
|
||||
val callbacks = recoveredGatewayCallbacks(handler, checkpoint)
|
||||
val queuedSuccessorPending = AtomicBoolean(false)
|
||||
val callbacks = recoveredGatewayCallbacks(handler, checkpoint, queuedSuccessorPending)
|
||||
val recovery = client.recoverTurn(
|
||||
storedId = sessionId,
|
||||
preferredLiveId = checkpoint.liveSessionId,
|
||||
callbacks = callbacks,
|
||||
queuedTurnProvider = { queued ->
|
||||
queuedSuccessorPending.set(true)
|
||||
createGatewayInboundTurnRegistration(
|
||||
client = client,
|
||||
storedSessionId = sessionId,
|
||||
queuedRecovery = QueuedRecoveryHandoff(
|
||||
checkpoint = checkpoint,
|
||||
completedHistory = initialHistory,
|
||||
queuedUserText = queued.user,
|
||||
),
|
||||
)
|
||||
},
|
||||
).getOrNull()
|
||||
if (recovery?.queued != null && !recovery.running && recovery.handle != null) {
|
||||
val ownsQueuedHandoff =
|
||||
chatHandler === handler &&
|
||||
handler.currentSessionId.value == sessionId &&
|
||||
activeProfileContextKey == contextKey &&
|
||||
activeStream === recovery.handle
|
||||
if (!ownsQueuedHandoff) recovery.handle.detach()
|
||||
if (ownsQueuedHandoff) {
|
||||
gatewayProcessController.sessionReady(sessionId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!ownsTurnCheckpoint(checkpoint, handler)) {
|
||||
recovery?.handle?.detach()
|
||||
return true
|
||||
}
|
||||
if (recovery?.running == true && recovery.handle != null) {
|
||||
if (recovery?.hasPendingWork == true && recovery.handle != null) {
|
||||
activeStream = recovery.handle
|
||||
activeStreamIsGateway = true
|
||||
activeTurnCheckpointSeed?.liveSessionId = recovery.liveSessionId
|
||||
handler.restoreInFlightTurn(
|
||||
checkpoint = checkpoint,
|
||||
upstreamAssistantText = recovery.inflight?.assistant,
|
||||
)
|
||||
handler.setTurnStatus(
|
||||
checkpoint.turnStatus?.takeIf { it.isNotBlank() }
|
||||
?: "Reconnected — Hermes is still working…",
|
||||
)
|
||||
restorePendingAsk(handler, checkpoint)
|
||||
if (recovery.running) {
|
||||
handler.restoreInFlightTurn(
|
||||
checkpoint = checkpoint,
|
||||
upstreamAssistantText = recovery.inflight?.assistant,
|
||||
)
|
||||
handler.setTurnStatus(
|
||||
if (recovery.queued != null) {
|
||||
"Reconnected — Hermes is working · queued: “${queuedPromptPreview(recovery.queued.user)}”"
|
||||
} else {
|
||||
checkpoint.turnStatus?.takeIf { it.isNotBlank() }
|
||||
?: "Reconnected — Hermes is still working…"
|
||||
},
|
||||
)
|
||||
restorePendingAsk(handler, checkpoint)
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
gatewayProcessController.sessionReady(sessionId)
|
||||
return true
|
||||
@@ -4056,6 +4252,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun recoveredGatewayCallbacks(
|
||||
handler: ChatHandler,
|
||||
checkpoint: ChatTurnCheckpoint,
|
||||
queuedSuccessorPending: AtomicBoolean,
|
||||
): GatewayTurnCallbacks {
|
||||
val messageId = checkpoint.assistant.id
|
||||
fun owns(): Boolean = ownsTurnCheckpoint(checkpoint, handler)
|
||||
@@ -4103,15 +4300,17 @@ class ChatViewModel : ViewModel() {
|
||||
onComplete = {
|
||||
if (owns()) {
|
||||
finalizeTurnSideEffects(handler, messageId)
|
||||
val expectedSessionId = checkpoint.sessionId
|
||||
viewModelScope.launch {
|
||||
val history = loadSessionHistory(expectedSessionId)
|
||||
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
|
||||
handler.loadMessageHistory(history)
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
if (!queuedSuccessorPending.get()) {
|
||||
val expectedSessionId = checkpoint.sessionId
|
||||
viewModelScope.launch {
|
||||
val history = loadSessionHistory(expectedSessionId)
|
||||
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
|
||||
handler.loadMessageHistory(history)
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4128,11 +4327,21 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onError = { error ->
|
||||
if (owns()) {
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
val latest = buildTurnCheckpoint() ?: checkpoint
|
||||
startCheckpointHistoryRecovery(handler, latest, error)
|
||||
if (queuedSuccessorPending.get()) {
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(error)
|
||||
clearTurnCheckpoint()
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearPendingAsk(approvalStamp = "Resolved")
|
||||
} else {
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
val latest = buildTurnCheckpoint() ?: checkpoint
|
||||
startCheckpointHistoryRecovery(handler, latest, error)
|
||||
}
|
||||
}
|
||||
},
|
||||
onToolGenerating = { name ->
|
||||
@@ -4567,7 +4776,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val selectedProfile = selectedProfileProvider()
|
||||
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
|
||||
val useIsolatedProfileApi = isolatedProfileApiProvider()
|
||||
client.createSessionResult(
|
||||
profileName = if (useIsolatedProfileApi) null else selectedProfile?.name,
|
||||
model = if (useIsolatedProfileApi) {
|
||||
@@ -5423,7 +5632,7 @@ class ChatViewModel : ViewModel() {
|
||||
return composeInjectedContext(
|
||||
interfaceContextPrompt = null,
|
||||
selectedProfile = profile,
|
||||
useIsolatedProfileApi = profile?.hasIsolatedApi == true,
|
||||
useIsolatedProfileApi = isolatedProfileApiProvider(),
|
||||
relayServerBlocks = relayBlocks,
|
||||
)
|
||||
}
|
||||
@@ -5441,7 +5650,9 @@ class ChatViewModel : ViewModel() {
|
||||
// Resolve the active profile pick once — used below for both
|
||||
// modelOverride and the system_message precedence rule.
|
||||
val selectedProfile = selectedProfileProvider()
|
||||
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
|
||||
val useIsolatedProfileApi = isolatedProfileApiProvider()
|
||||
val imageActivityProfile =
|
||||
AgentDisplay.profileRequestName(selectedProfile?.name) ?: "default"
|
||||
|
||||
// The injected system_message (persona + phone-status + per-turn
|
||||
// context) is built by composeInjectedContext so the chat-screen audit
|
||||
@@ -5480,6 +5691,7 @@ class ChatViewModel : ViewModel() {
|
||||
// Track the current message ID — starts with our generated ID,
|
||||
// but updates when the server sends message.started with its own ID.
|
||||
var currentMessageId = assistantMessageId
|
||||
var gatewayInterimSealedCurrentMessage = false
|
||||
|
||||
// The SSE endpoint this turn actually dispatched on (null on a gateway
|
||||
// dispatch) — set by dispatchSse below. onErrorCb keys the dropped-
|
||||
@@ -5525,6 +5737,26 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
activeStreamDeltas = streamDeltas
|
||||
|
||||
fun ensurePostInterimMessage() {
|
||||
if (!gatewayInterimSealedCurrentMessage) return
|
||||
streamDeltas.flushNow()
|
||||
val nextMessageId = UUID.randomUUID().toString()
|
||||
currentMessageId = nextMessageId
|
||||
gatewayInterimSealedCurrentMessage = false
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = nextMessageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
agentName = handler.activeAgentName,
|
||||
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
|
||||
),
|
||||
)
|
||||
updateTurnCheckpointAssistantId(nextMessageId)
|
||||
}
|
||||
|
||||
fun flushAndReleaseStreamDeltas() {
|
||||
streamDeltas.flushNow()
|
||||
if (activeStreamDeltas === streamDeltas) activeStreamDeltas = null
|
||||
@@ -5541,6 +5773,7 @@ class ChatViewModel : ViewModel() {
|
||||
updateTurnCheckpointAssistantId(serverMsgId)
|
||||
}
|
||||
val onTextDeltaCb = { delta: String ->
|
||||
ensurePostInterimMessage()
|
||||
if (!firstTokenNotified) {
|
||||
firstTokenNotified = true
|
||||
AppAnalytics.onFirstTokenReceived()
|
||||
@@ -5548,19 +5781,44 @@ class ChatViewModel : ViewModel() {
|
||||
streamDeltas.appendText(delta)
|
||||
}
|
||||
val onThinkingDeltaCb = { delta: String ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.appendThinking(delta)
|
||||
}
|
||||
val onToolCallStartCb = { toolCallId: String, toolName: String ->
|
||||
val onInterimMessageCb = { text: String, alreadyStreamed: Boolean ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
|
||||
if (!alreadyStreamed && text.isNotBlank()) {
|
||||
handler.onTextDelta(currentMessageId, text)
|
||||
}
|
||||
handler.onTurnComplete(currentMessageId)
|
||||
gatewayInterimSealedCurrentMessage = true
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val observedImageToolStates = mutableMapOf<String, String>()
|
||||
val onToolCallStartCb = { toolCallId: String, toolName: String ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
val alreadyObserved =
|
||||
toolName == "image_generate" &&
|
||||
observedImageToolStates.putIfAbsent(toolCallId, "running") != null
|
||||
if (!alreadyObserved) {
|
||||
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
|
||||
val alreadyCompleted = observedImageToolStates[toolCallId] == "completed"
|
||||
if (observedImageToolStates.containsKey(toolCallId)) {
|
||||
observedImageToolStates[toolCallId] = "completed"
|
||||
}
|
||||
if (!alreadyCompleted) {
|
||||
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
@@ -5571,7 +5829,52 @@ class ChatViewModel : ViewModel() {
|
||||
handler.onTurnComplete(currentMessageId)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
fun stopImageActivityBridge() {
|
||||
imageActivityJob?.cancel()
|
||||
imageActivityJob = null
|
||||
}
|
||||
fun startImageActivityBridge() {
|
||||
val relay = relayHttpClient ?: return
|
||||
stopImageActivityBridge()
|
||||
imageActivityJob = viewModelScope.launch {
|
||||
var consecutiveErrors = 0
|
||||
while (true) {
|
||||
val activeSessionId = handler.currentSessionId.value
|
||||
if (activeSessionId.isNullOrBlank()) {
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
val result = relay.fetchImageActivity(
|
||||
profile = imageActivityProfile,
|
||||
sessionId = activeSessionId,
|
||||
sinceEpochSeconds = (assistantTimestamp / 1000.0) - 1.0,
|
||||
)
|
||||
if (result.isFailure) {
|
||||
consecutiveErrors += 1
|
||||
if (consecutiveErrors >= 3) break
|
||||
delay(1_000)
|
||||
continue
|
||||
}
|
||||
consecutiveErrors = 0
|
||||
val snapshot = result.getOrNull() ?: break
|
||||
snapshot.activities.forEach { activity ->
|
||||
val prior = observedImageToolStates[activity.callId]
|
||||
if (prior == null) {
|
||||
onToolCallStartCb(activity.callId, activity.toolName)
|
||||
}
|
||||
if (
|
||||
activity.state == "completed" &&
|
||||
observedImageToolStates[activity.callId] != "completed"
|
||||
) {
|
||||
onToolCallDoneCb(activity.callId, null)
|
||||
}
|
||||
}
|
||||
delay(750)
|
||||
}
|
||||
}
|
||||
}
|
||||
val onCompleteCb = {
|
||||
stopImageActivityBridge()
|
||||
flushAndReleaseStreamDeltas()
|
||||
// Double-finalize guard: if a straggler completion arrives while
|
||||
// the answer-recovery poller is running, the normal completion
|
||||
@@ -5682,6 +5985,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
stopImageActivityBridge()
|
||||
flushAndReleaseStreamDeltas()
|
||||
val errorSessionId = handler.currentSessionId.value
|
||||
if (intentionallyCancelled) {
|
||||
@@ -6000,6 +6304,7 @@ class ChatViewModel : ViewModel() {
|
||||
dispatchSse(resolveSseFallback(handler))
|
||||
|
||||
else -> {
|
||||
startImageActivityBridge()
|
||||
val isNewSession = handler.currentSessionId.value == null
|
||||
val autoTitle = message.take(50).let { if (message.length > 50) "$it..." else it }
|
||||
// The gateway carries NO phone-context preamble: prompt.submit
|
||||
@@ -6040,11 +6345,13 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onStart = { },
|
||||
onTextDelta = onTextDeltaCb,
|
||||
onInterimMessage = onInterimMessageCb,
|
||||
onThinkingDelta = onThinkingDeltaCb,
|
||||
onToolCallStart = onToolCallStartCb,
|
||||
onToolCallDone = onToolCallDoneCb,
|
||||
onToolCallFailed = onToolCallFailedCb,
|
||||
onToolOutputRisk = { risk ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolOutputRisk(currentMessageId, risk)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
@@ -6057,11 +6364,13 @@ class ChatViewModel : ViewModel() {
|
||||
onUsage = onUsageCb,
|
||||
onError = onErrorCb,
|
||||
onToolGenerating = { name ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolGenerating(currentMessageId, name)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
@@ -6188,6 +6497,8 @@ class ChatViewModel : ViewModel() {
|
||||
activeStreamDeltas = null
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
imageActivityJob?.cancel()
|
||||
imageActivityJob = null
|
||||
_queuedMessages.value = emptyList()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
@@ -6720,6 +7031,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
imageActivityJob?.cancel()
|
||||
imageActivityJob = null
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
flushTurnCheckpointForTeardown()
|
||||
@@ -6776,6 +7089,11 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuedPromptPreview(prompt: String, maxChars: Int = 96): String {
|
||||
val compact = prompt.replace(Regex("\\s+"), " ").trim()
|
||||
return if (compact.length <= maxChars) compact else compact.take(maxChars - 1).trimEnd() + "…"
|
||||
}
|
||||
|
||||
/** Production adapter for the independently-testable process controller. */
|
||||
private class GatewayChatProcessSource(
|
||||
private val client: GatewayChatClient,
|
||||
|
||||
@@ -24,12 +24,14 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.DataManager
|
||||
import com.hermesandroid.relay.data.DemoContent
|
||||
import com.hermesandroid.relay.data.DemoMode
|
||||
import com.hermesandroid.relay.data.DashboardEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
@@ -62,9 +64,11 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardChatDisplaySettings
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.ToolsetInfo
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
@@ -92,6 +96,7 @@ import com.hermesandroid.relay.viewmodel.connection.PairingController
|
||||
import com.hermesandroid.relay.viewmodel.connection.ProfileController
|
||||
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -100,12 +105,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -188,6 +193,29 @@ internal fun isChatTransportReady(
|
||||
internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
|
||||
connection?.capabilities?.anySurfaceConfigured == true
|
||||
|
||||
/**
|
||||
* Resolve the Dashboard/Gateway surface for the route the resolver selected.
|
||||
*
|
||||
* A saved dashboard URL describes the primary route; it must not pin every
|
||||
* runtime route to that host. When a LAN connection has an explicit dashboard
|
||||
* URL and the resolver selects Tailscale, keeping the saved LAN URL makes
|
||||
* Gateway sessions, Manage, and standard voice all appear offline even though
|
||||
* the selected Tailscale candidate is reachable.
|
||||
*/
|
||||
internal fun resolveEffectiveDashboardUrl(
|
||||
connection: Connection?,
|
||||
endpoint: EndpointCandidate?,
|
||||
): String {
|
||||
if (connection == null) return ""
|
||||
endpoint?.dashboard?.url
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
endpoint?.api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.let { return it }
|
||||
return connection.resolvedDashboardUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Relay transport metadata to the connection's existing standard routes
|
||||
* without adopting the Relay QR's API/Dashboard identity.
|
||||
@@ -195,12 +223,65 @@ internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
|
||||
internal fun mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes: List<EndpointCandidate>,
|
||||
relayRoutes: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> = standardRoutes.map { standard ->
|
||||
val relay = relayRoutes
|
||||
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
|
||||
?.relay
|
||||
?: return@map standard
|
||||
standard.copy(relay = relay)
|
||||
): List<EndpointCandidate> {
|
||||
if (standardRoutes.isEmpty()) {
|
||||
return relayRoutes.sortedWith(
|
||||
compareBy<EndpointCandidate> { it.priority }.thenBy { it.role },
|
||||
)
|
||||
}
|
||||
|
||||
val standardRoles = standardRoutes.map { it.role.lowercase() }.toSet()
|
||||
val merged = standardRoutes.map { standard ->
|
||||
val paired = relayRoutes
|
||||
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
|
||||
?: return@map standard
|
||||
val sameHost = standard.routeHost()?.equals(paired.routeHost(), ignoreCase = true) == true
|
||||
// The Standard connection remains authoritative for any surface it
|
||||
// already configured. A Relay QR fills missing surfaces and refreshes
|
||||
// its own transport, so a manually-added dashboard-only Tailscale
|
||||
// route gains the QR's same-host API fallback instead of remaining
|
||||
// Gateway-only. Never graft a QR API from a different host.
|
||||
standard.copy(
|
||||
api = standard.api ?: paired.api.takeIf { sameHost },
|
||||
dashboard = standard.dashboard ?: paired.dashboard.takeIf { sameHost },
|
||||
relay = paired.relay ?: standard.relay,
|
||||
proxy = standard.proxy ?: paired.proxy,
|
||||
security = standard.security ?: paired.security,
|
||||
recommended = standard.recommended || paired.recommended,
|
||||
)
|
||||
}
|
||||
// Relay-only pairing used to map over Standard's existing roles and drop
|
||||
// every unmatched QR role. The common LAN-only Standard + LAN/Tailscale QR
|
||||
// therefore looked paired while it had no remote route. Preserve the full
|
||||
// signed QR route set, appending roles Standard did not know yet.
|
||||
val missingQrRoles = relayRoutes.filterNot {
|
||||
it.role.lowercase() in standardRoles
|
||||
}
|
||||
return (merged + missingQrRoles)
|
||||
.map(EndpointCandidate::withDerivedDashboard)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
private fun EndpointCandidate.withDerivedDashboard(): EndpointCandidate {
|
||||
if (dashboard != null) return this
|
||||
val derived = api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.let(::DashboardEndpoint)
|
||||
?: return this
|
||||
return copy(dashboard = derived)
|
||||
}
|
||||
|
||||
private fun EndpointCandidate.routeHost(): String? {
|
||||
val raw = primaryRouteUrl() ?: return null
|
||||
val normalized = when {
|
||||
raw.startsWith("ws://", ignoreCase = true) ->
|
||||
"http://${raw.substringAfter("://")}"
|
||||
raw.startsWith("wss://", ignoreCase = true) ->
|
||||
"https://${raw.substringAfter("://")}"
|
||||
else -> raw
|
||||
}
|
||||
return runCatching { URI(normalized).host }.getOrNull()
|
||||
}
|
||||
|
||||
/** Pure persistence policy used by Relay-only QR apply and its regression tests. */
|
||||
@@ -631,12 +712,25 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
/** Currently-active endpoint, for the Endpoints card. */
|
||||
val activeEndpoint = connectionManager.activeEndpoint
|
||||
|
||||
private fun activeRouteCandidatesSnapshot(): List<EndpointCandidate> {
|
||||
private suspend fun activeRouteCandidatesSnapshot(): List<EndpointCandidate> {
|
||||
val activeId = connectionStore.activeConnectionId.value ?: return emptyList()
|
||||
return connectionStore.connections.value
|
||||
val current = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeId }
|
||||
?.routeCandidates
|
||||
.orEmpty()
|
||||
?: return emptyList()
|
||||
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
|
||||
val pairedRoutes = deviceId?.let { id ->
|
||||
runCatching {
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), id).first()
|
||||
}.getOrDefault(emptyList())
|
||||
}.orEmpty()
|
||||
val recovered = mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes = current.routeCandidates,
|
||||
relayRoutes = pairedRoutes,
|
||||
)
|
||||
if (recovered != current.routeCandidates) {
|
||||
connectionStore.updateConnection(current.copy(routeCandidates = recovered))
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
private fun normalizeStandardRouteCandidates(
|
||||
@@ -986,22 +1080,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
/**
|
||||
* Runtime dashboard route. Explicit dashboard overrides stay on the
|
||||
* persisted connection, but auto-managed dashboard URLs follow the active
|
||||
* API route so Manage can move from LAN to Tailscale with Chat.
|
||||
* Runtime dashboard route. A selected endpoint's Dashboard/Gateway URL
|
||||
* wins because it is part of that route just like API and Relay. When an
|
||||
* older candidate has only API metadata, auto-managed dashboards can still
|
||||
* derive the conventional Dashboard URL; otherwise the saved URL remains
|
||||
* the fallback.
|
||||
*/
|
||||
val effectiveDashboardUrl: StateFlow<String> = combine(
|
||||
activeConnection,
|
||||
connectionManager.activeEndpoint,
|
||||
) { connection, endpoint ->
|
||||
when {
|
||||
connection == null -> ""
|
||||
endpoint != null &&
|
||||
Connection.isAutoManagedDashboardUrl(connection.dashboardUrl, connection.apiServerUrl) ->
|
||||
endpoint.dashboard?.url
|
||||
?: endpoint.api?.url?.let(Connection::deriveDefaultDashboardUrl).orEmpty()
|
||||
else -> connection.resolvedDashboardUrl
|
||||
}
|
||||
resolveEffectiveDashboardUrl(connection, endpoint)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
/**
|
||||
@@ -1056,7 +1145,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* persisted URL. Dashboard session cookies are host-scoped, so a sign-in
|
||||
* performed on the LAN host does not authenticate the Tailscale host —
|
||||
* every dashboard-riding surface (Manage, standard voice) uses this to
|
||||
* say *which* route it's on and why sign-in state didn't follow.
|
||||
* say *which* route rejected the saved shared session.
|
||||
*/
|
||||
val dashboardRouteMovedHint: StateFlow<String?> = combine(
|
||||
effectiveDashboardUrl,
|
||||
@@ -1993,6 +2082,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private val _relayInfo = MutableStateFlow<RelayHttpClient.RelayInfo?>(null)
|
||||
val relayInfo: StateFlow<RelayHttpClient.RelayInfo?> = _relayInfo.asStateFlow()
|
||||
private val _toolsetInventory = MutableStateFlow<List<ToolsetInfo>?>(null)
|
||||
val toolsetInventory: StateFlow<List<ToolsetInfo>?> = _toolsetInventory.asStateFlow()
|
||||
private val _diagnosticsCheckedAt = MutableStateFlow<Long?>(null)
|
||||
val diagnosticsCheckedAt: StateFlow<Long?> = _diagnosticsCheckedAt.asStateFlow()
|
||||
private val _diagnosticsRefreshing = MutableStateFlow(false)
|
||||
@@ -2013,6 +2104,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
viewModelScope.launch {
|
||||
val info = relayHttpClient.fetchRelayInfo().getOrNull()
|
||||
_relayInfo.value = info
|
||||
// Toolsets are profile-scoped upstream. Use the same routed client
|
||||
// as Chat so Diagnostics cannot pair a selected profile's Relay
|
||||
// state with the base/default profile's tool inventory.
|
||||
_toolsetInventory.value = _chatApiClient.value?.getToolsets()?.getOrNull()
|
||||
relayHttpClient.fetchUpdateCheck().onSuccess { _relayUpdateInfo.value = it }
|
||||
_diagnosticsCheckedAt.value = System.currentTimeMillis()
|
||||
_diagnosticsRefreshing.value = false
|
||||
@@ -3799,6 +3894,24 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard/Gateway can move independently of the optional API
|
||||
// fallback (for example, a custom dashboard-only route). Do not carry
|
||||
// the previous host's availability or cookie-backed status into the
|
||||
// new route while its probe is in flight. collectLatest cancels an
|
||||
// obsolete probe if the resolver changes routes again.
|
||||
viewModelScope.launch {
|
||||
effectiveDashboardUrl
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
|
||||
_standardAudioApiReachable.value = false
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
probeStandardVoice()
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a route change that was deferred because a turn was streaming.
|
||||
// (StateFlow already conflates/dedups, so no distinctUntilChanged.)
|
||||
viewModelScope.launch {
|
||||
@@ -4093,10 +4206,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
return
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
mirrorDashboardSessionCookies(
|
||||
store = dashboardCookieStoreFor(connectionId),
|
||||
targetUrl = dashboardUrl.orEmpty(),
|
||||
trustedHosts = trustedDashboardHosts(connectionId),
|
||||
)
|
||||
}
|
||||
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
try {
|
||||
val status = client.getStatus().getOrNull()
|
||||
if (status == null) {
|
||||
updateDashboardTopology(connectionId, null)
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
|
||||
_standardAudioApiReachable.value = false
|
||||
_serverChatDisplaySettings.value = null
|
||||
@@ -4104,6 +4225,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
|
||||
return
|
||||
}
|
||||
updateDashboardTopology(connectionId, status)
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
val authed = !status.authRequired || session?.authenticated == true
|
||||
recordDashboardStatusIfChanged(connectionId, status, session)
|
||||
@@ -4125,8 +4247,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = ctx.getString(R.string.conn_status_voice_signin),
|
||||
detail = "Dashboard sessions are per-host — a sign-in from another " +
|
||||
"route does not carry over; sign in once via Manage on this one",
|
||||
detail = "The encrypted dashboard session could not be reused on this " +
|
||||
"trusted route; open Manage to refresh the session",
|
||||
url = dashboardUrl,
|
||||
)
|
||||
}
|
||||
@@ -4147,6 +4269,60 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
private fun trustedDashboardHosts(connectionId: String): Set<String> {
|
||||
val connection = connectionStore.connections.value
|
||||
.firstOrNull { it.id == connectionId }
|
||||
?: return emptySet()
|
||||
val urls = buildList {
|
||||
connection.dashboardUrl?.let(::add)
|
||||
Connection.deriveDefaultDashboardUrl(connection.apiServerUrl)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::add)
|
||||
connection.routeCandidates.forEach { candidate ->
|
||||
candidate.dashboard?.url?.let(::add)
|
||||
candidate.api?.url
|
||||
?.let(Connection::deriveDefaultDashboardUrl)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::add)
|
||||
}
|
||||
}
|
||||
return urls.mapNotNullTo(mutableSetOf()) { url ->
|
||||
runCatching { URI(url).host?.lowercase() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private var topologyConnectionId: String? = null
|
||||
private var topologyGatewayMode: String? = null
|
||||
private var topologyProfiles: List<String> = emptyList()
|
||||
|
||||
fun selectedProfileUsesIsolatedApiRoute(): Boolean {
|
||||
val profile = profileController.selectedProfile.value ?: return false
|
||||
if (profile.hasIsolatedApi) return true
|
||||
val activeConnectionId = connectionStore.activeConnectionId.value
|
||||
val persisted = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeConnectionId }
|
||||
?.dashboardLastStatus
|
||||
val liveTopology = topologyConnectionId == activeConnectionId
|
||||
val mode = if (liveTopology) topologyGatewayMode else persisted?.gatewayMode
|
||||
val profiles = if (liveTopology) topologyProfiles else persisted?.profiles.orEmpty()
|
||||
return mode.equals("multiplex", ignoreCase = true) && profile.name in profiles
|
||||
}
|
||||
|
||||
/** Keep chat routing synchronized with the latest public dashboard topology. */
|
||||
private suspend fun updateDashboardTopology(connectionId: String, status: DashboardStatus?) {
|
||||
val nextMode = status?.gatewayMode
|
||||
val nextProfiles = status?.profiles.orEmpty()
|
||||
val changed = topologyConnectionId != connectionId ||
|
||||
topologyGatewayMode != nextMode ||
|
||||
topologyProfiles != nextProfiles
|
||||
topologyConnectionId = connectionId
|
||||
topologyGatewayMode = nextMode
|
||||
topologyProfiles = nextProfiles
|
||||
if (changed && connectionStore.activeConnectionId.value == connectionId) {
|
||||
rebuildChatApiClient()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshChatDisplaySettings(
|
||||
client: DashboardApiClient,
|
||||
authenticated: Boolean,
|
||||
@@ -4178,7 +4354,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val materiallySame = previous != null &&
|
||||
previous.reachable == reachable &&
|
||||
previous.authRequired == status?.authRequired &&
|
||||
previous.authenticated == session?.authenticated
|
||||
previous.authenticated == session?.authenticated &&
|
||||
previous.gatewayMode == status?.gatewayMode &&
|
||||
previous.profiles == status?.profiles.orEmpty()
|
||||
if (!materiallySame) {
|
||||
recordDashboardStatus(status = status, session = session, reachable = reachable)
|
||||
}
|
||||
@@ -4723,6 +4901,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
authProvider = session?.provider,
|
||||
gatewayTicketAvailable = gatewayTicketAvailable,
|
||||
message = message,
|
||||
gatewayMode = status?.gatewayMode,
|
||||
profiles = status?.profiles.orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -4755,6 +4935,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
authProvider = null,
|
||||
gatewayTicketAvailable = false,
|
||||
message = "Dashboard session cleared",
|
||||
gatewayMode = active?.dashboardLastStatus?.gatewayMode,
|
||||
profiles = active?.dashboardLastStatus?.profiles.orEmpty(),
|
||||
),
|
||||
)
|
||||
probeStandardVoice()
|
||||
@@ -5255,9 +5437,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private suspend fun rebuildChatApiClient() {
|
||||
val baseApiUrl = ProfileApiUrlResolver.normalize(effectiveApiServerUrlSnapshot())
|
||||
val profileApiUrl = ProfileApiUrlResolver.resolveForConnection(
|
||||
profileApiUrl = profileController.selectedProfile.value?.apiServerUrl,
|
||||
val selectedProfile = profileController.selectedProfile.value
|
||||
val activeConnectionId = connectionStore.activeConnectionId.value
|
||||
val topology = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeConnectionId }
|
||||
?.dashboardLastStatus
|
||||
val liveTopology = topologyConnectionId == activeConnectionId
|
||||
val profileApiUrl = ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = selectedProfile?.apiServerUrl,
|
||||
baseApiUrl = baseApiUrl,
|
||||
selectedProfileName = selectedProfile?.name,
|
||||
gatewayMode = if (liveTopology) topologyGatewayMode else topology?.gatewayMode,
|
||||
servedProfiles = if (liveTopology) topologyProfiles else topology?.profiles.orEmpty(),
|
||||
)
|
||||
val baseClient = _apiClient.value
|
||||
val key = apiKeyForClientBuild()
|
||||
@@ -5401,19 +5592,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun observeDeviceEndpoints(): kotlinx.coroutines.flow.Flow<List<EndpointCandidate>> {
|
||||
return activeConnection.flatMapLatest { connection ->
|
||||
val savedRoutes = connection?.routeCandidates.orEmpty()
|
||||
if (savedRoutes.isNotEmpty()) {
|
||||
kotlinx.coroutines.flow.flowOf(savedRoutes)
|
||||
} else {
|
||||
kotlinx.coroutines.flow.flow {
|
||||
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
|
||||
if (deviceId == null) {
|
||||
emit(emptyList())
|
||||
return@flow
|
||||
}
|
||||
emitAll(
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId)
|
||||
)
|
||||
}
|
||||
kotlinx.coroutines.flow.flow {
|
||||
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
|
||||
val pairedRoutes = deviceId?.let { id ->
|
||||
runCatching {
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), id).first()
|
||||
}.getOrDefault(emptyList())
|
||||
}.orEmpty()
|
||||
emit(
|
||||
mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes = savedRoutes,
|
||||
relayRoutes = pairedRoutes,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5422,7 +5613,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* Add or replace an extra fallback route on the active connection — the
|
||||
* standard path's manual equivalent of a v3 pairing QR's `endpoints`
|
||||
* array. The primary route (priority 0) mirrors the connection's main
|
||||
* Dashboard/Gateway route and is edited through the connection detail,
|
||||
* Dashboard/Gateway address and is edited through the connection detail,
|
||||
* never here.
|
||||
*
|
||||
* Legacy candidate sources (per-device PairingPreferences from old QR
|
||||
@@ -5436,12 +5627,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
*/
|
||||
fun saveExtraRoute(
|
||||
role: String,
|
||||
apiUrl: String,
|
||||
dashboardUrl: String,
|
||||
original: EndpointCandidate? = null,
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
if (original?.priority == 0) {
|
||||
onResult("The primary route mirrors the connection's API URL — edit that instead")
|
||||
onResult("The primary route mirrors the connection's Dashboard/Gateway address — edit that instead")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -5452,7 +5643,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
// Accept bare hosts/IPs — http:// is assumed and the standard
|
||||
// Dashboard/Gateway port defaults to 9119.
|
||||
val trimmedUrl = Connection.normalizeDashboardUrlInput(apiUrl)
|
||||
val trimmedUrl = Connection.normalizeDashboardUrlInput(dashboardUrl)
|
||||
val existing = seedRouteCandidates(current)
|
||||
if (existing.isEmpty()) {
|
||||
onResult("Set the connection's Dashboard/Gateway URL first")
|
||||
@@ -5468,6 +5659,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
priority = original?.priority
|
||||
?: ((withoutOriginal.maxOfOrNull { it.priority } ?: 0) + 1),
|
||||
dashboardUrl = trimmedUrl,
|
||||
apiServerUrl = Connection.deriveDefaultApiUrl(trimmedUrl),
|
||||
relayUrl = if (
|
||||
current.relayUrl.isNotBlank() ||
|
||||
original?.relay != null ||
|
||||
existing.any { it.relay != null }
|
||||
) {
|
||||
Connection.deriveDefaultApiUrl(trimmedUrl)
|
||||
?.let(Connection::deriveDefaultRelayUrl)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
if (candidate == null) {
|
||||
onResult(
|
||||
@@ -5501,13 +5703,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/**
|
||||
* Remove an extra fallback route. The primary route (priority 0) is
|
||||
* protected — it mirrors the connection's API URL. Clears a preferred-
|
||||
* protected — it mirrors the connection's Dashboard/Gateway address. Clears a preferred-
|
||||
* route override that pointed at the removed route, mirroring
|
||||
* [persistActiveConnectionUrls]' stale-preference handling.
|
||||
*/
|
||||
fun removeExtraRoute(candidate: EndpointCandidate, onResult: (String?) -> Unit = {}) {
|
||||
if (candidate.priority == 0) {
|
||||
onResult("The primary route can't be removed — edit the connection's API URL instead")
|
||||
onResult("The primary route can't be removed — edit the connection's Dashboard/Gateway address instead")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -5553,12 +5755,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* Routes card is currently displaying is what an edit starts from.
|
||||
*/
|
||||
private suspend fun seedRouteCandidates(current: Connection): List<EndpointCandidate> {
|
||||
current.routeCandidates.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
val fromPairing = runCatching {
|
||||
val deviceId = authManager.getOrCreateDeviceId()
|
||||
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId).first()
|
||||
}.getOrDefault(emptyList())
|
||||
if (fromPairing.isNotEmpty()) return fromPairing
|
||||
val recovered = mergeRelayTransportIntoStandardRoutes(
|
||||
standardRoutes = current.routeCandidates,
|
||||
relayRoutes = fromPairing,
|
||||
)
|
||||
if (recovered.isNotEmpty()) return recovered
|
||||
val dashboardUrl = current.resolvedDashboardUrl.takeIf { it.isNotBlank() }
|
||||
?: return emptyList()
|
||||
return listOfNotNull(
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/** Non-secret handle required to resume a dashboard-owned MCP OAuth flow. */
|
||||
data class PendingMcpOAuth(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val profile: String?,
|
||||
/** Non-secret connection + normalized dashboard identity that owns this flow. */
|
||||
val routeIdentity: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Process-recreatable Manage OAuth state. Authorization URLs, OAuth codes,
|
||||
* callback state, and tokens are deliberately never written here.
|
||||
*/
|
||||
class DashboardManageOAuthViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
private val flowId = savedStateHandle.getStateFlow<String?>(KEY_FLOW_ID, null)
|
||||
private val serverName = savedStateHandle.getStateFlow<String?>(KEY_SERVER_NAME, null)
|
||||
private val profile = savedStateHandle.getStateFlow<String?>(KEY_PROFILE, null)
|
||||
private val routeIdentity = savedStateHandle.getStateFlow<String?>(KEY_ROUTE_IDENTITY, null)
|
||||
|
||||
val pending: StateFlow<PendingMcpOAuth?> = combine(
|
||||
flowId,
|
||||
serverName,
|
||||
profile,
|
||||
routeIdentity,
|
||||
) { id, server, scope, route ->
|
||||
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) null
|
||||
else PendingMcpOAuth(id, server, scope, route)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, currentPending())
|
||||
|
||||
val unsupportedRoutes: StateFlow<ArrayList<String>> =
|
||||
savedStateHandle.getStateFlow(KEY_UNSUPPORTED_ROUTES, arrayListOf())
|
||||
val supportedRoutes: StateFlow<ArrayList<String>> =
|
||||
savedStateHandle.getStateFlow(KEY_SUPPORTED_ROUTES, arrayListOf())
|
||||
|
||||
fun remember(flowId: String, serverName: String, profile: String?, routeIdentity: String) {
|
||||
if (routeIdentity.isBlank()) return
|
||||
savedStateHandle[KEY_FLOW_ID] = flowId
|
||||
savedStateHandle[KEY_SERVER_NAME] = serverName
|
||||
savedStateHandle[KEY_PROFILE] = profile
|
||||
savedStateHandle[KEY_ROUTE_IDENTITY] = routeIdentity
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
savedStateHandle[KEY_FLOW_ID] = null
|
||||
savedStateHandle[KEY_SERVER_NAME] = null
|
||||
savedStateHandle[KEY_PROFILE] = null
|
||||
savedStateHandle[KEY_ROUTE_IDENTITY] = null
|
||||
}
|
||||
|
||||
fun markUnsupported(routeKey: String) {
|
||||
if (routeKey.isBlank()) return
|
||||
val next = ArrayList(unsupportedRoutes.value)
|
||||
if (routeKey !in next) {
|
||||
next += routeKey
|
||||
savedStateHandle[KEY_UNSUPPORTED_ROUTES] = next
|
||||
}
|
||||
removeRoute(KEY_SUPPORTED_ROUTES, supportedRoutes.value, routeKey)
|
||||
}
|
||||
|
||||
fun markSupported(routeKey: String) {
|
||||
if (routeKey.isBlank()) return
|
||||
val next = ArrayList(supportedRoutes.value)
|
||||
if (routeKey !in next) {
|
||||
next += routeKey
|
||||
savedStateHandle[KEY_SUPPORTED_ROUTES] = next
|
||||
}
|
||||
removeRoute(KEY_UNSUPPORTED_ROUTES, unsupportedRoutes.value, routeKey)
|
||||
}
|
||||
|
||||
private fun removeRoute(key: String, routes: ArrayList<String>, routeKey: String) {
|
||||
if (routeKey !in routes) return
|
||||
val next = ArrayList(routes)
|
||||
next.remove(routeKey)
|
||||
savedStateHandle[key] = next
|
||||
}
|
||||
|
||||
private fun currentPending(): PendingMcpOAuth? {
|
||||
val id = savedStateHandle.get<String>(KEY_FLOW_ID)
|
||||
val server = savedStateHandle.get<String>(KEY_SERVER_NAME)
|
||||
val route = savedStateHandle.get<String>(KEY_ROUTE_IDENTITY)
|
||||
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) return null
|
||||
return PendingMcpOAuth(id, server, savedStateHandle.get(KEY_PROFILE), route)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_FLOW_ID = "manage_mcp_oauth_flow_id"
|
||||
const val KEY_SERVER_NAME = "manage_mcp_oauth_server"
|
||||
const val KEY_PROFILE = "manage_mcp_oauth_profile"
|
||||
const val KEY_ROUTE_IDENTITY = "manage_mcp_oauth_route_identity"
|
||||
const val KEY_UNSUPPORTED_ROUTES = "manage_mcp_oauth_unsupported_routes"
|
||||
const val KEY_SUPPORTED_ROUTES = "manage_mcp_oauth_supported_routes"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ import kotlinx.coroutines.launch
|
||||
* live here.
|
||||
*/
|
||||
data class VoiceConfigUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val hasLoaded: Boolean = false,
|
||||
val voiceConfig: VoiceConfig? = null,
|
||||
val voiceConfigError: String? = null,
|
||||
val voiceOutputConfig: VoiceOutputConfig? = null,
|
||||
@@ -107,6 +109,7 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
|
||||
val configErrorEvents: SharedFlow<HumanError> = _configErrorEvents.asSharedFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var loadGeneration: Long = 0L
|
||||
|
||||
fun setBargeInEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { bargeInRepo.setEnabled(enabled) }
|
||||
@@ -132,65 +135,78 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
|
||||
*/
|
||||
fun loadVoiceConfig(client: RelayVoiceClient?, relayVoiceReady: Boolean) {
|
||||
loadJob?.cancel()
|
||||
val generation = ++loadGeneration
|
||||
if (client == null || !relayVoiceReady) {
|
||||
_configState.value = VoiceConfigUiState()
|
||||
return
|
||||
}
|
||||
_configState.value = VoiceConfigUiState(isLoading = true)
|
||||
loadJob = viewModelScope.launch {
|
||||
val voiceResult = client.getVoiceConfig()
|
||||
if (voiceResult.isSuccess) {
|
||||
_configState.update {
|
||||
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
|
||||
try {
|
||||
val voiceResult = client.getVoiceConfig()
|
||||
if (generation != loadGeneration) return@launch
|
||||
if (voiceResult.isSuccess) {
|
||||
_configState.update {
|
||||
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
|
||||
val outputResult = client.getVoiceOutputConfig()
|
||||
if (outputResult.isSuccess) {
|
||||
val config = outputResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
voiceOutputProviderOptions =
|
||||
it.voiceOutputProviderOptions + (provider.id to provider),
|
||||
)
|
||||
val outputResult = client.getVoiceOutputConfig()
|
||||
if (generation != loadGeneration) return@launch
|
||||
if (outputResult.isSuccess) {
|
||||
val config = outputResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
|
||||
if (generation != loadGeneration) return@launch
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
voiceOutputProviderOptions =
|
||||
it.voiceOutputProviderOptions + (provider.id to provider),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceOutputConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(voiceOutputConfigError = human.body) }
|
||||
_configErrorEvents.tryEmit(human)
|
||||
}
|
||||
|
||||
val realtimeResult = client.getRealtimeAgentConfig()
|
||||
if (realtimeResult.isSuccess) {
|
||||
val config = realtimeResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(realtimeConfig = config, realtimeConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
realtimeProviderOptions =
|
||||
it.realtimeProviderOptions + (provider.id to provider),
|
||||
)
|
||||
val realtimeResult = client.getRealtimeAgentConfig()
|
||||
if (generation != loadGeneration) return@launch
|
||||
if (realtimeResult.isSuccess) {
|
||||
val config = realtimeResult.getOrNull()
|
||||
_configState.update {
|
||||
it.copy(realtimeConfig = config, realtimeConfigError = null)
|
||||
}
|
||||
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
|
||||
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
|
||||
if (generation != loadGeneration) return@launch
|
||||
optionsResult.getOrNull()?.provider?.let { provider ->
|
||||
_configState.update {
|
||||
it.copy(
|
||||
realtimeProviderOptions =
|
||||
it.realtimeProviderOptions + (provider.id to provider),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
|
||||
}
|
||||
} finally {
|
||||
if (generation == loadGeneration) {
|
||||
_configState.update { it.copy(isLoading = false, hasLoaded = true) }
|
||||
}
|
||||
} else {
|
||||
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
|
||||
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
|
||||
import com.hermesandroid.relay.network.relay.VoiceHandoffEvent
|
||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.voice.VoiceIntentSyncBuilder
|
||||
@@ -53,6 +57,7 @@ import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -68,6 +73,7 @@ import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -82,6 +88,17 @@ import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
*/
|
||||
enum class VoiceState { Idle, Listening, Transcribing, Thinking, Speaking, Error }
|
||||
|
||||
private enum class StandardSpeechStreamState {
|
||||
Idle,
|
||||
Opening,
|
||||
Streaming,
|
||||
LegacyFallback,
|
||||
Consumed,
|
||||
}
|
||||
|
||||
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
||||
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
|
||||
|
||||
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
||||
|
||||
@@ -155,6 +172,16 @@ data class VoiceUiState(
|
||||
val backgroundRun: BackgroundRunState? = null,
|
||||
)
|
||||
|
||||
data class VoicePreviewUiState(
|
||||
val selectionKey: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val amplitude: Float = 0f,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isActive: Boolean get() = isLoading || isPlaying
|
||||
}
|
||||
|
||||
/** ADR 33 background/promoted Hermes run surface for the voice overlay. */
|
||||
data class BackgroundRunState(
|
||||
val runId: String? = null,
|
||||
@@ -579,6 +606,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(VoiceUiState())
|
||||
val uiState: StateFlow<VoiceUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _voicePreviewState = MutableStateFlow(VoicePreviewUiState())
|
||||
val voicePreviewState: StateFlow<VoicePreviewUiState> = _voicePreviewState.asStateFlow()
|
||||
private var voicePreviewJob: Job? = null
|
||||
private var voicePreviewGeneration: Long = 0L
|
||||
|
||||
// Rolling voice pipeline telemetry for StatsForNerds → Voice section.
|
||||
// Updated on discrete lifecycle events (turn start/stop, STT call
|
||||
// complete, TTS call complete, barge-in fire, threshold read, state
|
||||
@@ -658,6 +690,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var pendingRawDelta: StringBuilder = StringBuilder()
|
||||
|
||||
private var streamObserverJob: Job? = null
|
||||
private var standardSpeechStreamJob: Job? = null
|
||||
private var standardSpeechStream: VoiceSpeechStream? = null
|
||||
private var standardSpeechStreamGeneration: Long = 0L
|
||||
private var standardSpeechStreamState: StandardSpeechStreamState = StandardSpeechStreamState.Idle
|
||||
private var standardSpeechStreamText: StringBuilder = StringBuilder()
|
||||
private var standardSpeechStreamFinishRequested: Boolean = false
|
||||
private val standardSpeechStreamAudioSeen = AtomicBoolean(false)
|
||||
private val standardSpeechStreamAudioBytes = AtomicInteger(0)
|
||||
private val standardSpeechStreamBargeInStarted = AtomicBoolean(false)
|
||||
private var ttsConsumerJob: Job? = null
|
||||
private var realtimeTtsConsumerJob: Job? = null
|
||||
private var amplitudeBridgeJob: Job? = null
|
||||
@@ -928,6 +969,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
voiceRelayPreflight: (suspend () -> Result<Unit>)? = null,
|
||||
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
|
||||
) {
|
||||
cancelStandardSpeechStream("voice dependencies rewired")
|
||||
this.voiceClient = voiceClient
|
||||
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
|
||||
this.chatViewModel = chatViewModel
|
||||
@@ -1647,6 +1689,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// B4: tear down the barge-in listener + timers before we kill the
|
||||
// player so AEC doesn't try to track a released audio session.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("voice mode exited")
|
||||
duckingWatchdog?.cancel(); duckingWatchdog = null
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
handoffStatusClearJob?.cancel(); handoffStatusClearJob = null
|
||||
@@ -1742,6 +1785,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// listener and cancel any pending resume. Normal "tap mic to
|
||||
// talk" path also lands here, so we always leave Speaking cleanly.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("microphone capture started")
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
lastInterruptedAtChunkIndex = null
|
||||
clearSpokenChunksState()
|
||||
@@ -2053,6 +2097,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// bargeInDetected while the resume watchdog is deliberating.
|
||||
// stopBargeInListener is null-safe.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("speech interrupted")
|
||||
// 2026-04-18: the silence watchdog only runs during Listening, but
|
||||
// cancel defensively so a stale job from the prior turn can't
|
||||
// fire stopListening() after we've already returned to Idle.
|
||||
@@ -2254,6 +2299,222 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays an ephemeral Relay voice-output session using the editor's draft
|
||||
* selection. None of these values are persisted; saving remains an
|
||||
* explicit, separate settings action.
|
||||
*
|
||||
* Calling this with the currently-playing key acts as a pause/stop toggle.
|
||||
* Starting another key always stops the previous preview first, so two
|
||||
* providers can never talk over one another.
|
||||
*/
|
||||
fun previewVoiceOutput(
|
||||
selectionKey: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
voice: String,
|
||||
sampleRate: Int,
|
||||
language: String,
|
||||
sample: String = "Hello, this is Hermes. This is how this voice sounds.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
if (_voicePreviewState.value.isActive &&
|
||||
_voicePreviewState.value.selectionKey == selectionKey
|
||||
) {
|
||||
stopVoicePreview()
|
||||
return
|
||||
}
|
||||
|
||||
val client = voiceClient
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
if (client == null || pcmPlayer == null) {
|
||||
val error = IllegalStateException("Relay voice preview is not available")
|
||||
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
|
||||
onResult(Result.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
val previousJob = voicePreviewJob
|
||||
val generation = ++voicePreviewGeneration
|
||||
_voicePreviewState.value = VoicePreviewUiState(
|
||||
selectionKey = selectionKey,
|
||||
isLoading = true,
|
||||
)
|
||||
voicePreviewJob = viewModelScope.launch {
|
||||
val audioBytes = AtomicInteger(0)
|
||||
try {
|
||||
previousJob?.cancelAndJoin()
|
||||
if (generation != voicePreviewGeneration) return@launch
|
||||
pcmPlayer.stop()
|
||||
val result = client.runVoiceOutput(
|
||||
text = sample,
|
||||
renderMode = "verbatim",
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
) { event ->
|
||||
if (!event.isAudioDelta) return@runVoiceOutput
|
||||
val encoded = event.audioBase64 ?: return@runVoiceOutput
|
||||
val audio = try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
} catch (_: Exception) {
|
||||
return@runVoiceOutput
|
||||
}
|
||||
if (audio.isEmpty()) return@runVoiceOutput
|
||||
if (generation != voicePreviewGeneration) return@runVoiceOutput
|
||||
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
|
||||
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalResult = result.fold(
|
||||
onSuccess = {
|
||||
if (audioBytes.get() <= 0) {
|
||||
Result.failure(IllegalStateException("Voice preview returned no audio"))
|
||||
} else {
|
||||
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
|
||||
delay(drainMs)
|
||||
Result.success(Unit)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
if (generation == voicePreviewGeneration) {
|
||||
onResult(finalResult)
|
||||
_voicePreviewState.value = VoicePreviewUiState(
|
||||
error = finalResult.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} finally {
|
||||
if (generation == voicePreviewGeneration) {
|
||||
pcmPlayer.stop()
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey) {
|
||||
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a one-shot provider-native session with the Realtime editor's
|
||||
* unsaved provider/model/voice selection. The session endpoint accepts
|
||||
* these as request-scoped overrides, so auditioning never mutates relay
|
||||
* configuration or the active chat session.
|
||||
*/
|
||||
fun previewRealtimeAgent(
|
||||
selectionKey: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
voice: String,
|
||||
sampleRate: Int,
|
||||
sample: String = "Introduce this voice in one short sentence.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
if (_voicePreviewState.value.isActive && _voicePreviewState.value.selectionKey == selectionKey) {
|
||||
stopVoicePreview()
|
||||
return
|
||||
}
|
||||
val client = voiceClient
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
if (client == null || pcmPlayer == null) {
|
||||
val error = IllegalStateException("Realtime voice preview is not available")
|
||||
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
|
||||
onResult(Result.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
val previousJob = voicePreviewJob
|
||||
val generation = ++voicePreviewGeneration
|
||||
_voicePreviewState.value = VoicePreviewUiState(selectionKey = selectionKey, isLoading = true)
|
||||
voicePreviewJob = viewModelScope.launch {
|
||||
val audioBytes = AtomicInteger(0)
|
||||
try {
|
||||
previousJob?.cancelAndJoin()
|
||||
if (generation != voicePreviewGeneration) return@launch
|
||||
pcmPlayer.stop()
|
||||
val result = client.runRealtimeAgent(
|
||||
prompt = sample,
|
||||
inputPcm = ByteArray(0),
|
||||
inputSampleRate = 16_000,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
) { event, _ ->
|
||||
if (!event.isAudioDelta) return@runRealtimeAgent
|
||||
val encoded = event.audioBase64 ?: return@runRealtimeAgent
|
||||
val audio = try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
} catch (_: Exception) {
|
||||
return@runRealtimeAgent
|
||||
}
|
||||
if (audio.isEmpty()) return@runRealtimeAgent
|
||||
if (generation != voicePreviewGeneration) return@runRealtimeAgent
|
||||
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
|
||||
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalResult = result.fold(
|
||||
onSuccess = {
|
||||
if (audioBytes.get() <= 0) {
|
||||
Result.failure(IllegalStateException("Realtime preview returned no audio"))
|
||||
} else {
|
||||
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
|
||||
delay(drainMs)
|
||||
Result.success(Unit)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
if (generation == voicePreviewGeneration) {
|
||||
onResult(finalResult)
|
||||
_voicePreviewState.value = VoicePreviewUiState(error = finalResult.exceptionOrNull()?.message)
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} finally {
|
||||
if (generation == voicePreviewGeneration) {
|
||||
pcmPlayer.stop()
|
||||
_voicePreviewState.update { state ->
|
||||
if (state.selectionKey == selectionKey) {
|
||||
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopVoicePreview() {
|
||||
voicePreviewGeneration += 1
|
||||
voicePreviewJob?.cancel()
|
||||
voicePreviewJob = null
|
||||
realtimePcmPlayer?.stop()
|
||||
_voicePreviewState.value = VoicePreviewUiState()
|
||||
}
|
||||
|
||||
fun testRealtimeAgent(
|
||||
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
@@ -2820,6 +3081,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ignoreAssistantId = chatVm.messages.value
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
|
||||
// Kick off streaming observer BEFORE sending the message so we don't
|
||||
// miss early deltas that arrive synchronously from the callback.
|
||||
startStreamObserver(chatVm)
|
||||
@@ -2874,6 +3137,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun sessionIsCurrent(): Boolean =
|
||||
realtimeSessionGeneration.get() == sessionGeneration
|
||||
if (!prewarm) providerRealtimeAgentTurnActive.set(true)
|
||||
cancelStandardSpeechStream("provider-native realtime turn")
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
streamObserverJob?.cancel()
|
||||
@@ -3778,6 +4042,186 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Sentence-boundary streaming from ChatViewModel
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Optimistically open upstream's per-reply PCM socket while Hermes starts
|
||||
* the chat turn. Deltas are buffered until the fresh single-use dashboard
|
||||
* ticket is minted and the WebSocket opens. Older hosts, expired auth, and
|
||||
* non-streaming providers all converge on [activateStandardSpeechFallback]
|
||||
* before any PCM is heard, preserving the existing POST pipeline.
|
||||
*/
|
||||
private fun prepareStandardSpeechStream() {
|
||||
cancelStandardSpeechStream("new Standard voice turn")
|
||||
val client = voiceAudioClient ?: return
|
||||
if (client.effectiveRoute != VoiceAudioRoute.Standard || realtimePcmPlayer == null) return
|
||||
|
||||
val generation = ++standardSpeechStreamGeneration
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Opening
|
||||
standardSpeechStreamText = StringBuilder()
|
||||
standardSpeechStreamFinishRequested = false
|
||||
standardSpeechStreamAudioSeen.set(false)
|
||||
standardSpeechStreamAudioBytes.set(0)
|
||||
standardSpeechStreamBargeInStarted.set(false)
|
||||
|
||||
standardSpeechStreamJob = viewModelScope.launch {
|
||||
var openedSession: VoiceSpeechStream? = null
|
||||
try {
|
||||
val result = client.openSpeechStream(
|
||||
VoiceSpeechStreamCallbacks(
|
||||
onStart = { sampleRate, channels ->
|
||||
Log.i(
|
||||
TAG,
|
||||
"Standard speech stream ready sampleRate=$sampleRate channels=$channels",
|
||||
)
|
||||
},
|
||||
onPcm = { pcm, sampleRate ->
|
||||
viewModelScope.launch {
|
||||
if (generation != standardSpeechStreamGeneration ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Idle ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
handleStandardSpeechPcm(pcm, sampleRate)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
if (generation != standardSpeechStreamGeneration) {
|
||||
result.getOrNull()?.stop()
|
||||
return@launch
|
||||
}
|
||||
openedSession = result.getOrNull()
|
||||
if (openedSession == null) {
|
||||
activateStandardSpeechFallback(
|
||||
generation,
|
||||
result.exceptionOrNull() ?: IOException("Streaming voice unavailable"),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
standardSpeechStream = openedSession
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Streaming
|
||||
val buffered = standardSpeechStreamText.toString()
|
||||
if (buffered.isNotEmpty()) openedSession.append(buffered)
|
||||
if (standardSpeechStreamFinishRequested) openedSession.finish()
|
||||
|
||||
val outcome = openedSession.awaitOutcome()
|
||||
if (generation != standardSpeechStreamGeneration) return@launch
|
||||
standardSpeechStream = null
|
||||
if (shouldFallbackStandardSpeech(outcome)) {
|
||||
activateStandardSpeechFallback(generation, outcome.error)
|
||||
return@launch
|
||||
}
|
||||
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Consumed
|
||||
if (outcome.audioStarted) {
|
||||
realtimePcmPlayer?.flushBufferedPlayback()
|
||||
Log.i(
|
||||
TAG,
|
||||
"Standard speech stream finished status=${outcome.status} " +
|
||||
"bytes=${standardSpeechStreamAudioBytes.get()}",
|
||||
)
|
||||
}
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
if (generation == standardSpeechStreamGeneration) {
|
||||
activateStandardSpeechFallback(generation, error)
|
||||
}
|
||||
} finally {
|
||||
if (generation != standardSpeechStreamGeneration) openedSession?.stop()
|
||||
if (generation == standardSpeechStreamGeneration) standardSpeechStreamJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when the Standard WebSocket owns this text delta. */
|
||||
private fun offerStandardSpeechText(text: String): Boolean {
|
||||
if (text.isEmpty()) return standardSpeechStreamOwnsReply()
|
||||
return when (standardSpeechStreamState) {
|
||||
StandardSpeechStreamState.Opening -> {
|
||||
standardSpeechStreamText.append(text)
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Streaming -> {
|
||||
standardSpeechStreamText.append(text)
|
||||
standardSpeechStream?.append(text)
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Consumed -> true
|
||||
StandardSpeechStreamState.Idle,
|
||||
StandardSpeechStreamState.LegacyFallback,
|
||||
-> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun standardSpeechStreamOwnsReply(): Boolean =
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Opening ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Streaming ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Consumed
|
||||
|
||||
/** Returns true when stream completion is owned by the Standard socket. */
|
||||
private fun finishStandardSpeechStream(): Boolean {
|
||||
return when (standardSpeechStreamState) {
|
||||
StandardSpeechStreamState.Opening -> {
|
||||
standardSpeechStreamFinishRequested = true
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Streaming -> {
|
||||
standardSpeechStreamFinishRequested = true
|
||||
standardSpeechStream?.finish()
|
||||
true
|
||||
}
|
||||
StandardSpeechStreamState.Consumed -> true
|
||||
StandardSpeechStreamState.Idle,
|
||||
StandardSpeechStreamState.LegacyFallback,
|
||||
-> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun activateStandardSpeechFallback(generation: Long, error: Throwable?) {
|
||||
if (generation != standardSpeechStreamGeneration ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Idle
|
||||
) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Standard speech streaming unavailable; using POST fallback: ${error?.message}")
|
||||
standardSpeechStream?.stop()
|
||||
standardSpeechStream = null
|
||||
standardSpeechStreamState = StandardSpeechStreamState.LegacyFallback
|
||||
val buffered = standardSpeechStreamText.toString()
|
||||
standardSpeechStreamText = StringBuilder()
|
||||
if (buffered.isNotEmpty()) {
|
||||
appendSanitizedDelta(buffered)
|
||||
if (standardSpeechStreamFinishRequested) {
|
||||
flushRemainingBuffer()
|
||||
} else {
|
||||
drainSentences()
|
||||
rearmIdleFlush()
|
||||
}
|
||||
}
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
|
||||
private fun cancelStandardSpeechStream(reason: String) {
|
||||
if (standardSpeechStreamState != StandardSpeechStreamState.Idle) {
|
||||
Log.i(TAG, "Stopping Standard speech stream: $reason")
|
||||
}
|
||||
standardSpeechStreamGeneration += 1
|
||||
standardSpeechStream?.stop()
|
||||
standardSpeechStream = null
|
||||
standardSpeechStreamJob?.cancel()
|
||||
standardSpeechStreamJob = null
|
||||
standardSpeechStreamState = StandardSpeechStreamState.Idle
|
||||
standardSpeechStreamText = StringBuilder()
|
||||
standardSpeechStreamFinishRequested = false
|
||||
standardSpeechStreamAudioSeen.set(false)
|
||||
standardSpeechStreamAudioBytes.set(0)
|
||||
standardSpeechStreamBargeInStarted.set(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe [ChatViewModel.messages]. When the last assistant message
|
||||
* grows (isStreaming=true), diff the content against our last snapshot,
|
||||
@@ -3806,7 +4250,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else if (lastObservedMessageId != msgId) {
|
||||
// A new assistant turn appeared — flush whatever's left
|
||||
// from the previous one, then switch tracking.
|
||||
flushRemainingBuffer()
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
resetBrokeredToolSpeechState()
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
@@ -3829,7 +4273,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
flushRemainingBuffer()
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
// Speaking state will naturally end when TTS queue drains.
|
||||
// We can't easily wait here without blocking the collector;
|
||||
// the TTS consumer transitions back to Idle.
|
||||
@@ -3848,7 +4292,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* (the chat surface has its own renderer and wants the markdown).
|
||||
*/
|
||||
private fun onStreamDelta(delta: String, fullContent: String) {
|
||||
appendSanitizedDelta(delta)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Speaking,
|
||||
@@ -3856,6 +4299,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = fullContent,
|
||||
)
|
||||
}
|
||||
if (offerStandardSpeechText(delta)) return
|
||||
appendSanitizedDelta(delta)
|
||||
drainSentences()
|
||||
rearmIdleFlush()
|
||||
}
|
||||
@@ -3894,7 +4339,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun enqueueBrokeredToolStatus(status: String) {
|
||||
if (status.isBlank()) return
|
||||
if (enqueueSentenceForTts(status, immediate = true)) {
|
||||
val offeredToStandardStream = offerStandardSpeechText("$status ")
|
||||
if (offeredToStandardStream || enqueueSentenceForTts(status, immediate = true)) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
state = VoiceState.Speaking,
|
||||
@@ -4287,7 +4733,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
if (audio.isEmpty()) return
|
||||
handlePcmAudioChunk(
|
||||
audio = audio,
|
||||
sampleRate = event.sampleRate ?: 24_000,
|
||||
source = "Realtime",
|
||||
pcmPlayer = pcmPlayer,
|
||||
audioSeen = audioSeen,
|
||||
audioBytes = audioBytes,
|
||||
bargeInStarted = bargeInStarted,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleStandardSpeechPcm(audio: ByteArray, sampleRate: Int) {
|
||||
val pcmPlayer = realtimePcmPlayer ?: return
|
||||
handlePcmAudioChunk(
|
||||
audio = audio,
|
||||
sampleRate = sampleRate,
|
||||
source = "Standard",
|
||||
pcmPlayer = pcmPlayer,
|
||||
audioSeen = standardSpeechStreamAudioSeen,
|
||||
audioBytes = standardSpeechStreamAudioBytes,
|
||||
bargeInStarted = standardSpeechStreamBargeInStarted,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePcmAudioChunk(
|
||||
audio: ByteArray,
|
||||
sampleRate: Int,
|
||||
source: String,
|
||||
pcmPlayer: RealtimePcmPlayer,
|
||||
audioSeen: AtomicBoolean,
|
||||
audioBytes: AtomicInteger,
|
||||
bargeInStarted: AtomicBoolean,
|
||||
) {
|
||||
if (audio.isEmpty() || sampleRate <= 0) return
|
||||
audioSeen.set(true)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
lastRealtimeAudioDeltaAtMs = System.currentTimeMillis()
|
||||
@@ -4298,11 +4777,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
deliveringChipClearJob?.cancel()
|
||||
settleBackgroundRunChip(reason = "summary_audio_started")
|
||||
}
|
||||
val sampleRate = event.sampleRate ?: 24_000
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime audio delta event=${event.audioEventId ?: 0} bytes=${audio.size} " +
|
||||
"sampleRate=$sampleRate rms=${event.rmsLevel ?: -1f} peak=${event.peakLevel ?: -1f}",
|
||||
"$source audio delta bytes=${audio.size} sampleRate=$sampleRate",
|
||||
)
|
||||
val level = pcmPlayer.write(audio, sampleRate)
|
||||
scheduleRealtimeAmplitudeRelease(audio.size, sampleRate, lastRealtimeAudioDeltaAtMs)
|
||||
@@ -4727,7 +5204,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return decideAgentAudioCompletion(
|
||||
voiceMode = _uiState.value.voiceMode,
|
||||
observerStopped = streamObserverJob?.isActive != true,
|
||||
pendingTtsWork = pendingInTtsQueue.get(),
|
||||
pendingTtsWork = pendingInTtsQueue.get() +
|
||||
if (standardSpeechStreamState == StandardSpeechStreamState.Opening ||
|
||||
standardSpeechStreamState == StandardSpeechStreamState.Streaming
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
hasPendingSynthFiles = pendingTtsFiles.isNotEmpty(),
|
||||
realtimePlaybackRemainingMs = effectiveRemaining,
|
||||
realtimeTailGuardRemainingMs = continuousResumeTailGuardRemainingMs(),
|
||||
@@ -4737,6 +5221,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private fun finishAgentAudioOutput() {
|
||||
continuousResumeJob = null
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("audio output finished")
|
||||
realtimeAmplitudeDecayJob?.cancel()
|
||||
realtimeAmplitudeDecayJob = null
|
||||
firstFrameWatchdogJob?.cancel()
|
||||
@@ -5444,10 +5929,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
voicePreviewJob?.cancel()
|
||||
voicePreviewJob = null
|
||||
closeRealtimeSession()
|
||||
// B4: release the listener + VAD engine native resources before
|
||||
// anything else. stopBargeInListener is defensive / idempotent.
|
||||
stopBargeInListener()
|
||||
cancelStandardSpeechStream("view model cleared")
|
||||
duckingWatchdog?.cancel()
|
||||
resumeWatchdog?.cancel()
|
||||
silenceWatchdogJob?.cancel()
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<string name="chat_input_start_voice">Iniciar conversa por voz</string>
|
||||
<string name="chat_input_voice_setup_needed">Conversa por voz — configuração necessária</string>
|
||||
<string name="chat_input_stop_streaming">Parar transmissão</string>
|
||||
<string name="chat_input_steer_response">Direcionar a resposta</string>
|
||||
<string name="chat_input_steer_response">Corrigir a resposta</string>
|
||||
<string name="chat_input_queue_message">Colocar mensagem na fila</string>
|
||||
<!-- Bridge return labels -->
|
||||
<string name="bridge_return_chat_label">Chat</string>
|
||||
@@ -90,7 +90,7 @@
|
||||
<string name="demo_feature_manage">Gerenciar</string>
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">Edite sua mensagem…</string>
|
||||
<string name="chat_placeholder_steer">Direcione a resposta…</string>
|
||||
<string name="chat_placeholder_steer">Corrija a resposta…</string>
|
||||
<string name="chat_placeholder_queue">Coloque uma mensagem na fila…</string>
|
||||
<string name="chat_placeholder_message">Mensagem…</string>
|
||||
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
|
||||
@@ -330,7 +330,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 ou http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">API do Hermes usada pelo Chat e pelas sessões — a porta 8642 da API e http:// são presumidos para hosts sem esquema (a porta 9119 do painel é determinada separadamente)</string>
|
||||
<string name="cw_scan_message">Procurando o painel/a API do Hermes nesta LAN…</string>
|
||||
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e a voz — a chave da API é opcional para o Chat.</string>
|
||||
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e voz — a chave da API é usada apenas no fallback opcional pela API direta.</string>
|
||||
<string name="cw_pair_relay_section">Parear o Relay (opcional)</string>
|
||||
<string name="cw_pair_relay_section_desc">O plugin do Relay já está em execução? Faça o pareamento aqui para ativar Terminal, Bridge e permissões de canais.</string>
|
||||
<string name="cw_pair_relay_url_label">URL do Relay</string>
|
||||
@@ -1412,7 +1412,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Voz do plugin do Relay — provedores específicos por perfil e saída de voz em streaming.</string>
|
||||
<string name="voice_settings_optional">Opcional</string>
|
||||
<string name="voice_settings_signin_route_hint">Você está conectado pela rota %1$s, e os logins do painel são específicos por host — o login feito na sua rede doméstica não é transferido. Entre uma vez em Gerenciar enquanto estiver nesta rota para liberar a voz aqui também.</string>
|
||||
<string name="voice_settings_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Abra Gerenciar para entrar novamente e liberar a voz.</string>
|
||||
<string name="voice_settings_signin_default_hint">Seu painel do Hermes exige login antes que a voz padrão possa transcrever ou falar. Entrar uma vez em Gerenciar libera a voz para esta conexão.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Entrar por Gerenciar</string>
|
||||
<string name="voice_settings_unsupported_build_body">Esta versão do servidor Hermes ainda não oferece as rotas de áudio do painel. Atualize o hermes-agent no servidor ou pareie o Relay para usar a voz do Relay.</string>
|
||||
@@ -1642,7 +1642,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">É necessário entrar no painel</string>
|
||||
<string name="dashboard_signin_required_body">O Gerenciar usa a sessão do painel do Hermes em %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">Você está na rota %1$s. Os logins do painel são específicos por host, então o login da outra rota não é transferido — entre uma vez aqui, e o app manterá as duas sessões.</string>
|
||||
<string name="dashboard_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Entre novamente para atualizá-la nas rotas confiáveis desta conexão.</string>
|
||||
<string name="dashboard_signin_with_provider">Entrar com %1$s</string>
|
||||
<string name="dashboard_username_password">Nome de usuário e senha</string>
|
||||
<string name="dashboard_username">Nome de usuário</string>
|
||||
@@ -1688,6 +1688,31 @@
|
||||
<string name="dashboard_action_use">Usar</string>
|
||||
<string name="dashboard_action_describe">Descrever</string>
|
||||
<string name="dashboard_action_model">Modelo</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Endpoints personalizados</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">Provedores compatíveis com OpenAI</string>
|
||||
<string name="dashboard_action_authenticate">Autenticar</string>
|
||||
<string name="dashboard_action_validate">Validar</string>
|
||||
<string name="dashboard_action_edit">Editar</string>
|
||||
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">O Hermes abrirá o provedor no navegador. Volte aqui depois de aprovar o acesso; as credenciais permanecem no servidor Hermes.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">Autenticação MCP aprovada</string>
|
||||
<string name="dashboard_mcp_oauth_failed">Falha na autenticação MCP</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">Nenhum navegador está disponível para concluir a autenticação MCP.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Adicionar endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Editar endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_name">Nome</string>
|
||||
<string name="dashboard_custom_endpoint_url">URL base</string>
|
||||
<string name="dashboard_custom_endpoint_model">Modelo padrão</string>
|
||||
<string name="dashboard_custom_endpoint_key">Chave de API (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Deixe em branco para preservar uma chave existente ao salvar. A validação usa somente a chave inserida aqui; o Hermes não expõe nem apaga as chaves salvas.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Tamanho do contexto (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Descobrir modelos em /models</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Endpoint acessível · %1$d modelo(s)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Falha ao validar o endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Falha ao salvar o endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Endpoint personalizado salvo</string>
|
||||
<string name="dashboard_action_completed">%1$s concluído</string>
|
||||
<string name="dashboard_action_failed">Falha em %1$s</string>
|
||||
<string name="dashboard_more">Mais</string>
|
||||
@@ -1984,7 +2009,7 @@
|
||||
<string name="active_section_api_key_already_set">A chave da API já está definida</string>
|
||||
<string name="active_section_api_key_needed_hint">Use o API_SERVER_KEY criado no seu servidor Hermes. O aplicativo não fornece essa chave.</string>
|
||||
<string name="active_section_api_key_not_configured">Chave da API não configurada</string>
|
||||
<string name="active_section_api_key_optional">Chave da API (opcional)</string>
|
||||
<string name="active_section_api_key_optional">Chave da API direta</string>
|
||||
<string name="active_section_api_key_stored_hint">A chave está armazenada com segurança.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API acessível — revise a configuração de voz</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API acessível — a voz do Relay está configurada</string>
|
||||
@@ -2343,6 +2368,7 @@
|
||||
<string name="tool_progress_status_completed">concluída</string>
|
||||
<string name="tool_progress_status_failed">falhou</string>
|
||||
<string name="tool_progress_status_running">em execução</string>
|
||||
<string name="image_generation_rendering">Gerando imagem</string>
|
||||
<string name="tool_progress_cd_collapse">Recolher</string>
|
||||
<string name="tool_progress_cd_expand">Expandir</string>
|
||||
<!-- AgentIconRow -->
|
||||
@@ -3060,7 +3086,7 @@
|
||||
<string name="active_section_optional_api_fallback">Fallback opcional pela API direta</string>
|
||||
<string name="active_section_api_not_required">Não é necessário quando esta conexão usa o Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">Onde obtenho essa chave?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY é criada no seu servidor Hermes; este aplicativo não fornece a chave. Configure-a somente ao ativar o servidor de API opcional e insira aqui o mesmo valor.</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY é criada no seu servidor Hermes; este aplicativo não fornece a chave. Configure-a somente ao ativar o servidor de API opcional, que exige uma chave utilizável, e insira aqui o mesmo valor.</string>
|
||||
<string name="active_section_scan_relay_qr">Escanear QR do Relay</string>
|
||||
<string name="active_section_other_relay_methods">Outros métodos de pareamento</string>
|
||||
<string name="cw_pair_relay_for">Emparelhar Relay com %1$s</string>
|
||||
@@ -3128,4 +3154,15 @@
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · LAN doméstica</string>
|
||||
<string name="endpoints_tailscale_setup_hint">O Tailscale deve estar conectado nos dois dispositivos, e o Hermes Dashboard deve estar acessível na porta 9119.</string>
|
||||
<string name="endpoints_setup_help">Ajuda para configurar o Tailscale</string>
|
||||
<string name="voice_settings_provider_desc">Onde a fala e gerada. As opcoes disponiveis vêm deste host Hermes e dos provedores instalados.</string>
|
||||
<string name="voice_settings_model_desc">Latest acompanha atualizacoes do provedor. Escolha um modelo versionado para manter o comportamento de voz fixo.</string>
|
||||
<string name="voice_settings_voice_desc">Escolha como as respostas soam. Previsualizar uma voz nao salva a selecao.</string>
|
||||
<string name="voice_settings_language_desc">Automatico deixa o provedor detectar o idioma falado. Escolha um idioma somente quando a deteccao nao for confiavel.</string>
|
||||
<string name="voice_settings_language_auto">Automatico</string>
|
||||
<string name="voice_settings_provider_options_title">Opcoes de voz</string>
|
||||
<string name="voice_settings_provider_options_desc">Somente configuracoes aceitas pelo provedor selecionado sao mostradas.</string>
|
||||
<string name="voice_settings_auto_speak">Ler respostas em voz alta</string>
|
||||
<string name="voice_settings_auto_speak_desc">Fala automaticamente as respostas do assistente em superficies Hermes que respeitam esta configuracao do host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla a sessao de fala ao vivo, nao o modelo de chat Hermes. Latest acompanha atualizacoes do provedor; um modelo versionado fica fixo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">A voz usada dentro da sessao ao vivo. Vozes integradas e personalizadas aparecem quando o provedor as anuncia.</string>
|
||||
</resources>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="chat_input_start_voice">开始语音对话</string>
|
||||
<string name="chat_input_voice_setup_needed">语音对话——需要先设置</string>
|
||||
<string name="chat_input_stop_streaming">停止流式响应</string>
|
||||
<string name="chat_input_steer_response">引导回复方向</string>
|
||||
<string name="chat_input_steer_response">修正回复</string>
|
||||
<string name="chat_input_queue_message">排队发送消息</string>
|
||||
|
||||
<!-- Bridge 返回标签 -->
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
<!-- 聊天界面占位符 -->
|
||||
<string name="chat_placeholder_edit">编辑你的消息…</string>
|
||||
<string name="chat_placeholder_steer">引导回复方向…</string>
|
||||
<string name="chat_placeholder_steer">修正回复…</string>
|
||||
<string name="chat_placeholder_queue">排队发送消息…</string>
|
||||
<string name="chat_placeholder_message">输入消息…</string>
|
||||
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
|
||||
@@ -353,7 +353,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 或 http://你的服务器:8642</string>
|
||||
<string name="cw_api_url_supporting">聊天和会话使用的 Hermes API——裸主机名默认使用 API 端口 8642 和 http://(仪表盘的 9119 端口单独推导)</string>
|
||||
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥对聊天是可选的。</string>
|
||||
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥仅用于可选的直接 API 回退。</string>
|
||||
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
|
||||
<string name="cw_pair_relay_for">为 %1$s 配对 Relay</string>
|
||||
<string name="cw_pair_relay_scoped_desc">为这个已保存的 Hermes 连接添加可选的 Relay 扩展。这不会添加或替换服务器。</string>
|
||||
@@ -1473,7 +1473,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Relay 插件语音——支持按个人资料的提供商和流式语音输出。</string>
|
||||
<string name="voice_settings_optional">可选</string>
|
||||
<string name="voice_settings_signin_route_hint">您正通过 %1$s 路由连接,而仪表板登录是按主机区分的——家庭网络上的登录不会延续到此。请在此路由上在 Manage 中登录一次,以在此处解锁语音。</string>
|
||||
<string name="voice_settings_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请打开“管理”重新登录并解锁语音。</string>
|
||||
<string name="voice_settings_signin_default_hint">您的 Hermes 仪表板需要先登录,标准语音才能转录或朗读。在 Manage 中登录一次即可为此连接解锁。</string>
|
||||
<string name="voice_settings_sign_in_via_manage">通过 Manage 登录</string>
|
||||
<string name="voice_settings_unsupported_build_body">此 Hermes 服务器构建尚未公开仪表板音频路由。请在服务器上更新 hermes-agent,或配对 Relay 以使用 Relay 语音。</string>
|
||||
@@ -1712,7 +1712,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">需要登录仪表盘</string>
|
||||
<string name="dashboard_signin_required_body">管理功能使用位于 %1$s 的 Hermes 仪表盘会话。</string>
|
||||
<string name="dashboard_signin_route_hint">你当前使用 %1$s 路由。仪表盘登录按主机区分,所以在另一条路由上的登录不会自动延续——在这里登录一次,应用会同时保留两个会话。</string>
|
||||
<string name="dashboard_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请重新登录,以便在此连接的可信路由之间刷新会话。</string>
|
||||
<string name="dashboard_signin_with_provider">使用 %1$s 登录</string>
|
||||
<string name="dashboard_username_password">用户名与密码</string>
|
||||
<string name="dashboard_username">用户名</string>
|
||||
@@ -1763,6 +1763,31 @@
|
||||
<string name="dashboard_action_use">使用</string>
|
||||
<string name="dashboard_action_describe">描述</string>
|
||||
<string name="dashboard_action_model">模型</string>
|
||||
<string name="dashboard_tab_custom_endpoints">端点</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">端点</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">自定义端点</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 兼容提供商</string>
|
||||
<string name="dashboard_action_authenticate">认证</string>
|
||||
<string name="dashboard_action_validate">验证</string>
|
||||
<string name="dashboard_action_edit">编辑</string>
|
||||
<string name="dashboard_mcp_oauth_title">认证 %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes 将在浏览器中打开提供商。批准访问后请返回此页面;凭据将保留在 Hermes 服务器上。</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP 认证已获批准</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP 认证失败</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">没有可用于完成 MCP 认证的浏览器。</string>
|
||||
<string name="dashboard_custom_endpoint_add">添加端点</string>
|
||||
<string name="dashboard_custom_endpoint_edit">编辑端点</string>
|
||||
<string name="dashboard_custom_endpoint_name">名称</string>
|
||||
<string name="dashboard_custom_endpoint_url">基础 URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">默认模型</string>
|
||||
<string name="dashboard_custom_endpoint_key">API 密钥(可选)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">保存时留空可保留现有密钥。验证仅使用在此输入的密钥;Hermes 不会显示或清除已保存的密钥。</string>
|
||||
<string name="dashboard_custom_endpoint_context">上下文长度(可选)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">从 /models 发现模型</string>
|
||||
<string name="dashboard_custom_endpoint_valid">端点可访问 · %1$d 个模型</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">端点验证失败</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">端点保存失败</string>
|
||||
<string name="dashboard_custom_endpoint_saved">自定义端点已保存</string>
|
||||
<string name="dashboard_action_completed">%1$s 已完成</string>
|
||||
<string name="dashboard_action_failed">%1$s 失败</string>
|
||||
<string name="dashboard_more">更多</string>
|
||||
@@ -2077,7 +2102,7 @@
|
||||
<string name="active_section_api_key_already_set">API 密钥已设置</string>
|
||||
<string name="active_section_api_key_needed_hint">使用在 Hermes 服务器上创建的 API_SERVER_KEY。此密钥并非由应用提供。</string>
|
||||
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
|
||||
<string name="active_section_api_key_optional">API 密钥(可选)</string>
|
||||
<string name="active_section_api_key_optional">直接 API 的 API 密钥</string>
|
||||
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
|
||||
<string name="active_section_api_reachable_voice_review">API 可达——请检查语音配置</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API 可达——Relay 语音已配置</string>
|
||||
@@ -2452,6 +2477,7 @@
|
||||
<string name="tool_progress_status_completed">已完成</string>
|
||||
<string name="tool_progress_status_failed">失败</string>
|
||||
<string name="tool_progress_status_running">运行中</string>
|
||||
<string name="image_generation_rendering">正在生成图像</string>
|
||||
<string name="tool_progress_cd_collapse">折叠</string>
|
||||
<string name="tool_progress_cd_expand">展开</string>
|
||||
|
||||
@@ -3217,9 +3243,20 @@
|
||||
<string name="active_section_optional_api_fallback">可选的直接 API 回退</string>
|
||||
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
|
||||
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时进行配置,并在此输入相同的值。</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时配置;该服务器需要可用密钥,并在此输入相同的值。</string>
|
||||
<string name="active_section_scan_relay_qr">扫描 Relay 二维码</string>
|
||||
<string name="active_section_other_relay_methods">其他配对方式</string>
|
||||
<string name="endpoints_tailscale_setup_hint">两台设备都必须连接 Tailscale,并且 Hermes Dashboard 必须可通过端口 9119 访问。</string>
|
||||
<string name="endpoints_setup_help">Tailscale 设置帮助</string>
|
||||
<string name="voice_settings_provider_desc">语音生成的位置。可用选项来自此 Hermes 主机及其已安装的提供商。</string>
|
||||
<string name="voice_settings_model_desc">Latest 会跟随提供商升级。选择带版本的模型可固定语音行为。</string>
|
||||
<string name="voice_settings_voice_desc">选择回复的声音。预览语音不会保存选择。</string>
|
||||
<string name="voice_settings_language_desc">自动会让提供商检测口语语言。仅在检测不可靠时选择语言。</string>
|
||||
<string name="voice_settings_language_auto">自动</string>
|
||||
<string name="voice_settings_provider_options_title">语音选项</string>
|
||||
<string name="voice_settings_provider_options_desc">只显示所选提供商支持的设置。</string>
|
||||
<string name="voice_settings_auto_speak">朗读回复</string>
|
||||
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
|
||||
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</string>
|
||||
</resources>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="chat_input_start_voice">Sprachgespräch starten</string>
|
||||
<string name="chat_input_voice_setup_needed">Sprachgespräch — Einrichtung erforderlich</string>
|
||||
<string name="chat_input_stop_streaming">Streaming stoppen</string>
|
||||
<string name="chat_input_steer_response">Antwort steuern</string>
|
||||
<string name="chat_input_steer_response">Antwort korrigieren</string>
|
||||
<string name="chat_input_queue_message">Nachricht einreihen</string>
|
||||
|
||||
<!-- Bridge return labels -->
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">Nachricht bearbeiten…</string>
|
||||
<string name="chat_placeholder_steer">Antwort steuern…</string>
|
||||
<string name="chat_placeholder_steer">Antwort korrigieren…</string>
|
||||
<string name="chat_placeholder_queue">Nachricht einreihen…</string>
|
||||
<string name="chat_placeholder_message">Nachricht…</string>
|
||||
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
|
||||
@@ -353,7 +353,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 oder http://dein-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Von Chat und Sitzungen verwendete Hermes-API — bei reinen Hosts werden API-Port 8642 und http:// angenommen (Dashboard-Port 9119 wird separat abgeleitet)</string>
|
||||
<string name="cw_scan_message">Dieses LAN wird nach Hermes-Dashboard/API durchsucht…</string>
|
||||
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel ist für Chat optional.</string>
|
||||
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel gilt nur für den optionalen direkten API-Fallback.</string>
|
||||
<string name="cw_pair_relay_section">Relay koppeln (optional)</string>
|
||||
<string name="cw_pair_relay_for">Relay mit %1$s koppeln</string>
|
||||
<string name="cw_pair_relay_scoped_desc">Füge dieser gespeicherten Hermes-Verbindung die optionale Relay-Erweiterung hinzu. Dadurch wird kein Server hinzugefügt oder ersetzt.</string>
|
||||
@@ -1476,7 +1476,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Sprachfunktion des Relay-Plugins — profilbezogene Anbieter und Streaming-Sprachausgabe.</string>
|
||||
<string name="voice_settings_optional">Optional</string>
|
||||
<string name="voice_settings_signin_route_hint">Du bist über die Route %1$s verbunden, und Dashboard-Anmeldungen gelten pro Host — eine Anmeldung aus deinem Heimnetz wird nicht übernommen. Melde dich unter Verwalten einmal über diese Route an, um auch hier Sprache freizuschalten.</string>
|
||||
<string name="voice_settings_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Öffne Verwalten, um dich erneut anzumelden und Sprache freizuschalten.</string>
|
||||
<string name="voice_settings_signin_default_hint">Dein Hermes-Dashboard erfordert eine Anmeldung, bevor Standardsprache transkribieren oder sprechen kann. Eine einmalige Anmeldung unter Verwalten schaltet sie für diese Verbindung frei.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Über Verwalten anmelden</string>
|
||||
<string name="voice_settings_unsupported_build_body">Dieser Hermes-Server-Build stellt die Dashboard-Audiorouten noch nicht bereit. Aktualisiere hermes-agent auf dem Server oder kopple Relay, um Relay-Sprache zu verwenden.</string>
|
||||
@@ -1715,7 +1715,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">Dashboard-Anmeldung erforderlich</string>
|
||||
<string name="dashboard_signin_required_body">Verwalten verwendet die Hermes-Dashboard-Sitzung unter %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">Du verwendest die Route %1$s. Dashboard-Anmeldungen gelten pro Host, daher wird deine Anmeldung über die andere Route nicht übernommen — melde dich hier einmal an, dann behält die App beide Sitzungen.</string>
|
||||
<string name="dashboard_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Melde dich erneut an, um sie für die vertrauenswürdigen Routen dieser Verbindung zu aktualisieren.</string>
|
||||
<string name="dashboard_signin_with_provider">Mit %1$s anmelden</string>
|
||||
<string name="dashboard_username_password">Benutzername & Passwort</string>
|
||||
<string name="dashboard_username">Benutzername</string>
|
||||
@@ -1766,6 +1766,31 @@
|
||||
<string name="dashboard_action_use">Verwenden</string>
|
||||
<string name="dashboard_action_describe">Beschreiben</string>
|
||||
<string name="dashboard_action_model">Modell</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Endpunkte</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">Endpunkte</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Benutzerdefinierte Endpunkte</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-kompatible Anbieter</string>
|
||||
<string name="dashboard_action_authenticate">Authentifizieren</string>
|
||||
<string name="dashboard_action_validate">Validieren</string>
|
||||
<string name="dashboard_action_edit">Bearbeiten</string>
|
||||
<string name="dashboard_mcp_oauth_title">%1$s authentifizieren</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes öffnet den Anbieter in Ihrem Browser. Kehren Sie nach der Genehmigung des Zugriffs hierher zurück; die Anmeldedaten verbleiben auf dem Hermes-Server.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP-Authentifizierung genehmigt</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP-Authentifizierung fehlgeschlagen</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">Zum Abschluss der MCP-Authentifizierung ist kein Browser verfügbar.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Endpunkt hinzufügen</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Endpunkt bearbeiten</string>
|
||||
<string name="dashboard_custom_endpoint_name">Name</string>
|
||||
<string name="dashboard_custom_endpoint_url">Basis-URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">Standardmodell</string>
|
||||
<string name="dashboard_custom_endpoint_key">API-Schlüssel (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Lassen Sie das Feld leer, um beim Speichern einen vorhandenen Schlüssel beizubehalten. Für die Validierung wird nur ein hier eingegebener Schlüssel verwendet; Hermes legt gespeicherte Schlüssel weder offen noch löscht es sie.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Kontextlänge (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Modelle über /models ermitteln</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Endpunkt erreichbar · %1$d Modell(e)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Endpunktvalidierung fehlgeschlagen</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Speichern des Endpunkts fehlgeschlagen</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Benutzerdefinierter Endpunkt gespeichert</string>
|
||||
<string name="dashboard_action_completed">%1$s abgeschlossen</string>
|
||||
<string name="dashboard_action_failed">%1$s fehlgeschlagen</string>
|
||||
<string name="dashboard_more">Mehr</string>
|
||||
@@ -2080,7 +2105,7 @@
|
||||
<string name="active_section_api_key_already_set">API-Schlüssel ist bereits festgelegt</string>
|
||||
<string name="active_section_api_key_needed_hint">Verwende den auf deinem Hermes-Server erstellten API_SERVER_KEY. Die App stellt diesen Schlüssel nicht aus.</string>
|
||||
<string name="active_section_api_key_not_configured">API-Schlüssel nicht konfiguriert</string>
|
||||
<string name="active_section_api_key_optional">API-Schlüssel (optional)</string>
|
||||
<string name="active_section_api_key_optional">API-Schlüssel für direkte API</string>
|
||||
<string name="active_section_api_key_stored_hint">Schlüssel ist sicher gespeichert.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API erreichbar — Sprachkonfiguration prüfen</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API erreichbar — Relay-Sprache ist konfiguriert</string>
|
||||
@@ -2455,6 +2480,7 @@
|
||||
<string name="tool_progress_status_completed">abgeschlossen</string>
|
||||
<string name="tool_progress_status_failed">fehlgeschlagen</string>
|
||||
<string name="tool_progress_status_running">läuft</string>
|
||||
<string name="image_generation_rendering">Bild wird erstellt</string>
|
||||
<string name="tool_progress_cd_collapse">Einklappen</string>
|
||||
<string name="tool_progress_cd_expand">Ausklappen</string>
|
||||
|
||||
@@ -3283,9 +3309,20 @@
|
||||
<string name="active_section_optional_api_fallback">Optionaler direkter API-Fallback</string>
|
||||
<string name="active_section_api_not_required">Nicht erforderlich, wenn diese Verbindung das Hermes Dashboard verwendet.</string>
|
||||
<string name="active_section_where_api_key">Wo erhalte ich diesen Schlüssel?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY wird auf deinem Hermes-Server erstellt und nicht von dieser App bereitgestellt. Konfiguriere ihn nur für den optionalen API-Server und gib hier denselben Wert ein.</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY wird auf deinem Hermes-Server erstellt und nicht von dieser App bereitgestellt. Konfiguriere ihn nur für den optionalen API-Server, der einen verwendbaren Schlüssel verlangt, und gib hier denselben Wert ein.</string>
|
||||
<string name="active_section_scan_relay_qr">Relay-QR scannen</string>
|
||||
<string name="active_section_other_relay_methods">Andere Kopplungsmethoden</string>
|
||||
<string name="endpoints_tailscale_setup_hint">Tailscale muss auf beiden Geräten verbunden und Hermes Dashboard auf Port 9119 erreichbar sein.</string>
|
||||
<string name="endpoints_setup_help">Tailscale-Einrichtungshilfe</string>
|
||||
<string name="voice_settings_provider_desc">Wo Sprache erzeugt wird. Verfuegbare Optionen kommen von diesem Hermes-Host und seinen installierten Anbietern.</string>
|
||||
<string name="voice_settings_model_desc">Latest folgt Anbieter-Upgrades. Waehle ein versioniertes Modell, um das Sprachverhalten festzuhalten.</string>
|
||||
<string name="voice_settings_voice_desc">Waehle, wie Antworten klingen. Eine Stimmvorschau speichert die Auswahl nicht.</string>
|
||||
<string name="voice_settings_language_desc">Automatisch laesst den Anbieter die gesprochene Sprache erkennen. Waehle nur dann eine Sprache, wenn die Erkennung unzuverlaessig ist.</string>
|
||||
<string name="voice_settings_language_auto">Automatisch</string>
|
||||
<string name="voice_settings_provider_options_title">Sprachoptionen</string>
|
||||
<string name="voice_settings_provider_options_desc">Nur Einstellungen, die der ausgewaehlte Anbieter unterstuetzt, werden angezeigt.</string>
|
||||
<string name="voice_settings_auto_speak">Antworten vorlesen</string>
|
||||
<string name="voice_settings_auto_speak_desc">Spricht Assistentenantworten automatisch auf Hermes-Oberflaechen, die diese Host-Einstellung beachten.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Steuert die Live-Sprachsitzung, nicht das Hermes-Chatmodell. Latest folgt Anbieter-Upgrades; ein versioniertes Modell bleibt festgelegt.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">Die Stimme innerhalb der Live-Sitzung. Eingebaute und benutzerdefinierte Stimmen erscheinen, wenn der Anbieter sie meldet.</string>
|
||||
</resources>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<string name="chat_input_start_voice">Iniciar conversación de voz</string>
|
||||
<string name="chat_input_voice_setup_needed">Conversación de voz: se necesita configuración</string>
|
||||
<string name="chat_input_stop_streaming">dejar de transmitir</string>
|
||||
<string name="chat_input_steer_response">Dirigir la respuesta</string>
|
||||
<string name="chat_input_steer_response">Corregir la respuesta</string>
|
||||
<string name="chat_input_queue_message">mensaje de cola</string>
|
||||
<string name="bridge_return_chat_label">Charlar</string>
|
||||
<string name="bridge_return_manage_label">Administrar</string>
|
||||
@@ -84,7 +84,7 @@
|
||||
<string name="status_profile_format">perfil: %1$s</string>
|
||||
<string name="demo_feature_manage">Administrar</string>
|
||||
<string name="chat_placeholder_edit">Edita tu mensaje...</string>
|
||||
<string name="chat_placeholder_steer">Dirige la respuesta...</string>
|
||||
<string name="chat_placeholder_steer">Corrige la respuesta...</string>
|
||||
<string name="chat_placeholder_queue">Poner en cola un mensaje...</string>
|
||||
<string name="chat_placeholder_message">Mensaje…</string>
|
||||
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
|
||||
@@ -311,7 +311,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 o http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API utilizado por Chat y sesiones — API puerto 8642 y http:// asumido para hosts desnudos (el 9119 del tablero se deriva por separado)</string>
|
||||
<string name="cw_scan_message">Escaneando esta LAN en busca de Hermes dashboard/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API es opcional para Chat.</string>
|
||||
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API solo se usa para la alternativa directa opcional mediante API.</string>
|
||||
<string name="cw_pair_relay_section">Emparejar Relay (opcional)</string>
|
||||
<string name="cw_pair_relay_section_desc">¿Ya estás ejecutando el complemento Relay? Emparéjelo aquí para habilitar Terminal, Bridge y concesiones de canales.</string>
|
||||
<string name="cw_pair_relay_url_label">URL de Relay</string>
|
||||
@@ -1352,7 +1352,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Complemento de voz Relay: proveedores con reconocimiento de perfiles y salida de voz en streaming.</string>
|
||||
<string name="voice_settings_optional">Opcional</string>
|
||||
<string name="voice_settings_signin_route_hint">Estás conectado a través de la ruta %1$s y los inicios de sesión en el panel de control son por anfitrión: un inicio de sesión desde tu red doméstica no se transfiere. Inicia sesión una vez en Gestionar mientras estás en esta ruta para desbloquear la voz aquí también.</string>
|
||||
<string name="voice_settings_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Abre Gestionar para volver a iniciar sesión y desbloquear la voz.</string>
|
||||
<string name="voice_settings_signin_default_hint">Su panel Hermes requiere iniciar sesión antes de que la voz estándar pueda transcribir o hablar. Iniciar sesión una vez en Administrar lo desbloquea para esta conexión.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Iniciar sesión a través de Administrar</string>
|
||||
<string name="voice_settings_unsupported_build_body">Esta compilación del servidor Hermes aún no expone las rutas de audio del panel. Actualice el agente hermes en el servidor o empareje Relay para usar la voz Relay.</string>
|
||||
@@ -1571,7 +1571,7 @@
|
||||
<string name="dashboard_hours_ago">Hace %1$dh</string>
|
||||
<string name="dashboard_signin_required_title">Es necesario iniciar sesión en el panel</string>
|
||||
<string name="dashboard_signin_required_body">Administrar utiliza la sesión del panel Hermes en %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">Estás en la ruta %1$s. Los inicios de sesión en el panel de control son por anfitrión, por lo que tu inicio de sesión desde la otra ruta no se transfiere. Inicia sesión una vez aquí y la aplicación mantendrá ambas sesiones.</string>
|
||||
<string name="dashboard_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Vuelve a iniciar sesión para actualizarla en las rutas de confianza de esta conexión.</string>
|
||||
<string name="dashboard_signin_with_provider">Iniciar sesión con %1$s</string>
|
||||
<string name="dashboard_username_password">Nombre de usuario y contraseña</string>
|
||||
<string name="dashboard_username">Nombre de usuario</string>
|
||||
@@ -1612,6 +1612,31 @@
|
||||
<string name="dashboard_action_use">Usar</string>
|
||||
<string name="dashboard_action_describe">Describir</string>
|
||||
<string name="dashboard_action_model">Modelo</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Puntos de conexión</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">puntos de conexión</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Puntos de conexión personalizados</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">Proveedores compatibles con OpenAI</string>
|
||||
<string name="dashboard_action_authenticate">Autenticar</string>
|
||||
<string name="dashboard_action_validate">Validar</string>
|
||||
<string name="dashboard_action_edit">Editar</string>
|
||||
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes abrirá el proveedor en el navegador. Regrese aquí después de aprobar el acceso; las credenciales permanecen en el servidor de Hermes.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">Autenticación MCP aprobada</string>
|
||||
<string name="dashboard_mcp_oauth_failed">Error de autenticación MCP</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">No hay ningún navegador disponible para completar la autenticación MCP.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Agregar punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Editar punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_name">Nombre</string>
|
||||
<string name="dashboard_custom_endpoint_url">URL base</string>
|
||||
<string name="dashboard_custom_endpoint_model">Modelo predeterminado</string>
|
||||
<string name="dashboard_custom_endpoint_key">Clave de API (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Déjelo en blanco para conservar una clave existente al guardar. La validación solo usa la clave introducida aquí; Hermes no muestra ni borra las claves guardadas.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Longitud de contexto (opcional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Detectar modelos desde /models</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Punto de conexión accesible · %1$d modelo(s)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Error al validar el punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Error al guardar el punto de conexión</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Punto de conexión personalizado guardado</string>
|
||||
<string name="dashboard_action_completed">%1$s completado</string>
|
||||
<string name="dashboard_action_failed">%1$s falló</string>
|
||||
<string name="dashboard_more">Más</string>
|
||||
@@ -1890,7 +1915,7 @@
|
||||
<string name="active_section_api_key_already_set">La clave API ya está configurada</string>
|
||||
<string name="active_section_api_key_needed_hint">Use el API_SERVER_KEY creado en su servidor Hermes. La aplicación no proporciona esta clave.</string>
|
||||
<string name="active_section_api_key_not_configured">Clave API no configurada</string>
|
||||
<string name="active_section_api_key_optional">Clave API (opcional)</string>
|
||||
<string name="active_section_api_key_optional">Clave API para API directa</string>
|
||||
<string name="active_section_api_key_stored_hint">La clave se almacena de forma segura.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API accesible: revisar la configuración de voz</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API accesible: la voz Relay está configurada</string>
|
||||
@@ -2229,6 +2254,7 @@
|
||||
<string name="tool_progress_status_completed">terminado</string>
|
||||
<string name="tool_progress_status_failed">fallido</string>
|
||||
<string name="tool_progress_status_running">correr</string>
|
||||
<string name="image_generation_rendering">Generando imagen</string>
|
||||
<string name="tool_progress_cd_collapse">Colapsar</string>
|
||||
<string name="tool_progress_cd_expand">Expandir</string>
|
||||
<string name="agent_icon_title">Icono de agente</string>
|
||||
@@ -2966,11 +2992,22 @@
|
||||
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
|
||||
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional e introduzca aquí el mismo valor.</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional, que requiere una clave utilizable, e introduzca aquí el mismo valor.</string>
|
||||
<string name="active_section_choose_how_phone_reaches_named">Cómo accede este teléfono a %1$s</string>
|
||||
<string name="active_section_dashboard_home_lan">Dashboard · Red LAN doméstica</string>
|
||||
<string name="active_section_scan_relay_qr">Escanear QR de Relay</string>
|
||||
<string name="active_section_other_relay_methods">Otros métodos de vinculación</string>
|
||||
<string name="endpoints_tailscale_setup_hint">Tailscale debe estar conectado en ambos dispositivos y Hermes Dashboard debe ser accesible por el puerto 9119.</string>
|
||||
<string name="endpoints_setup_help">Ayuda para configurar Tailscale</string>
|
||||
<string name="voice_settings_provider_desc">Dónde se genera la voz. Las opciones disponibles vienen de este host Hermes y sus proveedores instalados.</string>
|
||||
<string name="voice_settings_model_desc">Latest sigue las actualizaciones del proveedor. Elige un modelo con versión para mantener fija la voz.</string>
|
||||
<string name="voice_settings_voice_desc">Elige cómo suenan las respuestas. La vista previa de una voz no guarda la selección.</string>
|
||||
<string name="voice_settings_language_desc">Automático permite que el proveedor detecte el idioma hablado. Elige un idioma solo si la detección no es fiable.</string>
|
||||
<string name="voice_settings_language_auto">Automático</string>
|
||||
<string name="voice_settings_provider_options_title">Opciones de voz</string>
|
||||
<string name="voice_settings_provider_options_desc">Solo se muestran los ajustes compatibles con el proveedor seleccionado.</string>
|
||||
<string name="voice_settings_auto_speak">Leer respuestas en voz alta</string>
|
||||
<string name="voice_settings_auto_speak_desc">Habla automáticamente las respuestas del asistente en las superficies de Hermes que respetan este ajuste del host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla la sesión de voz en vivo, no el modelo de chat de Hermes. Latest sigue las actualizaciones del proveedor; un modelo con versión queda fijo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">La voz usada dentro de la sesión en vivo. Las voces integradas y personalizadas aparecen cuando el proveedor las anuncia.</string>
|
||||
</resources>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="chat_input_start_voice">音声会話を開始する</string>
|
||||
<string name="chat_input_voice_setup_needed">音声会話 - 設定が必要です</string>
|
||||
<string name="chat_input_stop_streaming">ストリーミングを停止する</string>
|
||||
<string name="chat_input_steer_response">応答を制御する</string>
|
||||
<string name="chat_input_steer_response">応答を修正</string>
|
||||
<string name="chat_input_queue_message">キューメッセージ</string>
|
||||
|
||||
<!-- Bridge return labels -->
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">メッセージを編集してください…</string>
|
||||
<string name="chat_placeholder_steer">応答を制御します…</string>
|
||||
<string name="chat_placeholder_steer">応答を修正…</string>
|
||||
<string name="chat_placeholder_queue">メッセージをキューに入れます…</string>
|
||||
<string name="chat_placeholder_message">メッセージ…</string>
|
||||
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
|
||||
@@ -353,7 +353,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 または http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API チャットとセッションで使用されます — API ポート 8642 および http:// はベア ホストとして想定されます (ダッシュボードの 9119 は個別に派生します)</string>
|
||||
<string name="cw_scan_message">この LAN をスキャンして Hermes ダッシュボード/API を探しています…</string>
|
||||
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。チャットでは API キーはオプションです。</string>
|
||||
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。API キーは任意の直接 API フォールバックでのみ使います。</string>
|
||||
<string name="cw_pair_relay_section">Relay のペア (オプション)</string>
|
||||
<string name="cw_pair_relay_section_desc">すでに Relay プラグインを実行していますか?ここでペアリングすると、ターミナル、Bridge、およびチャネル許可が有効になります。</string>
|
||||
<string name="cw_pair_relay_url_label">Relay URL</string>
|
||||
@@ -1489,7 +1489,7 @@
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Relay プラグイン音声 — プロファイル対応プロバイダーとストリーミング音声出力。</string>
|
||||
<string name="voice_settings_optional">オプション</string>
|
||||
<string name="voice_settings_signin_route_hint">%1$s ルート経由で接続されており、ダッシュボードのサインインはホストごとに行われます。ホーム ネットワークからのサインインは引き継がれません。このルートの途中で [管理] に一度サインインすると、ここでも音声のロックが解除されます。</string>
|
||||
<string name="voice_settings_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。[管理] を開いて再度サインインし、音声を有効にしてください。</string>
|
||||
<string name="voice_settings_signin_default_hint">Hermes ダッシュボードでは、標準音声を書き起こしたり話したりする前にサインインする必要があります。管理に一度サインインすると、この接続のロックが解除されます。</string>
|
||||
<string name="voice_settings_sign_in_via_manage">「管理」経由でサインインする</string>
|
||||
<string name="voice_settings_unsupported_build_body">この Hermes サーバー ビルドでは、ダッシュボードのオーディオ ルートがまだ公開されていません。サーバー上の hermes-agent を更新するか、Relay をペアにして Relay 音声を使用します。</string>
|
||||
@@ -1728,7 +1728,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">ダッシュボードへのサインインが必要です</string>
|
||||
<string name="dashboard_signin_required_body">管理は、%1$s で Hermes ダッシュボード セッションを使用します。</string>
|
||||
<string name="dashboard_signin_route_hint">あなたは %1$s ルート上にいます。ダッシュボードのサインインはホストごとに行われるため、他のルートからのサインインは引き継がれません。ここで 1 回サインインすると、アプリは両方のセッションを維持します。</string>
|
||||
<string name="dashboard_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。再度サインインして、この接続の信頼済みルート全体でセッションを更新してください。</string>
|
||||
<string name="dashboard_signin_with_provider">%1$s でサインインします</string>
|
||||
<string name="dashboard_username_password">ユーザー名 & パスワード</string>
|
||||
<string name="dashboard_username">ユーザー名</string>
|
||||
@@ -1779,6 +1779,31 @@
|
||||
<string name="dashboard_action_use">使用</string>
|
||||
<string name="dashboard_action_describe">説明する</string>
|
||||
<string name="dashboard_action_model">モデル</string>
|
||||
<string name="dashboard_tab_custom_endpoints">エンドポイント</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">エンドポイント</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">カスタムエンドポイント</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 互換プロバイダー</string>
|
||||
<string name="dashboard_action_authenticate">認証</string>
|
||||
<string name="dashboard_action_validate">検証</string>
|
||||
<string name="dashboard_action_edit">編集</string>
|
||||
<string name="dashboard_mcp_oauth_title">%1$s を認証</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes はブラウザでプロバイダーを開きます。アクセスを承認したら、この画面に戻ってください。認証情報は Hermes サーバーに保存されます。</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP 認証が承認されました</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP 認証に失敗しました</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">MCP 認証を完了するためのブラウザを利用できません。</string>
|
||||
<string name="dashboard_custom_endpoint_add">エンドポイントを追加</string>
|
||||
<string name="dashboard_custom_endpoint_edit">エンドポイントを編集</string>
|
||||
<string name="dashboard_custom_endpoint_name">名前</string>
|
||||
<string name="dashboard_custom_endpoint_url">ベース URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">デフォルトモデル</string>
|
||||
<string name="dashboard_custom_endpoint_key">API キー(任意)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">保存時に既存のキーを保持するには空欄にしてください。検証にはここで入力したキーのみが使用されます。Hermes が保存済みのキーを表示または削除することはありません。</string>
|
||||
<string name="dashboard_custom_endpoint_context">コンテキスト長(任意)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">/models からモデルを検出</string>
|
||||
<string name="dashboard_custom_endpoint_valid">エンドポイントに接続できました · %1$d モデル</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">エンドポイントの検証に失敗しました</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">エンドポイントの保存に失敗しました</string>
|
||||
<string name="dashboard_custom_endpoint_saved">カスタムエンドポイントを保存しました</string>
|
||||
<string name="dashboard_action_completed">%1$s 完了</string>
|
||||
<string name="dashboard_action_failed">%1$s が失敗しました</string>
|
||||
<string name="dashboard_more">もっと</string>
|
||||
@@ -2093,7 +2118,7 @@
|
||||
<string name="active_section_api_key_already_set">API キーはすでに設定されています</string>
|
||||
<string name="active_section_api_key_needed_hint">Hermes サーバーで作成した API_SERVER_KEY を使用します。このキーはアプリから発行されません。</string>
|
||||
<string name="active_section_api_key_not_configured">API キーが構成されていません</string>
|
||||
<string name="active_section_api_key_optional">API キー (オプション)</string>
|
||||
<string name="active_section_api_key_optional">直接 API の API キー</string>
|
||||
<string name="active_section_api_key_stored_hint">鍵は安全に保管されます。</string>
|
||||
<string name="active_section_api_reachable_voice_review">API に到達可能 — 音声設定を確認してください</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API 到達可能 — Relay 音声が設定されています</string>
|
||||
@@ -2466,6 +2491,7 @@
|
||||
<string name="tool_progress_status_completed">完成した</string>
|
||||
<string name="tool_progress_status_failed">失敗した</string>
|
||||
<string name="tool_progress_status_running">走っている</string>
|
||||
<string name="image_generation_rendering">画像を生成中</string>
|
||||
<string name="tool_progress_cd_collapse">崩壊</string>
|
||||
<string name="tool_progress_cd_expand">拡大する</string>
|
||||
|
||||
@@ -3283,9 +3309,20 @@
|
||||
<string name="active_section_optional_api_fallback">任意の直接 API フォールバック</string>
|
||||
<string name="active_section_api_not_required">この接続が Hermes Dashboard を使用する場合は必要ありません。</string>
|
||||
<string name="active_section_where_api_key">このキーはどこで入手しますか?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY は Hermes サーバー上で作成します。このアプリからは発行されません。任意の API サーバーを有効にする場合のみ設定し、同じ値をここに入力してください。</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY は Hermes サーバー上で作成します。このアプリからは発行されません。任意の API サーバーを有効にする場合のみ設定します。このサーバーには使用可能なキーが必要です。同じ値をここに入力してください。</string>
|
||||
<string name="active_section_scan_relay_qr">Relay QR をスキャン</string>
|
||||
<string name="active_section_other_relay_methods">その他のペアリング方法</string>
|
||||
<string name="endpoints_tailscale_setup_hint">両方のデバイスで Tailscale に接続し、Hermes Dashboard がポート 9119 で到達可能である必要があります。</string>
|
||||
<string name="endpoints_setup_help">Tailscale セットアップヘルプ</string>
|
||||
<string name="voice_settings_provider_desc">音声を生成する場所です。利用可能な選択肢は、この Hermes ホストとインストール済みプロバイダーから取得されます。</string>
|
||||
<string name="voice_settings_model_desc">Latest はプロバイダーの更新に追従します。音声の動作を固定するには、バージョン付きモデルを選択します。</string>
|
||||
<string name="voice_settings_voice_desc">返信の声を選択します。音声をプレビューしても選択は保存されません。</string>
|
||||
<string name="voice_settings_language_desc">自動では、プロバイダーが話し言葉を検出します。検出が不安定な場合だけ言語を選択します。</string>
|
||||
<string name="voice_settings_language_auto">自動</string>
|
||||
<string name="voice_settings_provider_options_title">音声オプション</string>
|
||||
<string name="voice_settings_provider_options_desc">選択したプロバイダーが対応する設定だけを表示します。</string>
|
||||
<string name="voice_settings_auto_speak">返信を読み上げる</string>
|
||||
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
|
||||
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</string>
|
||||
</resources>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="chat_input_start_voice">Start voice conversation</string>
|
||||
<string name="chat_input_voice_setup_needed">Voice conversation — setup needed</string>
|
||||
<string name="chat_input_stop_streaming">Stop streaming</string>
|
||||
<string name="chat_input_steer_response">Steer the response</string>
|
||||
<string name="chat_input_steer_response">Correct the response</string>
|
||||
<string name="chat_input_queue_message">Queue message</string>
|
||||
|
||||
<!-- Bridge return labels -->
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
<!-- Chat screen placeholders -->
|
||||
<string name="chat_placeholder_edit">Edit your message…</string>
|
||||
<string name="chat_placeholder_steer">Steer the response…</string>
|
||||
<string name="chat_placeholder_steer">Correct the response…</string>
|
||||
<string name="chat_placeholder_queue">Queue a message…</string>
|
||||
<string name="chat_placeholder_message">Message…</string>
|
||||
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
|
||||
@@ -370,7 +370,7 @@
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 or http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API used by Chat and sessions — API port 8642 and http:// assumed for bare hosts (the dashboard\'s 9119 is derived separately)</string>
|
||||
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is optional for Chat.</string>
|
||||
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is only for the optional direct API fallback.</string>
|
||||
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
|
||||
<string name="cw_pair_relay_for">Pair Relay with %1$s</string>
|
||||
<string name="cw_pair_relay_scoped_desc">Add the optional Relay extension to this saved Hermes connection. This does not add or replace a server.</string>
|
||||
@@ -888,7 +888,7 @@
|
||||
<string name="active_section_optional_api_fallback">Optional direct API fallback</string>
|
||||
<string name="active_section_api_not_required">Not required when this connection uses the Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">Where do I get this?</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, then enter the same value here.</string>
|
||||
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, which requires a usable key, then enter the same value here.</string>
|
||||
<string name="active_section_core_hermes">Core Hermes</string>
|
||||
<string name="active_section_optional_relay">Optional Relay</string>
|
||||
<string name="active_section_extend_connection">Extend this connection</string>
|
||||
@@ -1540,17 +1540,17 @@
|
||||
<string name="voice_settings_back">Back</string>
|
||||
<string name="voice_settings_scope_label">Voice scope</string>
|
||||
<string name="voice_settings_global_voice">Global voice</string>
|
||||
<string name="voice_settings_standard_scope_body">This connection speaks through your Hermes server\'s configured TTS and STT (config.yaml on the server, or the dashboard\'s Audio settings) — it\'s host-wide, not per profile. Pair Relay to pick providers, models, and voices from the phone and carry them per profile.</string>
|
||||
<string name="voice_settings_standard_scope_body">Standard voice follows this Hermes host\'s existing TTS, STT, and voice configuration. These settings are shared across profiles; Relay can add profile-specific voice choices without replacing the host setup.</string>
|
||||
<string name="voice_settings_scope_resolving">Resolving the active profile\'s voice scope…</string>
|
||||
<string name="voice_settings_label_profile">Profile</string>
|
||||
<string name="voice_settings_label_scope">Scope</string>
|
||||
<string name="voice_settings_relay_scope_footer">Engine, route, and voice picks below are saved for this profile.</string>
|
||||
<string name="voice_settings_relay_scope_footer">Relay voice choices below override the host defaults for this profile only. Clear an override to follow the Hermes host again.</string>
|
||||
<string name="voice_settings_for_profile_title">Voice for this profile</string>
|
||||
<string name="voice_settings_engine_label">Voice engine</string>
|
||||
<string name="voice_settings_engine_hermes">Hermes Chat + Voice Output</string>
|
||||
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory; speech runs over the standard Hermes dashboard or Relay — whichever the STT/TTS route below picks.</string>
|
||||
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory. Speech uses the host\'s Standard configuration unless this profile selects a Relay voice override.</string>
|
||||
<string name="voice_settings_engine_realtime">Realtime Agent</string>
|
||||
<string name="voice_settings_engine_realtime_desc">Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.</string>
|
||||
<string name="voice_settings_engine_realtime_desc">A live provider session handles listening and speaking with its own voice model and voice. Hermes still handles tools, memory, and confirmations. Requires a paired Relay.</string>
|
||||
<string name="voice_settings_experimental">Experimental</string>
|
||||
<string name="voice_settings_realtime_no_relay">No Relay is configured for this connection, so the Realtime Agent can\'t start. Pair Relay in Settings → Connections, or switch back to Hermes Chat + Voice Output.</string>
|
||||
<string name="voice_settings_route_label">STT/TTS route</string>
|
||||
@@ -1564,19 +1564,19 @@
|
||||
<string name="voice_settings_status_auto_hermes">Ready — using Hermes</string>
|
||||
<string name="voice_settings_status_no_route">No route available yet</string>
|
||||
<string name="voice_settings_route_auto">Auto</string>
|
||||
<string name="voice_settings_route_auto_desc">Relay when paired; otherwise the Hermes dashboard. Recommended.</string>
|
||||
<string name="voice_settings_route_auto_desc">Use this profile\'s Relay voice when available, then fall back to the Hermes host. Recommended.</string>
|
||||
<string name="voice_settings_route_hermes">Hermes</string>
|
||||
<string name="voice_settings_route_hermes_desc">The dashboard audio path Hermes Desktop uses — works on a Hermes install, no Relay plugin required.</string>
|
||||
<string name="voice_settings_route_relay">Relay</string>
|
||||
<string name="voice_settings_route_relay_desc">Relay plugin voice — profile-aware providers and streaming voice output.</string>
|
||||
<string name="voice_settings_route_relay_desc">Use the provider, model, and voice saved for this profile through Relay.</string>
|
||||
<string name="voice_settings_optional">Optional</string>
|
||||
<string name="voice_settings_signin_route_hint">You\'re connected over the %1$s route, and dashboard sign-ins are per-host — a sign-in from your home network doesn\'t carry over. Sign in once in Manage while on this route to unlock voice here too.</string>
|
||||
<string name="voice_settings_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Open Manage to sign in again and unlock voice.</string>
|
||||
<string name="voice_settings_signin_default_hint">Your Hermes dashboard requires sign-in before standard voice can transcribe or speak. Signing in once in Manage unlocks it for this connection.</string>
|
||||
<string name="voice_settings_sign_in_via_manage">Sign in via Manage</string>
|
||||
<string name="voice_settings_unsupported_build_body">This Hermes server build doesn\'t expose the dashboard audio routes yet. Update hermes-agent on the server, or pair Relay to use Relay voice.</string>
|
||||
<string name="voice_settings_tts_title">Text-to-Speech</string>
|
||||
<string name="voice_settings_streaming_output_label">Streaming output (/voice/output)</string>
|
||||
<string name="voice_settings_streaming_output_desc">The provider, model, and voice the agent speaks with on the Relay path.</string>
|
||||
<string name="voice_settings_streaming_output_desc">Streams spoken replies with the provider, model, and voice saved for this profile. It does not change the Hermes chat model.</string>
|
||||
<string name="voice_settings_basic_synthesize_label">Basic synthesize fallback (/voice/synthesize)</string>
|
||||
<string name="voice_settings_basic_synthesize_desc">Always available as the stable speech safety net when provider-native voice is unavailable.</string>
|
||||
<string name="voice_settings_provider_unavailable">unavailable</string>
|
||||
@@ -1584,14 +1584,17 @@
|
||||
<string name="voice_settings_yes">yes</string>
|
||||
<string name="voice_settings_no">no</string>
|
||||
<string name="voice_settings_label_provider">Provider</string>
|
||||
<string name="voice_settings_provider_desc">Where speech is generated. Available choices come from this Hermes host and its installed providers.</string>
|
||||
<string name="voice_settings_label_enabled">Enabled</string>
|
||||
<string name="voice_settings_label_model">Model</string>
|
||||
<string name="voice_settings_model_desc">Latest follows provider upgrades. Choose a versioned model to keep voice behavior pinned.</string>
|
||||
<string name="voice_settings_label_voice">Voice</string>
|
||||
<string name="voice_settings_voice_desc">Choose how replies sound. Previewing a voice does not save the selection.</string>
|
||||
<string name="voice_settings_provider_gemini">Gemini</string>
|
||||
<string name="voice_settings_provider_xai">xAI</string>
|
||||
<string name="voice_settings_hide_enhanced">Hide enhanced voice (%1$s)</string>
|
||||
<string name="voice_settings_advanced_enhanced">Advanced: enhanced voice (%1$s)</string>
|
||||
<string name="voice_settings_enhanced_body">Pick a voice and tone for the %1$s TTS provider. Leave a field on \"Server default\" to use the relay\'s saved config.</string>
|
||||
<string name="voice_settings_enhanced_body">Choose the options advertised by %1$s for this profile. Leave a field on \"Server default\" to follow the Relay host configuration.</string>
|
||||
<string name="voice_settings_server_default">Server default</string>
|
||||
<string name="voice_settings_voice_blank_label">Voice (blank = server default)</string>
|
||||
<string name="voice_settings_supports_tone_tags">supports tone tags</string>
|
||||
@@ -1599,7 +1602,9 @@
|
||||
<string name="voice_settings_audio_tags_unsupported">Requires a Gemini 3.1 TTS model — pick one above to enable.</string>
|
||||
<string name="voice_settings_voice_direction_label">Voice direction (optional)</string>
|
||||
<string name="voice_settings_voice_direction_placeholder">e.g. Warm, calm narrator; unhurried pace.</string>
|
||||
<string name="voice_settings_language_label">Language (optional, e.g. en)</string>
|
||||
<string name="voice_settings_language_label">Language</string>
|
||||
<string name="voice_settings_language_desc">Automatic lets the provider detect the spoken language. Choose a language only when detection is unreliable.</string>
|
||||
<string name="voice_settings_language_auto">Automatic</string>
|
||||
<string name="voice_settings_status_active">active</string>
|
||||
<string name="voice_settings_status_unavailable">unavailable</string>
|
||||
<string name="voice_settings_status_disabled">disabled</string>
|
||||
@@ -1620,7 +1625,11 @@
|
||||
<string name="voice_settings_model_id">Model ID</string>
|
||||
<string name="voice_settings_voice_id">Voice ID</string>
|
||||
<string name="voice_settings_streaming_latency">Streaming latency: %1$d</string>
|
||||
<string name="voice_settings_fallback_desc">Use legacy Hermes TTS if streaming output fails before audio starts</string>
|
||||
<string name="voice_settings_fallback_desc">If Relay streaming cannot start, speak through the Hermes host\'s Standard voice.</string>
|
||||
<string name="voice_settings_provider_options_title">Voice options</string>
|
||||
<string name="voice_settings_provider_options_desc">Only settings supported by the selected provider are shown.</string>
|
||||
<string name="voice_settings_auto_speak">Read replies aloud</string>
|
||||
<string name="voice_settings_auto_speak_desc">Automatically speak assistant replies on Hermes surfaces that honor this host setting.</string>
|
||||
<string name="voice_settings_expressive_tags">Expressive speech tags</string>
|
||||
<string name="voice_settings_expressive_tags_desc">Let xAI add tone cues (whisper, laugh, sigh, pitch shifts) to streamed speech.</string>
|
||||
<string name="voice_settings_saving">Saving...</string>
|
||||
@@ -1628,7 +1637,9 @@
|
||||
<string name="voice_settings_save_and_test">Save & test</string>
|
||||
<string name="voice_settings_sample_rate_must_be_number">Sample rate must be a number</string>
|
||||
<string name="voice_settings_realtime_agent_title">Realtime Agent</string>
|
||||
<string name="voice_settings_realtime_agent_desc">Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.</string>
|
||||
<string name="voice_settings_realtime_agent_desc">Realtime uses its own provider model and voice for a live speech session. It does not replace the Hermes chat model; Hermes still owns tools and confirmations.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controls the live speech session, not the Hermes chat model. Latest follows provider upgrades; a versioned model stays pinned.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">The voice used inside the live session. Built-in and custom voices appear when the provider advertises them.</string>
|
||||
<string name="voice_settings_detailed_trace">Detailed trace</string>
|
||||
<string name="voice_settings_detailed_trace_desc">Show compact Hermes status and result provenance in the timeline</string>
|
||||
<string name="voice_settings_persistent_session">Persistent session</string>
|
||||
@@ -1643,7 +1654,7 @@
|
||||
<string name="voice_settings_delivery_speak">Speak</string>
|
||||
<string name="voice_settings_delivery_notify">Notify</string>
|
||||
<string name="voice_settings_delivery_show_only">Show only</string>
|
||||
<string name="voice_settings_realtime_defaults_desc">Server-side realtime voice agent defaults for this profile</string>
|
||||
<string name="voice_settings_realtime_defaults_desc">Realtime provider, model, and voice saved for this profile. These choices are separate from Standard Hermes voice.</string>
|
||||
<string name="voice_settings_save_realtime_agent">Save realtime agent</string>
|
||||
<string name="voice_settings_global_controls_title">Global Voice Controls</string>
|
||||
<string name="voice_settings_global_controls_desc">These settings apply to both voice engines, on every profile.</string>
|
||||
@@ -1691,7 +1702,7 @@
|
||||
<string name="voice_settings_signin_required">Sign in required.</string>
|
||||
<string name="voice_settings_server_config_title">Server voice config</string>
|
||||
<string name="voice_settings_standard_badge">Standard</string>
|
||||
<string name="voice_settings_server_config_desc">Edit the host\'s text-to-speech and speech-to-text settings (config.yaml tts.* / stt.*) — the same values the dashboard\'s Audio settings write. Applies to new voice turns.</string>
|
||||
<string name="voice_settings_server_config_desc">Edit this host\'s Standard Hermes voice settings (config.yaml tts.*, stt.*, and voice.*), matching the dashboard\'s Audio configuration. Changes are host-wide and apply to new voice turns.</string>
|
||||
<string name="voice_settings_no_elevenlabs_key">No ElevenLabs API key on the server — set ELEVENLABS_API_KEY in Manage → Keys to pick a voice from a list.</string>
|
||||
<string name="voice_settings_saving_ellipsis">Saving…</string>
|
||||
<string name="voice_settings_discard">Discard</string>
|
||||
@@ -1738,6 +1749,7 @@
|
||||
<string name="dashboard_tab_cron">Cron</string>
|
||||
<string name="dashboard_tab_mcp">MCP</string>
|
||||
<string name="dashboard_tab_catalog">Catalog</string>
|
||||
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
|
||||
<string name="dashboard_tab_profiles">Profiles</string>
|
||||
<string name="dashboard_tab_models">Models</string>
|
||||
<string name="dashboard_tab_keys">Keys</string>
|
||||
@@ -1747,6 +1759,7 @@
|
||||
<string name="dashboard_tab_cron_lower">cron</string>
|
||||
<string name="dashboard_tab_mcp_lower">mcp</string>
|
||||
<string name="dashboard_tab_catalog_lower">catalog</string>
|
||||
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
|
||||
<string name="dashboard_tab_profiles_lower">profiles</string>
|
||||
<string name="dashboard_tab_models_lower">models</string>
|
||||
<string name="dashboard_tab_keys_lower">keys</string>
|
||||
@@ -1763,6 +1776,8 @@
|
||||
<string name="dashboard_tile_mcp_sub">Servers, status, tools</string>
|
||||
<string name="dashboard_tile_catalog_title">Catalog</string>
|
||||
<string name="dashboard_tile_catalog_sub">Discover upstream servers</string>
|
||||
<string name="dashboard_tile_custom_endpoints_title">Custom Endpoints</string>
|
||||
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-compatible providers</string>
|
||||
<string name="dashboard_tile_models_title">Models</string>
|
||||
<string name="dashboard_tile_models_sub">Pick provider + default model</string>
|
||||
<string name="dashboard_tile_keys_title">Keys</string>
|
||||
@@ -1809,7 +1824,7 @@
|
||||
<!-- Sign-in card -->
|
||||
<string name="dashboard_signin_required_title">Dashboard sign-in required</string>
|
||||
<string name="dashboard_signin_required_body">Manage uses the Hermes dashboard session at %1$s.</string>
|
||||
<string name="dashboard_signin_route_hint">You\'re on the %1$s route. Dashboard sign-ins are per host, so your sign-in from the other route doesn\'t carry over — sign in once here and the app keeps both sessions.</string>
|
||||
<string name="dashboard_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Sign in again to refresh it across this connection\'s trusted routes.</string>
|
||||
<string name="dashboard_signin_with_provider">Sign in with %1$s</string>
|
||||
<string name="dashboard_username_password">Username & Password</string>
|
||||
<string name="dashboard_username">Username</string>
|
||||
@@ -1854,12 +1869,33 @@
|
||||
<string name="dashboard_action_enable">Enable</string>
|
||||
<string name="dashboard_action_disable">Disable</string>
|
||||
<string name="dashboard_action_test">Test</string>
|
||||
<string name="dashboard_action_authenticate">Authenticate</string>
|
||||
<string name="dashboard_action_validate">Validate</string>
|
||||
<string name="dashboard_action_edit">Edit</string>
|
||||
<string name="dashboard_action_remove">Remove</string>
|
||||
<string name="dashboard_action_soul">SOUL</string>
|
||||
<string name="dashboard_action_edit_soul">Edit SOUL</string>
|
||||
<string name="dashboard_action_use">Use</string>
|
||||
<string name="dashboard_action_describe">Describe</string>
|
||||
<string name="dashboard_action_model">Model</string>
|
||||
<string name="dashboard_mcp_oauth_title">Authenticate %1$s</string>
|
||||
<string name="dashboard_mcp_oauth_body">Hermes will open the provider in your browser. Return here after approving access; credentials stay on the Hermes server.</string>
|
||||
<string name="dashboard_mcp_oauth_approved">MCP authentication approved</string>
|
||||
<string name="dashboard_mcp_oauth_failed">MCP authentication failed</string>
|
||||
<string name="dashboard_mcp_oauth_no_browser">No browser is available to complete MCP authentication.</string>
|
||||
<string name="dashboard_custom_endpoint_add">Add endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_edit">Edit endpoint</string>
|
||||
<string name="dashboard_custom_endpoint_name">Name</string>
|
||||
<string name="dashboard_custom_endpoint_url">Base URL</string>
|
||||
<string name="dashboard_custom_endpoint_model">Default model</string>
|
||||
<string name="dashboard_custom_endpoint_key">API key (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_key_help">Leave blank to preserve an existing key when saving. Validation uses only a key entered here; Hermes does not expose or clear saved keys.</string>
|
||||
<string name="dashboard_custom_endpoint_context">Context length (optional)</string>
|
||||
<string name="dashboard_custom_endpoint_discover">Discover models from /models</string>
|
||||
<string name="dashboard_custom_endpoint_valid">Endpoint reachable · %1$d model(s)</string>
|
||||
<string name="dashboard_custom_endpoint_validate_failed">Endpoint validation failed</string>
|
||||
<string name="dashboard_custom_endpoint_save_failed">Endpoint save failed</string>
|
||||
<string name="dashboard_custom_endpoint_saved">Custom endpoint saved</string>
|
||||
<string name="dashboard_action_completed">%1$s completed</string>
|
||||
<string name="dashboard_action_failed">%1$s failed</string>
|
||||
<string name="dashboard_more">More</string>
|
||||
@@ -2174,7 +2210,7 @@
|
||||
<string name="active_section_api_key_already_set">API key is already set</string>
|
||||
<string name="active_section_api_key_needed_hint">Use the API_SERVER_KEY created on your Hermes server. The app does not issue this key.</string>
|
||||
<string name="active_section_api_key_not_configured">API key not configured</string>
|
||||
<string name="active_section_api_key_optional">API key (optional)</string>
|
||||
<string name="active_section_api_key_optional">API key for direct API</string>
|
||||
<string name="active_section_api_key_stored_hint">Key is stored securely.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API reachable — review voice config</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API reachable — Relay voice is configured</string>
|
||||
@@ -2549,6 +2585,7 @@
|
||||
<string name="tool_progress_status_completed">completed</string>
|
||||
<string name="tool_progress_status_failed">failed</string>
|
||||
<string name="tool_progress_status_running">running</string>
|
||||
<string name="image_generation_rendering">Rendering image</string>
|
||||
<string name="tool_progress_cd_collapse">Collapse</string>
|
||||
<string name="tool_progress_cd_expand">Expand</string>
|
||||
|
||||
@@ -3288,4 +3325,10 @@
|
||||
<string name="tool_output_risk_a11y">, %1$s output risk</string>
|
||||
<string name="tool_output_risk_findings">Output risk findings</string>
|
||||
<string name="tool_output_risk_redacted">Sensitive spans were redacted upstream.</string>
|
||||
<string name="diag_gateway_heartbeat" translatable="false">Gateway loop: %1$s%2$s</string>
|
||||
<string name="diag_toolsets_inventory" translatable="false">API toolsets: %1$d of %2$d enabled · Relay tools visible: %3$s</string>
|
||||
<string name="diag_yes" translatable="false">yes</string>
|
||||
<string name="diag_no" translatable="false">no</string>
|
||||
<string name="dashboard_nous_terminal_warning" translatable="false">The Nous provider login needs attention on the Hermes host. This is separate from Manage sign-in; chat can continue through another configured provider.</string>
|
||||
<string name="dashboard_gateway_topology" translatable="false">Gateway: %1$s · Profiles: %2$s · Ports: %3$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
- Visible warning badge when connected over http:// or ws://
|
||||
- Users are encouraged to set up TLS for production deployments
|
||||
|
||||
The app makes NO connections to external services — only user-configured endpoints.
|
||||
System CAs and CAs deliberately installed in Android's user credential store
|
||||
are accepted. This expands the available trust anchors without bypassing
|
||||
certificate-chain, hostname, or Relay certificate-pin verification.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="system" overridePins="false" />
|
||||
<certificates src="user" overridePins="false" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -42,6 +42,22 @@ class ConnectionDashboardFieldsTest {
|
||||
assertNull(Connection.deriveDefaultDashboardUrl("ws://localhost:8767"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultApiUrl_usesSameHostAndApiPort() {
|
||||
assertEquals(
|
||||
"http://100.75.1.2:8642",
|
||||
Connection.deriveDefaultApiUrl("http://100.75.1.2:9119"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveDefaultApiUrl_preservesHttpsAndIpv6Host() {
|
||||
assertEquals(
|
||||
"https://[fd7a:115c:a1e0::1]:8642",
|
||||
Connection.deriveDefaultApiUrl("https://[fd7a:115c:a1e0::1]:9119"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvedDashboardUrl_usesExplicitOverride() {
|
||||
val connection = sampleConnection(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NetworkSecurityConfigTest {
|
||||
private val document by lazy {
|
||||
val config = sequenceOf(
|
||||
File("src/main/res/xml/network_security_config.xml"),
|
||||
File("app/src/main/res/xml/network_security_config.xml"),
|
||||
).firstOrNull(File::isFile)
|
||||
?: error("network_security_config.xml not found")
|
||||
|
||||
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseConfig_trustsOnlySystemAndUserCredentialStores() {
|
||||
val certificates = document.getElementsByTagName("certificates")
|
||||
val sources = (0 until certificates.length).map { index ->
|
||||
certificates.item(index).attributes.getNamedItem("src").nodeValue
|
||||
}
|
||||
|
||||
assertEquals(listOf("system", "user"), sources)
|
||||
assertTrue(
|
||||
(0 until certificates.length).all { index ->
|
||||
certificates.item(index).attributes.getNamedItem("overridePins").nodeValue == "false"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseConfig_hasNoTrustOrHostnameBypass() {
|
||||
assertEquals(0, document.getElementsByTagName("debug-overrides").length)
|
||||
assertFalse(document.documentElement.textContent.contains("trust-all", ignoreCase = true))
|
||||
assertFalse(document.documentElement.textContent.contains("ignore tls", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
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.Before
|
||||
import org.junit.Test
|
||||
|
||||
class RelayHttpClientImageActivityTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchImageActivityDecodesLifecycleAndScopesRequest() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"profile": "victor",
|
||||
"activities": [{
|
||||
"call_id": "call-1",
|
||||
"tool_name": "image_generate",
|
||||
"state": "running",
|
||||
"started_at": 100.0,
|
||||
"completed_at": null
|
||||
}]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
val client = RelayHttpClient(
|
||||
okHttpClient = OkHttpClient(),
|
||||
relayUrlProvider = { server.url("/").toString() },
|
||||
sessionTokenProvider = { "paired-token" },
|
||||
)
|
||||
|
||||
val snapshot = client.fetchImageActivity("victor", "session-1", 99.0).getOrThrow()
|
||||
val request = server.takeRequest()
|
||||
|
||||
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
|
||||
assertEquals("victor", request.requestUrl?.queryParameter("profile"))
|
||||
assertEquals("session-1", request.requestUrl?.queryParameter("session_id"))
|
||||
assertEquals("call-1", snapshot?.activities?.single()?.callId)
|
||||
assertEquals("running", snapshot?.activities?.single()?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchImageActivityTreatsMissingOptionalRouteAsUnsupported() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
val client = RelayHttpClient(
|
||||
okHttpClient = OkHttpClient(),
|
||||
relayUrlProvider = { server.url("/").toString() },
|
||||
sessionTokenProvider = { "paired-token" },
|
||||
)
|
||||
|
||||
assertNull(client.fetchImageActivity("default", "session-1", 99.0).getOrThrow())
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceOutputSessionPayloadTest {
|
||||
|
||||
@Test
|
||||
fun draftOverridesAreIncludedWithoutPersistingConfig() {
|
||||
val payload = Json.parseToJsonElement(
|
||||
buildVoiceOutputSessionPayload(
|
||||
profile = "default",
|
||||
provider = "xai_tts",
|
||||
model = "tts-1",
|
||||
voice = "eve",
|
||||
sampleRate = 24_000,
|
||||
language = "en",
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals("default", payload.getValue("profile").jsonPrimitive.content)
|
||||
assertEquals("xai_tts", payload.getValue("provider").jsonPrimitive.content)
|
||||
assertEquals("tts-1", payload.getValue("model").jsonPrimitive.content)
|
||||
assertEquals("eve", payload.getValue("voice").jsonPrimitive.content)
|
||||
assertEquals(24_000, payload.getValue("sample_rate").jsonPrimitive.int)
|
||||
assertEquals("en", payload.getValue("language").jsonPrimitive.content)
|
||||
assertFalse(payload.containsKey("enabled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankDraftValuesFallBackToSavedServerDefaults() {
|
||||
val payload = Json.parseToJsonElement(
|
||||
buildVoiceOutputSessionPayload(
|
||||
profile = "default",
|
||||
provider = " ",
|
||||
model = "",
|
||||
voice = null,
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(setOf("profile"), payload.keys)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
* network-aware switching" (2026-04-19).
|
||||
*
|
||||
* Uses [MockWebServer] to stand up real loopback sockets so the
|
||||
* OkHttp → HEAD /health path exercises the production code unchanged.
|
||||
* OkHttp → GET /health path exercises the production code unchanged.
|
||||
* A test-local mutable clock drives cache-TTL assertions deterministically
|
||||
* without `Thread.sleep`.
|
||||
*/
|
||||
@@ -395,6 +395,30 @@ class EndpointResolverTest {
|
||||
assertEquals("/api/status", reachableServer.takeRequest(1, TimeUnit.SECONDS)?.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiHealthProbe_usesGetWhenServerRejectsHead() = runTest {
|
||||
reachableServer.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse = when {
|
||||
request.path != "/health" -> MockResponse().setResponseCode(404)
|
||||
request.method == "HEAD" -> MockResponse().setResponseCode(405)
|
||||
request.method == "GET" -> MockResponse().setResponseCode(200)
|
||||
else -> MockResponse().setResponseCode(405)
|
||||
}
|
||||
}
|
||||
val apiOnly = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
api = ApiEndpoint(reachableServer.hostName, reachableServer.port),
|
||||
)
|
||||
|
||||
val winner = EndpointResolver(fastClient, clock = { clockMillis.get() })
|
||||
.resolve(listOf(apiOnly))
|
||||
|
||||
assertEquals(apiOnly, winner)
|
||||
val request = reachableServer.takeRequest(1, TimeUnit.SECONDS)
|
||||
assertEquals("GET", request?.method)
|
||||
assertEquals("/health", request?.path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+118
@@ -1,6 +1,8 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@@ -59,4 +61,120 @@ class ProfileApiUrlResolverTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChatBase_buildsMultiplexProfilePath() {
|
||||
assertEquals(
|
||||
"https://hermes.example.test:8642/p/research_qa",
|
||||
ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = "https://hermes.example.test:8642/",
|
||||
selectedProfileName = "research_qa",
|
||||
gatewayMode = "multiplex",
|
||||
servedProfiles = listOf("default", "research_qa"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChatBase_keepsRootForDefaultProfile() {
|
||||
assertEquals(
|
||||
"https://hermes.example.test:8642",
|
||||
ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = "https://hermes.example.test:8642/",
|
||||
selectedProfileName = "default",
|
||||
gatewayMode = "multiplex",
|
||||
servedProfiles = listOf("default", "coder"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChatBase_keepsRootWithoutMultiplexTopology() {
|
||||
assertEquals(
|
||||
"https://hermes.example.test:8642",
|
||||
ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = "https://hermes.example.test:8642",
|
||||
selectedProfileName = "coder",
|
||||
gatewayMode = null,
|
||||
servedProfiles = emptyList(),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"https://hermes.example.test:8642",
|
||||
ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = "https://hermes.example.test:8642",
|
||||
selectedProfileName = "coder",
|
||||
gatewayMode = "single",
|
||||
servedProfiles = listOf("default", "coder"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChatBase_prefersDedicatedProfileApi() {
|
||||
assertEquals(
|
||||
"https://hermes.example.test:8647",
|
||||
ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = "http://127.0.0.1:8647",
|
||||
baseApiUrl = "https://hermes.example.test:8642",
|
||||
selectedProfileName = "coder",
|
||||
gatewayMode = "multiplex",
|
||||
servedProfiles = listOf("default", "coder"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChatBase_switchesProfilesWithoutRetainingPriorPrefix() {
|
||||
val base = "https://hermes.example.test:8642"
|
||||
val coder = ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = base,
|
||||
selectedProfileName = "coder",
|
||||
gatewayMode = "multiplex",
|
||||
servedProfiles = listOf("default", "coder", "research"),
|
||||
)
|
||||
val research = ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = base,
|
||||
selectedProfileName = "research",
|
||||
gatewayMode = "multiplex",
|
||||
servedProfiles = listOf("default", "coder", "research"),
|
||||
)
|
||||
|
||||
assertEquals("https://hermes.example.test:8642/p/coder", coder)
|
||||
assertEquals("https://hermes.example.test:8642/p/research", research)
|
||||
assertNotEquals(coder, research)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiplexBase_keepsPrefixForEveryDirectApiRouteFamily() {
|
||||
val base = ProfileApiUrlResolver.resolveChatBase(
|
||||
profileApiUrl = null,
|
||||
baseApiUrl = "https://hermes.example.test:8642",
|
||||
selectedProfileName = "coder",
|
||||
gatewayMode = "multiplex",
|
||||
servedProfiles = listOf("default", "coder"),
|
||||
)
|
||||
val paths = listOf(
|
||||
"/v1/models",
|
||||
"/api/sessions",
|
||||
"/api/sessions/session-1/messages",
|
||||
"/api/sessions/session-1/chat/stream",
|
||||
"/v1/chat/completions",
|
||||
"/v1/runs",
|
||||
"/v1/runs/run-1/events",
|
||||
"/v1/runs/run-1/stop",
|
||||
)
|
||||
|
||||
val encodedPaths = paths.map { path ->
|
||||
"$base$path".toHttpUrlOrNull()?.encodedPath
|
||||
}
|
||||
|
||||
assertEquals(paths.map { "/p/coder$it" }, encodedPaths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,47 @@ class ChatHandlerTest {
|
||||
assertEquals("Second", messages[1].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onStreamComplete_removesExactIntentionalSilenceMarker() {
|
||||
handler.onTextDelta("assist-1", "NO")
|
||||
handler.onTextDelta("assist-1", "_REPLY")
|
||||
|
||||
handler.onStreamComplete("assist-1")
|
||||
|
||||
assertTrue(handler.messages.value.isEmpty())
|
||||
assertFalse(handler.isStreaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onStreamComplete_removesEmptyAssistantPlaceholder() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assist-empty",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.onStreamComplete("assist-empty")
|
||||
|
||||
assertTrue(handler.messages.value.isEmpty())
|
||||
assertFalse(handler.isStreaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onStreamComplete_keepsProseThatMentionsSilenceMarker() {
|
||||
handler.onTextDelta("assist-1", "I will not use NO_REPLY here.")
|
||||
|
||||
handler.onStreamComplete("assist-1")
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(1, messages.size)
|
||||
assertEquals("I will not use NO_REPLY here.", messages.single().content)
|
||||
assertFalse(messages.single().isStreaming)
|
||||
}
|
||||
|
||||
// --- onStreamComplete ---
|
||||
|
||||
@Test
|
||||
@@ -387,6 +428,20 @@ class ChatHandlerTest {
|
||||
assertEquals("s2", sessions[0].sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateSessions_deduplicatesDuplicateServerRows() {
|
||||
handler.updateSessions(
|
||||
listOf(
|
||||
SessionItem(id = "s1", title = "First"),
|
||||
SessionItem(id = "s1", title = "Duplicate"),
|
||||
SessionItem(id = "s2", title = "Other"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("s1", "s2"), handler.sessions.value.map { it.sessionId })
|
||||
assertEquals("First", handler.sessions.value.first().title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateSessions_preservesLocalTitle_whenServerReturnsNullTitle() {
|
||||
// The server titles a session asynchronously after the first turn (and
|
||||
@@ -573,6 +628,38 @@ class ChatHandlerTest {
|
||||
assertEquals("s1", sessions[1].sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addSession_collapsesRepeatedOptimisticRows() {
|
||||
val session = ChatSession(sessionId = "s1", title = "Fresh", model = null)
|
||||
|
||||
handler.addSession(session)
|
||||
handler.addSession(session)
|
||||
|
||||
assertEquals(listOf("s1"), handler.sessions.value.map { it.sessionId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addSession_afterRefreshKeepsSingleServerEnrichedRow() {
|
||||
handler.updateSessions(
|
||||
listOf(
|
||||
SessionItem(
|
||||
id = "s1",
|
||||
title = "Server title",
|
||||
model = "gpt-5.6",
|
||||
messageCount = 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.addSession(ChatSession(sessionId = "s1", title = "Optimistic", model = null))
|
||||
|
||||
val session = handler.sessions.value.single()
|
||||
assertEquals("s1", session.sessionId)
|
||||
assertEquals("Server title", session.title)
|
||||
assertEquals("gpt-5.6", session.model)
|
||||
assertEquals(2, session.messageCount)
|
||||
}
|
||||
|
||||
// --- setSessionId ---
|
||||
|
||||
@Test
|
||||
@@ -657,6 +744,62 @@ class ChatHandlerTest {
|
||||
assertEquals(MessageRole.SYSTEM, handler.messages.value[0].role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_skipsTypedHiddenDisplayRows() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "hidden-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("internal compaction handoff"),
|
||||
displayKind = "hidden",
|
||||
),
|
||||
MessageItem(id = "visible-1", role = "user", content = JsonPrimitive("visible")),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, handler.messages.value.size)
|
||||
assertEquals("visible", handler.messages.value.single().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_rendersTypedModelSwitchAsSystemTimelineRow() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "model-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("The model changed"),
|
||||
displayKind = "model_switch",
|
||||
displayMetadata = buildJsonObject { put("model", "gpt-5.6") },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals(MessageRole.SYSTEM, msg.role)
|
||||
assertEquals("Model changed to gpt-5.6", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_rendersTypedDelegationCompletionAsSystemTimelineRow() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "delegation-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("background task complete"),
|
||||
displayKind = "async_delegation_complete",
|
||||
displayMetadata = buildJsonObject { put("task_count", 2) },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals(MessageRole.SYSTEM, msg.role)
|
||||
assertEquals("2 background tasks completed", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_skipsToolMessages() {
|
||||
val items = listOf(
|
||||
|
||||
+251
@@ -59,6 +59,36 @@ class DashboardApiClientTest {
|
||||
assertEquals("0.16.0", status.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStatus_parsesNousAndOptionalGatewayTopology() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
"""
|
||||
{
|
||||
"auth_required": false,
|
||||
"nous_session_valid": "terminal",
|
||||
"profiles": ["default", "worker"],
|
||||
"gateway_mode": "multiplex",
|
||||
"gateways": [{
|
||||
"profile": "default",
|
||||
"ports": {"api_server": 8642, "webhook": 8080},
|
||||
"served_profiles": ["default", "worker"]
|
||||
}]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val status = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
.getStatus().getOrThrow()
|
||||
|
||||
assertEquals("terminal", status.nousSessionValid)
|
||||
assertEquals(listOf("default", "worker"), status.profiles)
|
||||
assertEquals("multiplex", status.gatewayMode)
|
||||
assertEquals(8642, status.gateways.single().ports["api_server"])
|
||||
assertEquals(listOf("default", "worker"), status.gateways.single().servedProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
|
||||
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
|
||||
@@ -273,6 +303,81 @@ class DashboardApiClientTest {
|
||||
assertEquals(listOf("hermes_session"), cookies.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mirrorDashboardSessionCookies_reusesEncryptedSessionOnTrustedRoute() {
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
store.save(
|
||||
listOf(
|
||||
storedCookie("hermes_session_at", "access", "192.168.1.20"),
|
||||
storedCookie("hermes_session_rt", "refresh", "192.168.1.20"),
|
||||
storedCookie("hermes_session_provider", "basic", "192.168.1.20"),
|
||||
),
|
||||
)
|
||||
|
||||
val mirrored = mirrorDashboardSessionCookies(
|
||||
store = store,
|
||||
targetUrl = "http://100.64.0.20:9119",
|
||||
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
|
||||
)
|
||||
val cookies = DashboardCookieJar(store).loadForRequest(
|
||||
"http://100.64.0.20:9119/api/auth/me".toHttpUrl(),
|
||||
)
|
||||
|
||||
assertEquals(3, mirrored)
|
||||
assertEquals(
|
||||
listOf("hermes_session_at", "hermes_session_rt", "hermes_session_provider"),
|
||||
cookies.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mirrorDashboardSessionCookies_doesNotCopyPkceOrToUnknownHost() {
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
store.save(
|
||||
listOf(
|
||||
storedCookie("hermes_session_at", "access", "192.168.1.20"),
|
||||
storedCookie("hermes_session_pkce", "verifier", "192.168.1.20"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
mirrorDashboardSessionCookies(
|
||||
store = store,
|
||||
targetUrl = "http://attacker.example:9119",
|
||||
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
1,
|
||||
mirrorDashboardSessionCookies(
|
||||
store = store,
|
||||
targetUrl = "http://100.64.0.20:9119",
|
||||
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
|
||||
),
|
||||
)
|
||||
val mirroredNames = DashboardCookieJar(store).loadForRequest(
|
||||
"http://100.64.0.20:9119/api/auth/me".toHttpUrl(),
|
||||
).map { it.name }
|
||||
|
||||
assertEquals(listOf("hermes_session_at"), mirroredNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mirrorDashboardSessionCookies_explicitSignOutClearsEveryRoute() {
|
||||
val store = InMemoryDashboardCookieStore()
|
||||
store.save(listOf(storedCookie("hermes_session", "session", "192.168.1.20")))
|
||||
mirrorDashboardSessionCookies(
|
||||
store = store,
|
||||
targetUrl = "http://100.64.0.20:9119",
|
||||
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
|
||||
)
|
||||
|
||||
store.clear()
|
||||
|
||||
assertTrue(store.load().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStatus_defaultsMissingAuthFieldsForOlderDashboard() = runTest {
|
||||
server.enqueue(
|
||||
@@ -330,6 +435,22 @@ class DashboardApiClientTest {
|
||||
assertEquals("basic", session.provider)
|
||||
}
|
||||
|
||||
private fun storedCookie(
|
||||
name: String,
|
||||
value: String,
|
||||
domain: String,
|
||||
) = StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = Long.MAX_VALUE,
|
||||
domain = domain,
|
||||
path = "/",
|
||||
secure = false,
|
||||
httpOnly = true,
|
||||
hostOnly = true,
|
||||
persistent = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun currentSession_mapsUnauthorizedToUnauthenticated() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
@@ -470,6 +591,122 @@ class DashboardApiClientTest {
|
||||
assertTrue(body.contains(""""enable":false"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpOAuth_preservesProfileAndParsesOpaqueFlow() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
"""{"flow_id":"opaque-flow","server_name":"hosted","status":"authorization_required","authorization_url":"https://auth.example/authorize?state=secret"}""",
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
"""{"flow_id":"opaque-flow","server_name":"hosted","status":"approved","authorization_url":null}""",
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(server.url("/").toString())
|
||||
|
||||
val started = client.startMcpOAuth("hosted tools", profile = "work profile").getOrThrow()
|
||||
val approved = client.getMcpOAuthFlow(started.flowId).getOrThrow()
|
||||
|
||||
assertEquals("opaque-flow", started.flowId)
|
||||
assertEquals("approved", approved.status)
|
||||
assertEquals("/api/mcp/servers/hosted%20tools/auth?profile=work%20profile", server.takeRequest().path)
|
||||
assertEquals("/api/mcp/oauth/flows/opaque-flow", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpOAuthCapability_canonicalMissingFlowUsesReadOnlyGetAndIsSupported() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"detail":"OAuth flow not found or expired"}"""),
|
||||
)
|
||||
val client = DashboardApiClient(server.url("/").toString())
|
||||
|
||||
assertTrue(client.supportsHostedMcpOAuth().getOrThrow())
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__", request.path)
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpOAuthCapability_genericFastApi404UsesReadOnlyGetAndIsUnsupported() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"detail":"Not Found"}"""),
|
||||
)
|
||||
val client = DashboardApiClient(server.url("/").toString())
|
||||
|
||||
assertFalse(client.supportsHostedMcpOAuth().getOrThrow())
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__", request.path)
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpMutations_preserveSelectedProfile() = runTest {
|
||||
repeat(5) {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}"))
|
||||
}
|
||||
val client = DashboardApiClient(server.url("/").toString())
|
||||
|
||||
client.setMcpServerEnabled("hosted", true, "work profile").getOrThrow()
|
||||
client.testMcpServer("hosted", "work profile").getOrThrow()
|
||||
client.removeMcpServer("hosted", "work profile").getOrThrow()
|
||||
client.installMcpCatalogEntry("hosted", profile = "work profile").getOrThrow()
|
||||
|
||||
assertEquals("/api/mcp/servers/hosted/enabled?profile=work%20profile", server.takeRequest().path)
|
||||
assertEquals("/api/mcp/servers/hosted/test?profile=work%20profile", server.takeRequest().path)
|
||||
assertEquals("/api/mcp/servers/hosted?profile=work%20profile", server.takeRequest().path)
|
||||
assertEquals("/api/mcp/catalog/install?profile=work%20profile", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customEndpointCrud_usesPublicDashboardRoutesAndRedactedResponse() = runTest {
|
||||
val listBody = """
|
||||
{"endpoints":[{"id":"local","name":"Local","base_url":"https://llm.example/v1","model":"qwen","models":["qwen"],"has_api_key":true,"api_key_preview":"sk-…1234","is_current":true}],"current":{"provider":"local","model":"qwen"}}
|
||||
""".trimIndent()
|
||||
repeat(5) {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
if (it == 2) """{"ok":true,"reachable":true,"message":"","models":["qwen"]}"""
|
||||
else if (it == 3) """{"ok":true,"provider":"local","model":"qwen"}"""
|
||||
else listBody,
|
||||
))
|
||||
}
|
||||
val client = DashboardApiClient(server.url("/").toString())
|
||||
val draft = DashboardCustomEndpointDraft(
|
||||
id = "local",
|
||||
name = "Local",
|
||||
baseUrl = "https://llm.example/v1",
|
||||
model = "qwen",
|
||||
apiKey = "never-persist-this",
|
||||
)
|
||||
|
||||
val listed = client.getCustomEndpoints().getOrThrow()
|
||||
client.saveCustomEndpoint(draft).getOrThrow()
|
||||
val validation = client.validateCustomEndpoint(draft).getOrThrow()
|
||||
client.activateCustomEndpoint("local").getOrThrow()
|
||||
client.deleteCustomEndpoint("local").getOrThrow()
|
||||
|
||||
assertEquals("local", listed.currentProvider)
|
||||
assertTrue(listed.endpoints.single().hasApiKey)
|
||||
assertEquals(listOf("qwen"), validation.models)
|
||||
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
||||
val save = server.takeRequest()
|
||||
assertEquals("/api/providers/custom-endpoints", save.path)
|
||||
assertTrue(save.body.readUtf8().contains("never-persist-this"))
|
||||
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileActions_useActiveAndDeleteRoutes() = runTest {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
|
||||
@@ -749,6 +986,20 @@ class DashboardApiClientTest {
|
||||
assertEquals("/api/config/schema", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getTtsToolsetConfig_hitsRuntimeProviderRegistry() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"name":"tts","has_category":true,"providers":[]}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.getTtsToolsetConfig().getOrThrow()
|
||||
|
||||
assertEquals("/api/tools/toolsets/tts/config", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewSessionPrune_postsDryRunAndParsesPreview() = runTest {
|
||||
server.enqueue(
|
||||
|
||||
+32
-4
@@ -55,18 +55,46 @@ class DashboardConfigEditingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceConfigFields_filtersToTtsAndSttPrefixesOnly() {
|
||||
fun voiceConfigFields_includesStandardVoiceBehaviorFields() {
|
||||
val voice = voiceConfigFields(parseConfigSchema(schema)).map { it.key }
|
||||
|
||||
assertEquals(
|
||||
listOf("tts.provider", "tts.elevenlabs.voice_id", "stt.enabled", "stt.local.model"),
|
||||
listOf(
|
||||
"tts.provider",
|
||||
"tts.elevenlabs.voice_id",
|
||||
"stt.enabled",
|
||||
"stt.local.model",
|
||||
"voice.silence_threshold",
|
||||
),
|
||||
voice,
|
||||
)
|
||||
// The CLI-only `voice.*` category and unrelated `model` are excluded.
|
||||
assertFalse(voice.any { it.startsWith("voice.") })
|
||||
assertFalse(voice.contains("model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseTtsToolsetProviders_usesStableIdsAndKeepsRuntimeStatus() {
|
||||
val providers = parseTtsToolsetProviders(
|
||||
obj(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{"name":"Edge TTS","tts_provider":"edge","status":"ready","is_active":true},
|
||||
{"name":"My Plugin","tts_provider":"my_plugin","status":"needs_keys","is_active":false},
|
||||
{"name":"Old backend without a stable id","status":"ready"},
|
||||
{"name":"Duplicate","tts_provider":"edge","status":"ready"}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("edge", "my_plugin"), providers.map { it.id })
|
||||
assertEquals("Edge TTS", providers.first().name)
|
||||
assertEquals("ready", providers.first().status)
|
||||
assertTrue(providers.first().isActive)
|
||||
assertEquals("needs_keys", providers.last().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configValueAt_readsNestedDotPathsAndMissing() {
|
||||
val tree = obj(
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardStatusTopologyTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parseStatus_readsMultiplexTopology() {
|
||||
val root = json.decodeFromString<JsonObject>(
|
||||
"""{
|
||||
"auth_required": false,
|
||||
"gateway_mode": "multiplex",
|
||||
"profiles": ["default", "coder"]
|
||||
}""",
|
||||
)
|
||||
|
||||
val status = DashboardApiClient.parseStatus(root)
|
||||
|
||||
assertEquals("multiplex", status.gatewayMode)
|
||||
assertEquals(listOf("default", "coder"), status.profiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseStatus_keepsOlderPayloadCompatible() {
|
||||
val status = DashboardApiClient.parseStatus(
|
||||
json.decodeFromString<JsonObject>("""{"auth_required": true}"""),
|
||||
)
|
||||
|
||||
assertNull(status.gatewayMode)
|
||||
assertEquals(emptyList<String>(), status.profiles)
|
||||
assertEquals(false, status.componentHealth.supported)
|
||||
assertEquals(emptyList<String>(), status.componentHealth.components.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseStatus_readsComponentHealthRollup() {
|
||||
val root = json.decodeFromString<JsonObject>(
|
||||
"""{
|
||||
"auth_required": false,
|
||||
"overall": "degraded",
|
||||
"components": {
|
||||
"gateway": {"status": "ok"},
|
||||
"storage": {
|
||||
"status": "degraded",
|
||||
"message": "state DB unavailable",
|
||||
"healthy": false,
|
||||
"unhandled_5xx_count_5m": 2
|
||||
},
|
||||
"platforms": {"configured": 3, "connected": 1}
|
||||
}
|
||||
}""",
|
||||
)
|
||||
|
||||
val status = DashboardApiClient.parseStatus(root)
|
||||
|
||||
assertEquals(true, status.componentHealth.supported)
|
||||
assertEquals("degraded", status.componentHealth.overall)
|
||||
assertEquals(listOf("gateway", "platforms", "storage"), status.componentHealth.components.map { it.name })
|
||||
val storage = status.componentHealth.components.first { it.name == "storage" }
|
||||
assertEquals("degraded", storage.status)
|
||||
assertEquals("state DB unavailable", storage.message)
|
||||
assertEquals(false, storage.healthy)
|
||||
assertEquals(2, storage.unhandled5xxCount5m)
|
||||
val platforms = status.componentHealth.components.first { it.name == "platforms" }
|
||||
assertEquals(3, platforms.configured)
|
||||
assertEquals(1, platforms.connected)
|
||||
}
|
||||
}
|
||||
+461
-17
@@ -61,12 +61,32 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var recoveryAssistant = ""
|
||||
|
||||
@Volatile
|
||||
var recoveryInflightStreaming: Boolean? = null
|
||||
|
||||
@Volatile
|
||||
var recoveryQueuedUser: String? = null
|
||||
|
||||
@Volatile
|
||||
var recoveryProject: JsonObject? = null
|
||||
|
||||
/** Optional durable-id -> live-id mapping for multi-session switch tests. */
|
||||
val resumeLiveSessionIds = ConcurrentHashMap<String, String>()
|
||||
|
||||
@Volatile
|
||||
var steerStatus = "queued"
|
||||
|
||||
@Volatile
|
||||
var redirectStatus = "redirected"
|
||||
|
||||
@Volatile
|
||||
var compressPayload: JsonObject = buildJsonObject {
|
||||
put("status", "completed")
|
||||
put("removed", 2)
|
||||
put("before_messages", 8)
|
||||
put("after_messages", 4)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var reasoningEffort = "medium"
|
||||
|
||||
@@ -172,10 +192,18 @@ class GatewayClientHarness(
|
||||
)
|
||||
}
|
||||
"process.kill" -> buildJsonObject { put("status", "killed") }
|
||||
"session.redirect" -> buildJsonObject {
|
||||
put("status", redirectStatus)
|
||||
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
|
||||
}
|
||||
"session.steer" -> buildJsonObject {
|
||||
put("status", steerStatus)
|
||||
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
|
||||
}
|
||||
"session.compress" -> compressPayload
|
||||
"slash.exec" -> buildJsonObject {
|
||||
put("output", "legacy compression started")
|
||||
}
|
||||
"image.attach_bytes", "image.attach.bytes" -> buildJsonObject {
|
||||
put("attached", true)
|
||||
put("count", 1)
|
||||
@@ -225,6 +253,10 @@ class GatewayClientHarness(
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
||||
"model" -> buildJsonObject {
|
||||
put("key", "model")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "")
|
||||
}
|
||||
"reasoning" -> {
|
||||
reasoningEffort = (params["value"] as? JsonPrimitive)?.contentOrNull ?: reasoningEffort
|
||||
buildJsonObject {
|
||||
@@ -232,6 +264,10 @@ class GatewayClientHarness(
|
||||
put("value", reasoningEffort)
|
||||
}
|
||||
}
|
||||
"fast" -> buildJsonObject {
|
||||
put("key", "fast")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
@@ -259,15 +295,24 @@ class GatewayClientHarness(
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
if (recoveryRunning) {
|
||||
recoveryProject?.let { project ->
|
||||
put("info", buildJsonObject { put("project", project) })
|
||||
}
|
||||
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
||||
if (recoveryRunning || recoveryInflightStreaming != null) {
|
||||
put("inflight", buildJsonObject {
|
||||
put("user", "research this")
|
||||
put("assistant", recoveryAssistant)
|
||||
put("streaming", true)
|
||||
put("streaming", inflightStreaming)
|
||||
})
|
||||
}
|
||||
recoveryQueuedUser?.let { user ->
|
||||
put("queued", buildJsonObject { put("user", user) })
|
||||
}
|
||||
}
|
||||
|
||||
fun recoveryResult(sessionId: String): JsonObject = recoveryPayload(sessionId)
|
||||
|
||||
init {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
@@ -475,6 +520,7 @@ class GatewayChatClientTest {
|
||||
|
||||
// Verify the create carried the title and the stored id was reported.
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("webui", (create["source"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("hello", (create["title"] as? JsonPrimitive)?.contentOrNull)
|
||||
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
|
||||
@@ -879,6 +925,7 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val resume = harness.awaitRpc("session.resume")
|
||||
assertEquals("webui", (resume["source"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(
|
||||
"20260101_010101_aaaaaa",
|
||||
(resume["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
@@ -954,23 +1001,23 @@ class GatewayChatClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `socket loss mid-turn reconnects without resume and completes on the original session`() {
|
||||
fun `socket loss mid-turn activates original session without resume and completes`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
|
||||
val ws1 = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
// Server connection dies mid-turn (Wi-Fi roam analogue). The client
|
||||
// must reconnect with a FRESH ticket but WITHOUT session.resume —
|
||||
// upstream resume would mint a new id + fresh agent and orphan the
|
||||
// running turn — then keep consuming the still-running turn's events,
|
||||
// which arrive tagged with the ORIGINAL live session id ("live-1")
|
||||
// on the shared gateway stream.
|
||||
// must reconnect with a FRESH ticket and session.activate the original
|
||||
// live runtime. session.resume would mint/rebind a different runtime and
|
||||
// orphan the running turn.
|
||||
harness.rpcLog.clear()
|
||||
ws1.close(1011, "server crashed")
|
||||
|
||||
val ws2 = harness.awaitServerSocket()
|
||||
assertTrue("reconnect must mint a fresh ticket", harness.ticketMints.get() >= 2)
|
||||
val activate = harness.awaitRpc("session.activate")
|
||||
assertEquals("live-1", (activate["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
|
||||
ws2.send(
|
||||
harness.eventFrame("message.delta", buildJsonObject { put("text", "after rejoin") }, "live-1"),
|
||||
@@ -991,6 +1038,51 @@ class GatewayChatClientTest {
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid-turn activate method-not-found keeps legacy socket recovery`() {
|
||||
harness.methodNotFound += "session.activate"
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
|
||||
val ws1 = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.rpcLog.clear()
|
||||
ws1.close(1011, "radio roam")
|
||||
val ws2 = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.activate")
|
||||
ws2.send(
|
||||
harness.eventFrame("message.complete", buildJsonObject { put("text", "legacy") }, "live-1"),
|
||||
)
|
||||
|
||||
assertTrue(r.completeLatch.await(10, TimeUnit.SECONDS))
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.resume" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lost submit ack before first event rejoins without duplicate fallback`() {
|
||||
harness.suppressAckMethods += "prompt.submit"
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "only once", null, r.callbacks) { r.preflightFailures += it }
|
||||
val ws1 = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
ws1.close(1011, "ack lost")
|
||||
val ws2 = harness.awaitServerSocket()
|
||||
val activate = harness.awaitRpc("session.activate")
|
||||
assertEquals("live-1", (activate["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
ws2.send(
|
||||
harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"),
|
||||
)
|
||||
|
||||
assertTrue(r.completeLatch.await(10, TimeUnit.SECONDS))
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "prompt.submit" })
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.resume" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed rejoin surfaces stream error`() {
|
||||
val r = Recorder()
|
||||
@@ -1033,39 +1125,105 @@ class GatewayChatClientTest {
|
||||
assertEquals("rm -rf /tmp/x — cleanup", ask.text)
|
||||
}
|
||||
|
||||
// --- Steer ---
|
||||
// --- Active-turn correction ---
|
||||
|
||||
@Test
|
||||
fun `steer queued when the server accepts`() {
|
||||
fun `redirect queued when the server accepts active-turn correction`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.steerStatus = "queued"
|
||||
harness.redirectStatus = "redirected"
|
||||
assertEquals(SteerResult.Queued, runBlocking { client.steer("focus on tests") })
|
||||
val steer = harness.awaitRpc("session.steer")
|
||||
assertEquals("live-1", (steer["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("focus on tests", (steer["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
val redirect = harness.awaitRpc("session.redirect")
|
||||
assertEquals("live-1", (redirect["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("focus on tests", (redirect["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer rejected propagates to the caller`() {
|
||||
fun `redirect falls back to steer when unsupported`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.steerStatus = "rejected"
|
||||
harness.methodNotFound.add("session.redirect")
|
||||
harness.steerStatus = "queued"
|
||||
assertEquals(SteerResult.Queued, runBlocking { client.steer("legacy correction") })
|
||||
harness.awaitRpc("session.redirect")
|
||||
val steer = harness.awaitRpc("session.steer")
|
||||
assertEquals("live-1", (steer["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("legacy correction", (steer["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redirect rejected propagates to the caller`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.redirectStatus = "rejected"
|
||||
assertEquals(SteerResult.Rejected, runBlocking { client.steer("too late") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer with no live session fails without touching the wire`() {
|
||||
fun `redirect with no live session fails without touching the wire`() {
|
||||
assertEquals(SteerResult.Failed, runBlocking { client.steer("nothing running") })
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.redirect" })
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress session uses dedicated rpc and parses authoritative messages`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
harness.compressPayload = buildJsonObject {
|
||||
put("status", "completed")
|
||||
put("removed", 3)
|
||||
put("before_messages", 9)
|
||||
put("after_messages", 4)
|
||||
put(
|
||||
"messages",
|
||||
harness.json.parseToJsonElement(
|
||||
"""
|
||||
[
|
||||
{"id":"1","role":"user","content":"hello"},
|
||||
{"id":"2","role":"assistant","content":"summary"}
|
||||
]
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val result = runBlocking { client.compressSession("tests").getOrThrow() }
|
||||
val rpc = harness.awaitRpc("session.compress")
|
||||
assertEquals("live-1", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("tests", (rpc["focus_topic"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(3, result.removed)
|
||||
assertEquals(2, result.messages.size)
|
||||
assertTrue(harness.rpcLog.none { it.first == "slash.exec" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress session falls back to slash exec when rpc is missing`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.methodNotFound.add("session.compress")
|
||||
val result = runBlocking { client.compressSession().getOrThrow() }
|
||||
harness.awaitRpc("session.compress")
|
||||
val slash = harness.awaitRpc("slash.exec")
|
||||
assertEquals("/compress", (slash["command"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("legacy", result.status)
|
||||
}
|
||||
|
||||
// --- Profile-bound sessions (upstream tui_gateway: session.create/resume
|
||||
// take a `profile` arg; a session's agent is built from it) ---
|
||||
|
||||
@@ -1176,6 +1334,20 @@ class GatewayChatClientTest {
|
||||
assertEquals(true, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create binds explicit normal fast tier`() {
|
||||
val r = Recorder()
|
||||
client.sessionModelProvider = {
|
||||
GatewaySessionModel(model = null, provider = null, reasoningEffort = null, fast = false)
|
||||
}
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals(false, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create omits reasoning_effort and fast when unset`() {
|
||||
val r = Recorder()
|
||||
@@ -1485,6 +1657,43 @@ class GatewayChatClientTest {
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `model update targets live session without global scope`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.setModel("grok-4.3 --provider xai") }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val rpc = harness.awaitRpc("config.set")
|
||||
assertEquals("model", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("grok-4.3 --provider xai", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(rpc.containsKey("scope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fast update targets live session without global scope`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.setFast(false) }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertFalse(result.getOrThrow())
|
||||
val rpc = harness.awaitRpc("config.set")
|
||||
assertEquals("fast", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("normal", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(rpc.containsKey("scope"))
|
||||
}
|
||||
|
||||
// --- Edit & regenerate ---
|
||||
|
||||
@Test
|
||||
@@ -1670,6 +1879,241 @@ class GatewayChatClientTest {
|
||||
assertEquals(listOf("recovered"), recorder.textDeltas.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps queued-only resume live`() {
|
||||
harness.recoveryQueuedUser = "do this next"
|
||||
val queuedRecorder = Recorder()
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
Recorder().callbacks,
|
||||
queuedTurnProvider = {
|
||||
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
|
||||
},
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertFalse(recovery.running)
|
||||
assertTrue(recovery.hasPendingWork)
|
||||
assertEquals("do this next", recovery.queued?.user)
|
||||
assertNull(recovery.inflight)
|
||||
assertNotNull(recovery.handle)
|
||||
recovery.handle!!.detach()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued-only activation reroutes events received before acknowledgement`() {
|
||||
runBlocking {
|
||||
harness.recoveryQueuedUser = "do this next"
|
||||
harness.suppressAckMethods += "session.activate"
|
||||
val priorRecorder = Recorder()
|
||||
val queuedRecorder = Recorder()
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
"live-queued",
|
||||
priorRecorder.callbacks,
|
||||
queuedTurnProvider = {
|
||||
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
|
||||
},
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
ack.ws.send(harness.eventFrame("message.start", null, "live-queued"))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "queued answer") },
|
||||
"live-queued",
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult("live-queued"))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (queuedRecorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(emptyList<String>(), priorRecorder.textDeltas.toList())
|
||||
assertEquals(listOf("queued answer"), queuedRecorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps inflight-only resume live`() {
|
||||
harness.recoveryInflightStreaming = true
|
||||
harness.recoveryAssistant = "partial"
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
|
||||
}
|
||||
|
||||
assertTrue(recovery.running)
|
||||
assertTrue(recovery.hasPendingWork)
|
||||
assertEquals("partial", recovery.inflight?.assistant)
|
||||
assertNull(recovery.queued)
|
||||
assertNotNull(recovery.handle)
|
||||
recovery.handle!!.detach()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps inflight and queued resume live`() {
|
||||
harness.recoveryInflightStreaming = true
|
||||
harness.recoveryQueuedUser = "follow up"
|
||||
val priorRecorder = Recorder()
|
||||
val queuedRecorder = Recorder()
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
priorRecorder.callbacks,
|
||||
queuedTurnProvider = {
|
||||
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
|
||||
},
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertTrue(recovery.running)
|
||||
assertTrue(recovery.hasPendingWork)
|
||||
assertEquals("follow up", recovery.queued?.user)
|
||||
assertNotNull(recovery.inflight)
|
||||
assertNotNull(recovery.handle)
|
||||
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "current answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
assertTrue(priorRecorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "queued answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "queued answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(queuedRecorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("current answer"), priorRecorder.textDeltas.toList())
|
||||
assertEquals(listOf("queued answer"), queuedRecorder.textDeltas.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inflight and queued activation preserves turn boundary before acknowledgement`() {
|
||||
runBlocking {
|
||||
harness.recoveryInflightStreaming = true
|
||||
harness.recoveryQueuedUser = "follow up"
|
||||
harness.suppressAckMethods += "session.activate"
|
||||
val priorRecorder = Recorder()
|
||||
val queuedRecorder = Recorder()
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
"live-running-queued",
|
||||
priorRecorder.callbacks,
|
||||
queuedTurnProvider = {
|
||||
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
|
||||
},
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", " current") },
|
||||
"live-running-queued",
|
||||
),
|
||||
)
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "current") },
|
||||
"live-running-queued",
|
||||
),
|
||||
)
|
||||
ack.ws.send(harness.eventFrame("message.start", null, "live-running-queued"))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "queued") },
|
||||
"live-running-queued",
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult("live-running-queued"))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (queuedRecorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(listOf(" current"), priorRecorder.textDeltas.toList())
|
||||
assertEquals(listOf("queued"), queuedRecorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn settles resume with neither inflight nor queued work`() {
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
|
||||
}
|
||||
|
||||
assertFalse(recovery.running)
|
||||
assertFalse(recovery.hasPendingWork)
|
||||
assertNull(recovery.inflight)
|
||||
assertNull(recovery.queued)
|
||||
assertNull(recovery.handle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn parses optional session project`() {
|
||||
harness.recoveryProject = buildJsonObject {
|
||||
put("id", "project-17")
|
||||
put("slug", "hermes-relay")
|
||||
put("name", "Hermes Relay")
|
||||
put("primary_path", "/workspace/hermes-relay")
|
||||
put("future_field", "ignored")
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
|
||||
}
|
||||
|
||||
assertEquals("project-17", client.serverProject.value?.id)
|
||||
assertEquals("hermes-relay", client.serverProject.value?.slug)
|
||||
assertEquals("Hermes Relay", client.serverProject.value?.name)
|
||||
assertEquals("/workspace/hermes-relay", client.serverProject.value?.primaryPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn clears project for legacy session info`() {
|
||||
harness.recoveryProject = buildJsonObject { put("name", "Previous Project") }
|
||||
runBlocking {
|
||||
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
|
||||
}
|
||||
assertEquals("Previous Project", client.serverProject.value?.name)
|
||||
|
||||
harness.recoveryProject = null
|
||||
runBlocking {
|
||||
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
|
||||
}
|
||||
|
||||
assertNull(client.serverProject.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn returns no handle for an already-settled session`() {
|
||||
harness.recoveryRunning = false
|
||||
|
||||
+129
-5
@@ -18,6 +18,7 @@ class GatewayEventMapperTest {
|
||||
|
||||
private class Recorder {
|
||||
val textDeltas = mutableListOf<String>()
|
||||
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
||||
val thinkingDeltas = mutableListOf<String>()
|
||||
val toolStarts = mutableListOf<Pair<String, String>>()
|
||||
val toolDones = mutableListOf<Pair<String, String?>>()
|
||||
@@ -42,6 +43,7 @@ class GatewayEventMapperTest {
|
||||
onSessionId = { sessionIds += it },
|
||||
onStart = { starts++ },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
onToolCallDone = { id, preview -> toolDones += id to preview },
|
||||
@@ -169,6 +171,17 @@ class GatewayEventMapperTest {
|
||||
assertEquals(2, r.textDeltas.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete backfills text when response was not previewed`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent("message.complete", obj("""{"text":"Final answer","response_previewed":false}"""))
|
||||
|
||||
assertEquals(listOf("Final answer"), r.textDeltas)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete backfills text and reasoning when nothing streamed`() {
|
||||
val r = Recorder()
|
||||
@@ -182,6 +195,111 @@ class GatewayEventMapperTest {
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message interim seals preview and previewed complete does not duplicate`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer","response_previewed":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer" to false), r.interimMessages)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
assertEquals(1, r.starts)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete after interim backfills distinct final when not previewed`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent("message.complete", obj("""{"text":"final answer"}"""))
|
||||
|
||||
assertEquals(listOf("candidate answer" to false), r.interimMessages)
|
||||
assertEquals(listOf("final answer"), r.textDeltas)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already streamed interim seals without replaying text`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("message.delta", obj("""{"text":"candidate answer"}"""))
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer" to true), r.interimMessages)
|
||||
assertEquals(listOf("candidate answer"), r.textDeltas)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete suppresses exact intentional silence marker`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent("message.complete", obj("""{"text":"[SILENT]"}"""))
|
||||
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message delta suppresses exact intentional silence marker`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent("message.delta", obj("""{"text":"NO_REPLY"}"""))
|
||||
mapper.onEvent("message.delta", obj("""{"text":"The profile said NO_REPLY for context."}"""))
|
||||
|
||||
assertEquals(listOf("The profile said NO_REPLY for context."), r.textDeltas)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message interim already streamed seals without replaying text`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("message.delta", obj("""{"text":"candidate "}"""))
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":true}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer","response_previewed":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate "), r.textDeltas)
|
||||
assertEquals(listOf("candidate answer" to true), r.interimMessages)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message interim already streamed without text does not backfill previewed complete`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("message.delta", obj("""{"text":"candidate"}"""))
|
||||
mapper.onEvent("message.interim", obj("""{"already_streamed":true}"""))
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate","response_previewed":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate"), r.textDeltas)
|
||||
assertEquals(listOf("" to true), r.interimMessages)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second message start signals turn boundary`() {
|
||||
val r = Recorder()
|
||||
@@ -564,18 +682,24 @@ class GatewayEventMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sudo secret and approval expiry events preserve correlation contract`() {
|
||||
fun `sudo secret clarify and approval expiry events preserve correlation contract`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("sudo.expire", obj("""{"request_id":"r2"}"""))
|
||||
mapper.onEvent("secret.expire", obj("""{"request_id":"r3"}"""))
|
||||
mapper.onEvent("clarify.expire", obj("""{"request_id":"r4"}"""))
|
||||
mapper.onEvent("approval.expire", obj("""{"request_id":"ignored"}"""))
|
||||
|
||||
assertEquals(
|
||||
listOf(GatewayAsk.Kind.SUDO, GatewayAsk.Kind.SECRET, GatewayAsk.Kind.APPROVAL),
|
||||
listOf(
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
GatewayAsk.Kind.CLARIFY,
|
||||
GatewayAsk.Kind.APPROVAL,
|
||||
),
|
||||
r.interactionExpiries.map { it.kind },
|
||||
)
|
||||
assertEquals(listOf("r2", "r3", null), r.interactionExpiries.map { it.requestId })
|
||||
assertEquals(listOf("r2", "r3", "r4", null), r.interactionExpiries.map { it.requestId })
|
||||
}
|
||||
|
||||
// --- Forward compat ---
|
||||
@@ -601,10 +725,10 @@ class GatewayEventMapperTest {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
listOf(
|
||||
"reasoning.delta", "thinking.delta", "message.delta", "message.start",
|
||||
"reasoning.delta", "thinking.delta", "message.delta", "message.interim", "message.start",
|
||||
"message.complete", "error", "clarify.request", "approval.request",
|
||||
"sudo.request", "secret.request", "reasoning.available",
|
||||
"sudo.expire", "secret.expire", "approval.expire",
|
||||
"sudo.expire", "secret.expire", "clarify.expire", "approval.expire",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
"tool.output_risk", "moa.reference", "moa.aggregating",
|
||||
|
||||
@@ -18,6 +18,30 @@ import org.junit.Test
|
||||
*/
|
||||
class HermesApiClientTest {
|
||||
|
||||
@Test
|
||||
fun toolsetInventory_parserPreservesEnabledAndResolvedTools() {
|
||||
val parsed = parseToolsetListBody(
|
||||
Json { ignoreUnknownKeys = true },
|
||||
"""
|
||||
{
|
||||
"object": "list",
|
||||
"platform": "api_server",
|
||||
"data": [{
|
||||
"name": "relay",
|
||||
"label": "Relay",
|
||||
"enabled": true,
|
||||
"configured": true,
|
||||
"tools": ["relay_status", "android_phone_status"]
|
||||
}]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("relay", parsed?.single()?.name)
|
||||
assertTrue(parsed?.single()?.enabled == true)
|
||||
assertEquals(listOf("relay_status", "android_phone_status"), parsed?.single()?.tools)
|
||||
}
|
||||
|
||||
// --- buildApiRequestOrNull (#131 guard, streaming paths) ---
|
||||
|
||||
@Test
|
||||
@@ -286,6 +310,73 @@ class HermesApiClientTest {
|
||||
assertEquals("http://localhost:8642/v1/models", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelOptionsPreserveAliasRequestIdAndRootMetadata() {
|
||||
val parsed = parseModelOptionsBody(
|
||||
Json { ignoreUnknownKeys = true },
|
||||
"""{"object":"list","data":[{"id":"fast","root":"gpt-5-mini","parent":"hermes-agent"}]}""",
|
||||
)
|
||||
assertEquals("fast", parsed?.single()?.id)
|
||||
assertEquals("gpt-5-mini", parsed?.single()?.root)
|
||||
assertEquals("hermes-agent", parsed?.single()?.parent)
|
||||
assertEquals("Routes to gpt-5-mini", parsed?.single()?.routeDetail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelOptionsIgnoreMalformedRowsButKeepCompatibilityShape() {
|
||||
val parsed = parseModelOptionsBody(
|
||||
Json { ignoreUnknownKeys = true },
|
||||
"""{"data":[null,{"root":"missing-id"},{"id":"hermes-agent","extra":true}]}""",
|
||||
)
|
||||
assertEquals(listOf("hermes-agent"), parsed?.map { it.id })
|
||||
assertNull(parseModelOptionsBody(Json, "not-json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drainFailurePreservesCodeMessageAndBoundedRetryHint() {
|
||||
val message = streamHttpFailureMessage(
|
||||
code = 503,
|
||||
reason = "Service Unavailable",
|
||||
retryAfter = "1",
|
||||
body = """{"error":{"message":"Gateway is shutting down","code":"gateway_draining"}}""",
|
||||
json = Json,
|
||||
)
|
||||
assertEquals(
|
||||
"API error 503: gateway_draining: Gateway is shutting down (Retry-After: 1s)",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ordinaryProvider503KeepsGenericClassificationAndUnsafeRetryHintIsIgnored() {
|
||||
val message = streamHttpFailureMessage(
|
||||
code = 503,
|
||||
reason = "Service Unavailable",
|
||||
retryAfter = "999999999",
|
||||
body = """{"error":{"message":"provider offline","code":"provider_unavailable"}}""",
|
||||
json = Json,
|
||||
)
|
||||
assertEquals("API error 503: provider_unavailable: provider offline", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drainRetryIsSinglePreEventAndBounded() {
|
||||
val drain = "API error 503: gateway_draining: Gateway is shutting down"
|
||||
assertEquals(1_000L, gatewayDrainRetryDelayMillis(503, "1", drain, false, false))
|
||||
assertEquals(5_000L, gatewayDrainRetryDelayMillis(503, "999", drain, false, false))
|
||||
assertNull(gatewayDrainRetryDelayMillis(503, "1", drain, true, false))
|
||||
assertNull(gatewayDrainRetryDelayMillis(503, "1", drain, false, true))
|
||||
assertNull(gatewayDrainRetryDelayMillis(503, "1", "provider unavailable", false, false))
|
||||
assertNull(gatewayDrainRetryDelayMillis(
|
||||
503,
|
||||
"1",
|
||||
"API error 503: provider_unavailable: message mentions gateway_draining",
|
||||
false,
|
||||
false,
|
||||
))
|
||||
assertNull(gatewayDrainRetryDelayMillis(500, "1", drain, false, false))
|
||||
}
|
||||
|
||||
// --- Skills endpoint compatibility ---
|
||||
|
||||
@Test
|
||||
|
||||
+12
@@ -30,6 +30,18 @@ import org.junit.Test
|
||||
* [ChatPayloadResult.droppedAttachments] — never a silent drop.
|
||||
*/
|
||||
class HermesChatPayloadsTest {
|
||||
@Test
|
||||
fun `model route alias id is sent unchanged on every SSE payload`() {
|
||||
val alias = "fast-route"
|
||||
val session = buildSessionChatStreamPayload(message = "hi", modelOverride = alias)
|
||||
val completion = buildChatCompletionsStreamPayload(message = "hi", modelOverride = alias)
|
||||
val run = buildRunStreamPayload(message = "hi", modelOverride = alias)
|
||||
|
||||
assertEquals(alias, session.payload["model"]?.jsonPrimitive?.content)
|
||||
assertEquals(alias, completion.payload["model"]?.jsonPrimitive?.content)
|
||||
assertEquals(alias, run.payload["model"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class McpOAuthFlowCoordinatorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before fun setUp() { server = MockWebServer().also { it.start() } }
|
||||
@After fun tearDown() { server.shutdown() }
|
||||
|
||||
@Test
|
||||
fun complete_opensHttpsAndPollsToApproved() = runTest {
|
||||
enqueueFlow("authorization_required", "https://auth.example/start")
|
||||
enqueueFlow("starting")
|
||||
enqueueFlow("approved")
|
||||
val opened = mutableListOf<String>()
|
||||
val coordinator = McpOAuthFlowCoordinator(
|
||||
DashboardApiClient(server.url("/").toString()),
|
||||
pollDelayMillis = 0,
|
||||
maxPolls = 3,
|
||||
sleep = {},
|
||||
)
|
||||
|
||||
val result = coordinator.complete("hosted", profile = "victor") {
|
||||
opened += it
|
||||
true
|
||||
}.getOrThrow()
|
||||
|
||||
assertEquals("approved", result.status)
|
||||
assertEquals(listOf("https://auth.example/start"), opened)
|
||||
assertEquals("/api/mcp/servers/hosted/auth?profile=victor", server.takeRequest().path)
|
||||
assertEquals("/api/mcp/oauth/flows/opaque", server.takeRequest().path)
|
||||
assertEquals("/api/mcp/oauth/flows/opaque", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun complete_rejectsNonHttpsWithoutOpeningBrowser() = runTest {
|
||||
enqueueFlow("authorization_required", "http://auth.example/start")
|
||||
var opened = false
|
||||
val result = McpOAuthFlowCoordinator(
|
||||
DashboardApiClient(server.url("/").toString()),
|
||||
pollDelayMillis = 0,
|
||||
sleep = {},
|
||||
).complete("hosted") {
|
||||
opened = true
|
||||
true
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertFalse(opened)
|
||||
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("HTTPS"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun complete_boundsTransientPollFailures() = runTest {
|
||||
enqueueFlow("authorization_required", "https://auth.example/start")
|
||||
repeat(3) { server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable")) }
|
||||
val result = McpOAuthFlowCoordinator(
|
||||
DashboardApiClient(server.url("/").toString()),
|
||||
pollDelayMillis = 0,
|
||||
maxPolls = 20,
|
||||
maxConsecutiveFailures = 3,
|
||||
sleep = {},
|
||||
).complete("hosted") { true }
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("HTTP 503"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun complete_surfacesOlderServerDuplicateAndCapacityResponsesWithoutOpening() = runTest {
|
||||
for (code in listOf(404, 409, 429)) {
|
||||
server.enqueue(MockResponse().setResponseCode(code).setBody("{}"))
|
||||
var opened = false
|
||||
val result = McpOAuthFlowCoordinator(
|
||||
DashboardApiClient(server.url("/").toString()),
|
||||
pollDelayMillis = 0,
|
||||
sleep = {},
|
||||
).complete("hosted") {
|
||||
opened = true
|
||||
true
|
||||
}
|
||||
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("HTTP $code"))
|
||||
assertFalse(opened)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resume_afterRecreationPollsExistingOpaqueFlowWithoutAuthorizationUrl() = runTest {
|
||||
enqueueFlow("approved")
|
||||
val recreated = McpOAuthFlowCoordinator(
|
||||
DashboardApiClient(server.url("/").toString()),
|
||||
pollDelayMillis = 0,
|
||||
sleep = {},
|
||||
)
|
||||
|
||||
val result = recreated.resume("opaque").getOrThrow()
|
||||
|
||||
assertEquals("approved", result.status)
|
||||
assertEquals("/api/mcp/oauth/flows/opaque", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resume_preservesCoroutineCancellation() = runTest {
|
||||
enqueueFlow("starting")
|
||||
val coordinator = McpOAuthFlowCoordinator(
|
||||
DashboardApiClient(server.url("/").toString()),
|
||||
pollDelayMillis = 1,
|
||||
maxPolls = 2,
|
||||
sleep = { throw CancellationException("screen left") },
|
||||
)
|
||||
|
||||
var cancelled = false
|
||||
try {
|
||||
coordinator.resume("opaque")
|
||||
} catch (_: CancellationException) {
|
||||
cancelled = true
|
||||
}
|
||||
assertTrue(cancelled)
|
||||
}
|
||||
|
||||
private fun enqueueFlow(status: String, authorizationUrl: String? = null) {
|
||||
val url = authorizationUrl?.let { "\"$it\"" } ?: "null"
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(
|
||||
"""{"flow_id":"opaque","server_name":"hosted","status":"$status","authorization_url":$url,"error":null}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class StandardHermesVoiceClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardAudioTimeoutBoundsMatchUpstreamDesktopFloorAndRelayCap() {
|
||||
assertEquals(180L, standardHermesDashboardAudioTimeoutSeconds(90L))
|
||||
assertEquals(180L, standardHermesDashboardAudioTimeoutSeconds(180L))
|
||||
assertEquals(420L, standardHermesDashboardAudioTimeoutSeconds(420L))
|
||||
assertEquals(600L, standardHermesDashboardAudioTimeoutSeconds(900L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardAudioClientRaisesOnlyDashboardAudioTimeouts() {
|
||||
val baseClient = DashboardApiClient.defaultClient()
|
||||
val audioClient = standardHermesDashboardAudioClient(baseClient)
|
||||
val audioTimeoutMillis = TimeUnit.SECONDS.toMillis(180L).toInt()
|
||||
|
||||
assertEquals(audioTimeoutMillis, audioClient.callTimeoutMillis)
|
||||
assertEquals(audioTimeoutMillis, audioClient.readTimeoutMillis)
|
||||
assertEquals(audioTimeoutMillis, audioClient.writeTimeoutMillis)
|
||||
assertEquals(baseClient.connectTimeoutMillis, audioClient.connectTimeoutMillis)
|
||||
|
||||
assertEquals(TimeUnit.SECONDS.toMillis(45L).toInt(), baseClient.readTimeoutMillis)
|
||||
assertEquals(TimeUnit.SECONDS.toMillis(30L).toInt(), baseClient.writeTimeoutMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speechStreamUsesFreshTicketBuffersUntilOpenAndCarriesOddPcmByte() = runTest {
|
||||
server.enqueue(ticketResponse())
|
||||
lateinit var listener: WebSocketListener
|
||||
lateinit var websocketRequest: Request
|
||||
lateinit var socket: RecordingWebSocket
|
||||
val pcm = mutableListOf<ByteArray>()
|
||||
val formats = mutableListOf<Pair<Int, Int>>()
|
||||
val client = testClient { request, capturedListener ->
|
||||
websocketRequest = request
|
||||
listener = capturedListener
|
||||
RecordingWebSocket(request).also { socket = it }
|
||||
}
|
||||
|
||||
val stream = client.openSpeechStream(
|
||||
VoiceSpeechStreamCallbacks(
|
||||
onStart = { rate, channels -> formats += rate to channels },
|
||||
onPcm = { bytes, _ -> pcm += bytes },
|
||||
),
|
||||
).getOrThrow()!!
|
||||
stream.append("Hello ")
|
||||
stream.append("world.")
|
||||
assertTrue(socket.sentText.isEmpty())
|
||||
|
||||
listener.onOpen(socket, switchingProtocols(websocketRequest))
|
||||
assertEquals(listOf("{\"text\":\"Hello \"}", "{\"text\":\"world.\"}"), socket.sentText)
|
||||
listener.onMessage(socket, "{\"type\":\"start\",\"sample_rate\":24000,\"channels\":1}")
|
||||
listener.onMessage(socket, byteArrayOf(1, 2, 3).toByteString())
|
||||
listener.onMessage(socket, byteArrayOf(4, 5, 6).toByteString())
|
||||
stream.finish()
|
||||
listener.onMessage(socket, "{\"type\":\"end\"}")
|
||||
|
||||
val outcome = stream.awaitOutcome()
|
||||
assertEquals(VoiceSpeechStreamStatus.Completed, outcome.status)
|
||||
assertTrue(outcome.audioStarted)
|
||||
assertEquals(listOf(24_000 to 1), formats)
|
||||
assertEquals(listOf(1, 2), pcm[0].map { it.toInt() })
|
||||
assertEquals(listOf(3, 4, 5, 6), pcm[1].map { it.toInt() })
|
||||
assertTrue(socket.sentText.last().contains("\"done\":true"))
|
||||
val ticketRequest = server.takeRequest(1, TimeUnit.SECONDS)!!
|
||||
assertEquals("/api/auth/ws-ticket", ticketRequest.path)
|
||||
assertEquals("/api/audio/speak-stream", websocketRequest.url.encodedPath)
|
||||
assertEquals("ticket-1", websocketRequest.url.queryParameter("ticket"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speechStreamFallbackBeforeAudioRequestsLegacyPlayback() = runTest {
|
||||
server.enqueue(ticketResponse())
|
||||
lateinit var listener: WebSocketListener
|
||||
lateinit var socket: RecordingWebSocket
|
||||
val client = testClient { request, capturedListener ->
|
||||
listener = capturedListener
|
||||
RecordingWebSocket(request).also { socket = it }
|
||||
}
|
||||
|
||||
val stream = client.openSpeechStream(
|
||||
VoiceSpeechStreamCallbacks(onPcm = { _, _ -> }),
|
||||
).getOrThrow()!!
|
||||
listener.onOpen(socket, switchingProtocols(socket.request()))
|
||||
listener.onMessage(socket, "{\"type\":\"fallback\"}")
|
||||
|
||||
val outcome = stream.awaitOutcome()
|
||||
assertEquals(VoiceSpeechStreamStatus.Fallback, outcome.status)
|
||||
assertFalse(outcome.audioStarted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speechStreamFailureAfterAudioNeverRequestsReplay() = runTest {
|
||||
server.enqueue(ticketResponse())
|
||||
lateinit var listener: WebSocketListener
|
||||
lateinit var socket: RecordingWebSocket
|
||||
val client = testClient { request, capturedListener ->
|
||||
listener = capturedListener
|
||||
RecordingWebSocket(request).also { socket = it }
|
||||
}
|
||||
|
||||
val stream = client.openSpeechStream(
|
||||
VoiceSpeechStreamCallbacks(onPcm = { _, _ -> }),
|
||||
).getOrThrow()!!
|
||||
listener.onOpen(socket, switchingProtocols(socket.request()))
|
||||
listener.onMessage(socket, byteArrayOf(1, 2).toByteString())
|
||||
listener.onFailure(socket, IOException("route lost"), null)
|
||||
|
||||
val outcome = stream.awaitOutcome()
|
||||
assertEquals(VoiceSpeechStreamStatus.Failed, outcome.status)
|
||||
assertTrue(outcome.audioStarted)
|
||||
}
|
||||
|
||||
private fun testClient(
|
||||
socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
): StandardHermesVoiceClient = StandardHermesVoiceClient(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
okHttpClient = DashboardApiClient.defaultClient(),
|
||||
dashboardUrlProvider = { server.url("/").toString() },
|
||||
webSocketFactory = socketFactory,
|
||||
)
|
||||
|
||||
private fun ticketResponse(): MockResponse = MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("{\"ticket\":\"ticket-1\",\"ttl_seconds\":30}")
|
||||
|
||||
private fun switchingProtocols(request: Request): Response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(101)
|
||||
.message("Switching Protocols")
|
||||
.build()
|
||||
|
||||
private class RecordingWebSocket(private val ownRequest: Request) : WebSocket {
|
||||
val sentText = mutableListOf<String>()
|
||||
var cancelled = false
|
||||
|
||||
override fun request(): Request = ownRequest
|
||||
override fun queueSize(): Long = 0L
|
||||
override fun send(text: String): Boolean = sentText.add(text)
|
||||
override fun send(bytes: ByteString): Boolean = true
|
||||
override fun close(code: Int, reason: String?): Boolean = true
|
||||
override fun cancel() {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageGenerationPlaceholderTest {
|
||||
|
||||
@Test
|
||||
fun `active image generation uses diffusion placeholder`() {
|
||||
val active = ToolCall(
|
||||
id = "image-1",
|
||||
name = " image_generate ",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
)
|
||||
|
||||
assertTrue(active.showsImageGenerationPlaceholder())
|
||||
assertFalse(active.copy(isComplete = true, success = true).showsImageGenerationPlaceholder())
|
||||
assertFalse(active.copy(name = "video_generate").showsImageGenerationPlaceholder())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active image generation remains visible when generic tools are hidden`() {
|
||||
val active = ToolCall(
|
||||
id = "image-1",
|
||||
name = "image_generate",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
)
|
||||
|
||||
assertTrue(active.isVisibleForToolDisplay("off"))
|
||||
assertFalse(active.copy(name = "terminal").isVisibleForToolDisplay("off"))
|
||||
assertFalse(active.copy(isComplete = true).isVisibleForToolDisplay("off"))
|
||||
assertTrue(active.copy(name = "terminal").isVisibleForToolDisplay("compact"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image progress owns the bubble until a media result arrives`() {
|
||||
val active = ToolCall(
|
||||
id = "image-1",
|
||||
name = "image_generate",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
)
|
||||
val completed = active.copy(isComplete = true, success = true)
|
||||
|
||||
assertTrue(
|
||||
shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = listOf(active),
|
||||
isStreaming = true,
|
||||
hasMediaResult = false,
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = listOf(completed),
|
||||
isStreaming = true,
|
||||
hasMediaResult = false,
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = listOf(completed),
|
||||
isStreaming = true,
|
||||
hasMediaResult = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed or failed turn cannot leave a stale image canvas`() {
|
||||
val completed = ToolCall(
|
||||
id = "image-1",
|
||||
name = "image_generate",
|
||||
args = null,
|
||||
result = null,
|
||||
success = true,
|
||||
isComplete = true,
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = listOf(completed),
|
||||
isStreaming = false,
|
||||
hasMediaResult = false,
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = listOf(completed.copy(success = false)),
|
||||
isStreaming = true,
|
||||
hasMediaResult = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diffusion field changes across the animation cycle`() {
|
||||
val noisy = diffusionSignal(column = 12, row = 8, time = 0.25f, denoise = diffusionDenoise(0.08f))
|
||||
val resolved = diffusionSignal(column = 12, row = 8, time = 3.5f, denoise = diffusionDenoise(0.62f))
|
||||
|
||||
assertTrue(noisy in 0f..1f)
|
||||
assertTrue(resolved in 0f..1f)
|
||||
assertNotEquals(noisy, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denoise cycle resolves then resets`() {
|
||||
val early = diffusionDenoise(0.05f)
|
||||
val resolved = diffusionDenoise(0.82f)
|
||||
val reset = diffusionDenoise(0.99f)
|
||||
|
||||
assertTrue(early < resolved)
|
||||
assertTrue(reset < resolved)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class ImageGenerationPlaceholderUiTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `placeholder announces rendering image status`() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
ImageGenerationPlaceholder(Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
compose.mainClock.advanceTimeBy(2_400)
|
||||
compose.onNodeWithContentDescription("Rendering image").assertExists()
|
||||
compose.onRoot().captureRoboImage("build/verification-shots/image-generation-placeholder.png")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InlineImageDataUrlTest {
|
||||
private fun url(mime: String, bytes: ByteArray): String =
|
||||
"data:$mime;base64,${Base64.getEncoder().encodeToString(bytes)}"
|
||||
|
||||
@Test
|
||||
fun acceptsUpstreamImageMimesWhenMagicMatches() {
|
||||
val fixtures = mapOf(
|
||||
"image/png" to byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
"image/jpeg" to byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte(), 0x00),
|
||||
"image/webp" to "RIFF0000WEBP".encodeToByteArray(),
|
||||
"image/gif" to "GIF89a".encodeToByteArray(),
|
||||
"image/bmp" to "BM".encodeToByteArray(),
|
||||
)
|
||||
fixtures.forEach { (mime, bytes) ->
|
||||
assertEquals(mime, decodeInlineImageDataUrl(url(mime, bytes))?.mime)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnknownMimeInvalidBase64AndMimeSpoofing() {
|
||||
assertNull(decodeInlineImageDataUrl(url("image/svg+xml", "<svg/>".encodeToByteArray())))
|
||||
assertNull(decodeInlineImageDataUrl("data:image/png;base64,%%%"))
|
||||
assertNull(decodeInlineImageDataUrl(url("image/png", "GIF89a".encodeToByteArray())))
|
||||
assertNull(decodeInlineImageDataUrl("data:image/png,not-base64"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsOversizeBeforeAndAfterDecode() {
|
||||
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
assertNull(decodeInlineImageDataUrl(url("image/png", png + ByteArray(9)), maxBytes = 16))
|
||||
val tooLong = "data:image/png;base64," + "A".repeat(25)
|
||||
assertNull(decodeInlineImageDataUrl(tooLong, maxBytes = 16))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun samplesLargeImagesToBoundedThumbnailMemory() {
|
||||
assertEquals(4, inlineImageSampleSize(4_000, 4_000))
|
||||
assertEquals(1, inlineImageSampleSize(1_024, 1_024))
|
||||
assertNull(inlineImageSampleSize(40_000, 100))
|
||||
assertNull(inlineImageSampleSize(Int.MAX_VALUE, Int.MAX_VALUE))
|
||||
assertNull(inlineImageSampleSize(0, 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markdownExtractionPreservesSensitivityForDataUrl() {
|
||||
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
val (body, images) = extractChatInlineImages("before ||})|| after")
|
||||
assertEquals("before after", body)
|
||||
assertEquals(1, images.size)
|
||||
assertTrue(images.single().sensitive)
|
||||
assertNotNull(decodeInlineImageDataUrl(images.single().src))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleInlineImagesAreCappedPerMessage() {
|
||||
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
val marker = "})"
|
||||
val (body, images) = extractChatInlineImages(List(7) { marker }.joinToString("\n"))
|
||||
assertEquals(INLINE_IMAGE_DATA_MAX_PER_MESSAGE, images.size)
|
||||
assertTrue(body.contains("Additional inline images omitted"))
|
||||
assertEquals(1, Regex("Additional inline images omitted").findAll(body).count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cumulativeRawImageBudgetOmitsLaterImages() {
|
||||
val signature = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
val fourMiBPng = signature + ByteArray(4 * 1024 * 1024 - signature.size)
|
||||
val marker = "})"
|
||||
val (body, images) = extractChatInlineImages(List(3) { marker }.joinToString("\n"))
|
||||
assertEquals(2, images.size)
|
||||
assertTrue(body.contains("Additional inline images omitted"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveShareDecodeRunsOnRequestedBackgroundDispatcher() = runBlocking {
|
||||
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
val callerThread = Thread.currentThread()
|
||||
val backgroundThread = AtomicReference(callerThread)
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "inline-image-decode-test").also(backgroundThread::set)
|
||||
}
|
||||
.asCoroutineDispatcher().use { dispatcher ->
|
||||
val decoded = decodeInlineImageDataUrlOffMain(url("image/png", png), dispatcher)
|
||||
assertNotNull(decoded)
|
||||
assertFalse(callerThread === backgroundThread.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SafeMarkdownHighlightedCodeTest {
|
||||
@Test
|
||||
fun malformedMultilineComment_ignoresReversedHighlightRange() {
|
||||
val code = "*/path/*"
|
||||
val unsafeHighlights = builder()
|
||||
.code(code)
|
||||
.build()
|
||||
.getHighlights()
|
||||
|
||||
assertTrue(
|
||||
"The dependency reproducer must contain a reversed range",
|
||||
unsafeHighlights.any { it.location.start > it.location.end },
|
||||
)
|
||||
|
||||
val annotated = buildSafeHighlightedAnnotatedString(
|
||||
code = code,
|
||||
language = null,
|
||||
highlightsBuilder = builder(),
|
||||
)
|
||||
|
||||
assertEquals(code, annotated.text)
|
||||
assertTrue(annotated.spanStyles.all { it.start in 0..it.end && it.end <= code.length })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validCode_keepsSyntaxHighlighting() {
|
||||
val code = "val answer = 42"
|
||||
val annotated = buildSafeHighlightedAnnotatedString(
|
||||
code = code,
|
||||
language = "kotlin",
|
||||
highlightsBuilder = builder(),
|
||||
)
|
||||
|
||||
assertEquals(code, annotated.text)
|
||||
assertTrue(annotated.spanStyles.isNotEmpty())
|
||||
assertTrue(annotated.spanStyles.all { it.start in 0 until it.end && it.end <= code.length })
|
||||
}
|
||||
|
||||
private fun builder(): Highlights.Builder =
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = true))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SessionSourceBadgeTest {
|
||||
@Test
|
||||
fun `dashboard webui sessions are treated as own chats`() {
|
||||
assertNull(sourceBadge("webui"))
|
||||
assertNull(sourceBadge(" WebUI "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `browser web sessions retain their source badge`() {
|
||||
assertEquals("Web", sourceBadge("web")?.label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatHeaderSubtitleTest {
|
||||
@Test
|
||||
fun `active turn status replaces static model metadata`() {
|
||||
assertEquals(
|
||||
"Streaming",
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = true,
|
||||
statusText = "Streaming",
|
||||
projectName = "Hermes Relay",
|
||||
personalityName = "Victor",
|
||||
modelName = "GPT-5.6",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle subtitle retains project personality and model metadata`() {
|
||||
assertEquals(
|
||||
"Hermes Relay \u00B7 Victor \u00B7 GPT-5.6",
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = false,
|
||||
statusText = "Connected",
|
||||
projectName = "Hermes Relay",
|
||||
personalityName = "Victor",
|
||||
modelName = "GPT-5.6",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardManageParityTest {
|
||||
@Test
|
||||
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
|
||||
val oauth = Json.parseToJsonElement(
|
||||
"""{"name":"hosted","url":"https://mcp.example/mcp","transport":"http","auth":"oauth","enabled":true}""",
|
||||
).jsonObject
|
||||
val header = Json.parseToJsonElement(
|
||||
"""{"name":"hosted","url":"https://mcp.example/mcp","transport":"http","auth":"header","enabled":true}""",
|
||||
).jsonObject
|
||||
|
||||
assertTrue(dashboardActionsFor(oauth).any { it.kind == DashboardActionKind.AuthenticateMcp })
|
||||
assertFalse(dashboardActionsFor(header).any { it.kind == DashboardActionKind.AuthenticateMcp })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customEndpointSummary_neverContainsSecretAndRequiresConfirmationForMutations() {
|
||||
val root = Json.parseToJsonElement(
|
||||
"""{"endpoints":[{"id":"local","name":"Local","base_url":"https://llm.example/v1","model":"qwen","has_api_key":true,"api_key_preview":"sk-…1234","is_current":false}]}""",
|
||||
)
|
||||
|
||||
val row = summarizeCustomEndpoints(root).single()
|
||||
|
||||
assertEquals("https://llm.example/v1", row.subtitle)
|
||||
assertFalse(row.meta.orEmpty().contains("sk-"))
|
||||
assertTrue(row.actions.first { it.kind == DashboardActionKind.ActivateCustomEndpoint }.destructive)
|
||||
assertTrue(row.actions.first { it.kind == DashboardActionKind.DeleteCustomEndpoint }.destructive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mcpManagePlumbing_usesEffectiveProfileForListAndRowActions() {
|
||||
assertEquals(
|
||||
"/api/mcp/servers?profile=work%20profile",
|
||||
dashboardSectionRequestPath("/api/mcp/servers", "work profile"),
|
||||
)
|
||||
assertEquals(
|
||||
"/api/providers/custom-endpoints",
|
||||
dashboardSectionRequestPath("/api/providers/custom-endpoints", "work profile"),
|
||||
)
|
||||
val scoped = scopeDashboardManageItems(
|
||||
"/api/mcp/servers",
|
||||
"work profile",
|
||||
listOf(DashboardSummaryItem(id = "hosted", title = "Hosted")),
|
||||
)
|
||||
assertEquals("work profile", scoped.single().profile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oauthCapabilityGate_requiresConfirmedSupportAndNoPendingFlow() {
|
||||
assertFalse(canStartMcpOAuth(capabilitySupported = false, pending = null))
|
||||
assertTrue(canStartMcpOAuth(capabilitySupported = true, pending = null))
|
||||
assertFalse(
|
||||
canStartMcpOAuth(
|
||||
capabilitySupported = true,
|
||||
pending = PendingMcpOAuth("flow-a", "server-a", "work", "connection-a|https://a.example"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissedPendingA_thenRequestedB_keepsDialogBoundToAAndBlocksB() {
|
||||
val pendingA = PendingMcpOAuth("flow-a", "server-a", "work", "connection-a|https://a.example")
|
||||
val requestedB = DashboardSummaryItem(id = "server-b", title = "Server B", profile = "work")
|
||||
|
||||
val resolved = resolveMcpOAuthDialogItem(requestedB, pendingA)
|
||||
|
||||
assertEquals("server-a", resolved?.id)
|
||||
assertEquals("server-a", resolved?.title)
|
||||
assertFalse(canStartMcpOAuth(capabilitySupported = true, pending = pendingA))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionSwitch_holdsPendingFlowWithoutAllowingCrossHostResume() {
|
||||
val routeA = mcpOAuthRouteIdentity(
|
||||
"connection-a",
|
||||
"HTTPS://user:password@Dashboard.Example:443/manage/?ticket=opaque#fragment",
|
||||
)
|
||||
val routeB = mcpOAuthRouteIdentity("connection-b", "https://other.example/")
|
||||
val pendingA = PendingMcpOAuth("opaque-flow-a", "server-a", "work", routeA!!)
|
||||
|
||||
assertEquals("connection-a|https://dashboard.example/manage", routeA)
|
||||
assertFalse(canResumeMcpOAuth(pendingA, routeB))
|
||||
assertTrue(canResumeMcpOAuth(pendingA, routeA))
|
||||
}
|
||||
}
|
||||
@@ -176,4 +176,24 @@ class ModelOptionsParserTest {
|
||||
// Authenticated first; server (canonical) order kept within each group.
|
||||
assertEquals(listOf("z-auth", "b-auth", "a-skel"), options.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledAndExcludedProvidersAreHiddenWhenUpstreamMarksThemExplicitly() {
|
||||
val options = parse(
|
||||
"""
|
||||
{
|
||||
"excluded_providers": ["anthropic"],
|
||||
"providers": [
|
||||
{"slug": "openai", "name": "OpenAI", "models": ["gpt-5.5"], "authenticated": true},
|
||||
{"slug": "anthropic", "name": "Anthropic", "models": [], "authenticated": false},
|
||||
{"slug": "xai", "name": "xAI", "models": ["grok-4"], "enabled": false},
|
||||
{"slug": "local", "name": "Local", "models": ["llama"], "excluded": true},
|
||||
{"slug": "ollama", "name": "Ollama", "models": ["qwen"]}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("openai", "ollama"), options.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RelayErrorClassifierTest {
|
||||
@Test
|
||||
fun gatewayDrainIsNotMisclassifiedAsProviderOutage() {
|
||||
val err = classifyError(
|
||||
IOException("API error 503: gateway_draining: Gateway is shutting down (Retry-After: 1s)"),
|
||||
context = "send_message",
|
||||
)
|
||||
assertEquals("Hermes is restarting", err.title)
|
||||
assertTrue(err.body.contains("draining"))
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectivityErrorsAreClassifiedForSnackbarSuppression() {
|
||||
// The "can't reach the server" family the themed banner owns.
|
||||
|
||||
+239
-2
@@ -139,6 +139,25 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals("victor", gatewayClient.sessionProfileProvider())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewaySessionCreateProviderPreservesExplicitFastFalse() {
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("fast", false) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
while (viewModel.fastEnabled.value != false) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
if (System.currentTimeMillis() >= deadline) error("fast=false did not reconcile")
|
||||
Thread.sleep(20)
|
||||
}
|
||||
|
||||
assertEquals(false, gatewayClient.sessionModelProvider()?.fast)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyConnectionCanSendWithoutApiClient() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
@@ -528,6 +547,224 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedOnlyRecoverySettlesPriorCheckpointBeforeStreamingQueuedTurn() {
|
||||
val now = System.currentTimeMillis()
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = PROFILE_CONTEXT,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("prior-user", "First prompt", now - 3_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "prior-assistant",
|
||||
content = "Old partial",
|
||||
timestamp = now - 2_900L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = now - 2_900L,
|
||||
updatedAt = now,
|
||||
),
|
||||
)
|
||||
persistedHistory = listOf(
|
||||
MessageItem(
|
||||
id = "server-prior-user",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "user",
|
||||
content = JsonPrimitive("First prompt"),
|
||||
),
|
||||
MessageItem(
|
||||
id = "server-prior-assistant",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Completed first answer"),
|
||||
),
|
||||
)
|
||||
gatewayHarness.recoveryRunning = false
|
||||
gatewayHarness.recoveryQueuedUser = "Second queued prompt"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition {
|
||||
handler.messages.value.any {
|
||||
it.role == MessageRole.USER && it.content == "Second queued prompt"
|
||||
} && handler.messages.value.any {
|
||||
it.id.startsWith("gateway-inbound-") && it.isStreaming
|
||||
}
|
||||
}
|
||||
val beforeEvents = handler.messages.value
|
||||
assertTrue(beforeEvents.any {
|
||||
it.role == MessageRole.ASSISTANT &&
|
||||
it.content == "Completed first answer" &&
|
||||
!it.isStreaming
|
||||
})
|
||||
assertFalse(beforeEvents.any { it.id == "prior-assistant" && it.isStreaming })
|
||||
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Second answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
persistedHistory = persistedHistory + listOf(
|
||||
MessageItem(
|
||||
id = "server-queued-user",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Second queued prompt"),
|
||||
),
|
||||
MessageItem(
|
||||
id = "server-queued-assistant",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Second answer"),
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Second answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
awaitCondition {
|
||||
handler.messages.value.count {
|
||||
it.role == MessageRole.ASSISTANT && it.content == "Second answer"
|
||||
} == 1
|
||||
}
|
||||
assertTrue(handler.messages.value.any { it.content == "Completed first answer" })
|
||||
assertFalse(handler.messages.value.any {
|
||||
it.id == "prior-assistant" && it.content.contains("Second answer")
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runningAndQueuedRecoveryCreatesANewPromptAndCheckpointAfterCurrentCompletion() {
|
||||
val now = System.currentTimeMillis()
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = PROFILE_CONTEXT,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("prior-user", "First prompt", now - 3_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "prior-assistant",
|
||||
content = "Current partial",
|
||||
timestamp = now - 2_900L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = now - 2_900L,
|
||||
updatedAt = now,
|
||||
),
|
||||
)
|
||||
gatewayHarness.recoveryRunning = true
|
||||
gatewayHarness.recoveryAssistant = "Current partial"
|
||||
gatewayHarness.recoveryQueuedUser = "Second queued prompt"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition {
|
||||
handler.messages.value.any { it.id == "prior-assistant" && it.isStreaming }
|
||||
}
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Current partial completed") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
handler.messages.value.any {
|
||||
it.role == MessageRole.USER && it.content == "Second queued prompt"
|
||||
} && handler.messages.value.any {
|
||||
it.id.startsWith("gateway-inbound-") && it.isStreaming
|
||||
}
|
||||
}
|
||||
awaitCondition { checkpointStore.checkpoint?.user?.content == "Second queued prompt" }
|
||||
assertTrue(
|
||||
"prior turn was not settled before queued handoff: ${handler.messages.value}",
|
||||
handler.messages.value.any {
|
||||
it.id == "prior-assistant" &&
|
||||
it.content.endsWith("Current partial completed") &&
|
||||
!it.isStreaming
|
||||
},
|
||||
)
|
||||
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Second answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
persistedHistory = listOf(
|
||||
MessageItem(
|
||||
id = "server-prior-user",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "user",
|
||||
content = JsonPrimitive("First prompt"),
|
||||
),
|
||||
MessageItem(
|
||||
id = "server-prior-assistant",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Current partial completed"),
|
||||
),
|
||||
MessageItem(
|
||||
id = "server-queued-user",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Second queued prompt"),
|
||||
),
|
||||
MessageItem(
|
||||
id = "server-queued-assistant",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Second answer"),
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Second answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
awaitCondition { checkpointStore.checkpoint == null }
|
||||
assertEquals(
|
||||
1,
|
||||
handler.messages.value.count {
|
||||
it.role == MessageRole.USER && it.content == "Second queued prompt"
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
1,
|
||||
handler.messages.value.count {
|
||||
it.role == MessageRole.ASSISTANT && it.content == "Second answer"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
@@ -585,7 +822,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
|
||||
@Test
|
||||
fun queuedMessageDrainsAfterUnsolicitedTurnCompletes() {
|
||||
gatewayHarness.steerStatus = "rejected"
|
||||
gatewayHarness.redirectStatus = "rejected"
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
@@ -597,7 +834,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
|
||||
viewModel.sendMessage("Run this next")
|
||||
gatewayHarness.awaitRpc("session.steer")
|
||||
gatewayHarness.awaitRpc("session.redirect")
|
||||
awaitCondition { viewModel.queuedMessages.value == listOf("Run this next") }
|
||||
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class DashboardManageOAuthViewModelTest {
|
||||
@Test
|
||||
fun pendingFlow_survivesRecreationWithOnlyOpaqueNonSecretFields() = runTest {
|
||||
val handle = SavedStateHandle()
|
||||
val first = DashboardManageOAuthViewModel(handle)
|
||||
first.remember("opaque-flow", "hosted", "work", "connection-a|https://dashboard.example")
|
||||
advanceUntilIdle()
|
||||
|
||||
val recreated = DashboardManageOAuthViewModel(handle)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
PendingMcpOAuth("opaque-flow", "hosted", "work", "connection-a|https://dashboard.example"),
|
||||
recreated.pending.value,
|
||||
)
|
||||
val persistedText = handle.keys().associateWith { key -> handle.get<Any?>(key) }.toString()
|
||||
assertFalse(persistedText.contains("authorization_url"))
|
||||
assertFalse(persistedText.contains("code="))
|
||||
assertFalse(persistedText.contains("state="))
|
||||
assertFalse(persistedText.contains("token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAndCapabilityRoutes_areStableAcrossRecreation() = runTest {
|
||||
val handle = SavedStateHandle()
|
||||
val first = DashboardManageOAuthViewModel(handle)
|
||||
first.remember("opaque-flow", "hosted", null, "connection-a|https://dashboard.example")
|
||||
first.markUnsupported("https://dashboard.example|work")
|
||||
first.markSupported("https://dashboard.example|personal")
|
||||
first.clear()
|
||||
advanceUntilIdle()
|
||||
|
||||
val recreated = DashboardManageOAuthViewModel(handle)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(recreated.pending.value)
|
||||
assertTrue("https://dashboard.example|work" in recreated.unsupportedRoutes.value)
|
||||
assertTrue("https://dashboard.example|personal" in recreated.supportedRoutes.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capabilityRoute_changesAreMutuallyExclusive() = runTest {
|
||||
val handle = SavedStateHandle()
|
||||
val viewModel = DashboardManageOAuthViewModel(handle)
|
||||
val route = "https://dashboard.example|work"
|
||||
|
||||
viewModel.markUnsupported(route)
|
||||
viewModel.markSupported(route)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(route in viewModel.supportedRoutes.value)
|
||||
assertFalse(route in viewModel.unsupportedRoutes.value)
|
||||
|
||||
viewModel.markUnsupported(route)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(route in viewModel.supportedRoutes.value)
|
||||
assertTrue(route in viewModel.unsupportedRoutes.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.DashboardEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class EffectiveDashboardRouteTest {
|
||||
|
||||
@Test
|
||||
fun `selected route dashboard wins over explicit primary dashboard`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
)
|
||||
val tailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
dashboard = DashboardEndpoint("http://100.71.8.56:9119"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:9119",
|
||||
resolveEffectiveDashboardUrl(connection, tailscale),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API-only route derives dashboard even when primary dashboard is explicit`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
)
|
||||
val tailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:9119",
|
||||
resolveEffectiveDashboardUrl(connection, tailscale),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit saved dashboard remains fallback when selected route lacks standard surfaces`() {
|
||||
val connection = connection(
|
||||
dashboardUrl = "https://dashboard.example.com",
|
||||
apiServerUrl = "https://api.example.com",
|
||||
)
|
||||
val incompleteRoute = EndpointCandidate(
|
||||
role = "custom",
|
||||
priority = 1,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://dashboard.example.com",
|
||||
resolveEffectiveDashboardUrl(connection, incompleteRoute),
|
||||
)
|
||||
}
|
||||
|
||||
private fun connection(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
) = Connection(
|
||||
id = "connection",
|
||||
label = "Hermes",
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = "",
|
||||
tokenStoreKey = "hermes_auth_connection",
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
+123
@@ -113,4 +113,127 @@ class RelayPairingPersistencePolicyTest {
|
||||
result.routeCandidates.map { it.relay?.url },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relay pairing appends QR routes missing from LAN-only standard connection`() {
|
||||
val lan = EndpointCandidate(
|
||||
role = "lan",
|
||||
priority = 0,
|
||||
dashboard = DashboardEndpoint("http://192.168.1.20:9119"),
|
||||
api = ApiEndpoint("192.168.1.20", 8642),
|
||||
)
|
||||
val current = Connection(
|
||||
id = "hermes-node",
|
||||
label = "Hermes node",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
relayUrl = "",
|
||||
tokenStoreKey = "hermes_auth_node",
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
routeCandidates = listOf(lan),
|
||||
)
|
||||
val relayRoutes = listOf(
|
||||
EndpointCandidate(
|
||||
role = "lan",
|
||||
priority = 0,
|
||||
api = ApiEndpoint("192.168.1.20", 8642),
|
||||
relay = RelayEndpoint("ws://192.168.1.20:8767", "ws"),
|
||||
),
|
||||
EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
relay = RelayEndpoint("ws://100.71.8.56:8767", "ws"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = preserveStandardConnectionWhileApplyingRelay(
|
||||
current = current,
|
||||
relayUrl = "ws://192.168.1.20:8767",
|
||||
relayRoutes = relayRoutes,
|
||||
)
|
||||
|
||||
assertEquals(listOf("lan", "tailscale"), result.routeCandidates.map { it.role })
|
||||
assertEquals("100.71.8.56", result.routeCandidates[1].api?.host)
|
||||
assertEquals(
|
||||
"http://100.71.8.56:9119",
|
||||
result.routeCandidates[1].dashboard?.url,
|
||||
)
|
||||
assertEquals("ws://100.71.8.56:8767", result.routeCandidates[1].relay?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relay pairing repairs an already persisted API-only tailscale route`() {
|
||||
val tailscaleApi = ApiEndpoint("100.71.8.56", 8642)
|
||||
val tailscaleRelay = RelayEndpoint("ws://100.71.8.56:8767", "ws")
|
||||
val current = Connection(
|
||||
id = "hermes-node",
|
||||
label = "Hermes node",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
relayUrl = "ws://192.168.1.20:8767",
|
||||
tokenStoreKey = "hermes_auth_node",
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
routeCandidates = listOf(
|
||||
EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = tailscaleApi,
|
||||
relay = tailscaleRelay,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = preserveStandardConnectionWhileApplyingRelay(
|
||||
current = current,
|
||||
relayUrl = current.relayUrl,
|
||||
relayRoutes = current.routeCandidates,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:9119",
|
||||
result.routeCandidates.single().dashboard?.url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relay pairing fills API and Relay on manually-added dashboard route`() {
|
||||
val tailscaleDashboard = DashboardEndpoint("http://100.71.8.56:9119")
|
||||
val current = Connection(
|
||||
id = "hermes-node",
|
||||
label = "Hermes node",
|
||||
apiServerUrl = "http://192.168.1.20:8642",
|
||||
relayUrl = "ws://192.168.1.20:8767",
|
||||
tokenStoreKey = "hermes_auth_node",
|
||||
dashboardUrl = "http://192.168.1.20:9119",
|
||||
routeCandidates = listOf(
|
||||
EndpointCandidate(
|
||||
role = "lan",
|
||||
priority = 0,
|
||||
dashboard = DashboardEndpoint("http://192.168.1.20:9119"),
|
||||
api = ApiEndpoint("192.168.1.20", 8642),
|
||||
),
|
||||
EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
dashboard = tailscaleDashboard,
|
||||
),
|
||||
),
|
||||
)
|
||||
val qrTailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
relay = RelayEndpoint("ws://100.71.8.56:8767", "ws"),
|
||||
)
|
||||
|
||||
val result = preserveStandardConnectionWhileApplyingRelay(
|
||||
current = current,
|
||||
relayUrl = current.relayUrl,
|
||||
relayRoutes = listOf(qrTailscale),
|
||||
)
|
||||
|
||||
val route = result.routeCandidates.single { it.role == "tailscale" }
|
||||
assertEquals(tailscaleDashboard, route.dashboard)
|
||||
assertEquals(qrTailscale.api, route.api)
|
||||
assertEquals(qrTailscale.relay, route.relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
class StandardSpeechFallbackTest {
|
||||
@Test
|
||||
fun preAudioFallbackAndFailureUseLegacySpeech() {
|
||||
assertTrue(
|
||||
shouldFallbackStandardSpeech(
|
||||
VoiceSpeechStreamOutcome(VoiceSpeechStreamStatus.Fallback, audioStarted = false),
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldFallbackStandardSpeech(
|
||||
VoiceSpeechStreamOutcome(
|
||||
VoiceSpeechStreamStatus.Failed,
|
||||
audioStarted = false,
|
||||
error = IOException("auth expired"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun postAudioDisconnectNeverReplaysWholeReply() {
|
||||
assertFalse(
|
||||
shouldFallbackStandardSpeech(
|
||||
VoiceSpeechStreamOutcome(
|
||||
VoiceSpeechStreamStatus.Failed,
|
||||
audioStarted = true,
|
||||
error = IOException("route changed"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldFallbackStandardSpeech(
|
||||
VoiceSpeechStreamOutcome(VoiceSpeechStreamStatus.Completed, audioStarted = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedStreamWithoutAudioUsesLegacySpeech() {
|
||||
assertTrue(
|
||||
shouldFallbackStandardSpeech(
|
||||
VoiceSpeechStreamOutcome(VoiceSpeechStreamStatus.Completed, audioStarted = false),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Voice Settings design QA
|
||||
|
||||
Status: **passed**
|
||||
|
||||
## Compared
|
||||
|
||||
- Source: `voice-audit/mockup-04-recommended-hybrid.png`
|
||||
- Implementation: `voice-audit/implementation-03-provider-preview-refined.png`
|
||||
- Viewport: Android `Medium_Phone`, 1080 x 2400 px (about 390 dp wide), font scale 1.0
|
||||
- State: Output tab, xAI Grok TTS, Eve selected and actively previewing
|
||||
|
||||
## Resolved findings
|
||||
|
||||
- **P2 - Group boundaries were too weak.** Provider, model/voice, and language/quality surfaces now use the product's rounded Material cards with outline-token borders and a subtle surface-variant fill.
|
||||
- **P2 - Preview state was too tall.** The existing voice waveform now accepts a caller-provided height; inline preview uses a compact 28 dp waveform while the full voice overlay keeps its 56 dp default.
|
||||
- **P2 - Long catalogs displaced the core controls.** The selected/recommended voices remain inline, while the full catalog opens from a compact `View all voices` action.
|
||||
- **P2 - Secondary controls competed with selection.** Latency, fallback, expressive tags, and manual IDs now stay inside the collapsed Language & quality section.
|
||||
- **P3 - Active tab lacked the target emphasis.** The selected segment now uses the theme primary/on-primary colors.
|
||||
|
||||
## Final review
|
||||
|
||||
- Layout and hierarchy match the selected hybrid: summary, Output/Listening/Advanced tabs, grouped provider, grouped model/voice, inline play/stop, active waveform, and collapsed language/quality.
|
||||
- Interactions are wired: tabs switch sections, provider/model/voice selections update the draft, only one preview can play, the active play button becomes Stop, and full catalogs remain accessible.
|
||||
- Accessibility: native Material controls retain semantics and practical tap targets; the active preview has explicit play/stop descriptions; the screen remains vertically scrollable for large text and long provider catalogs.
|
||||
- Accepted native adaptation: Android exposed dropdowns and dynamic account-backed catalogs use more vertical space than the static concept. The hierarchy and core actions remain intact without clipping or overlap.
|
||||
|
||||
No open P0, P1, or P2 findings.
|
||||
@@ -178,6 +178,13 @@ Modes combine: `--purge --service` runs both.
|
||||
- **For the `curl | sh` / `irm | iex` binary install:** no runtime deps — the binary is self-contained. `~/.hermes/bin/` on PATH.
|
||||
- **For local clone + `npm link`:** Node.js ≥21 — needed for the built-in global `WebSocket`. Older Node needs `--experimental-websocket`; we don't support that.
|
||||
|
||||
On Windows, the prebuilt CLI trusts certificates from the Windows system store
|
||||
in addition to its bundled roots. Local Node runs gain the same behavior on
|
||||
Node 22.19 or newer; older supported Node releases keep their existing bundled
|
||||
and `NODE_EXTRA_CA_CERTS` trust behavior because they do not expose the required
|
||||
system-CA APIs. Certificate validation is never disabled, and the Relay's TOFU
|
||||
SPKI pin is still checked after the certificate chain is accepted.
|
||||
|
||||
## First-time pairing
|
||||
|
||||
On the Hermes host, mint a one-time pairing invite:
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"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:win": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-windows-x64 src/cli.ts --outfile dist/bin/hermes-relay-win-x64",
|
||||
"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: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",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface BunSystemCaBootstrapContext {
|
||||
platform: NodeJS.Platform
|
||||
bunVersion?: string
|
||||
nodeUseSystemCa?: string
|
||||
}
|
||||
|
||||
/** Bun reads NODE_USE_SYSTEM_CA only during process startup. */
|
||||
export const shouldRelaunchBunWithWindowsSystemCa = ({
|
||||
platform,
|
||||
bunVersion,
|
||||
nodeUseSystemCa
|
||||
}: BunSystemCaBootstrapContext): boolean =>
|
||||
platform === 'win32' && typeof bunVersion === 'string' && bunVersion.length > 0 && nodeUseSystemCa !== '1'
|
||||
@@ -0,0 +1,42 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { shouldRelaunchBunWithWindowsSystemCa } from './bunSystemCaBootstrap.js'
|
||||
|
||||
// Bun's Windows trust-store switch is startup-only. The released Windows
|
||||
// executable therefore performs one early self-relaunch before importing any
|
||||
// module that can create a TLS context. NODE_USE_SYSTEM_CA=1 keeps Bun's
|
||||
// bundled roots and adds Windows roots for both node:tls and global WebSocket.
|
||||
if (
|
||||
shouldRelaunchBunWithWindowsSystemCa({
|
||||
platform: process.platform,
|
||||
bunVersion: process.versions.bun,
|
||||
nodeUseSystemCa: process.env.NODE_USE_SYSTEM_CA
|
||||
})
|
||||
) {
|
||||
const child = spawnSync(process.execPath, process.argv.slice(2), {
|
||||
env: { ...process.env, NODE_USE_SYSTEM_CA: '1' },
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (child.error) {
|
||||
process.stderr.write(`hermes-relay: could not enable Windows system CA trust: ${child.error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(child.status ?? 1)
|
||||
}
|
||||
|
||||
const { main } = await import('./cli.js')
|
||||
|
||||
main()
|
||||
.then((code) => process.exit(code ?? 0))
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
process.stderr.write(`hermes-relay: ${message}\n`)
|
||||
if (process.env.HERMES_DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error)
|
||||
}
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -24,6 +24,12 @@ import type {
|
||||
SessionInterruptResponse,
|
||||
SessionResumeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import {
|
||||
sessionProjectName,
|
||||
sessionQueuedPromptPreview,
|
||||
sessionResumeActivity,
|
||||
type SessionResumeActivity
|
||||
} from '../gatewayTypes.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
import { renderLogo } from '../lib/logo.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
@@ -31,6 +37,7 @@ import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { deleteSession, saveSession } from '../remoteSessions.js'
|
||||
import { runRelayChatTurn, type RelayChatTurnHandle } from '../relayChat.js'
|
||||
import { CliRenderer } from '../renderer.js'
|
||||
import { SessionResumeDrain } from '../resumeDrain.js'
|
||||
import { fetchRecentSessions, pickSession } from '../sessionPicker.js'
|
||||
import { ensureToolsConsent } from '../tools/consent.js'
|
||||
import { configureComputerUseRuntime } from '../tools/computerGrants.js'
|
||||
@@ -336,17 +343,40 @@ async function resolveHermesSessionId(
|
||||
|
||||
async function createOrResumeSession(
|
||||
gw: GatewayClient,
|
||||
resumeId: string | null
|
||||
): Promise<{ sessionId: string; model: string | null }> {
|
||||
resumeId: string | null,
|
||||
renderer: CliRenderer
|
||||
): Promise<{
|
||||
sessionId: string
|
||||
model: string | null
|
||||
projectName: string | null
|
||||
queuedPrompt: string | null
|
||||
resumeActivity: SessionResumeActivity
|
||||
resumeDrain: Promise<void> | null
|
||||
}> {
|
||||
const cols = process.stdout.columns ?? 80
|
||||
|
||||
if (resumeId) {
|
||||
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
if (!r?.session_id) {
|
||||
throw new Error(`failed to resume session ${resumeId}`)
|
||||
const drain = new SessionResumeDrain(gw, (event) => renderer.handle(event))
|
||||
try {
|
||||
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
if (!r?.session_id) {
|
||||
throw new Error(`failed to resume session ${resumeId}`)
|
||||
}
|
||||
const resumeActivity = sessionResumeActivity(r)
|
||||
const drained = drain.activate(r.session_id, resumeActivity)
|
||||
return {
|
||||
sessionId: r.session_id,
|
||||
model: r.info?.model ?? null,
|
||||
projectName: sessionProjectName(r.info),
|
||||
queuedPrompt: sessionQueuedPromptPreview(r),
|
||||
resumeActivity,
|
||||
resumeDrain: resumeActivity === 'idle' ? null : drained
|
||||
}
|
||||
} catch (error) {
|
||||
drain.cancel()
|
||||
throw error
|
||||
}
|
||||
return { sessionId: r.session_id, model: r.info?.model ?? null }
|
||||
}
|
||||
|
||||
const raw = await gw.request<SessionCreateResponse>('session.create', { cols })
|
||||
@@ -354,7 +384,14 @@ async function createOrResumeSession(
|
||||
if (!r?.session_id) {
|
||||
throw new Error('failed to create session')
|
||||
}
|
||||
return { sessionId: r.session_id, model: r.info?.model ?? null }
|
||||
return {
|
||||
sessionId: r.session_id,
|
||||
model: r.info?.model ?? null,
|
||||
projectName: sessionProjectName(r.info),
|
||||
queuedPrompt: null,
|
||||
resumeActivity: 'idle',
|
||||
resumeDrain: null
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a REPL slash-command line to the right AttachPayload source.
|
||||
@@ -650,17 +687,46 @@ export async function chatCommand(args: ParsedArgs): Promise<number> {
|
||||
return 1
|
||||
}
|
||||
|
||||
let session: { sessionId: string; model: string | null }
|
||||
let session: {
|
||||
sessionId: string
|
||||
model: string | null
|
||||
projectName: string | null
|
||||
queuedPrompt: string | null
|
||||
resumeActivity: SessionResumeActivity
|
||||
resumeDrain: Promise<void> | null
|
||||
}
|
||||
try {
|
||||
session = await createOrResumeSession(gw, resumeId)
|
||||
session = await createOrResumeSession(gw, resumeId, renderer)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
tearDown()
|
||||
return 1
|
||||
}
|
||||
|
||||
if (session.model) {
|
||||
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}… on ${session.model}\n`)
|
||||
if (session.model || session.projectName) {
|
||||
const modelDetail = session.model ? ` on ${session.model}` : ''
|
||||
const projectDetail = session.projectName ? ` · ${session.projectName}` : ''
|
||||
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}…${modelDetail}${projectDetail}\n`)
|
||||
}
|
||||
if (session.resumeActivity === 'running-and-queued') {
|
||||
process.stderr.write('Resumed live turn; the next accepted prompt is queued.\n')
|
||||
} else if (session.resumeActivity === 'running') {
|
||||
process.stderr.write('Resumed live turn still in progress.\n')
|
||||
} else if (session.resumeActivity === 'queued') {
|
||||
process.stderr.write('Resumed session with an accepted prompt waiting to run.\n')
|
||||
}
|
||||
if (session.queuedPrompt) {
|
||||
process.stderr.write(`Queued prompt: ${session.queuedPrompt}\n`)
|
||||
}
|
||||
if (session.resumeDrain) {
|
||||
try {
|
||||
await session.resumeDrain
|
||||
process.stderr.write('Resumed activity completed.\n')
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
tearDown()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
renderer.handle({
|
||||
type: 'session.info',
|
||||
|
||||
@@ -31,6 +31,12 @@ import type {
|
||||
SessionCreateResponse,
|
||||
SessionResumeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import {
|
||||
sessionProjectName,
|
||||
sessionQueuedPromptPreview,
|
||||
sessionResumeActivity,
|
||||
type SessionResumeActivity
|
||||
} from '../gatewayTypes.js'
|
||||
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
|
||||
import { formatError } from '../lib/hints.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
@@ -38,6 +44,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { theme as makeTheme, type Theme } from '../lib/theme.js'
|
||||
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { SessionResumeDrain } from '../resumeDrain.js'
|
||||
import { deleteSession, getSession, listSessions, saveSession } from '../remoteSessions.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
import { openInBrowser, startVoiceServer } from '../voiceServer.js'
|
||||
@@ -437,18 +444,47 @@ function waitForReady(gw: GatewayClient, timeoutMs = READY_TIMEOUT_MS): Promise<
|
||||
async function createOrResumeSession(
|
||||
gw: GatewayClient,
|
||||
resumeId: string | null
|
||||
): Promise<{ sessionId: string; model: string | null }> {
|
||||
): Promise<{
|
||||
sessionId: string
|
||||
model: string | null
|
||||
projectName: string | null
|
||||
queuedPrompt: string | null
|
||||
resumeActivity: SessionResumeActivity
|
||||
resumeDrain: Promise<void> | null
|
||||
}> {
|
||||
const cols = process.stdout.columns ?? 80
|
||||
if (resumeId) {
|
||||
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
if (!r?.session_id) throw new Error(`failed to resume session ${resumeId}`)
|
||||
return { sessionId: r.session_id, model: r.info?.model ?? null }
|
||||
const drain = new SessionResumeDrain(gw)
|
||||
try {
|
||||
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
if (!r?.session_id) throw new Error(`failed to resume session ${resumeId}`)
|
||||
const resumeActivity = sessionResumeActivity(r)
|
||||
const drained = drain.activate(r.session_id, resumeActivity)
|
||||
return {
|
||||
sessionId: r.session_id,
|
||||
model: r.info?.model ?? null,
|
||||
projectName: sessionProjectName(r.info),
|
||||
queuedPrompt: sessionQueuedPromptPreview(r),
|
||||
resumeActivity,
|
||||
resumeDrain: resumeActivity === 'idle' ? null : drained
|
||||
}
|
||||
} catch (error) {
|
||||
drain.cancel()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const raw = await gw.request<SessionCreateResponse>('session.create', { cols })
|
||||
const r = asRpcResult<SessionCreateResponse>(raw)
|
||||
if (!r?.session_id) throw new Error('failed to create session')
|
||||
return { sessionId: r.session_id, model: r.info?.model ?? null }
|
||||
return {
|
||||
sessionId: r.session_id,
|
||||
model: r.info?.model ?? null,
|
||||
projectName: sessionProjectName(r.info),
|
||||
queuedPrompt: null,
|
||||
resumeActivity: 'idle',
|
||||
resumeDrain: null
|
||||
}
|
||||
}
|
||||
|
||||
async function voiceMode(args: ParsedArgs): Promise<number> {
|
||||
@@ -493,7 +529,14 @@ async function voiceMode(args: ParsedArgs): Promise<number> {
|
||||
return 1
|
||||
}
|
||||
|
||||
let session: { sessionId: string; model: string | null }
|
||||
let session: {
|
||||
sessionId: string
|
||||
model: string | null
|
||||
projectName: string | null
|
||||
queuedPrompt: string | null
|
||||
resumeActivity: SessionResumeActivity
|
||||
resumeDrain: Promise<void> | null
|
||||
}
|
||||
try {
|
||||
session = await createOrResumeSession(gw, conversation)
|
||||
} catch (e) {
|
||||
@@ -501,8 +544,30 @@ async function voiceMode(args: ParsedArgs): Promise<number> {
|
||||
await tearDown()
|
||||
return 1
|
||||
}
|
||||
if (session.model) {
|
||||
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}… on ${session.model}\n`)
|
||||
if (session.model || session.projectName) {
|
||||
const modelDetail = session.model ? ` on ${session.model}` : ''
|
||||
const projectDetail = session.projectName ? ` · ${session.projectName}` : ''
|
||||
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}…${modelDetail}${projectDetail}\n`)
|
||||
}
|
||||
if (session.resumeActivity === 'running-and-queued') {
|
||||
process.stderr.write('Resumed live turn; the next accepted prompt is queued.\n')
|
||||
} else if (session.resumeActivity === 'running') {
|
||||
process.stderr.write('Resumed live turn still in progress.\n')
|
||||
} else if (session.resumeActivity === 'queued') {
|
||||
process.stderr.write('Resumed session with an accepted prompt waiting to run.\n')
|
||||
}
|
||||
if (session.queuedPrompt) {
|
||||
process.stderr.write(`Queued prompt: ${session.queuedPrompt}\n`)
|
||||
}
|
||||
if (session.resumeDrain) {
|
||||
try {
|
||||
await session.resumeDrain
|
||||
process.stderr.write('Resumed activity completed.\n')
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
await tearDown()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -62,11 +62,54 @@ export interface SessionCreateResponse {
|
||||
}
|
||||
|
||||
export interface SessionResumeResponse {
|
||||
inflight?: {
|
||||
assistant?: string
|
||||
streaming?: boolean
|
||||
user?: string
|
||||
}
|
||||
info?: SessionInfo
|
||||
message_count?: number
|
||||
messages: GatewayTranscriptMessage[]
|
||||
queued?: { user?: string }
|
||||
resumed?: string
|
||||
running?: boolean
|
||||
session_id: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type SessionResumeActivity = 'idle' | 'queued' | 'running' | 'running-and-queued'
|
||||
|
||||
/** Sanitize untrusted gateway text for a bounded, single-line terminal status. */
|
||||
export function terminalStatusPreview(value: string | undefined, maxChars: number): string | null {
|
||||
const compact = value
|
||||
?.replaceAll(/\x1b\[[?\d;]*[a-zA-Z~]/g, '')
|
||||
.replaceAll(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
||||
.replaceAll(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!compact) return null
|
||||
const limit = Math.max(2, maxChars)
|
||||
return compact.length <= limit ? compact : `${compact.slice(0, limit - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
/** Classify optional live-work fields added by newer vanilla Hermes gateways. */
|
||||
export function sessionResumeActivity(response: SessionResumeResponse): SessionResumeActivity {
|
||||
const running = response.running === true || response.inflight?.streaming === true
|
||||
const queued = typeof response.queued?.user === 'string' && response.queued.user.trim().length > 0
|
||||
if (running && queued) return 'running-and-queued'
|
||||
if (running) return 'running'
|
||||
if (queued) return 'queued'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
/** Return the accepted next-turn prompt as a safe one-line status preview. */
|
||||
export function sessionQueuedPromptPreview(response: SessionResumeResponse, maxChars = 120): string | null {
|
||||
return terminalStatusPreview(response.queued?.user, maxChars)
|
||||
}
|
||||
|
||||
/** Return a displayable project name without requiring newer gateway metadata. */
|
||||
export function sessionProjectName(info: SessionInfo | undefined): string | null {
|
||||
return terminalStatusPreview(info?.project?.name, 80)
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
@@ -139,6 +182,18 @@ export type GatewayEvent =
|
||||
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
|
||||
| { payload?: undefined; session_id?: string; type: 'message.start' }
|
||||
| {
|
||||
payload?: {
|
||||
already_streamed?: boolean
|
||||
message?: string
|
||||
preview?: string
|
||||
rendered?: string
|
||||
response_previewed?: boolean
|
||||
text?: string
|
||||
}
|
||||
session_id?: string
|
||||
type: 'message.interim'
|
||||
}
|
||||
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
|
||||
@@ -175,7 +230,7 @@ export type GatewayEvent =
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
|
||||
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
|
||||
| {
|
||||
payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }
|
||||
payload?: { reasoning?: string; rendered?: string; response_previewed?: boolean; text?: string; usage?: Usage }
|
||||
session_id?: string
|
||||
type: 'message.complete'
|
||||
}
|
||||
|
||||
@@ -103,6 +103,28 @@ export class CliRenderer {
|
||||
return
|
||||
}
|
||||
|
||||
case 'message.interim': {
|
||||
const text = ev.payload?.text ?? ev.payload?.message ?? ev.payload?.preview ?? ev.payload?.rendered
|
||||
if (ev.payload?.already_streamed === true) {
|
||||
if (this.inAssistantMessage && !this.lastDeltaEndedWithNewline) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
this.inAssistantMessage = false
|
||||
this.lastDeltaEndedWithNewline = true
|
||||
} else if (typeof text === 'string' && text.length > 0) {
|
||||
if (!this.inAssistantMessage) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
process.stdout.write(text)
|
||||
if (!text.endsWith('\n')) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
this.inAssistantMessage = false
|
||||
this.lastDeltaEndedWithNewline = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'message.complete': {
|
||||
if (this.inAssistantMessage && !this.lastDeltaEndedWithNewline) {
|
||||
process.stdout.write('\n')
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { GatewayEvent, SessionResumeActivity } from './gatewayTypes.js'
|
||||
|
||||
interface GatewayEventSource {
|
||||
on(event: 'event', listener: (event: GatewayEvent) => void): unknown
|
||||
off(event: 'event', listener: (event: GatewayEvent) => void): unknown
|
||||
}
|
||||
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 300_000
|
||||
const MAX_BUFFERED_EVENTS = 512
|
||||
|
||||
/**
|
||||
* Attach before `session.resume`, then drain every already-accepted turn to a
|
||||
* terminal boundary before a CLI surface is allowed to submit another prompt.
|
||||
*/
|
||||
export class SessionResumeDrain {
|
||||
private readonly buffered: GatewayEvent[] = []
|
||||
private readonly listener: (event: GatewayEvent) => void
|
||||
private readonly promise: Promise<void>
|
||||
private resolvePromise!: () => void
|
||||
private rejectPromise!: (error: Error) => void
|
||||
private targetSessionId: string | null = null
|
||||
private remainingTurns = 0
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private settled = false
|
||||
|
||||
constructor(
|
||||
private readonly source: GatewayEventSource,
|
||||
private readonly onEvent?: (event: GatewayEvent) => void,
|
||||
private readonly idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS
|
||||
) {
|
||||
this.promise = new Promise<void>((resolve, reject) => {
|
||||
this.resolvePromise = resolve
|
||||
this.rejectPromise = reject
|
||||
})
|
||||
this.listener = (event) => this.observe(event)
|
||||
source.on('event', this.listener)
|
||||
}
|
||||
|
||||
activate(sessionId: string, activity: SessionResumeActivity): Promise<void> {
|
||||
if (this.targetSessionId !== null) {
|
||||
throw new Error('resume activity drain already activated')
|
||||
}
|
||||
this.targetSessionId = sessionId
|
||||
this.remainingTurns = activity === 'running-and-queued' ? 2 : activity === 'idle' ? 0 : 1
|
||||
if (this.remainingTurns === 0) {
|
||||
this.finish()
|
||||
return this.promise
|
||||
}
|
||||
this.armTimer()
|
||||
const pending = this.buffered.splice(0)
|
||||
pending.forEach((event) => this.observe(event))
|
||||
return this.promise
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.finish()
|
||||
}
|
||||
|
||||
private observe(event: GatewayEvent): void {
|
||||
if (this.settled) return
|
||||
if (this.targetSessionId === null) {
|
||||
if (this.buffered.length < MAX_BUFFERED_EVENTS) this.buffered.push(event)
|
||||
return
|
||||
}
|
||||
if (event.session_id !== this.targetSessionId) return
|
||||
this.onEvent?.(event)
|
||||
this.armTimer()
|
||||
if (event.type === 'message.complete' || event.type === 'error') {
|
||||
this.remainingTurns -= 1
|
||||
if (this.remainingTurns <= 0) this.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private armTimer(): void {
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.finish(new Error(`resumed activity idle timeout after ${this.idleTimeoutMs}ms`))
|
||||
}, this.idleTimeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
private finish(error?: Error): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
this.buffered.length = 0
|
||||
this.source.off('event', this.listener)
|
||||
if (error) this.rejectPromise(error)
|
||||
else this.resolvePromise()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user