Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97231eb291 | ||
|
|
f7c707be03 | ||
|
|
331fad0827 | ||
|
|
f131fa08cc | ||
|
|
cbc81513bd | ||
|
|
3ad5ad8dc7 | ||
|
|
d695553c0b | ||
|
|
6b324fa822 | ||
|
|
52ee97f2b5 | ||
|
|
76bfe97c78 | ||
|
|
c8a3072704 | ||
|
|
a7612d7f05 | ||
|
|
e82ed47573 | ||
|
|
c6e4a2877d | ||
|
|
79335fea16 | ||
|
|
9c8b6c30bf | ||
|
|
4cf89df627 | ||
|
|
ad98ca9486 | ||
|
|
9d50f401ad | ||
|
|
2bc62c84e0 | ||
|
|
c3011e286c | ||
|
|
9458fea4f7 | ||
|
|
94b29c4a01 | ||
|
|
3d11cfb5f2 | ||
|
|
0f589d030f | ||
|
|
af96579e06 | ||
|
|
f0167ee00f | ||
|
|
a5798e5e6c | ||
|
|
479d788967 | ||
|
|
3f6723da6f | ||
|
|
c1cd376d8b | ||
|
|
87e2fb710d | ||
|
|
5a617f9482 | ||
|
|
13152a4fab | ||
|
|
7f31c88f46 | ||
|
|
3e857b54a2 | ||
|
|
163e3341da | ||
|
|
a5419df579 | ||
|
|
e834c603d0 | ||
|
|
a31d715569 | ||
|
|
c6b8d891ac | ||
|
|
8996f34347 | ||
|
|
f5f1a4c7d4 | ||
|
|
31eccc631e | ||
|
|
64ba979816 | ||
|
|
5be0979d74 | ||
|
|
80b1a3b6df | ||
|
|
257a11ffa2 | ||
|
|
62f3572e11 | ||
|
|
621e526c7d | ||
|
|
a5afec36d9 | ||
|
|
29bf9a08ac | ||
|
|
342e977594 | ||
|
|
24e767e7ae | ||
|
|
fdf210ec2b | ||
|
|
d05ab31296 | ||
|
|
0e0cc16f47 | ||
|
|
e90be03f2e | ||
|
|
f1e4fdcc91 | ||
|
|
5b8b3da350 | ||
|
|
447ec356d7 | ||
|
|
ab359e5efb | ||
|
|
86b8161cb7 | ||
|
|
e401fdb0c7 | ||
|
|
d4325d5aab | ||
|
|
c734d75484 | ||
|
|
0411804780 | ||
|
|
8f89c2841d | ||
|
|
933c1842a0 | ||
|
|
dfe1b53327 | ||
|
|
d36580a983 | ||
|
|
2d178ff884 | ||
|
|
d61955f82a | ||
|
|
adec6ed2c0 | ||
|
|
e9e44c8bb2 | ||
|
|
c6b0732a02 | ||
|
|
d919115788 | ||
|
|
49da085ae8 | ||
|
|
889b6f0858 | ||
|
|
de9211b7c5 | ||
|
|
7ca5c61be5 |
@@ -67,7 +67,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
working-directory: website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
bun-version-file: 'desktop/.bun-version'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
@@ -153,6 +153,40 @@ jobs:
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
|
||||
smoke-windows-cli-release-asset:
|
||||
name: Smoke exact Windows CLI release asset
|
||||
runs-on: windows-latest
|
||||
needs:
|
||||
- validate-release
|
||||
- build-cli-binaries
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Repeated launch and process cleanup gate
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$exe = (Resolve-Path 'release-assets/hermes-relay-win-x64.exe').Path
|
||||
1..20 | ForEach-Object {
|
||||
$output = & $exe --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "Windows CLI smoke failed with exit $LASTEXITCODE" }
|
||||
if ($output -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
|
||||
throw "Unexpected Windows CLI version output: $output"
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
$leftovers = Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.ExecutablePath -eq $exe
|
||||
}
|
||||
if ($leftovers) {
|
||||
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
|
||||
}
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
@@ -175,7 +209,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
bun-version-file: 'desktop/.bun-version'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -375,6 +409,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- smoke-windows-cli-release-asset
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -6,19 +6,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.10.0] - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Android preserves composer drafts across app restarts.** Text, quote/edit context, and pending attachments remain scoped to their exact connection, profile, and session in bounded app-private no-backup storage, and successful sends remove the saved draft.
|
||||
- **Android can turn large pastes into reviewable text attachments.** The default-on Chat setting converts inserts of at least 5,000 characters into a compact attachment while preserving surrounding text; Gateway uploads the file through upstream Hermes and fallback transports retain the pasted content as text.
|
||||
- **Android renders Markdown incrementally while replies stream.** The native streaming parser retains stable message, selection, and AST identities from the first token through completion, including provisional paragraphs, lists, links, fenced code, and tables.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Android software keyboard exposes Return in the multiline composer.** The dedicated composer button sends, while physical Enter, Shift+Enter, and caret-arrow behavior remain unchanged. (#367)
|
||||
- **Open chats reattach after Android returns to the foreground.** Gateway reconnect restores the visible session subscription and reconciles missed work without requiring the user to leave and reopen the conversation. (#365)
|
||||
- **Imported credentials fail closed before network or secure-state mutation.** Control characters and malformed values are rejected before header construction or encrypted-state replacement without logging credential material.
|
||||
- **Streaming follow remains stable through completion.** Deliberate scrollback stays untouched, bottom-follow uses one bounded owner, and Markdown, voice actions, timestamps, and token metadata settle without rebuilding the bubble or resetting its scroll anchor. (#341)
|
||||
|
||||
## [1.9.1] - 2026-08-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Android adopts Hermes-owned profile creation, shared avatars, and animated pets.** Current Gateways provide the profile roster, explicit shared/copied/isolated authentication choices, partial create outcomes, validated avatar upload/fetch/clear, and profile-scoped pet selection that follows the agent across supported Hermes clients. Older hosts retain authenticated Dashboard creation plus Relay/local presentation fallbacks, and profile deletion remains Dashboard-only.
|
||||
- **Android identifies proactive messages delivered after reconnect.** Relay marks messages flushed from its bounded offline queue, Thread bubbles label them as received “While away,” and Android shows one accessible localized summary for the completed batch.
|
||||
- **Android can create finite recurring schedules from Manage.** The native editor uses the authenticated Hermes Gateway `cron.manage` contract, optionally stops after 1–999 runs, and rejects invalid counts rather than silently creating unlimited work.
|
||||
- **Chat resets retain content-free local evidence.** New-chat and Thread transitions save a bounded app-private checkpoint for user-reviewed Diagnostics without prompts, message text, IDs, profile names, paths, URLs, media, tool payloads, secrets, or telemetry.
|
||||
- **Android surfaces host resource risk before chat state is lost.** Current Hermes Dashboard memory and disk pressure signals render as a persistent, capability-gated warning; older hosts remain unchanged and no telemetry is added.
|
||||
- **Android honors Hermes model-selection safeguards.** Every Gateway model transition, including fresh-chat and Server-default choices, now avoids raw session overrides; picks requiring cost or data-training consent show Hermes' exact warning and apply only after a confirmed second request.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Profile identity sources are explicit in Agent Passport.** Server-owned static avatars and upstream pets follow the Hermes profile, while phone picks, Relay-host imports, phone-only animated icons, and Sphere skins remain separate local presentation choices.
|
||||
- **Interactive Gateway asks remain resolver-bound.** Android continues to use upstream clarify, approval, sudo, and secret response RPCs; connector-only prompt/reaction operations are not copied into Relay cards as a second approval protocol.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shared avatar picks now persist from Android's filesystem picker.** The app accepts any image Android can decode, applies display orientation, and safely resizes or re-encodes it to the upstream PNG/JPEG/WebP and 2,000,000-byte contract. Successful writes update the local shared cache immediately, and upload failures remain visible beside the control.
|
||||
|
||||
- **Nous-hosted Android sign-in follows the official native broker contract.** The gateway now selects its native provider exactly as Hermes Desktop does, callback attempts retain the upstream five-minute window, and post-callback failures explain whether the one-time code, hosted gateway, network, response, or secure storage prevented session creation without exposing auth material.
|
||||
- **Android edit-and-regenerate fails closed on incomplete durable history.** Mixed Gateway transcripts now require the selected message's durable row identity instead of attempting an ordinal-only rewind, while older Hermes histories with no row identities remain editable.
|
||||
- **Android fails closed when a Gateway does not confirm the selected profile.** Named-profile session creation and recovery now require Hermes to echo the exact owning profile, preventing stale or older gateways from silently running the launch profile under another agent's identity. Profile inspection also keeps read-only Gateway data available when `profiles.configure` is unsupported while disabling further write attempts without discarding drafts.
|
||||
- **Android attachment sends are bounded and fail closed.** Picked files are size-limited while streaming into the encoder, cold and queued Gateway sends upload only after the exact session is ready, and an unsupported or interrupted document upload no longer falls through to a text-only route while its file card implies delivery. Every attachment type retains the same compact collapse/expand affordance.
|
||||
|
||||
## [0.4.0-beta.4] - 2026-08-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Windows management UI remains available while the daemon is stopped.** Missing, stale, malformed, or temporarily unavailable daemon status now resolves to an explicit stopped state instead of trapping the tray on its loading screen, so configuration, diagnostics, host management, and daemon controls remain accessible.
|
||||
|
||||
## [1.9.0] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Android session browsing matches Hermes Desktop's recent organization model.** The primary session drawer can toggle between the active profile and all profiles, group by recency, project, status, or profile, order by supported session metrics, and narrow rows by status, project, profile, or pull-request state without collapsing duplicate IDs across profile stores. Named profiles receive stable identity-color badges with locally persisted color overrides.
|
||||
- **Android can edit current Hermes profiles through the standard Gateway.** The Profile Inspector capability-gates `profiles.describe` and `profiles.configure`, keeps Relay-only memory editing and older-Hermes fallback intact, and reports partial section saves without discarding failed drafts.
|
||||
- **Android sessions show their coding context when Hermes supplies it.** Session rows can display repository, Git branch, and the current state of the pull request created by that session while older hosts remain unchanged.
|
||||
- Android Manage can now finish host-owned backup workflows, edit or remove learning nodes with explicit recovery guidance, configure and activate memory providers, and complete profile-scoped WhatsApp QR onboarding through the authenticated upstream Dashboard contracts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android network clients shut down safely during route changes.** Replacing an authenticated Dashboard client now moves OkHttp connection-pool eviction off the main thread, preventing a live TLS socket close from crashing the app with `NetworkOnMainThreadException`. (#334)
|
||||
- **Android preserves authoritative Gateway outcomes.** Protected-file cards cannot offer forbidden persistent scopes, compression no-ops show the server result, bounded resume failures do not create context-free replacement sessions, and edit/regenerate retains durable row identities across consecutive rewinds.
|
||||
- **Android routes and uploads against live upstream truth.** Multiplex API fallback trusts `served_profiles` instead of installed profiles, and generic documents carry the Gateway-issued `@file:` reference into ordinary and queued prompts.
|
||||
- **Android clarify cards preserve upstream decision semantics.** Multi-select prompts keep independent selections and submit one exact list, while server expiry events—not an invented local deadline—retire unanswered cards.
|
||||
- **Android keeps profile management and retained automation truthful.** Custom Endpoint list and mutation routes now follow the selected Hermes profile, while completed one-shot cron jobs show their retained outcome and expose only valid Runs/Delete actions.
|
||||
- **Android and Relay recover more generated media reliably.** Android accepts upstream-valid wrapped, punctuated, adjacent, spaced, and Windows `MEDIA:` markers without consuming fenced examples, and Relay translates Docker-visible workspace, home, cache, and configured-mount paths before applying its existing credential, sandbox, and size checks.
|
||||
- **Android keeps cross-profile sessions with their owning agent.** Opening a session from All Profiles hydrates, resumes, sends, and renders with that session's profile without changing the global profile selection; New Chat from that view starts with the default profile.
|
||||
- **Android reactions and standard voice follow the active conversation.** Reactions resolve durable rows for both user and assistant messages, while Vanilla Hermes voice remains on the authenticated Gateway instead of requiring the optional API fallback.
|
||||
- **Android session navigation behaves predictably.** The drawer closes on outside taps, uses an ungrouped recent-session list by default, retains project grouping as an explicit option, and exposes secondary actions in All Profiles mode.
|
||||
|
||||
## [0.4.0-beta.3] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows tray polling can no longer accumulate unbounded helper processes.** Grant discovery now uses lightweight local state, management refreshes are single-flight and visibility-aware, and child probes have hard timeouts, bounded output, tree cleanup, caching, and backoff. A dedicated bounded `tray.log` records sanitized operational failures without mixing them into daemon logs.
|
||||
- **Concurrent Desktop lifecycle requests cannot start duplicate daemons.** Cross-process lifecycle and runtime ownership locks serialize startup and recovery while preserving stale-owner cleanup.
|
||||
|
||||
## [1.8.0] - 2026-08-14
|
||||
|
||||
|
||||
+4
-14
@@ -1,27 +1,17 @@
|
||||
# Hermes-Relay CLI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-14
|
||||
**Release Date:** 2026-08-15
|
||||
|
||||
This beta makes connection recovery and Activity evidence inspectable in the compact management UI, and keeps the preferred CUA control engine usable when its upstream whole-desktop accessibility probe times out.
|
||||
This patch keeps the Windows management UI usable when the Relay daemon is stopped or its status cannot be read.
|
||||
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Inspectable Activity evidence.** Commands, files, device work, connection lifecycle, and computer control share a consistent event stepper with dedicated failure details.
|
||||
- **Optional screenshot retention.** Screenshot events can keep bounded local PNG evidence for Off, 1 day, 7 days, or 30 days and open it in a larger borderless viewer. Evidence stays outside the JSON activity log.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connection state is live and actionable.** The UI distinguishes connected, reconnecting, and stopped states, shows retry timing, and offers Retry now without freezing the popup.
|
||||
- **Connection notices stay out of the way.** Compact connect, disconnect, and reconnect cards appear only while the main management UI is hidden.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CUA readiness no longer depends on the flaky global accessibility scan.** Hermes verifies the canonical runtime, required tools, daemon, and safe permission mode before structured control; explicit accessibility health remains available for diagnosis and individual actions still fail closed.
|
||||
- **Connection errors retain useful context.** Activity records bounded retry and recovery evidence without flooding one event per backoff attempt.
|
||||
- **Stopped daemons no longer block the management UI.** Missing, stale, malformed, or temporarily unavailable daemon status falls back to an explicit stopped state while hosts, settings, activity, CLI details, diagnostics, and daemon controls continue loading normally.
|
||||
- **Starting the daemon restores live status without reopening the UI.** A valid running status continues through the same bounded, single-flight snapshot path introduced in beta.3.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-08-17 — Android composer continuity and large-paste review
|
||||
|
||||
Android's multiline composer now leaves the software IME action as Return while
|
||||
the dedicated trailing button sends. Physical keyboard Enter, Shift+Enter, and
|
||||
directional caret behavior retain their existing contracts.
|
||||
|
||||
Composer drafts now persist in bounded app-private no-backup storage using
|
||||
small owner metadata plus content-addressed attachment blobs. Draft ownership
|
||||
follows the exact connection, opened session profile, session, and draft slot;
|
||||
profile and connection switches save before restoring, lifecycle stop flushes
|
||||
the latest state, and successful sends remove the saved draft.
|
||||
|
||||
A default-on Chat setting converts a single insertion of at least 5,000
|
||||
characters into a reviewable text attachment. Preparation runs off the UI
|
||||
thread behind a visible loading card. Current Gateways send it through upstream
|
||||
`file.attach`; API-server SSE and proactive Thread paths materialize the same
|
||||
UTF-8 content into the prompt so no route silently loses the paste.
|
||||
|
||||
## 2026-08-14 — Android 1.9.0 session identity and conversation controls
|
||||
|
||||
Hermes-Relay Android 1.9.0 is published from the immutable
|
||||
`android-v1.9.0` tag. Multi-profile session browsing now keeps the aggregate
|
||||
drawer scope selected while transcript hydration, resume, sending, and header
|
||||
identity follow the session's owning profile. New Chat from All Profiles uses
|
||||
the default profile, and the session list starts ungrouped while retaining
|
||||
project grouping and the other desktop-style views as explicit options.
|
||||
|
||||
Message reactions now resolve durable rows for both user and assistant
|
||||
messages. Vanilla Hermes voice remains on the authenticated Gateway instead of
|
||||
requiring the optional API fallback. Session rows can expose profile, project,
|
||||
branch, and pull-request context without crowding the chat header, secondary
|
||||
drawer actions remain available in All Profiles, and outside taps dismiss the
|
||||
drawer.
|
||||
|
||||
## 2026-08-09 — Gateway activity recovery and chat speech
|
||||
|
||||
Successful Android Gateway turns now reconcile against their profile-owned,
|
||||
|
||||
@@ -128,6 +128,7 @@ optional Windows installer.
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `desktop/package.json` | canonical CLI version |
|
||||
| `desktop/.bun-version` | exact Bun compiler/runtime for standalone binaries |
|
||||
| `desktop/package-lock.json` | npm root/workspace package metadata |
|
||||
| `desktop/src/version.ts` | compiled CLI runtime version |
|
||||
| `desktop/tray/Cargo.toml` | native systray package version |
|
||||
@@ -152,6 +153,8 @@ manually, run `npm run sync:version` before checking. `npm run verify` is the
|
||||
single Windows release-parity gate: version sync, type-check, tests, TypeScript
|
||||
build, compiled CLI smoke, and tray formatting, Clippy, check, and tests. CI runs
|
||||
the portable portions on every desktop change and the Windows tray gates separately.
|
||||
Release jobs read `desktop/.bun-version`; cross-built and Windows-built artifacts
|
||||
must not silently embed different Bun runtime versions.
|
||||
|
||||
## Branching policy
|
||||
|
||||
|
||||
+29
-17
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.8.1
|
||||
# Hermes-Relay-Android v1.10.0
|
||||
|
||||
**Release Date:** August 9, 2026
|
||||
**Release Date:** August 18, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.8.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.10.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,23 +12,35 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch keeps long Hermes conversations complete and aligns Android's
|
||||
Gateway behavior with current upstream turn contracts.
|
||||
This release makes Android chat more continuous: drafts survive restarts, large
|
||||
pastes become reviewable attachments, live replies render with incremental
|
||||
Markdown, and foreground reconnect or completion no longer disrupts the open
|
||||
conversation.
|
||||
|
||||
## Added
|
||||
|
||||
- Preserve text, quote/edit context, and pending attachments in the exact
|
||||
connection, profile, and session draft across app restarts.
|
||||
- Convert large pastes into reviewable text attachments before sending while
|
||||
retaining compatible text delivery on fallback transports.
|
||||
- Render paragraphs, lists, links, fenced code, and tables incrementally from
|
||||
the first streamed token without replacing the message at completion.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Complete transcript reads page explicitly across both API-server and
|
||||
profile-scoped Dashboard routes, so sessions beyond Hermes' latest-500
|
||||
default retain stable history, sharing, retry, edit, and recovery anchors.
|
||||
- Gateway submit rejections preserve the authoritative server message without
|
||||
silently falling through to SSE.
|
||||
- Gateway event envelopes reconcile consistently, and edit-and-regenerate
|
||||
requests send the required truncation confirmation.
|
||||
- Reattach the visible Gateway session after background/foreground reconnect
|
||||
and reconcile missed work without leaving the conversation.
|
||||
- Expose Return on the software keyboard while keeping the dedicated Send
|
||||
action and physical-keyboard behavior distinct.
|
||||
- Reject malformed imported credentials before network-header construction or
|
||||
encrypted-state replacement.
|
||||
- Keep intentional scrollback fixed and bottom-follow stable while Markdown,
|
||||
voice actions, timestamps, and token metadata settle.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.8.1** (versionCode **42**).
|
||||
- Standard Chat, sessions, Manage, and Vanilla Hermes voice continue to work
|
||||
against unmodified upstream Hermes.
|
||||
- The optional Relay plugin is not required for standard Android chat or hosted
|
||||
Dashboard authentication.
|
||||
- App version: **1.10.0** (versionCode **45**).
|
||||
- Standard Chat, sessions, Manage, profile identity, streaming Markdown, and
|
||||
Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- The optional Relay plugin is not required for standard Android chat,
|
||||
foreground session reattachment, or streaming Markdown.
|
||||
|
||||
@@ -954,7 +954,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
|
||||
|
||||
**Refinement (2026-06-29) — unified-session model: "Threads."** Going further on "unified surface": the agent conversation is **not a separate tab/segment** at all — it is a **source-tagged session inside the one Chat surface**, a **Thread** (`source=phone`). What makes a Thread special vs. a normal gateway chat are *session properties*, not a separate UI: (a) the agent can initiate, (b) relay `proactive` transport + relay-gated, (c) standing/named DM. **Scrollback = the gateway session store** (same read path Chat uses); **live receive = relay `proactive` push** (→ notification); **send = `proactive.reply`**. `ProactiveInboxStore` is demoted to a live-push cache + outbox (no parallel history). The Thread capability shows in the **best-path/capability UI** (relay tier, like terminal/bridge/voice) and as a clean **Threads** entry — thread-spool icon, NOT a phone glyph — pinned atop the session drawer when active; never a connection-wizard step. Degrades cleanly (no plugin → no `source=phone` sessions → Chat unchanged). **Supersedes the "separate Agent lane / 4th nav segment" sketch** and merges with the "source attribution in Chat" goal below. Keep the two "gateway" senses straight: *platform layer* (the Thread's `source`) ≠ *dashboard `/api/ws` transport* (how live bytes flow). Full re-cut: docs/decisions.md ADR 12.
|
||||
|
||||
- **Outbound buffering — ✅ relay-side DONE (2026-06-29).** `ProactiveChannel.push()` now queues agent→phone messages in a bounded deque (drop-oldest, 24 h TTL) when no phone is subscribed and returns `{queued: true}` (not 503); `_flush_outbound` delivers FIFO on the next subscribe (stale pruned). Inspect/cancel via `peek_outbound`/`cancel_outbound` + loopback `GET`/`DELETE /phone/outbound`. **UI surfacing of the queued state** (host-side, since the queue exists while the phone is OFFLINE): (a) ✅ **desktop CLI `relay queue` / `relay queue --clear` / `--cancel <id>` DONE (2026-06-29)** over the new endpoints (loopback-only — run on the relay host); a dashboard Relay-tab view is the optional GUI equivalent; (b) **remaining** — in the threaded agent surface, mark messages that arrived-while-away, and show the user's OWN pending replies (the Phase 3 reply queue) with a sending/Cancel affordance — that's where phone-side "queued + cancel" belongs.
|
||||
- **Outbound buffering — ✅ relay-side + arrived-while-away receive UX DONE.** `ProactiveChannel.push()` queues agent→phone messages in a bounded deque (drop-oldest, 24 h TTL) when no phone is subscribed and returns `{queued: true}`. `_flush_outbound` delivers FIFO on the next subscribe (stale pruned), marks flushed messages, and sends one batch-complete count; Android labels those Thread bubbles “While away” and shows one accessible batch summary without changing unread behavior. Inspect/cancel remains available through `peek_outbound`/`cancel_outbound`, loopback `GET`/`DELETE /phone/outbound`, and desktop `relay queue`. **Remaining:** show the user's OWN pending replies (the Phase 3 reply queue) with an honest Queued/Cancel affordance; a dashboard queue view remains optional.
|
||||
- **Threads surface (unified-session model — see ADR 12 + the Refinement above).** Build order, each shippable: **(1)** source tags in the session drawer (`source=phone` → clean **Threads** chip + thread-spool icon, NOT a phone glyph) — also delivers the "source attribution in Chat" goal; **(2)** open a Thread in Chat from its session-store history (reuse the existing message-history path); **(3)** route the live `proactive` push into the session view + notification + unread, demoting `ProactiveInboxStore` to cache/outbox; **(4)** reply from the Chat composer via `proactive.reply` + persist the user turn + local `Sending/Queued/Failed` status — **MVP**; **(5)** a **Threads capability row** in the best-path UI + a pinned **Threads** entry atop the drawer (thread-spool icon, shown only when relay-paired + opted-in) + retire `HermesInboxScreen`, re-point the notification deep-link + Settings "View messages"; **(6)** outbox/retry on reconnect; **(7)** relay `proactive.reply.ack` (honest Delivered) + `proactive.cancel`; **(8)** multi-thread `chat_id` (named/project Threads). **Verify gate before (1):** confirm the app's session-list/history path surfaces a `source=phone` session cleanly (upstream `session.list` returns all sources flat, so it should — but check whether the drawer currently filters it out). Honesty call: do NOT show "Delivered" until (7) lands (can't confirm it client-side before the ack).
|
||||
- **Status (2026-06-29, implemented UNBUILT — verify in Studio):** **CODE-COMPLETE on `dev`:** slice **1** (drawer source tags + `ThreadSpoolGlyph` + Threads filter), **2** (open a Thread from history — free via the existing `loadSessionHistory` path), **3-parse** (carry `reply_to` on `ProactiveMessage`), **4** (composer reply in a `source=phone` session routes over `proactive.reply`; `MessageDeliveryStatus` SENDING→DELIVERED/FAILED on the bubble), **5** (Threads capability row in `SessionPathCard` + `threadsCapabilityActive` drawer wiring), **7** (relay `proactive.reply.ack` + `proactive.cancel` — 25/25 `unittest` green — and client ack handling). **DONE since (2026-06-29, built + on phone):** live **in-thread reply rendering**; **user-created named Threads** ("+ New Thread"); **retire `HermesInboxScreen`**; relay slice-7 ack/cancel **DEPLOYED** to the host so **"Delivered" is live**. **DONE (2026-08-14):** notification taps survive cold start and open the exact `chat_id`; agent-initiated outbound messages appear as connection-scoped provisional Threads backed by the bounded proactive store, then promote to the real `source=phone` session after the first reply. **DEFERRED:** per-session **unread badge**; **outbox/retry** (needs multiplexer connection-state); **agent-initiated** named Threads (upstream `send_message` thread param). On-device verifies for the create-flow: fresh-`chat_id` auto-create, the `…:dm:<chat_id>` id form, `renameSession` on a phone session.
|
||||
- **User-created Threads (slice 8, Discord-style) — CODE-COMPLETE on `dev` (built + installed 2026-06-29; on-device behavior pending).** "+ New Thread" in the drawer's Threads view → name dialog → `ChatViewModel.startNewThread` mints a fresh `chat_id`; the first composer message opens it over `proactive.reply` (gateway auto-creates the `source=phone` session) → `switchToCreatedThread` polls + switches to the real session + applies the name. Existing-thread replies route by the `chat_id` parsed from the session id (`…:dm:<chat_id>`; opaque id → home fallback). **On-device verifies:** (1) a fresh-`chat_id` no-`reply_to` inbound creates a new `source=phone` session; (2) the phone session id carries the `…:dm:<chat_id>` form the client parses; (3) `renameSession` titles a phone session. **Remaining slice-8:** AGENT-initiated named Threads (the upstream `send_message` thread/chat_id param so the agent can open its own named Threads).
|
||||
|
||||
@@ -319,6 +319,7 @@ dependencies {
|
||||
|
||||
// Coil 3 — async image loading for generated images in chat
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.exifinterface)
|
||||
|
||||
@@ -371,8 +372,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.71.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.71.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.72.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
@@ -1 +1 @@
|
||||
Long sessions now retain complete history beyond Hermes' latest-500 default, keeping edit, retry, sharing, and recovery anchors stable. Gateway submit rejections preserve the server's message without unintended SSE fallback, while event envelopes and edit-and-regenerate requests follow current upstream contracts.
|
||||
See Markdown take shape while replies stream without a final message rebuild or scroll jump. Return from another app and resume the open Hermes session automatically. Composer drafts and pending attachments now survive restarts, large pastes become reviewable text attachments, and the software keyboard exposes Return while the dedicated button sends.
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.10.0",
|
||||
"title": "Chat that stays put",
|
||||
"date": "2026-08-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Watch replies take shape",
|
||||
"bullets": [
|
||||
"Render paragraphs, lists, links, fenced code, and tables incrementally without replacing the message at completion.",
|
||||
"Keep bottom-follow smooth while intentional scrollback remains exactly where you left it."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Pick up where you left off",
|
||||
"bullets": [
|
||||
"Resume the visible Hermes session automatically after returning from another app.",
|
||||
"Restore composer text, quote or edit context, and pending attachments in the correct conversation after an app restart."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Review before sending",
|
||||
"bullets": [
|
||||
"Turn large pastes into compact text attachments while preserving compatible fallback delivery.",
|
||||
"Use Return on the software keyboard while the dedicated composer button remains the Send action."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.9.1",
|
||||
"title": "Profile identity that sticks",
|
||||
"date": "2026-08-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Identity follows the right scope",
|
||||
"bullets": [
|
||||
"Change shared avatars from Android with automatic orientation, resizing, and safe conversion to the Hermes profile-asset contract.",
|
||||
"Select upstream animated pets that follow the Hermes profile while phone-only animated icons, local avatar overrides, and Sphere skins stay local."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Profile setup stays explicit",
|
||||
"bullets": [
|
||||
"Create profiles with clear shared, copied, or isolated authentication choices and see partial setup outcomes.",
|
||||
"Named-profile sessions and profile drafts fail closed when Hermes cannot confirm their owner."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Safer Gateway operations",
|
||||
"bullets": [
|
||||
"Attachments, rewinds, recovery, model-consent changes, and hosted sign-in now follow stricter upstream contracts.",
|
||||
"Finite schedules, bounded reset evidence, and host resource warnings make consequential actions easier to review."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.9.0",
|
||||
"title": "Better sessions, reactions, and voice",
|
||||
"date": "2026-08-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Sessions keep their identity",
|
||||
"bullets": [
|
||||
"Browse one profile or all profiles, customize sorting and filters, and optionally group sessions by project, recency, status, or profile.",
|
||||
"Cross-profile sessions hydrate, resume, and send with their owning agent without changing the global profile selection; New Chat in All Profiles uses the default profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Conversation controls stay attached",
|
||||
"bullets": [
|
||||
"Reactions pin to durable rows on both user and assistant messages.",
|
||||
"Vanilla Hermes voice stays on the authenticated Gateway instead of requiring the optional API fallback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Context without clutter",
|
||||
"bullets": [
|
||||
"Session rows show profile, project, branch, and pull-request context when Hermes supplies it, while the default view remains ungrouped.",
|
||||
"The session drawer restores secondary actions in All Profiles and closes when you tap outside it."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.8.1",
|
||||
"title": "Complete, reliable transcripts",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
v1.8.1 - Complete, reliable transcripts
|
||||
v1.10.0 - Chat that stays put
|
||||
|
||||
* Keep complete history in long sessions beyond Hermes' latest-500 default.
|
||||
* Preserve stable edit, retry, sharing, and recovery anchors while paging history.
|
||||
* Show authoritative Gateway rejection messages without an unintended fallback.
|
||||
* Reconcile Gateway events and edit-and-regenerate requests with current upstream contracts.
|
||||
* See Markdown take shape while replies stream, without a final message rebuild.
|
||||
* Keep the bottom smoothly followed—or scroll back without being pulled away.
|
||||
* Return from another app and resume the open Hermes session automatically.
|
||||
* Keep composer drafts and pending attachments across app restarts.
|
||||
* Turn large pastes into reviewable text attachments before sending.
|
||||
* Use Return on the software keyboard while the dedicated button sends.
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.os.Build
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
@@ -38,7 +40,14 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
*/
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components { add(OkHttpNetworkFetcherFactory()) }
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
|
||||
import com.hermesandroid.relay.data.sameBrokerAuthority
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -216,21 +219,47 @@ class AuthManager(
|
||||
tokenStoreKey: String,
|
||||
secrets: ConnectionAuthSecrets,
|
||||
) {
|
||||
val normalized = normalizeStoredSecrets(secrets)
|
||||
withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
|
||||
writeOrRemove(store, KEY_SESSION_TOKEN, normalized.sessionToken)
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, normalized.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, normalized.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, normalized.apiKey)
|
||||
writeOrRemove(
|
||||
store,
|
||||
KEY_PROFILE_API_KEYS,
|
||||
secrets.profileApiKeys.takeIf { it.isNotEmpty() }?.let(::encodeProfileApiKeys),
|
||||
normalized.profileApiKeys
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(::encodeProfileApiKeys),
|
||||
)
|
||||
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
writeOrRemove(store, KEY_PAIRED_META, normalized.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a backup fully before any existing encrypted state is replaced. */
|
||||
fun validateStoredSecrets(secrets: ConnectionAuthSecrets) {
|
||||
normalizeStoredSecrets(secrets)
|
||||
}
|
||||
|
||||
private fun normalizeStoredSecrets(secrets: ConnectionAuthSecrets): ConnectionAuthSecrets =
|
||||
secrets.copy(
|
||||
sessionToken = secrets.sessionToken?.let {
|
||||
normalizeCredentialForHeader(it, "Relay session credential")
|
||||
}?.takeIf { it.isNotEmpty() },
|
||||
refreshToken = secrets.refreshToken?.let {
|
||||
normalizeCredentialForHeader(it, "Relay refresh credential")
|
||||
}?.takeIf { it.isNotEmpty() },
|
||||
apiKey = secrets.apiKey?.let {
|
||||
normalizeCredentialForHeader(it, "API credential")
|
||||
}?.takeIf { it.isNotEmpty() },
|
||||
profileApiKeys = secrets.profileApiKeys
|
||||
.mapValues { (_, value) ->
|
||||
normalizeCredentialForHeader(value, "Profile API credential")
|
||||
}
|
||||
.filterValues { it.isNotEmpty() },
|
||||
)
|
||||
|
||||
private fun tokenStoreForBackup(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
@@ -286,6 +315,7 @@ class AuthManager(
|
||||
?: return@mapNotNull null
|
||||
val model = obj["model"]?.jsonPrimitive?.contentOrNull
|
||||
?: "unknown"
|
||||
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
val description = obj["description"]?.jsonPrimitive?.contentOrNull
|
||||
?: ""
|
||||
val systemMessage = obj["system_message"]?.jsonPrimitive?.contentOrNull
|
||||
@@ -305,9 +335,15 @@ class AuthManager(
|
||||
?.jsonPrimitive?.intOrNull
|
||||
val apiServerKeyPresent = obj["api_server_key_present"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val isDefault = obj["is_default"]?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val hasAvatar = obj["has_avatar"]?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val uiMeta = (obj["ui_meta"] as? JsonObject)
|
||||
?.takeIf(::isSafeProfileUiMeta)
|
||||
?: JsonObject(emptyMap())
|
||||
Profile(
|
||||
name = name,
|
||||
model = model,
|
||||
provider = provider,
|
||||
description = description,
|
||||
systemMessage = systemMessage,
|
||||
gatewayRunning = gatewayRunning,
|
||||
@@ -318,6 +354,9 @@ class AuthManager(
|
||||
apiServerHost = apiServerHost,
|
||||
apiServerPort = apiServerPort,
|
||||
apiServerKeyPresent = apiServerKeyPresent,
|
||||
isDefault = isDefault,
|
||||
hasAvatar = hasAvatar,
|
||||
uiMeta = uiMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -596,6 +635,8 @@ class AuthManager(
|
||||
*/
|
||||
private val _apiKeyPresent = MutableStateFlow(false)
|
||||
val apiKeyPresent: StateFlow<Boolean> = _apiKeyPresent.asStateFlow()
|
||||
private val _apiKeyError = MutableStateFlow<String?>(null)
|
||||
val apiKeyError: StateFlow<String?> = _apiKeyError.asStateFlow()
|
||||
|
||||
init {
|
||||
// Register as system channel handler for auth messages
|
||||
@@ -615,19 +656,40 @@ class AuthManager(
|
||||
val s = store()
|
||||
val existingToken = s.getString(KEY_SESSION_TOKEN)
|
||||
if (existingToken != null) {
|
||||
_authState.value = AuthState.Paired(existingToken)
|
||||
_currentPairedSession.value = loadStoredMetadata(existingToken)
|
||||
Log.i(
|
||||
TAG,
|
||||
"init: hydrated existing session_token=${existingToken.take(8)}… " +
|
||||
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
|
||||
)
|
||||
runCatching {
|
||||
normalizeCredentialForHeader(existingToken, "Relay session credential")
|
||||
.also { require(it.isNotEmpty()) }
|
||||
}.onSuccess { normalized ->
|
||||
if (normalized != existingToken) s.putString(KEY_SESSION_TOKEN, normalized)
|
||||
_authState.value = AuthState.Paired(normalized)
|
||||
_currentPairedSession.value = loadStoredMetadata(normalized)
|
||||
Log.i(TAG, "init: hydrated existing session credential")
|
||||
}.onFailure {
|
||||
_authState.value = AuthState.Failed(
|
||||
"Saved Relay credential is malformed. Re-pair this connection.",
|
||||
)
|
||||
Log.w(TAG, "init: rejected malformed saved Relay credential")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
}
|
||||
// Converge the plain api-key-present hint with the decrypted
|
||||
// truth (also repairs a hint that predates legacy migration).
|
||||
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
|
||||
val storedApiKey = s.getString(KEY_API_KEY)
|
||||
if (storedApiKey != null) {
|
||||
runCatching {
|
||||
normalizeCredentialForHeader(storedApiKey, "API credential")
|
||||
.also { require(it.isNotEmpty()) }
|
||||
}.onSuccess { normalized ->
|
||||
if (normalized != storedApiKey) s.putString(KEY_API_KEY, normalized)
|
||||
_apiKeyError.value = null
|
||||
}.onFailure {
|
||||
_apiKeyError.value =
|
||||
"Saved API credential is malformed. Replace or clear it."
|
||||
Log.w(TAG, "init: rejected malformed saved API credential")
|
||||
}
|
||||
}
|
||||
recordApiKeyHint(!storedApiKey.isNullOrBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -804,10 +866,20 @@ class AuthManager(
|
||||
val deviceId = getDeviceId()
|
||||
val payload = when (currentState) {
|
||||
is AuthState.Paired -> {
|
||||
val refreshToken = store().getString(KEY_REFRESH_TOKEN)
|
||||
val refreshToken = store().getString(KEY_REFRESH_TOKEN)?.let { raw ->
|
||||
runCatching {
|
||||
normalizeCredentialForHeader(raw, "Relay refresh credential")
|
||||
}.getOrElse {
|
||||
_authState.value = AuthState.Failed(
|
||||
"Saved Relay credential is malformed. Re-pair this connection.",
|
||||
)
|
||||
Log.w(TAG, "authenticate: rejected malformed refresh credential")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…, " +
|
||||
"authenticate: sending saved session credential (state=Paired, " +
|
||||
"refresh=${!refreshToken.isNullOrBlank()})"
|
||||
)
|
||||
buildJsonObject {
|
||||
@@ -995,10 +1067,26 @@ class AuthManager(
|
||||
|
||||
// --- API Key storage (for direct Hermes API Server auth) ---
|
||||
|
||||
suspend fun getApiKey(): String? = store().getString(KEY_API_KEY)
|
||||
suspend fun getApiKey(): String? {
|
||||
val raw = store().getString(KEY_API_KEY) ?: return null
|
||||
return runCatching {
|
||||
normalizeCredentialForHeader(raw, "API credential")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}.onSuccess {
|
||||
_apiKeyError.value = null
|
||||
}.onFailure {
|
||||
_apiKeyError.value = "Saved API credential is malformed. Replace or clear it."
|
||||
Log.w(TAG, "getApiKey: rejected malformed saved API credential")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun setApiKey(key: String) {
|
||||
val trimmed = key.trim()
|
||||
val trimmed = runCatching {
|
||||
normalizeCredentialForHeader(key, "API credential")
|
||||
}.getOrElse {
|
||||
_apiKeyError.value = "API credentials must be a single line."
|
||||
throw it
|
||||
}
|
||||
val s = store()
|
||||
if (trimmed.isBlank()) {
|
||||
s.remove(KEY_API_KEY)
|
||||
@@ -1007,11 +1095,13 @@ class AuthManager(
|
||||
s.putString(KEY_API_KEY, trimmed)
|
||||
recordApiKeyHint(true)
|
||||
}
|
||||
_apiKeyError.value = null
|
||||
}
|
||||
|
||||
suspend fun clearApiKey() {
|
||||
store().remove(KEY_API_KEY)
|
||||
recordApiKeyHint(false)
|
||||
_apiKeyError.value = null
|
||||
}
|
||||
|
||||
suspend fun getProfileApiKey(profileName: String): String? =
|
||||
@@ -1023,7 +1113,7 @@ class AuthManager(
|
||||
profileApiKeysMutex.withLock {
|
||||
val tokenStore = store()
|
||||
val keys = decodeProfileApiKeys(tokenStore.getString(KEY_PROFILE_API_KEYS)).toMutableMap()
|
||||
val normalizedKey = key.trim()
|
||||
val normalizedKey = normalizeCredentialForHeader(key, "Profile API credential")
|
||||
if (normalizedKey.isBlank()) keys.remove(normalizedProfile)
|
||||
else keys[normalizedProfile] = normalizedKey
|
||||
if (keys.isEmpty()) tokenStore.remove(KEY_PROFILE_API_KEYS)
|
||||
@@ -1042,7 +1132,10 @@ class AuthManager(
|
||||
scope.launch {
|
||||
try {
|
||||
val payload = envelope.payload
|
||||
val token = payload["session_token"]?.jsonPrimitive?.contentOrNull
|
||||
val token = payload["session_token"]?.jsonPrimitive?.contentOrNull?.let { raw ->
|
||||
normalizeCredentialForHeader(raw, "Relay session credential")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
Log.w(
|
||||
@@ -1059,13 +1152,14 @@ class AuthManager(
|
||||
val refreshToken = payload["refresh_token"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { normalizeCredentialForHeader(it, "Relay refresh credential") }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
if (refreshToken != null) {
|
||||
s.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
Log.i(TAG, "handleAuthOk: stored rotated refresh token")
|
||||
}
|
||||
_authState.value = AuthState.Paired(token)
|
||||
Log.i(TAG, "handleAuthOk: Paired(token=${token.take(8)}…)")
|
||||
Log.i(TAG, "handleAuthOk: paired with server-issued session credential")
|
||||
// Per-connection signal for socket-scoped consumers (e.g.
|
||||
// re-sending proactive.subscribe). Fires on every auth.ok.
|
||||
_authOkEvents.tryEmit(Unit)
|
||||
@@ -1158,6 +1252,11 @@ class AuthManager(
|
||||
// handler is exactly why the broken `_sessionLabels` parser
|
||||
// (stringifying object entries) sat undetected for so long.
|
||||
Log.w(TAG, "auth.ok parse failed: ${e.message}", e)
|
||||
if (e is InvalidCredentialException) {
|
||||
_authState.value = AuthState.Failed(
|
||||
"Relay returned a malformed credential. Re-pair this connection.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1181,7 +1280,14 @@ class AuthManager(
|
||||
protocolVersion = current.protocolVersion,
|
||||
hostId = current.hostId,
|
||||
credentialKind = "route",
|
||||
token = credential["token"]?.jsonPrimitive?.contentOrNull ?: return,
|
||||
token = credential["token"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
runCatching {
|
||||
normalizeCredentialForHeader(it, "Hermes Reach credential")
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "Ignoring malformed Hermes Reach route credential")
|
||||
return
|
||||
}
|
||||
} ?: return,
|
||||
expiresAt = credential["expires_at"]?.jsonPrimitive?.longOrNull,
|
||||
)
|
||||
val validated = active.copy(broker = replacement).takeIf { it.hasHermesReach() } ?: return
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
import java.util.WeakHashMap
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Immutable owner of one composer draft.
|
||||
@@ -86,15 +104,15 @@ data class ChatComposerDraft(
|
||||
*/
|
||||
interface ChatComposerDraftStore {
|
||||
fun observe(key: ChatComposerDraftKey): Flow<ChatComposerDraft>
|
||||
fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft
|
||||
fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft)
|
||||
fun update(
|
||||
suspend fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft
|
||||
suspend fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft)
|
||||
suspend fun update(
|
||||
key: ChatComposerDraftKey,
|
||||
transform: (ChatComposerDraft) -> ChatComposerDraft,
|
||||
)
|
||||
fun remove(key: ChatComposerDraftKey)
|
||||
fun removeSession(connectionId: String, profileId: String, sessionId: String)
|
||||
fun clear()
|
||||
suspend fun remove(key: ChatComposerDraftKey)
|
||||
suspend fun removeSession(connectionId: String, profileId: String, sessionId: String)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
class InMemoryChatComposerDraftStore : ChatComposerDraftStore {
|
||||
@@ -105,43 +123,370 @@ class InMemoryChatComposerDraftStore : ChatComposerDraftStore {
|
||||
.map { it[key] ?: ChatComposerDraft() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
override fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft =
|
||||
override suspend fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft =
|
||||
drafts.value[key] ?: ChatComposerDraft()
|
||||
|
||||
@Synchronized
|
||||
override fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
|
||||
val normalized = draft.normalized()
|
||||
drafts.value = if (normalized.isEmpty) {
|
||||
drafts.value - key
|
||||
} else {
|
||||
drafts.value + (key to normalized)
|
||||
override suspend fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
|
||||
synchronized(drafts) {
|
||||
val normalized = draft.normalized()
|
||||
drafts.value = if (normalized.isEmpty) {
|
||||
drafts.value - key
|
||||
} else {
|
||||
drafts.value + (key to normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun update(
|
||||
override suspend fun update(
|
||||
key: ChatComposerDraftKey,
|
||||
transform: (ChatComposerDraft) -> ChatComposerDraft,
|
||||
) {
|
||||
save(key, transform(snapshot(key)))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun remove(key: ChatComposerDraftKey) {
|
||||
drafts.value = drafts.value - key
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeSession(connectionId: String, profileId: String, sessionId: String) {
|
||||
drafts.value = drafts.value.filterKeys { key ->
|
||||
key.connectionId != connectionId ||
|
||||
key.profileId != profileId ||
|
||||
key.sessionId != sessionId
|
||||
override suspend fun remove(key: ChatComposerDraftKey) {
|
||||
synchronized(drafts) {
|
||||
drafts.value = drafts.value - key
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun clear() {
|
||||
drafts.value = emptyMap()
|
||||
override suspend fun removeSession(connectionId: String, profileId: String, sessionId: String) {
|
||||
synchronized(drafts) {
|
||||
drafts.value = drafts.value.filterKeys { key ->
|
||||
key.connectionId != connectionId ||
|
||||
key.profileId != profileId ||
|
||||
key.sessionId != sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
synchronized(drafts) {
|
||||
drafts.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-private durable composer storage.
|
||||
*
|
||||
* The caller supplies a directory under `noBackupFilesDir`: drafts survive
|
||||
* process death and ordinary app exits but never enter Android cloud backup.
|
||||
* Metadata stays small JSON while attachment bytes are content-addressed blobs,
|
||||
* so typing does not repeatedly rewrite Base64 payloads.
|
||||
*/
|
||||
class PersistentChatComposerDraftStore(
|
||||
private val root: File,
|
||||
) : ChatComposerDraftStore {
|
||||
private val mutex = Mutex()
|
||||
private val updates = MutableSharedFlow<ChatComposerDraftKey>(extraBufferCapacity = 64)
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val draftsDir = File(root, "drafts")
|
||||
private val blobsDir = File(root, "blobs")
|
||||
private val contentBlobIds = WeakHashMap<String, String>()
|
||||
|
||||
override fun observe(key: ChatComposerDraftKey): Flow<ChatComposerDraft> = flow {
|
||||
emit(snapshot(key))
|
||||
emitAll(
|
||||
updates
|
||||
.filter { it == key }
|
||||
.map { snapshot(key) }
|
||||
.distinctUntilChanged(),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun snapshot(key: ChatComposerDraftKey): ChatComposerDraft = withContext(Dispatchers.IO) {
|
||||
mutex.withLock { readDraft(key) }
|
||||
}
|
||||
|
||||
override suspend fun save(key: ChatComposerDraftKey, draft: ChatComposerDraft) {
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val normalized = draft.normalized()
|
||||
if (normalized.isEmpty) {
|
||||
draftFile(key).delete()
|
||||
} else {
|
||||
ensureDirectories()
|
||||
val persisted = normalized.toPersisted(key)
|
||||
atomicWrite(
|
||||
draftFile(key),
|
||||
json.encodeToString(PersistedDraft.serializer(), persisted)
|
||||
.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
pruneAndCollect(except = key)
|
||||
}
|
||||
}
|
||||
updates.tryEmit(key)
|
||||
}
|
||||
|
||||
override suspend fun update(
|
||||
key: ChatComposerDraftKey,
|
||||
transform: (ChatComposerDraft) -> ChatComposerDraft,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val normalized = transform(readDraft(key)).normalized()
|
||||
if (normalized.isEmpty) {
|
||||
draftFile(key).delete()
|
||||
} else {
|
||||
ensureDirectories()
|
||||
atomicWrite(
|
||||
draftFile(key),
|
||||
json.encodeToString(
|
||||
PersistedDraft.serializer(),
|
||||
normalized.toPersisted(key),
|
||||
).toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
pruneAndCollect(except = key)
|
||||
}
|
||||
}
|
||||
updates.tryEmit(key)
|
||||
}
|
||||
|
||||
override suspend fun remove(key: ChatComposerDraftKey) {
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
draftFile(key).delete()
|
||||
collectOrphanBlobs()
|
||||
}
|
||||
}
|
||||
updates.tryEmit(key)
|
||||
}
|
||||
|
||||
override suspend fun removeSession(connectionId: String, profileId: String, sessionId: String) {
|
||||
val removed = mutableListOf<ChatComposerDraftKey>()
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
draftFiles().forEach { file ->
|
||||
val persisted = readPersisted(file) ?: return@forEach
|
||||
val key = persisted.key.toDomain()
|
||||
if (
|
||||
key.connectionId == connectionId &&
|
||||
key.profileId == profileId &&
|
||||
key.sessionId == sessionId
|
||||
) {
|
||||
file.delete()
|
||||
removed += key
|
||||
}
|
||||
}
|
||||
collectOrphanBlobs()
|
||||
}
|
||||
}
|
||||
removed.forEach(updates::tryEmit)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
root.listFiles().orEmpty().forEach(File::deleteRecursively)
|
||||
contentBlobIds.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatComposerDraft.toPersisted(key: ChatComposerDraftKey): PersistedDraft =
|
||||
PersistedDraft(
|
||||
key = PersistedKey.from(key),
|
||||
text = text,
|
||||
selectionStart = selectionStart,
|
||||
selectionEnd = selectionEnd,
|
||||
quotedMessageId = context.quotedMessageId,
|
||||
editingMessageId = context.editingMessageId,
|
||||
attachments = attachments.mapNotNull(::persistAttachment),
|
||||
savedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
private fun persistAttachment(attachment: Attachment): PersistedAttachment? {
|
||||
val rawBytes = attachment.composerRawText
|
||||
?.takeIf { attachment.isLargePaste }
|
||||
?.toByteArray(Charsets.UTF_8)
|
||||
val cachedBlobId = if (rawBytes == null) contentBlobIds[attachment.content] else null
|
||||
val cachedBlob = cachedBlobId?.let { File(blobsDir, "$it.blob") }
|
||||
if (cachedBlobId != null && cachedBlob?.exists() == true) {
|
||||
return attachment.toPersistedAttachment(cachedBlobId)
|
||||
}
|
||||
val bytes = rawBytes
|
||||
?: runCatching { Base64.getDecoder().decode(attachment.content) }.getOrNull()
|
||||
?: return null
|
||||
if (bytes.isEmpty()) return null
|
||||
val blobId = sha256(bytes)
|
||||
val blob = File(blobsDir, "$blobId.blob")
|
||||
if (!blob.exists()) atomicWrite(blob, bytes)
|
||||
if (rawBytes == null) contentBlobIds[attachment.content] = blobId
|
||||
return attachment.toPersistedAttachment(blobId)
|
||||
}
|
||||
|
||||
private fun Attachment.toPersistedAttachment(blobId: String): PersistedAttachment =
|
||||
PersistedAttachment(
|
||||
contentType = contentType,
|
||||
blobId = blobId,
|
||||
fileName = fileName,
|
||||
fileSize = fileSize,
|
||||
sensitive = sensitive,
|
||||
isLargePaste = isLargePaste,
|
||||
composerId = composerId,
|
||||
)
|
||||
|
||||
private fun readDraft(key: ChatComposerDraftKey): ChatComposerDraft {
|
||||
val persisted = readPersisted(draftFile(key)) ?: return ChatComposerDraft()
|
||||
if (persisted.key.toDomain() != key) return ChatComposerDraft()
|
||||
return ChatComposerDraft(
|
||||
text = persisted.text,
|
||||
selectionStart = persisted.selectionStart,
|
||||
selectionEnd = persisted.selectionEnd,
|
||||
context = ChatComposerDraftContext(
|
||||
quotedMessageId = persisted.quotedMessageId,
|
||||
editingMessageId = persisted.editingMessageId,
|
||||
),
|
||||
attachments = persisted.attachments.mapNotNull { attachment ->
|
||||
val blob = File(blobsDir, "${attachment.blobId}.blob")
|
||||
val bytes = runCatching { blob.readBytes() }.getOrNull()
|
||||
?.takeIf(ByteArray::isNotEmpty) ?: return@mapNotNull null
|
||||
val content = Base64.getEncoder().encodeToString(bytes)
|
||||
contentBlobIds[content] = attachment.blobId
|
||||
Attachment(
|
||||
contentType = attachment.contentType,
|
||||
content = content,
|
||||
fileName = attachment.fileName,
|
||||
fileSize = attachment.fileSize ?: bytes.size.toLong(),
|
||||
sensitive = attachment.sensitive,
|
||||
isLargePaste = attachment.isLargePaste,
|
||||
composerId = attachment.composerId,
|
||||
)
|
||||
},
|
||||
).normalized()
|
||||
}
|
||||
|
||||
private fun readPersisted(file: File): PersistedDraft? = runCatching {
|
||||
json.decodeFromString(PersistedDraft.serializer(), file.readText(Charsets.UTF_8))
|
||||
}.getOrNull()
|
||||
|
||||
private fun pruneAndCollect(except: ChatComposerDraftKey) {
|
||||
val exceptFile = draftFile(except)
|
||||
val candidates = draftFiles()
|
||||
.filterNot { it == exceptFile }
|
||||
.sortedBy(File::lastModified)
|
||||
candidates
|
||||
.take((draftFiles().size - MAX_DRAFTS).coerceAtLeast(0))
|
||||
.forEach { it.delete() }
|
||||
collectOrphanBlobs()
|
||||
for (oldest in candidates) {
|
||||
if (blobsDir.listFiles().orEmpty().sumOf(File::length) <= MAX_BLOB_BYTES) break
|
||||
if (oldest.exists()) {
|
||||
oldest.delete()
|
||||
collectOrphanBlobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectOrphanBlobs() {
|
||||
val referenced = draftFiles()
|
||||
.mapNotNull(::readPersisted)
|
||||
.flatMap { draft -> draft.attachments.map(PersistedAttachment::blobId) }
|
||||
.toSet()
|
||||
blobsDir.listFiles().orEmpty()
|
||||
.filter { it.isFile && it.extension == "blob" && it.nameWithoutExtension !in referenced }
|
||||
.forEach(File::delete)
|
||||
}
|
||||
|
||||
private fun ensureDirectories() {
|
||||
check(draftsDir.exists() || draftsDir.mkdirs()) { "Could not create composer draft directory" }
|
||||
check(blobsDir.exists() || blobsDir.mkdirs()) { "Could not create composer blob directory" }
|
||||
}
|
||||
|
||||
private fun draftFiles(): List<File> = draftsDir.listFiles().orEmpty()
|
||||
.filter { it.isFile && it.extension == "json" }
|
||||
|
||||
private fun draftFile(key: ChatComposerDraftKey): File =
|
||||
File(draftsDir, "${sha256(key.storageIdentity().toByteArray(Charsets.UTF_8))}.json")
|
||||
|
||||
private fun atomicWrite(target: File, bytes: ByteArray) {
|
||||
target.parentFile?.let { parent ->
|
||||
check(parent.exists() || parent.mkdirs()) { "Could not create composer storage directory" }
|
||||
}
|
||||
val temporary = File(target.parentFile, ".${target.name}.${System.nanoTime()}.tmp")
|
||||
try {
|
||||
FileOutputStream(temporary).use { output ->
|
||||
output.write(bytes)
|
||||
output.fd.sync()
|
||||
}
|
||||
try {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
temporary.toPath(),
|
||||
target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatComposerDraftKey.storageIdentity(): String =
|
||||
listOf(connectionId, profileId, sessionId, draftId).joinToString("\u0000")
|
||||
|
||||
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(bytes)
|
||||
.joinToString("") { byte -> "%02x".format(byte) }
|
||||
|
||||
companion object {
|
||||
private const val MAX_DRAFTS = 64
|
||||
private const val MAX_BLOB_BYTES = 128L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class PersistedDraft(
|
||||
val key: PersistedKey,
|
||||
val text: String,
|
||||
val selectionStart: Int,
|
||||
val selectionEnd: Int,
|
||||
val quotedMessageId: String? = null,
|
||||
val editingMessageId: String? = null,
|
||||
val attachments: List<PersistedAttachment> = emptyList(),
|
||||
val savedAtEpochMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedKey(
|
||||
val connectionId: String,
|
||||
val profileId: String,
|
||||
val sessionId: String,
|
||||
val draftId: String,
|
||||
) {
|
||||
fun toDomain(): ChatComposerDraftKey = ChatComposerDraftKey(
|
||||
connectionId = connectionId,
|
||||
profileId = profileId,
|
||||
sessionId = sessionId,
|
||||
draftId = draftId,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun from(key: ChatComposerDraftKey): PersistedKey = PersistedKey(
|
||||
connectionId = key.connectionId,
|
||||
profileId = key.profileId,
|
||||
sessionId = key.sessionId,
|
||||
draftId = key.draftId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class PersistedAttachment(
|
||||
val contentType: String,
|
||||
val blobId: String,
|
||||
val fileName: String? = null,
|
||||
val fileSize: Long? = null,
|
||||
val sensitive: Boolean = false,
|
||||
val isLargePaste: Boolean = false,
|
||||
val composerId: String? = null,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.data
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -30,6 +31,8 @@ class ChatInputPreferencesRepository(
|
||||
companion object {
|
||||
internal val KEY_PHYSICAL_KEYBOARD_ENTER =
|
||||
stringPreferencesKey("physical_keyboard_enter_behavior")
|
||||
internal val KEY_CONVERT_LARGE_PASTES =
|
||||
booleanPreferencesKey("convert_large_pastes_to_attachments")
|
||||
}
|
||||
|
||||
val physicalKeyboardEnterBehavior: Flow<PhysicalKeyboardEnterBehavior> = dataStore.data
|
||||
@@ -40,9 +43,19 @@ class ChatInputPreferencesRepository(
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
val convertLargePastesToAttachments: Flow<Boolean> = dataStore.data
|
||||
.map { preferences -> preferences[KEY_CONVERT_LARGE_PASTES] ?: true }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setPhysicalKeyboardEnterBehavior(behavior: PhysicalKeyboardEnterBehavior) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_PHYSICAL_KEYBOARD_ENTER] = behavior.storedValue
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setConvertLargePastesToAttachments(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_CONVERT_LARGE_PASTES] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.util.Base64
|
||||
|
||||
const val LARGE_PASTE_THRESHOLD_CHARS = 5_000
|
||||
|
||||
data class TextTransportAttachments(
|
||||
val message: String,
|
||||
val attachments: List<Attachment>,
|
||||
)
|
||||
|
||||
fun largePasteAttachment(text: String, composerId: String? = null): Attachment {
|
||||
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||
return Attachment(
|
||||
contentType = "text/plain; charset=utf-8",
|
||||
content = Base64.getEncoder().encodeToString(bytes),
|
||||
fileName = "pasted-text.txt",
|
||||
fileSize = bytes.size.toLong(),
|
||||
isLargePaste = true,
|
||||
composerId = composerId,
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareTextTransportAttachments(
|
||||
message: String,
|
||||
attachments: List<Attachment>,
|
||||
): TextTransportAttachments {
|
||||
val largePastes = attachments.filter(Attachment::isLargePaste)
|
||||
if (largePastes.isEmpty()) return TextTransportAttachments(message, attachments)
|
||||
|
||||
val materialized = largePastes.mapNotNull { attachment ->
|
||||
runCatching {
|
||||
val text = String(Base64.getDecoder().decode(attachment.content), Charsets.UTF_8)
|
||||
val name = attachment.fileName?.takeIf(String::isNotBlank) ?: "pasted text"
|
||||
"--- $name ---\n$text"
|
||||
}.getOrNull()
|
||||
}
|
||||
return TextTransportAttachments(
|
||||
message = (listOf(message) + materialized)
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString("\n\n"),
|
||||
attachments = attachments.filterNot(Attachment::isLargePaste),
|
||||
)
|
||||
}
|
||||
@@ -147,6 +147,12 @@ data class ChatMessage(
|
||||
* never used as a Compose key or synthesized client-side.
|
||||
*/
|
||||
val rowId: Long? = null,
|
||||
/**
|
||||
* Durable iOS-style tapbacks attached to this server message. Hermes keeps
|
||||
* one reaction per author in the message's display metadata; the UI also
|
||||
* updates this list optimistically while a reaction write is in flight.
|
||||
*/
|
||||
val reactions: List<MessageReaction> = emptyList(),
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
@@ -156,6 +162,29 @@ data class ChatMessage(
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MessageReaction(
|
||||
val emoji: String,
|
||||
val author: String,
|
||||
/** Epoch seconds, matching the Gateway/Desktop contract. */
|
||||
val at: Double,
|
||||
)
|
||||
|
||||
/** Apply Hermes' one-reaction-per-author, re-tap-to-retract semantics. */
|
||||
internal fun applyMessageReaction(
|
||||
reactions: List<MessageReaction>,
|
||||
emoji: String?,
|
||||
author: String = "user",
|
||||
at: Double = System.currentTimeMillis() / 1000.0,
|
||||
): List<MessageReaction> {
|
||||
val previous = reactions.firstOrNull { it.author == author }
|
||||
val withoutAuthor = reactions.filterNot { it.author == author }
|
||||
return if (emoji.isNullOrBlank() || previous?.emoji == emoji) {
|
||||
withoutAuthor
|
||||
} else {
|
||||
withoutAuthor + MessageReaction(emoji = emoji, author = author, at = at)
|
||||
}
|
||||
}
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
@@ -299,7 +328,13 @@ data class Attachment(
|
||||
* existing outbound/inbound call site stays valid and unflagged media
|
||||
* renders exactly as before.
|
||||
*/
|
||||
val sensitive: Boolean = false
|
||||
val sensitive: Boolean = false,
|
||||
/** Local composer metadata; never serialized onto the Hermes wire. */
|
||||
val isLargePaste: Boolean = false,
|
||||
/** Stable id for an asynchronous composer preparation; never sent to Hermes. */
|
||||
val composerId: String? = null,
|
||||
/** Raw UTF-8 text retained only while a large-paste attachment is preparing. */
|
||||
val composerRawText: String? = null,
|
||||
) {
|
||||
val isImage: Boolean get() = contentType.startsWith("image/")
|
||||
|
||||
@@ -412,6 +447,11 @@ data class ChatSession(
|
||||
val title: String?,
|
||||
val model: String?,
|
||||
val messageCount: Int = 0,
|
||||
val inputTokens: Int = 0,
|
||||
val outputTokens: Int = 0,
|
||||
val actualCostUsd: Double? = null,
|
||||
val estimatedCostUsd: Double? = null,
|
||||
val isActive: Boolean = false,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L,
|
||||
@@ -436,6 +476,12 @@ data class ChatSession(
|
||||
val pullRequestState: String? = null,
|
||||
val pullRequestDraft: Boolean = false,
|
||||
) {
|
||||
val totalTokens: Int
|
||||
get() = inputTokens + outputTokens
|
||||
|
||||
val costUsd: Double
|
||||
get() = actualCostUsd ?: estimatedCostUsd ?: 0.0
|
||||
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@ class DataManager(
|
||||
|
||||
suspend fun restoreConnectionBackup(backup: AppBackup) {
|
||||
val store = connectionStore ?: return
|
||||
// Validate every credential before deleting or replacing any current
|
||||
// encrypted state. A malformed/hostile backup fails atomically.
|
||||
backup.connectionSecrets.forEach { secret ->
|
||||
AuthManager.validateStoredSecrets(secret.auth)
|
||||
}
|
||||
deleteSensitivePreferenceFiles()
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
|
||||
@@ -36,6 +36,8 @@ data class ProactiveInboxEntry(
|
||||
val chatId: String? = null,
|
||||
/** Owning saved connection. Null only for entries written by older builds. */
|
||||
val connectionId: String? = null,
|
||||
/** Relay proved this row came from its bounded offline queue. */
|
||||
val arrivedWhileAway: Boolean = false,
|
||||
)
|
||||
|
||||
private val Context.proactiveInboxStore: DataStore<Preferences> by
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal fun profileAvatarMime(bytes: ByteArray): String? = when {
|
||||
bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals(
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
) -> "image/png"
|
||||
bytes.size >= 3 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() &&
|
||||
bytes[2] == 0xff.toByte() -> "image/jpeg"
|
||||
bytes.size >= 12 && bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
|
||||
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) -> "image/webp"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any image Android can decode into the small static format accepted by
|
||||
* upstream `profiles.set_asset`. ImageDecoder applies camera EXIF orientation
|
||||
* and downsamples before allocating the bitmap on current Android releases.
|
||||
*/
|
||||
internal fun prepareProfileAvatar(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
maxBytes: Int,
|
||||
): ByteArray? {
|
||||
val original = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val output = ByteArrayOutputStream(minOf(maxBytes + 1, 64 * 1024))
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
while (output.size() <= maxBytes) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
}.getOrNull()
|
||||
if (original != null && original.size <= maxBytes && profileAvatarMime(original) != null) {
|
||||
return original
|
||||
}
|
||||
|
||||
val bitmap = decodeProfileAvatar(context, uri) ?: return null
|
||||
return try {
|
||||
encodeProfileAvatar(bitmap, maxBytes)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeProfileAvatar(context: Context, uri: Uri): Bitmap? = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { decoder, info, _ ->
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
val width = info.size.width
|
||||
val height = info.size.height
|
||||
val longest = maxOf(width, height)
|
||||
if (longest > PROFILE_AVATAR_MAX_DIMENSION) {
|
||||
val scale = PROFILE_AVATAR_MAX_DIMENSION.toFloat() / longest
|
||||
decoder.setTargetSize(
|
||||
(width * scale).roundToInt().coerceAtLeast(1),
|
||||
(height * scale).roundToInt().coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > PROFILE_AVATAR_MAX_DIMENSION) sample *= 2
|
||||
context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
internal fun encodeProfileAvatar(bitmap: Bitmap, maxBytes: Int): ByteArray? {
|
||||
var working = bitmap.scaledToFit(PROFILE_AVATAR_MAX_DIMENSION)
|
||||
var ownsWorking = working !== bitmap
|
||||
try {
|
||||
while (true) {
|
||||
val format = if (working.hasAlpha()) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
|
||||
val qualities = if (format == Bitmap.CompressFormat.PNG) intArrayOf(100) else intArrayOf(92, 82, 72, 62)
|
||||
for (quality in qualities) {
|
||||
val encoded = ByteArrayOutputStream().use { output ->
|
||||
if (!working.compress(format, quality, output)) null else output.toByteArray()
|
||||
}
|
||||
if (encoded != null && encoded.size <= maxBytes) return encoded
|
||||
}
|
||||
if (maxOf(working.width, working.height) <= PROFILE_AVATAR_MIN_DIMENSION) return null
|
||||
val next = Bitmap.createScaledBitmap(
|
||||
working,
|
||||
(working.width * 0.75f).roundToInt().coerceAtLeast(1),
|
||||
(working.height * 0.75f).roundToInt().coerceAtLeast(1),
|
||||
true,
|
||||
)
|
||||
if (ownsWorking) working.recycle()
|
||||
working = next
|
||||
ownsWorking = true
|
||||
}
|
||||
} finally {
|
||||
if (ownsWorking) working.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Bitmap.scaledToFit(maxDimension: Int): Bitmap {
|
||||
val longest = maxOf(width, height)
|
||||
if (longest <= maxDimension) return this
|
||||
val scale = maxDimension.toFloat() / longest
|
||||
return Bitmap.createScaledBitmap(
|
||||
this,
|
||||
(width * scale).roundToInt().coerceAtLeast(1),
|
||||
(height * scale).roundToInt().coerceAtLeast(1),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
private const val PROFILE_AVATAR_MAX_DIMENSION = 1024
|
||||
private const val PROFILE_AVATAR_MIN_DIMENSION = 128
|
||||
@@ -2,6 +2,35 @@ package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
internal fun isSafeProfileUiMeta(meta: JsonObject): Boolean {
|
||||
if (meta.toString().toByteArray(Charsets.UTF_8).size > 65_536) return false
|
||||
fun containsEmbeddedAsset(value: String): Boolean {
|
||||
val compact = value.trim()
|
||||
return compact.startsWith("data:image/", ignoreCase = true) ||
|
||||
compact.startsWith("iVBORw0KGgo") || // PNG
|
||||
compact.startsWith("/9j/") || // JPEG
|
||||
compact.startsWith("UklGR") || // RIFF/WebP
|
||||
compact.startsWith("R0lGOD") || // GIF
|
||||
compact.startsWith("UEsDB") // ZIP/pet archive
|
||||
}
|
||||
fun safe(element: JsonElement): Boolean = when (element) {
|
||||
is JsonObject -> element.size <= 128 && element.all { (key, value) ->
|
||||
key.length <= 128 && safe(value)
|
||||
}
|
||||
is JsonArray -> element.size <= 128 && element.all(::safe)
|
||||
is JsonPrimitive -> {
|
||||
val value = element.contentOrNull
|
||||
value == null || (value.length <= 4_096 && !containsEmbeddedAsset(value))
|
||||
}
|
||||
}
|
||||
return safe(meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent profile advertised by a Hermes server in its `auth.ok` payload.
|
||||
@@ -56,6 +85,7 @@ import kotlinx.serialization.Serializable
|
||||
data class Profile(
|
||||
val name: String,
|
||||
val model: String,
|
||||
val provider: String = "",
|
||||
val description: String = "",
|
||||
@SerialName("system_message")
|
||||
val systemMessage: String? = null,
|
||||
@@ -75,6 +105,12 @@ data class Profile(
|
||||
val apiServerPort: Int? = null,
|
||||
@SerialName("api_server_key_present")
|
||||
val apiServerKeyPresent: Boolean = false,
|
||||
@SerialName("is_default")
|
||||
val isDefault: Boolean = false,
|
||||
@SerialName("has_avatar")
|
||||
val hasAvatar: Boolean = false,
|
||||
@SerialName("ui_meta")
|
||||
val uiMeta: JsonObject = JsonObject(emptyMap()),
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
|
||||
@@ -4,13 +4,15 @@ import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Local-only per-profile agent icons — the visual twin of [ProfileDisplayAliasStore].
|
||||
* Per-profile icon cache. Local fallback paths and Hermes-owned avatar cache
|
||||
* paths use separate keys so syncing either side never silently overwrites the other.
|
||||
*
|
||||
* Stores a **file path** to an image that was copied into app storage (not a SAF
|
||||
* content URI, so it survives without a persistable-permission grant). Like the
|
||||
@@ -25,6 +27,8 @@ class ProfileIconStore(
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_icon__"
|
||||
private const val SERVER_PREFIX = "profile_avatar_server__"
|
||||
private const val LOCAL_OVERRIDE_PREFIX = "profile_icon_override__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
@@ -34,6 +38,20 @@ class ProfileIconStore(
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
|
||||
private fun serverKeyFor(connectionId: String, profileName: String) =
|
||||
stringPreferencesKey("$SERVER_PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}")
|
||||
|
||||
private fun serverConnectionPrefix(connectionId: String): String =
|
||||
"$SERVER_PREFIX${connectionId}__"
|
||||
|
||||
private fun localOverrideKeyFor(connectionId: String, profileName: String?) =
|
||||
booleanPreferencesKey(
|
||||
"$LOCAL_OVERRIDE_PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}",
|
||||
)
|
||||
|
||||
private fun localOverrideConnectionPrefix(connectionId: String): String =
|
||||
"$LOCAL_OVERRIDE_PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setIcon(connectionId: String, profileName: String?, path: String?) {
|
||||
@@ -52,11 +70,39 @@ class ProfileIconStore(
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun setServerAvatar(connectionId: String, profileName: String, path: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = serverKeyFor(connectionId, profileName)
|
||||
if (path.isNullOrBlank()) prefs.remove(key) else prefs[key] = path
|
||||
}
|
||||
}
|
||||
|
||||
fun serverAvatarFlow(connectionId: String, profileName: String): Flow<String?> {
|
||||
val key = serverKeyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun setLocalOverride(connectionId: String, profileName: String?, enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = localOverrideKeyFor(connectionId, profileName)
|
||||
if (enabled) prefs[key] = true else prefs.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun localOverrideFlow(connectionId: String, profileName: String?): Flow<Boolean> {
|
||||
val key = localOverrideKeyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] ?: false }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
val prefixes = listOf(
|
||||
connectionPrefix(connectionId),
|
||||
serverConnectionPrefix(connectionId),
|
||||
localOverrideConnectionPrefix(connectionId),
|
||||
)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.filter { key -> prefixes.any(key.name::startsWith) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
@@ -66,5 +112,11 @@ class ProfileIconStore(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun preferredProfileIcon(
|
||||
server: String?,
|
||||
local: String?,
|
||||
useLocalOverride: Boolean,
|
||||
): String? = if (useLocalOverride && !local.isNullOrBlank()) local else server ?: local
|
||||
|
||||
internal val Context.profileIconsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_icons")
|
||||
|
||||
@@ -164,6 +164,8 @@ enum class GatewayProfileSection(val wireName: String) {
|
||||
Model("model"),
|
||||
Skills("skills"),
|
||||
Toolsets("toolsets"),
|
||||
McpServers("mcp_servers"),
|
||||
UiMeta("ui_meta"),
|
||||
}
|
||||
|
||||
/** Null leaves a section unchanged; empty lists retain upstream replace semantics. */
|
||||
@@ -174,6 +176,9 @@ data class GatewayProfilePatch(
|
||||
val model: String? = null,
|
||||
val disabledSkills: List<String>? = null,
|
||||
val enabledToolsets: List<String>? = null,
|
||||
val enabledMcpServers: List<String>? = null,
|
||||
/** Small interoperable preferences only; binary assets belong in profiles.set_asset. */
|
||||
val uiMeta: JsonObject? = null,
|
||||
) {
|
||||
val requestedSections: Set<GatewayProfileSection>
|
||||
get() = buildSet {
|
||||
@@ -182,9 +187,67 @@ data class GatewayProfilePatch(
|
||||
if (provider != null && model != null) add(GatewayProfileSection.Model)
|
||||
if (disabledSkills != null) add(GatewayProfileSection.Skills)
|
||||
if (enabledToolsets != null) add(GatewayProfileSection.Toolsets)
|
||||
if (enabledMcpServers != null) add(GatewayProfileSection.McpServers)
|
||||
if (uiMeta != null) add(GatewayProfileSection.UiMeta)
|
||||
}
|
||||
}
|
||||
|
||||
enum class GatewayProfileAuthChoice {
|
||||
/** Share the launch profile's refreshable OAuth/token store; copy static environment keys. */
|
||||
Shared,
|
||||
|
||||
/** Copy the current credential snapshot into a separate profile-owned store. */
|
||||
Copied,
|
||||
|
||||
/** Copy no credentials or provider defaults. */
|
||||
Isolated,
|
||||
}
|
||||
|
||||
data class GatewayProfileCreateRequest(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val cloneFrom: String? = null,
|
||||
val cloneAll: Boolean = false,
|
||||
val noSkills: Boolean = false,
|
||||
val soul: String? = null,
|
||||
val model: String? = null,
|
||||
val provider: String? = null,
|
||||
val authChoice: GatewayProfileAuthChoice = GatewayProfileAuthChoice.Shared,
|
||||
)
|
||||
|
||||
data class GatewayProfileCreateResult(
|
||||
val name: String,
|
||||
val soulWritten: Boolean,
|
||||
val modelSet: Boolean,
|
||||
val mirroredEnvironment: Boolean,
|
||||
val mirroredAuth: String?,
|
||||
val modelInherited: Boolean,
|
||||
val voiceMirrored: Boolean,
|
||||
) {
|
||||
fun partialMessages(request: GatewayProfileCreateRequest): List<String> = buildList {
|
||||
if (!request.soul.isNullOrBlank() && !soulWritten) add("SOUL was not saved")
|
||||
if (!request.model.isNullOrBlank() && !modelSet) add("model was not saved")
|
||||
if (request.authChoice == GatewayProfileAuthChoice.Shared && mirroredAuth != "shared") {
|
||||
add("shared sign-in was not confirmed")
|
||||
}
|
||||
if (
|
||||
request.authChoice == GatewayProfileAuthChoice.Copied &&
|
||||
!mirroredEnvironment && mirroredAuth != "true"
|
||||
) {
|
||||
add("no credential source was copied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GatewayProfileAsset(
|
||||
val data: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
class GatewayProfileManagementUnsupportedException(
|
||||
operation: String,
|
||||
) : Exception("$operation is not supported by this gateway")
|
||||
|
||||
data class GatewayProfileConfigureResult(
|
||||
val requested: Set<GatewayProfileSection>,
|
||||
val applied: Set<GatewayProfileSection>,
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -16,6 +17,8 @@ import kotlinx.serialization.json.Json
|
||||
data class ProfilePresentation(
|
||||
val order: List<String> = emptyList(),
|
||||
val hidden: Set<String> = emptySet(),
|
||||
/** Local-only named-profile accent overrides, stored as normalized RGB hex. */
|
||||
val colors: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -63,14 +66,17 @@ class ProfilePresentationStore(
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val listSerializer = ListSerializer(String.serializer())
|
||||
private val mapSerializer = MapSerializer(String.serializer(), String.serializer())
|
||||
|
||||
private fun orderKey(connectionId: String) = stringPreferencesKey("order_$connectionId")
|
||||
private fun hiddenKey(connectionId: String) = stringPreferencesKey("hidden_$connectionId")
|
||||
private fun colorsKey(connectionId: String) = stringPreferencesKey("colors_$connectionId")
|
||||
|
||||
fun presentationFlow(connectionId: String): Flow<ProfilePresentation> = dataStore.data.map { prefs ->
|
||||
ProfilePresentation(
|
||||
order = decode(prefs[orderKey(connectionId)]),
|
||||
hidden = decode(prefs[hiddenKey(connectionId)]).toSet(),
|
||||
colors = decodeMap(prefs[colorsKey(connectionId)]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,10 +88,18 @@ class ProfilePresentationStore(
|
||||
dataStore.edit { it[hiddenKey(connectionId)] = json.encodeToString(listSerializer, hidden.sorted()) }
|
||||
}
|
||||
|
||||
suspend fun setColors(connectionId: String, colors: Map<String, String>) {
|
||||
dataStore.edit {
|
||||
if (colors.isEmpty()) it.remove(colorsKey(connectionId))
|
||||
else it[colorsKey(connectionId)] = json.encodeToString(mapSerializer, colors.toSortedMap())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit {
|
||||
it.remove(orderKey(connectionId))
|
||||
it.remove(hiddenKey(connectionId))
|
||||
it.remove(colorsKey(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +112,12 @@ class ProfilePresentationStore(
|
||||
} else {
|
||||
runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun decodeMap(raw: String?): Map<String, String> = if (raw == null) {
|
||||
emptyMap()
|
||||
} else {
|
||||
runCatching { json.decodeFromString(mapSerializer, raw) }.getOrDefault(emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profilePresentationDataStore: DataStore<Preferences>
|
||||
|
||||
@@ -148,6 +148,8 @@ class ConnectionManager(
|
||||
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
|
||||
/** Exact-authority pinned client for a plugin-proxy WSS URL. */
|
||||
private val proxyClientProvider: ((String) -> OkHttpClient?)? = null,
|
||||
/** Test seam for observing lifecycle teardown without opening a socket. */
|
||||
private val okHttpClientFactory: (() -> OkHttpClient)? = null,
|
||||
) {
|
||||
private val supervisorJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
|
||||
@@ -158,6 +160,7 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
private fun buildClient(url: String? = null): OkHttpClient {
|
||||
okHttpClientFactory?.let { return it() }
|
||||
val builder = OkHttpClient.Builder()
|
||||
// OkHttp's 10s default connectTimeout is LAN-tuned; a Tailscale
|
||||
// DERP-relayed cold-start handshake can exceed it, and a failed
|
||||
|
||||
+25
-12
@@ -5,7 +5,9 @@ import android.util.Log
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
@@ -50,7 +52,7 @@ class ProactiveMessageHandler(
|
||||
/** Sink for the dedicated Hermes inbox (Phase 2a) — the always-present log. */
|
||||
private val toInbox: ((ProactiveMessage) -> Unit)? = null,
|
||||
/** Sink for injecting into the active chat session (Phase 2b). */
|
||||
var toSession: ((ProactiveMessage) -> Unit)? = null,
|
||||
var toSession: ((ProactiveMessage) -> Boolean)? = null,
|
||||
/**
|
||||
* Sink for the relay's per-reply ack (`proactive.reply.ack`) — lets the
|
||||
* chat layer settle a Thread reply bubble from SENDING → DELIVERED. Wired
|
||||
@@ -66,6 +68,8 @@ class ProactiveMessageHandler(
|
||||
* [toSession]; wired after construction.
|
||||
*/
|
||||
var injectIntoThread: ((ProactiveMessage) -> Boolean)? = null,
|
||||
/** One callback per completed queued-message flush, never per message. */
|
||||
var onBacklogDelivered: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
|
||||
fun onMessage(envelope: Envelope) {
|
||||
@@ -80,6 +84,10 @@ class ProactiveMessageHandler(
|
||||
}
|
||||
// Subscribe ack — informational; nothing to do client-side.
|
||||
"proactive.subscribed" -> Log.d(TAG, "proactive subscribe acked")
|
||||
"proactive.backlog.complete" -> {
|
||||
val count = envelope.payload["count"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
if (count > 0) onBacklogDelivered?.invoke(count)
|
||||
}
|
||||
// Per-reply ack — settle the matching Thread reply bubble (the
|
||||
// `client_msg_id` is the id the app stamped on its own reply).
|
||||
"proactive.reply.ack" -> {
|
||||
@@ -99,21 +107,23 @@ class ProactiveMessageHandler(
|
||||
// session until the phone replies, so this cache is the provisional
|
||||
// Thread transcript during that gap.
|
||||
toInbox?.invoke(msg)
|
||||
// Unified Threads: if this message belongs to the Thread currently open
|
||||
// in Chat, render it inline there and stop before raising a notification.
|
||||
if (injectIntoThread?.invoke(msg) == true) return
|
||||
// The surfacing hint selects the additional surface.
|
||||
// The surfacing hint selects the additional surface. Thread injection
|
||||
// is best-effort presentation of the persisted row, not itself a reason
|
||||
// to suppress an explicitly requested notification.
|
||||
when (msg.surfacing?.lowercase()) {
|
||||
"inbox" -> { /* inbox only — already recorded above */ }
|
||||
"inbox" -> {
|
||||
injectIntoThread?.invoke(msg)
|
||||
}
|
||||
"session" -> {
|
||||
val sink = toSession
|
||||
// Legacy explicit "inject into active session" path; if no sink
|
||||
// (or no active chat) fall back to a notification so it isn't
|
||||
// silently missed (the inbox copy already exists either way).
|
||||
if (sink != null) sink.invoke(msg) else notify(msg)
|
||||
val delivered = injectIntoThread?.invoke(msg) == true ||
|
||||
toSession?.invoke(msg) == true
|
||||
if (!delivered) notify(msg)
|
||||
}
|
||||
// null / "default" / "notification" / anything unrecognized.
|
||||
else -> notify(msg)
|
||||
else -> {
|
||||
injectIntoThread?.invoke(msg)
|
||||
notify(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +148,7 @@ class ProactiveMessageHandler(
|
||||
surfacing = payload["surfacing"]?.jsonPrimitive?.contentOrNull,
|
||||
sentAt = payload["sent_at"]?.jsonPrimitive?.contentOrNull?.toLongOrNull(),
|
||||
replyTo = payload["reply_to"]?.jsonPrimitive?.contentOrNull,
|
||||
arrivedWhileAway = payload["queued_delivery"]?.jsonPrimitive?.booleanOrNull == true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,4 +170,6 @@ data class ProactiveMessage(
|
||||
val sentAt: Long?,
|
||||
/** Id of the message this one answers, if any (server threading hint). */
|
||||
val replyTo: String? = null,
|
||||
/** True only when Relay explicitly marked this as a reconnect queue flush. */
|
||||
val arrivedWhileAway: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -299,10 +299,10 @@ class RelayHttpClient(
|
||||
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchMedia failed for $token: ${e.message}")
|
||||
Log.w(TAG, "fetchMedia failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchMedia unexpected error for $token: ${e.message}")
|
||||
Log.w(TAG, "fetchMedia unexpected error: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
|
||||
/** Secret-free failure raised before OkHttp sees a malformed credential. */
|
||||
class InvalidCredentialException internal constructor(message: String) : IOException(message)
|
||||
|
||||
/**
|
||||
* Normalize only harmless surrounding horizontal whitespace. Credentials are
|
||||
* otherwise single-line visible ASCII: embedded whitespace, CR/LF, controls,
|
||||
* and non-ASCII input are rejected instead of repaired or logged.
|
||||
*/
|
||||
fun normalizeCredentialForHeader(raw: String, label: String): String {
|
||||
val normalized = raw.trim(' ', '\t')
|
||||
if (normalized.any { it < '!' || it > '~' }) {
|
||||
throw InvalidCredentialException(
|
||||
"Invalid $label — enter or import a single-line value.",
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun Request.Builder.bearerAuthorization(
|
||||
rawCredential: String,
|
||||
label: String,
|
||||
): Request.Builder {
|
||||
val credential = normalizeCredentialForHeader(rawCredential, label)
|
||||
if (credential.isEmpty()) return this
|
||||
return header("Authorization", "Bearer $credential")
|
||||
}
|
||||
|
||||
fun Request.Builder.credentialHeader(
|
||||
name: String,
|
||||
rawCredential: String,
|
||||
label: String,
|
||||
): Request.Builder {
|
||||
val credential = normalizeCredentialForHeader(rawCredential, label)
|
||||
if (credential.isEmpty()) return this
|
||||
return header(name, credential)
|
||||
}
|
||||
@@ -102,7 +102,11 @@ fun buildPluginProxyClient(
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val request = if (token != null) {
|
||||
chain.request().newBuilder()
|
||||
.header("X-Hermes-Relay-Session", token)
|
||||
.credentialHeader(
|
||||
"X-Hermes-Relay-Session",
|
||||
token,
|
||||
"Relay session credential",
|
||||
)
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
|
||||
@@ -471,7 +471,12 @@ class ChatHandler {
|
||||
* across the history reconcile; idempotent on the proactive [messageId] so a
|
||||
* re-delivered push (e.g. an outbound-buffer flush) never double-posts.
|
||||
*/
|
||||
fun addAgentThreadMessage(text: String, messageId: String?, agentName: String?) {
|
||||
fun addAgentThreadMessage(
|
||||
text: String,
|
||||
messageId: String?,
|
||||
agentName: String?,
|
||||
arrivedWhileAway: Boolean = false,
|
||||
) {
|
||||
val id = messageId?.let { "proactive-$it" } ?: "proactive-${java.util.UUID.randomUUID()}"
|
||||
_messages.update { list ->
|
||||
if (messageId != null && list.any { it.id == id }) return@update list
|
||||
@@ -481,6 +486,7 @@ class ChatHandler {
|
||||
content = text,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
agentName = agentName,
|
||||
badges = if (arrivedWhileAway) listOf("While away") else emptyList(),
|
||||
clientOnly = true,
|
||||
)
|
||||
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
@@ -524,6 +530,19 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [messageId] can be addressed without downgrading a durable
|
||||
* transcript to an ordinal-only rewind. A wholly legacy transcript has no
|
||||
* row ids and remains compatible with older Gateways. Once any visible
|
||||
* user turn has a durable row id, the selected turn must have one too;
|
||||
* otherwise the caller must refresh instead of guessing by position.
|
||||
*/
|
||||
fun hasSafeGatewayRewindAddress(messageId: String): Boolean {
|
||||
val userTurns = _messages.value.filter { it.isGatewayRewindUser() }
|
||||
val target = userTurns.firstOrNull { it.matchesIdentity(messageId) } ?: return false
|
||||
return target.rowId != null || userTurns.none { it.rowId != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebind visible user turns after Gateway rewrites a truncated durable
|
||||
* prefix. The response is positional in the same user-ordinal space used
|
||||
@@ -1520,7 +1539,8 @@ class ChatHandler {
|
||||
// this as the same visible row across the post-turn reload.
|
||||
prior.copy(
|
||||
id = messageId,
|
||||
rowId = item.rowId,
|
||||
rowId = item.resolvedRowId,
|
||||
reactions = item.reactions,
|
||||
role = role,
|
||||
content = cleanedContent,
|
||||
attachments = carriedAttachments,
|
||||
@@ -1551,7 +1571,8 @@ class ChatHandler {
|
||||
// nothing local to carry).
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
rowId = item.rowId,
|
||||
rowId = item.resolvedRowId,
|
||||
reactions = item.reactions,
|
||||
role = role,
|
||||
content = cleanedContent,
|
||||
attachments = carriedAttachments,
|
||||
@@ -1624,7 +1645,7 @@ class ChatHandler {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (relay, reload): token=${hit.token}")
|
||||
Log.d(TAG, "Media marker accepted from reloaded Relay history")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
@@ -2050,6 +2071,11 @@ class ChatHandler {
|
||||
title = resolvedTitle,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
updatedAt = activityAtMs,
|
||||
startedAt = startedAtMs,
|
||||
lastActivityAt = lastActivityAtMs,
|
||||
@@ -2485,7 +2511,7 @@ class ChatHandler {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (relay): token=${hit.token}")
|
||||
Log.d(TAG, "Media marker accepted from Relay stream")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/**
|
||||
* User-reviewed input for upstream `cron.manage` creation.
|
||||
*
|
||||
* Repeat is intentionally bounded on the client. Upstream treats zero and
|
||||
* negative values as an unlimited schedule, which is unsafe when the user
|
||||
* explicitly chose a finite run count.
|
||||
*/
|
||||
data class CronCreationDraft(
|
||||
val name: String,
|
||||
val schedule: String,
|
||||
val prompt: String,
|
||||
val repeat: Int? = null,
|
||||
val profile: String? = null,
|
||||
) {
|
||||
fun validated(): Result<CronCreationDraft> = runCatching {
|
||||
val cleanName = name.trim()
|
||||
val cleanSchedule = schedule.trim()
|
||||
val cleanPrompt = prompt.trim()
|
||||
require(cleanName.isNotEmpty()) { "Schedule name is required" }
|
||||
require(cleanSchedule.isNotEmpty()) { "Schedule is required" }
|
||||
require(cleanPrompt.isNotEmpty()) { "Task instructions are required" }
|
||||
require(repeat == null || repeat in MIN_REPEAT..MAX_REPEAT) {
|
||||
"Run count must be between $MIN_REPEAT and $MAX_REPEAT"
|
||||
}
|
||||
copy(
|
||||
name = cleanName,
|
||||
schedule = cleanSchedule,
|
||||
prompt = cleanPrompt,
|
||||
profile = profile?.trim()?.takeIf(String::isNotEmpty),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_REPEAT = 1
|
||||
const val MAX_REPEAT = 999
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse an optional finite-count field without ever coercing invalid input to unlimited. */
|
||||
internal fun parseFiniteRepeat(value: String): Result<Int?> = runCatching {
|
||||
val clean = value.trim()
|
||||
if (clean.isEmpty()) return@runCatching null
|
||||
val parsed = clean.toIntOrNull() ?: throw IllegalArgumentException("Run count must be a whole number")
|
||||
require(parsed in CronCreationDraft.MIN_REPEAT..CronCreationDraft.MAX_REPEAT) {
|
||||
"Run count must be between ${CronCreationDraft.MIN_REPEAT} and ${CronCreationDraft.MAX_REPEAT}"
|
||||
}
|
||||
parsed
|
||||
}
|
||||
@@ -66,6 +66,29 @@ data class DashboardStatus(
|
||||
@SerialName("gateway_mode") val gatewayMode: String? = null,
|
||||
val gateways: List<DashboardGatewayTopology> = emptyList(),
|
||||
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
|
||||
val memory: DashboardMemoryStatus? = null,
|
||||
val disk: DashboardDiskStatus? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardMemoryStatus(
|
||||
val pressure: String,
|
||||
@SerialName("gateway_rss_mb") val gatewayRssMb: Int? = null,
|
||||
@SerialName("system_total_mb") val systemTotalMb: Int? = null,
|
||||
@SerialName("system_available_mb") val systemAvailableMb: Int? = null,
|
||||
@SerialName("swap_used_mb") val swapUsedMb: Int? = null,
|
||||
@SerialName("sampled_at") val sampledAt: String? = null,
|
||||
@SerialName("last_boot_unclean") val lastBootUnclean: Boolean = false,
|
||||
@SerialName("last_boot_suspected_oom") val lastBootSuspectedOom: Boolean = false,
|
||||
@SerialName("boot_id") val bootId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardDiskStatus(
|
||||
val pressure: String,
|
||||
@SerialName("total_mb") val totalMb: Int? = null,
|
||||
@SerialName("free_mb") val freeMb: Int? = null,
|
||||
@SerialName("used_percent") val usedPercent: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -1711,6 +1734,35 @@ class DashboardApiClient(
|
||||
gatewayMode = root.stringField("gateway_mode"),
|
||||
gateways = gateways,
|
||||
componentHealth = parseComponentHealth(root),
|
||||
memory = parseMemoryStatus(root["memory"] as? JsonObject),
|
||||
disk = parseDiskStatus(root["disk"] as? JsonObject),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMemoryStatus(obj: JsonObject?): DashboardMemoryStatus? {
|
||||
obj ?: return null
|
||||
val pressure = obj.stringField("pressure")?.lowercase() ?: return null
|
||||
return DashboardMemoryStatus(
|
||||
pressure = pressure,
|
||||
gatewayRssMb = obj.intField("gateway_rss_mb"),
|
||||
systemTotalMb = obj.intField("system_total_mb"),
|
||||
systemAvailableMb = obj.intField("system_available_mb"),
|
||||
swapUsedMb = obj.intField("swap_used_mb"),
|
||||
sampledAt = obj.stringField("sampled_at"),
|
||||
lastBootUnclean = obj.booleanField("last_boot_unclean") ?: false,
|
||||
lastBootSuspectedOom = obj.booleanField("last_boot_suspected_oom") ?: false,
|
||||
bootId = obj.stringField("boot_id"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDiskStatus(obj: JsonObject?): DashboardDiskStatus? {
|
||||
obj ?: return null
|
||||
val pressure = obj.stringField("pressure")?.lowercase() ?: return null
|
||||
return DashboardDiskStatus(
|
||||
pressure = pressure,
|
||||
totalMb = obj.intField("total_mb"),
|
||||
freeMb = obj.intField("free_mb"),
|
||||
usedPercent = (obj["used_percent"] as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+700
-38
@@ -2,13 +2,20 @@ package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.GatewayProfileConfigureResult
|
||||
import com.hermesandroid.relay.data.GatewayProfileAsset
|
||||
import com.hermesandroid.relay.data.GatewayProfileAuthChoice
|
||||
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
|
||||
import com.hermesandroid.relay.data.GatewayProfileCreateResult
|
||||
import com.hermesandroid.relay.data.GatewayProfileDescription
|
||||
import com.hermesandroid.relay.data.GatewayProfileEditorClient
|
||||
import com.hermesandroid.relay.data.GatewayProfileEditorUnsupportedException
|
||||
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
|
||||
import com.hermesandroid.relay.data.GatewayProfilePatch
|
||||
import com.hermesandroid.relay.data.GatewayProfileSection
|
||||
import com.hermesandroid.relay.data.GatewayProfileSkill
|
||||
import com.hermesandroid.relay.data.GatewayProfileToolset
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
@@ -41,6 +48,7 @@ import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.Base64
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -102,7 +110,17 @@ class GatewayChatClient(
|
||||
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
|
||||
private val sessionSource = "webui"
|
||||
@Volatile
|
||||
private var profileEditorSupported: Boolean? = null
|
||||
private var profileDescribeSupported: Boolean? = null
|
||||
@Volatile
|
||||
private var profileConfigureSupported: Boolean? = null
|
||||
@Volatile
|
||||
private var profileListSupported: Boolean? = null
|
||||
@Volatile
|
||||
private var profileCreateSupported: Boolean? = null
|
||||
@Volatile
|
||||
private var profileGetAssetSupported: Boolean? = null
|
||||
@Volatile
|
||||
private var profileSetAssetSupported: Boolean? = null
|
||||
companion object {
|
||||
private const val TAG = "GatewayChatClient"
|
||||
|
||||
@@ -126,6 +144,7 @@ class GatewayChatClient(
|
||||
private const val ASK_UNBOUNDED_TIMEOUT_MS = 600_000L
|
||||
|
||||
private const val RPC_TIMEOUT_MS = 15_000L
|
||||
const val PROFILE_AVATAR_MAX_BYTES = 2_000_000
|
||||
|
||||
/**
|
||||
* `prompt.submit` ack ceiling — mirrors upstream desktop's
|
||||
@@ -373,14 +392,10 @@ class GatewayChatClient(
|
||||
sessionProfileProvider().takeIf { !it.isNullOrBlank() }
|
||||
|
||||
/**
|
||||
* Supplies the explicit in-chat overrides to bind onto each fresh
|
||||
* `session.create` (upstream honors `model`/`provider`/`reasoning_effort`/
|
||||
* `fast` → the new session's per-session overrides). Pulled live so it
|
||||
* always reflects the current picker + safety/speed controls; null (or all
|
||||
* fields null) = no explicit override, so the new session inherits the
|
||||
* profile / server default. Wired by ChatViewModel. A live session keeps its
|
||||
* agent config, so this only affects session creation — mid-session switches
|
||||
* go through [setModel]/[setReasoning]/[setFast] (`config.set`).
|
||||
* Supplies non-model overrides for each fresh `session.create`. Model and
|
||||
* provider must never ride this raw create contract because that bypasses
|
||||
* upstream model-selection confirmation; all model changes use [setModel]
|
||||
* after [prepareModelSelectionSession].
|
||||
*/
|
||||
@Volatile
|
||||
var sessionModelProvider: () -> GatewaySessionModel? = { null }
|
||||
@@ -531,6 +546,9 @@ class GatewayChatClient(
|
||||
* user explicitly queued behind an active turn. Newer gateways use the
|
||||
* additive `queued:true` marker to preserve run-after semantics while
|
||||
* the previous turn is still settling; older gateways ignore it.
|
||||
* @param onAttachmentFailure invoked when attachment bytes could not be
|
||||
* safely bound to this Gateway turn. Callers must surface that failure
|
||||
* instead of falling back to a transport without equivalent media.
|
||||
* @param onPreflightFailure invoked INSTEAD of starting the turn when the
|
||||
* gateway could not be reached / authenticated / the prompt could not
|
||||
* be submitted — i.e. nothing started server-side, so the caller can
|
||||
@@ -547,6 +565,7 @@ class GatewayChatClient(
|
||||
truncateBeforeRowId: Long? = null,
|
||||
queuedFollowUp: Boolean = false,
|
||||
onSurvivorUserRowIds: (List<Long?>) -> Unit = { },
|
||||
onAttachmentFailure: ((String) -> Unit)? = null,
|
||||
onPreflightFailure: (reason: String) -> Unit,
|
||||
): ActiveTurnHandle {
|
||||
val turn = GatewayTurn(dispatchOn(callbacks))
|
||||
@@ -568,21 +587,33 @@ class GatewayChatClient(
|
||||
turn.tracer.mark("session")
|
||||
}
|
||||
if (turn.cancelled) return@launch
|
||||
val stagedImagePaths = mutableListOf<String>()
|
||||
val attachmentRefs = attachments.mapNotNull { attachment ->
|
||||
val upload = uploadAttachment(attachment).getOrElse { e ->
|
||||
throw GatewayPreflightException("attachment upload failed: ${e.message}")
|
||||
cleanupStagedAttachments(stagedImagePaths)
|
||||
throw GatewayAttachmentPreflightException("attachment upload failed: ${e.message}")
|
||||
}
|
||||
stagedImagePaths += upload.attachedImagePaths()
|
||||
if (attachment.requiresPromptReference()) {
|
||||
upload.stringField("ref_text")
|
||||
?: throw GatewayPreflightException(
|
||||
"attachment upload failed: Hermes returned no readable file reference",
|
||||
)
|
||||
?: run {
|
||||
cleanupStagedAttachments(stagedImagePaths)
|
||||
throw GatewayAttachmentPreflightException(
|
||||
"attachment upload failed: Hermes returned no readable file reference",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (turn.cancelled) return@launch
|
||||
if (!awaitCancelledTurnDrain(turn, storedSessionId)) return@launch
|
||||
if (turn.cancelled) {
|
||||
cleanupStagedAttachments(stagedImagePaths)
|
||||
return@launch
|
||||
}
|
||||
if (!awaitCancelledTurnDrain(turn, storedSessionId)) {
|
||||
cleanupStagedAttachments(stagedImagePaths)
|
||||
return@launch
|
||||
}
|
||||
activeTurn = turn
|
||||
turn.armWatchdog()
|
||||
// Generic `file.attach` uploads are staged artifacts, not
|
||||
@@ -598,9 +629,11 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", liveSessionId ?: error("no live session"))
|
||||
put("text", submittedText)
|
||||
if (truncateBeforeUserOrdinal != null || truncateBeforeRowId != null) {
|
||||
put("confirm_truncate", true)
|
||||
}
|
||||
truncateBeforeUserOrdinal?.let { ordinal ->
|
||||
put("truncate_before_user_ordinal", ordinal)
|
||||
put("confirm_truncate", true)
|
||||
if (ordinal == 0) put("confirm_empty_truncate", true)
|
||||
}
|
||||
truncateBeforeRowId?.let { put("truncate_before_row_id", it) }
|
||||
@@ -636,12 +669,19 @@ class GatewayChatClient(
|
||||
// and duplicate the optimistic user turn on another
|
||||
// transport. Surface the holder-aware upstream message
|
||||
// through the normal failed-turn callback instead.
|
||||
cleanupStagedAttachments(stagedImagePaths)
|
||||
turn.tracer.done("submit-rejected")
|
||||
turn.callbacks.onError(
|
||||
submitError?.message ?: "Hermes rejected the new session",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
if (attachments.isNotEmpty()) {
|
||||
cleanupStagedAttachments(stagedImagePaths)
|
||||
throw GatewayAttachmentPreflightException(
|
||||
submitError?.message ?: "attachment prompt submission could not be confirmed",
|
||||
)
|
||||
}
|
||||
throw GatewayPreflightException(
|
||||
submitError?.message ?: "prompt.submit failed",
|
||||
)
|
||||
@@ -665,6 +705,18 @@ class GatewayChatClient(
|
||||
turn.tracer.done("resume-rejected")
|
||||
turn.callbacks.onError(e.message ?: "Hermes could not resume this session")
|
||||
}
|
||||
} catch (e: GatewayAttachmentPreflightException) {
|
||||
if (activeTurn === turn) activeTurn = null
|
||||
if (!turn.cancelled) {
|
||||
Log.w(TAG, "Gateway attachment preflight failed: ${e.message}")
|
||||
turn.disarmWatchdog()
|
||||
turn.tracer.done("attachment-preflight-fail")
|
||||
callbackDispatcher {
|
||||
(onAttachmentFailure ?: onPreflightFailure)(
|
||||
e.message ?: "attachment could not be sent",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (activeTurn === turn) activeTurn = null
|
||||
if (!turn.cancelled) {
|
||||
@@ -850,6 +902,66 @@ class GatewayChatClient(
|
||||
return sessionReady
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a session-scoped target before a model-selection `config.set`.
|
||||
*
|
||||
* Unlike [prewarmAwait], a draft is deliberately materialized here because
|
||||
* sending a model with `session.create` bypasses upstream's cost/data-policy
|
||||
* confirmation guard, while a sessionless `config.set` mutates global
|
||||
* configuration. Draft creation therefore binds only the profile and
|
||||
* non-model session options; the model always follows through [setModel].
|
||||
*/
|
||||
suspend fun prepareModelSelectionSession(requestedStoredId: String?): Result<String> {
|
||||
if (requestedStoredId != null) {
|
||||
return if (prewarmAwait(requestedStoredId)) {
|
||||
Result.success(requestedStoredId)
|
||||
} else {
|
||||
Result.failure(GatewayPreflightException("gateway session is unavailable"))
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val requestedProfile = currentSessionProfile()
|
||||
connectMutex.withLock {
|
||||
ensureConnected()
|
||||
val reusableStoredId = storedSessionId
|
||||
if (
|
||||
liveSessionId != null && reusableStoredId != null &&
|
||||
liveSessionProfile == requestedProfile
|
||||
) {
|
||||
return@withLock reusableStoredId
|
||||
}
|
||||
if (liveSessionId != null || storedSessionId != null) {
|
||||
throw GatewayPreflightException("draft session state changed before model selection")
|
||||
}
|
||||
val created = rpc(
|
||||
"session.create",
|
||||
buildJsonObject {
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
currentSessionModel()?.let { options ->
|
||||
options.reasoningEffort
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { put("reasoning_effort", it) }
|
||||
options.fast?.let { put("fast", it) }
|
||||
}
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException("session.create failed: ${error.message}")
|
||||
}
|
||||
requireConfirmedSessionProfile(created, requestedProfile)
|
||||
val live = created.stringField("session_id")
|
||||
?: throw GatewayPreflightException("session.create returned no session_id")
|
||||
val stored = created.stringField("stored_session_id") ?: live
|
||||
liveSessionId = live
|
||||
storedSessionId = stored
|
||||
liveSessionProfile = requestedProfile
|
||||
if (cancelledTurnDrain?.storedSessionId != stored) cancelledTurnDrain = null
|
||||
stored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattach callbacks to a turn that survived the Android UI/process.
|
||||
*
|
||||
@@ -944,6 +1056,22 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
requireConfirmedSessionProfile(response, requestedProfile)
|
||||
} catch (error: GatewayPreflightException) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn?.discardDeferredEvents()
|
||||
boundTurn?.detach()
|
||||
if (!preferredLiveId.isNullOrBlank()) {
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
|
||||
}
|
||||
liveSessionId = null
|
||||
storedSessionId = null
|
||||
liveSessionProfile = null
|
||||
throw error
|
||||
}
|
||||
|
||||
val recoveredLiveId = response.stringField("session_id")
|
||||
?: run {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
@@ -1280,6 +1408,245 @@ class GatewayChatClient(
|
||||
.onSuccess { commandsCatalogCache = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a schedule through upstream's authenticated `cron.manage` RPC.
|
||||
* No Relay scheduler or compatibility endpoint is involved.
|
||||
*/
|
||||
suspend fun createCronJob(draft: CronCreationDraft): Result<JsonObject> {
|
||||
val validated = draft.validated().getOrElse { return Result.failure(it) }
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
return rpc(
|
||||
"cron.manage",
|
||||
buildJsonObject {
|
||||
put("action", "add")
|
||||
put("name", validated.name)
|
||||
put("schedule", validated.schedule)
|
||||
put("prompt", validated.prompt)
|
||||
validated.repeat?.let { put("repeat", it) }
|
||||
validated.profile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Current Hermes-owned profile roster; older gateways fail soft to the Dashboard/Relay list. */
|
||||
suspend fun listProfiles(): Result<List<Profile>> {
|
||||
if (profileListSupported == false) {
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
val response = rpc(
|
||||
"profiles.list",
|
||||
buildJsonObject { put("include_sessions", false) },
|
||||
)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileListSupported = false
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
|
||||
}
|
||||
return response.mapCatching { payload ->
|
||||
(payload["profiles"] as? JsonArray).orEmpty().mapNotNull { raw ->
|
||||
val row = raw as? JsonObject ?: return@mapNotNull null
|
||||
val name = row.stringField("name")?.trim()?.takeIf(String::isNotEmpty)
|
||||
?: return@mapNotNull null
|
||||
val uiMeta = (row["ui_meta"] as? JsonObject)
|
||||
?.takeIf(::isSafeProfileUiMeta)
|
||||
?: JsonObject(emptyMap())
|
||||
Profile(
|
||||
name = name,
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description").orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
uiMeta = uiMeta,
|
||||
)
|
||||
}
|
||||
}.onSuccess { profileListSupported = true }
|
||||
}
|
||||
|
||||
/** Create through the Gateway so auth behavior is explicit and server-owned. */
|
||||
suspend fun createProfile(request: GatewayProfileCreateRequest): Result<GatewayProfileCreateResult> {
|
||||
if (profileCreateSupported == false) {
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.create"))
|
||||
}
|
||||
val name = request.name.trim()
|
||||
if (name.isEmpty()) return Result.failure(IllegalArgumentException("profile name required"))
|
||||
if ((request.model == null) != (request.provider == null)) {
|
||||
return Result.failure(IllegalArgumentException("provider and model must be supplied together"))
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
val response = rpc(
|
||||
"profiles.create",
|
||||
buildJsonObject {
|
||||
put("name", name)
|
||||
request.description?.trim()?.takeIf(String::isNotEmpty)?.let { put("description", it) }
|
||||
request.cloneFrom?.trim()?.takeIf(String::isNotEmpty)?.let { put("clone_from", it) }
|
||||
put("clone_all", request.cloneAll)
|
||||
put("no_skills", request.noSkills)
|
||||
request.soul?.let { put("soul", it) }
|
||||
request.model?.trim()?.takeIf(String::isNotEmpty)?.let { put("model", it) }
|
||||
request.provider?.trim()?.takeIf(String::isNotEmpty)?.let { put("provider", it) }
|
||||
put("mirror_credentials", request.authChoice != GatewayProfileAuthChoice.Isolated)
|
||||
put("share_auth", request.authChoice == GatewayProfileAuthChoice.Shared)
|
||||
},
|
||||
)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileCreateSupported = false
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.create"))
|
||||
}
|
||||
return response.mapCatching { payload ->
|
||||
val returnedName = payload.stringField("name")
|
||||
?: throw GatewayRpcException("profiles.create returned no profile name")
|
||||
if (returnedName != name) throw GatewayRpcException("profiles.create returned a different profile")
|
||||
val mirrored = payload["mirrored"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
val auth = mirrored["auth"] as? JsonPrimitive
|
||||
GatewayProfileCreateResult(
|
||||
name = returnedName,
|
||||
soulWritten = (payload["soul_written"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
modelSet = (payload["model_set"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
mirroredEnvironment = (mirrored["env"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
mirroredAuth = auth?.contentOrNull,
|
||||
modelInherited = (mirrored["model_inherited"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
voiceMirrored = (mirrored["voice"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
)
|
||||
}.onSuccess { profileCreateSupported = true }
|
||||
}
|
||||
|
||||
suspend fun getProfileAvatar(profileName: String): Result<GatewayProfileAsset?> {
|
||||
if (profileGetAssetSupported == false) {
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.get_asset"))
|
||||
}
|
||||
val name = profileName.trim()
|
||||
if (name.isEmpty()) return Result.failure(IllegalArgumentException("profile name required"))
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
val response = rpc(
|
||||
"profiles.get_asset",
|
||||
buildJsonObject { put("name", name); put("asset", "avatar") },
|
||||
)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileGetAssetSupported = false
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.get_asset"))
|
||||
}
|
||||
return response.mapCatching { payload ->
|
||||
if ((payload["found"] as? JsonPrimitive)?.booleanOrNull != true) return@mapCatching null
|
||||
val declaredMime = payload.stringField("mime")
|
||||
?: throw GatewayRpcException("profiles.get_asset returned no mime")
|
||||
val encoded = payload.stringField("data")
|
||||
?: throw GatewayRpcException("profiles.get_asset returned no data")
|
||||
val (dataMime, base64) = splitImageData(encoded)
|
||||
if (dataMime != null && dataMime != declaredMime) {
|
||||
throw GatewayRpcException("profiles.get_asset mime mismatch")
|
||||
}
|
||||
val bytes = runCatching { Base64.getDecoder().decode(base64) }
|
||||
.getOrElse { throw GatewayRpcException("profiles.get_asset returned malformed base64") }
|
||||
if (bytes.size > PROFILE_AVATAR_MAX_BYTES) {
|
||||
throw GatewayRpcException("profiles.get_asset returned an oversized avatar")
|
||||
}
|
||||
val actualMime = imageMime(bytes)
|
||||
?: throw GatewayRpcException("profiles.get_asset returned an unsupported image")
|
||||
if (actualMime != declaredMime) throw GatewayRpcException("profiles.get_asset magic mismatch")
|
||||
val declaredSize = (payload["size"] as? JsonPrimitive)?.intOrNull
|
||||
if (declaredSize != null && declaredSize != bytes.size) {
|
||||
throw GatewayRpcException("profiles.get_asset size mismatch")
|
||||
}
|
||||
GatewayProfileAsset(bytes, actualMime)
|
||||
}.onSuccess { profileGetAssetSupported = true }
|
||||
}
|
||||
|
||||
suspend fun setProfileAvatar(profileName: String, bytes: ByteArray): Result<Int> {
|
||||
val mime = imageMime(bytes)
|
||||
?: return Result.failure(IllegalArgumentException("avatar must be PNG, JPEG, or WebP"))
|
||||
if (bytes.size > PROFILE_AVATAR_MAX_BYTES) {
|
||||
return Result.failure(IllegalArgumentException("avatar exceeds the 2,000,000 byte limit"))
|
||||
}
|
||||
return setProfileAvatarPayload(
|
||||
profileName,
|
||||
expectedSize = bytes.size,
|
||||
params = buildJsonObject {
|
||||
put("name", profileName.trim())
|
||||
put("asset", "avatar")
|
||||
put("data", "data:$mime;base64,${Base64.getEncoder().encodeToString(bytes)}")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun clearProfileAvatar(profileName: String): Result<Int> = setProfileAvatarPayload(
|
||||
profileName,
|
||||
expectedSize = 0,
|
||||
params = buildJsonObject {
|
||||
put("name", profileName.trim())
|
||||
put("asset", "avatar")
|
||||
put("clear", true)
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun setProfileAvatarPayload(
|
||||
profileName: String,
|
||||
expectedSize: Int,
|
||||
params: JsonObject,
|
||||
): Result<Int> {
|
||||
if (profileSetAssetSupported == false) {
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.set_asset"))
|
||||
}
|
||||
if (profileName.trim().isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("profile name required"))
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
val response = rpc("profiles.set_asset", params)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileSetAssetSupported = false
|
||||
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.set_asset"))
|
||||
}
|
||||
return response.mapCatching { payload ->
|
||||
if ((payload["ok"] as? JsonPrimitive)?.booleanOrNull != true) {
|
||||
throw GatewayRpcException("profiles.set_asset was not acknowledged")
|
||||
}
|
||||
val size = (payload["size"] as? JsonPrimitive)?.intOrNull
|
||||
?: throw GatewayRpcException("profiles.set_asset returned no size")
|
||||
if (size != expectedSize) throw GatewayRpcException("profiles.set_asset size mismatch")
|
||||
size
|
||||
}.onSuccess { profileSetAssetSupported = true }
|
||||
}
|
||||
|
||||
private fun splitImageData(value: String): Pair<String?, String> {
|
||||
if (!value.startsWith("data:")) return null to value
|
||||
val marker = ";base64,"
|
||||
val split = value.indexOf(marker)
|
||||
if (split <= 5) throw GatewayRpcException("profiles.get_asset returned a malformed data URL")
|
||||
return value.substring(5, split) to value.substring(split + marker.length)
|
||||
}
|
||||
|
||||
private fun imageMime(bytes: ByteArray): String? = when {
|
||||
bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals(
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
) -> "image/png"
|
||||
bytes.size >= 3 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() &&
|
||||
bytes[2] == 0xff.toByte() -> "image/jpeg"
|
||||
bytes.size >= 12 && bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
|
||||
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) -> "image/webp"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability probe and authoritative editor snapshot. A method-not-found
|
||||
* response is sticky for this client so older Hermes builds keep using the
|
||||
@@ -1288,7 +1655,7 @@ class GatewayChatClient(
|
||||
override suspend fun describeProfile(
|
||||
profileName: String,
|
||||
): Result<GatewayProfileDescription> {
|
||||
if (profileEditorSupported == false) {
|
||||
if (profileDescribeSupported == false) {
|
||||
return Result.failure(GatewayProfileEditorUnsupportedException())
|
||||
}
|
||||
val name = profileName.trim()
|
||||
@@ -1304,13 +1671,13 @@ class GatewayChatClient(
|
||||
)
|
||||
val error = response.exceptionOrNull()
|
||||
if (error.isMethodNotFound()) {
|
||||
profileEditorSupported = false
|
||||
profileDescribeSupported = false
|
||||
return Result.failure(GatewayProfileEditorUnsupportedException())
|
||||
}
|
||||
return response.mapCatching { payload ->
|
||||
parseProfileDescription(payload, expectedName = name)
|
||||
}.onSuccess {
|
||||
profileEditorSupported = true
|
||||
profileDescribeSupported = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1319,7 +1686,7 @@ class GatewayChatClient(
|
||||
profileName: String,
|
||||
patch: GatewayProfilePatch,
|
||||
): Result<GatewayProfileConfigureResult> {
|
||||
if (profileEditorSupported != true) {
|
||||
if (profileDescribeSupported != true || profileConfigureSupported == false) {
|
||||
return Result.failure(GatewayProfileEditorUnsupportedException())
|
||||
}
|
||||
val name = profileName.trim()
|
||||
@@ -1327,6 +1694,9 @@ class GatewayChatClient(
|
||||
if ((patch.provider == null) != (patch.model == null)) {
|
||||
return Result.failure(IllegalArgumentException("provider and model must be saved together"))
|
||||
}
|
||||
if (patch.uiMeta != null && !isSafeProfileUiMeta(patch.uiMeta)) {
|
||||
return Result.failure(IllegalArgumentException("ui_meta must contain only small preference/reference metadata"))
|
||||
}
|
||||
val requested = patch.requestedSections
|
||||
if (requested.isEmpty()) return Result.success(GatewayProfileConfigureResult(emptySet(), emptySet()))
|
||||
val params = buildJsonObject {
|
||||
@@ -1341,14 +1711,25 @@ class GatewayChatClient(
|
||||
patch.enabledToolsets?.let { names ->
|
||||
put("enabled_toolsets", JsonArray(names.map(::JsonPrimitive)))
|
||||
}
|
||||
patch.enabledMcpServers?.let { names ->
|
||||
put("enabled_mcp_servers", JsonArray(names.map(::JsonPrimitive)))
|
||||
}
|
||||
patch.uiMeta?.let { put("ui_meta", it) }
|
||||
}
|
||||
return rpc("profiles.configure", params).mapCatching { payload ->
|
||||
val response = rpc("profiles.configure", params)
|
||||
if (response.exceptionOrNull().isMethodNotFound()) {
|
||||
profileConfigureSupported = false
|
||||
return Result.failure(GatewayProfileEditorUnsupportedException())
|
||||
}
|
||||
return response.mapCatching { payload ->
|
||||
val appliedObject = payload["applied"] as? JsonObject
|
||||
?: throw GatewayRpcException("profiles.configure returned no applied map")
|
||||
val applied = requested.filterTo(linkedSetOf()) { section ->
|
||||
(appliedObject[section.wireName] as? JsonPrimitive)?.booleanOrNull == true
|
||||
}
|
||||
GatewayProfileConfigureResult(requested = requested, applied = applied)
|
||||
}.onSuccess {
|
||||
profileConfigureSupported = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1828,78 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the active profile-scoped Hermes pet and its renderer contract. */
|
||||
suspend fun petInfo(
|
||||
profile: String? = currentSessionProfile(),
|
||||
knownRevision: String? = null,
|
||||
): Result<GatewayPetInfo> {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
return rpc(
|
||||
"pet.info",
|
||||
buildJsonObject {
|
||||
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
|
||||
knownRevision?.trim()?.takeIf { it.isNotEmpty() }?.let { put("knownRevision", it) }
|
||||
},
|
||||
).mapCatching(::parseGatewayPetInfo)
|
||||
}
|
||||
|
||||
/** List adoptable pets for the active Hermes profile. */
|
||||
suspend fun petGallery(
|
||||
profile: String? = currentSessionProfile(),
|
||||
localOnly: Boolean = false,
|
||||
): Result<GatewayPetGallery> {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
return rpc(
|
||||
"pet.gallery",
|
||||
buildJsonObject {
|
||||
put("localOnly", localOnly)
|
||||
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
|
||||
},
|
||||
).mapCatching(::parseGatewayPetGallery)
|
||||
}
|
||||
|
||||
/** Install if necessary and activate one pet in the selected Hermes profile. */
|
||||
suspend fun selectPet(
|
||||
slug: String,
|
||||
profile: String? = currentSessionProfile(),
|
||||
): Result<Unit> = mutatePet("pet.select", slug, profile)
|
||||
|
||||
/** Disable the active pet without deleting it from the selected Hermes profile. */
|
||||
suspend fun disablePet(profile: String? = currentSessionProfile()): Result<Unit> =
|
||||
mutatePet("pet.disable", slug = null, profile = profile)
|
||||
|
||||
private suspend fun mutatePet(method: String, slug: String?, profile: String?): Result<Unit> {
|
||||
val normalizedSlug = slug?.trim()
|
||||
if (normalizedSlug != null && !PETDEX_SLUG.matches(normalizedSlug)) {
|
||||
return Result.failure(IllegalArgumentException("invalid Petdex slug"))
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
return rpc(
|
||||
method,
|
||||
buildJsonObject {
|
||||
normalizedSlug?.let { put("slug", it) }
|
||||
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
|
||||
},
|
||||
timeoutMs = PET_MUTATION_TIMEOUT_MS,
|
||||
).mapCatching { response ->
|
||||
if ((response["ok"] as? JsonPrimitive)?.booleanOrNull != true) {
|
||||
throw GatewayRpcException("$method returned an invalid response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current chat session's running and recently-finished background
|
||||
* processes. Callers never provide a session id: this wrapper resolves and
|
||||
@@ -1646,12 +2099,16 @@ class GatewayChatClient(
|
||||
* which falls through to `command.dispatch` and reports a spurious
|
||||
* "not a quick/plugin/skill command" failure even when the switch applied.
|
||||
*/
|
||||
suspend fun setModel(value: String): Result<JsonObject> =
|
||||
suspend fun setModel(
|
||||
value: String,
|
||||
confirmSelection: Boolean = false,
|
||||
): Result<JsonObject> =
|
||||
rpc(
|
||||
"config.set",
|
||||
buildJsonObject {
|
||||
put("key", "model")
|
||||
put("value", value)
|
||||
if (confirmSelection) put("confirm_expensive_model", true)
|
||||
liveSessionId?.let { put("session_id", it) }
|
||||
},
|
||||
)
|
||||
@@ -1661,7 +2118,7 @@ class GatewayChatClient(
|
||||
* id. This follows the upstream gateway contract, which resolves the row
|
||||
* atomically inside the active session. A null emoji removes the reaction.
|
||||
*/
|
||||
suspend fun reactToNewest(role: String, emoji: String?): Result<JsonObject> {
|
||||
suspend fun reactToMessage(rowId: Long?, role: String, emoji: String?): Result<JsonObject> {
|
||||
require(role == "user" || role == "assistant") { "unsupported reaction role" }
|
||||
val sid = liveSessionId
|
||||
?: return Result.failure(GatewayRpcException("no live session"))
|
||||
@@ -1669,7 +2126,7 @@ class GatewayChatClient(
|
||||
"message.react",
|
||||
buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("newest_role", role)
|
||||
if (rowId != null) put("row_id", rowId) else put("newest_role", role)
|
||||
if (emoji == null) put("emoji", JsonNull) else put("emoji", emoji)
|
||||
put("author", "user")
|
||||
},
|
||||
@@ -1974,7 +2431,11 @@ class GatewayChatClient(
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
val result = resumed.getOrNull()
|
||||
val result = resumed.getOrNull()?.takeIf { response ->
|
||||
runCatching { requireConfirmedSessionProfile(response, requestedProfile) }
|
||||
.onFailure { Log.w(TAG, "Gateway prewarm rejected profile scope: ${it.message}") }
|
||||
.isSuccess
|
||||
}
|
||||
val live = result?.stringField("session_id")
|
||||
if (
|
||||
live != null &&
|
||||
@@ -2067,6 +2528,28 @@ class GatewayChatClient(
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A named profile is safe to bind only when Hermes echoes that exact owner
|
||||
* in the authoritative session result. Current multiplex gateways report
|
||||
* `info.profile_name` on create/resume. Missing metadata means an older
|
||||
* gateway may have ignored `params.profile`; a different name means the
|
||||
* requested profile disappeared or resolved to the launch profile.
|
||||
*/
|
||||
private fun requireConfirmedSessionProfile(result: JsonObject, requestedProfile: String?) {
|
||||
val expected = requestedProfile?.trim()?.takeIf { it.isNotEmpty() } ?: return
|
||||
val actual = (result["info"] as? JsonObject)
|
||||
?.stringField("profile_name")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
if (actual != expected) {
|
||||
val detail = actual?.let { "Hermes returned profile '$it'" }
|
||||
?: "Hermes did not confirm profile ownership"
|
||||
throw GatewayPreflightException(
|
||||
"$detail; refusing to use it as selected profile '$expected'",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
|
||||
private suspend fun ensureLiveProcessSession(): Result<String> {
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -2167,6 +2650,7 @@ class GatewayChatClient(
|
||||
}
|
||||
val live = result?.stringField("session_id")
|
||||
if (live != null) {
|
||||
requireConfirmedSessionProfile(result, requestedProfile)
|
||||
liveSessionId = live
|
||||
storedSessionId = requestedStoredId
|
||||
liveSessionProfile = requestedProfile
|
||||
@@ -2188,20 +2672,10 @@ class GatewayChatClient(
|
||||
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
|
||||
// per-session overrides. Upstream tui_gateway session.create
|
||||
// reads `model`/`provider` (→ model_override), `reasoning_effort`
|
||||
// (→ create_reasoning_override) and `fast` (→ priority service
|
||||
// tier) — verified server.py:4175-4191. Without this a fresh
|
||||
// chat ignores the picker/safety controls and builds the agent
|
||||
// from the global default, and worse, setting effort/fast before
|
||||
// the first message runs a SESSIONLESS config.set that upstream
|
||||
// applies as a GLOBAL config write. A live session keeps its own
|
||||
// config — this is create-only; mid-session switches use
|
||||
// config.set (setModel/setReasoning/setFast).
|
||||
// Only non-model draft settings may ride session.create. Model
|
||||
// and provider always pass through confirmation-aware config.set
|
||||
// before they become active.
|
||||
currentSessionModel()?.let { sm ->
|
||||
sm.model?.takeIf { it.isNotBlank() }?.let { put("model", it) }
|
||||
sm.provider?.takeIf { it.isNotBlank() }?.let { put("provider", it) }
|
||||
sm.reasoningEffort?.takeIf { it.isNotBlank() }?.let { put("reasoning_effort", it) }
|
||||
sm.fast?.let { put("fast", it) }
|
||||
}
|
||||
@@ -2211,6 +2685,7 @@ class GatewayChatClient(
|
||||
}
|
||||
val live = created.stringField("session_id")
|
||||
?: throw GatewayPreflightException("session.create returned no session_id")
|
||||
requireConfirmedSessionProfile(created, requestedProfile)
|
||||
val stored = created.stringField("stored_session_id") ?: live
|
||||
liveSessionId = live
|
||||
storedSessionId = stored
|
||||
@@ -2830,6 +3305,25 @@ class GatewayChatClient(
|
||||
val sid = liveSessionId
|
||||
?: return Result.failure(GatewayRpcException("no live session"))
|
||||
val mime = attachment.contentType.substringBefore(';').trim().lowercase()
|
||||
val size = maxOf(
|
||||
attachment.sizeBytes ?: 0L,
|
||||
decodedBase64Size(attachment.base64),
|
||||
)
|
||||
val limit = when {
|
||||
mime.startsWith("image/") -> ATTACH_IMAGE_MAX_BYTES
|
||||
mime == "application/pdf" -> ATTACH_PDF_MAX_BYTES
|
||||
else -> ATTACH_FILE_MAX_BYTES
|
||||
}
|
||||
if (size <= 0L) {
|
||||
return Result.failure(GatewayRpcException("attachment is empty"))
|
||||
}
|
||||
if (size > limit) {
|
||||
return Result.failure(
|
||||
GatewayRpcException(
|
||||
"attachment is too large (${size / (1024 * 1024)} MB; max ${limit / (1024 * 1024)} MB)",
|
||||
),
|
||||
)
|
||||
}
|
||||
return when {
|
||||
mime.startsWith("image/") -> uploadImage(sid, attachment)
|
||||
mime == "application/pdf" -> rpc(
|
||||
@@ -2855,6 +3349,23 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupStagedAttachments(paths: List<String>) {
|
||||
val sid = liveSessionId ?: return
|
||||
paths.asReversed().distinct().forEach { path ->
|
||||
val removed = rpc(
|
||||
"image.detach",
|
||||
buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("path", path)
|
||||
},
|
||||
timeoutMs = ATTACH_RPC_TIMEOUT_MS,
|
||||
)
|
||||
if (removed.isFailure) {
|
||||
Log.w(TAG, "Could not clean up staged attachment: ${removed.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun GatewayAttachment.requiresPromptReference(): Boolean {
|
||||
val mime = contentType.substringBefore(';').trim().lowercase()
|
||||
return !mime.startsWith("image/") && mime != "application/pdf"
|
||||
@@ -3330,11 +3841,15 @@ data class GatewayAttachment(
|
||||
val base64: String,
|
||||
val ext: String?,
|
||||
val contentType: String,
|
||||
val sizeBytes: Long? = null,
|
||||
)
|
||||
|
||||
/** Connect/auth/submit failed before the turn started — safe to fall back to SSE. */
|
||||
internal class GatewayPreflightException(message: String) : Exception(message)
|
||||
|
||||
/** Attachment bytes were not safely bound to a Gateway turn; never silently fall through to SSE. */
|
||||
internal class GatewayAttachmentPreflightException(message: String) : Exception(message)
|
||||
|
||||
/** One connect attempt failed; [GatewayChatClient] may retry with a fresh ticket. */
|
||||
internal class GatewayConnectAttemptException(message: String) : Exception(message)
|
||||
|
||||
@@ -3345,6 +3860,9 @@ internal class GatewayAuthoritativeResumeException(message: String) : Exception(
|
||||
internal class GatewayRpcException(message: String, val code: Int? = null) : Exception(message)
|
||||
|
||||
private const val JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
private const val ATTACH_IMAGE_MAX_BYTES = 25L * 1024L * 1024L
|
||||
private const val ATTACH_PDF_MAX_BYTES = 50L * 1024L * 1024L
|
||||
private const val ATTACH_FILE_MAX_BYTES = 50L * 1024L * 1024L
|
||||
private val AUTHORITATIVE_PROMPT_SUBMIT_REJECTIONS = setOf(
|
||||
4004, // malformed truncation target
|
||||
4018, // durable/ordinal target is no longer present
|
||||
@@ -3359,6 +3877,21 @@ private val AUTHORITATIVE_PROMPT_SUBMIT_REJECTIONS = setOf(
|
||||
private const val MAX_RECOVERED_CORRECTIONS = 32
|
||||
private const val MAX_RECOVERED_CORRECTION_CHARS = 32_768
|
||||
private const val PET_THUMB_DATA_PREFIX = "data:image/png;base64,"
|
||||
|
||||
internal fun decodedBase64Size(value: String): Long {
|
||||
val compactLength = value.count { !it.isWhitespace() }
|
||||
if (compactLength == 0) return 0L
|
||||
val padding = value.trimEnd().takeLast(2).count { it == '=' }
|
||||
return (compactLength.toLong() * 3L / 4L - padding).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun JsonObject.attachedImagePaths(): List<String> {
|
||||
val direct = stringField("path")?.let(::listOf).orEmpty()
|
||||
val pages = (this["pages"] as? JsonArray).orEmpty().mapNotNull { page ->
|
||||
(page as? JsonObject)?.stringField("path")
|
||||
}
|
||||
return direct + pages
|
||||
}
|
||||
private const val MAX_PET_THUMB_BASE64_CHARS = 512 * 1024
|
||||
private val PETDEX_SLUG = Regex("[a-z0-9][a-z0-9-]{0,127}")
|
||||
private val STANDARD_BASE64 = Regex("[A-Za-z0-9+/]*={0,2}")
|
||||
@@ -3381,6 +3914,135 @@ private fun isValidPetThumbnailDataUri(raw: String): Boolean {
|
||||
STANDARD_BASE64.matches(payload)
|
||||
}
|
||||
|
||||
private const val MAX_PET_SPRITESHEET_BYTES = 32L * 1024L * 1024L
|
||||
private const val PET_MUTATION_TIMEOUT_MS = 120_000L
|
||||
private const val MAX_PET_GALLERY_ITEMS = 10_000
|
||||
private const val MAX_PET_STATE_ROWS = 64
|
||||
|
||||
data class GatewayPetInfo(
|
||||
val enabled: Boolean,
|
||||
val slug: String? = null,
|
||||
val displayName: String? = null,
|
||||
val mime: String? = null,
|
||||
val spritesheet: ByteArray? = null,
|
||||
val spritesheetRevision: String? = null,
|
||||
val spritesheetUnchanged: Boolean = false,
|
||||
val frameWidth: Int = 0,
|
||||
val frameHeight: Int = 0,
|
||||
val framesPerState: Int = 0,
|
||||
val framesByState: Map<String, Int> = emptyMap(),
|
||||
val framesByRow: Map<String, Int> = emptyMap(),
|
||||
val loopMs: Int = 0,
|
||||
val scale: Float = 1f,
|
||||
val stateRows: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class GatewayPetGalleryItem(
|
||||
val slug: String,
|
||||
val displayName: String,
|
||||
val installed: Boolean,
|
||||
val spritesheetUrl: String?,
|
||||
val curated: Boolean,
|
||||
val generated: Boolean,
|
||||
)
|
||||
|
||||
data class GatewayPetGallery(
|
||||
val enabled: Boolean,
|
||||
val active: String?,
|
||||
val pets: List<GatewayPetGalleryItem>,
|
||||
)
|
||||
|
||||
private fun parseGatewayPetInfo(response: JsonObject): GatewayPetInfo {
|
||||
val enabled = (response["enabled"] as? JsonPrimitive)?.booleanOrNull
|
||||
?: throw GatewayRpcException("pet.info returned an invalid response")
|
||||
if (!enabled) return GatewayPetInfo(enabled = false)
|
||||
|
||||
val slug = response.stringField("slug")?.takeIf(PETDEX_SLUG::matches)
|
||||
?: throw GatewayRpcException("pet.info returned an invalid slug")
|
||||
val revision = response.stringField("spritesheetRevision")?.takeIf { it.length <= 256 }
|
||||
?: throw GatewayRpcException("pet.info returned no spritesheet revision")
|
||||
val unchanged = (response["spritesheetUnchanged"] as? JsonPrimitive)?.booleanOrNull == true
|
||||
val mime = response.stringField("mime")?.takeIf { it == "image/png" || it == "image/webp" }
|
||||
val encoded = response.stringField("spritesheetBase64")
|
||||
val spritesheet = when {
|
||||
encoded != null -> {
|
||||
if (decodedBase64Size(encoded) > MAX_PET_SPRITESHEET_BYTES ||
|
||||
encoded.length % 4 != 0 || !STANDARD_BASE64.matches(encoded)
|
||||
) throw GatewayRpcException("pet.info returned an invalid spritesheet")
|
||||
runCatching { Base64.getDecoder().decode(encoded) }
|
||||
.getOrElse { throw GatewayRpcException("pet.info returned an invalid spritesheet") }
|
||||
}
|
||||
unchanged -> null
|
||||
else -> throw GatewayRpcException("pet.info returned no spritesheet")
|
||||
}
|
||||
if (spritesheet != null && mime == null) {
|
||||
throw GatewayRpcException("pet.info returned an invalid spritesheet mime")
|
||||
}
|
||||
|
||||
fun requiredDimension(name: String, max: Int): Int =
|
||||
(response[name] as? JsonPrimitive)?.intOrNull
|
||||
?.takeIf { it in 1..max }
|
||||
?: throw GatewayRpcException("pet.info returned an invalid $name")
|
||||
|
||||
val rows = (response["stateRows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { name ->
|
||||
name.isNotEmpty() && name.length <= 64
|
||||
}
|
||||
}
|
||||
if (rows.isEmpty() || rows.size > MAX_PET_STATE_ROWS) {
|
||||
throw GatewayRpcException("pet.info returned invalid state rows")
|
||||
}
|
||||
fun frameCounts(name: String): Map<String, Int> {
|
||||
val values = response[name] as? JsonObject ?: return emptyMap()
|
||||
if (values.size > MAX_PET_STATE_ROWS) throw GatewayRpcException("pet.info returned too many frame counts")
|
||||
return values.mapNotNull { (key, value) ->
|
||||
val count = (value as? JsonPrimitive)?.intOrNull
|
||||
if (key.length <= 64 && count != null && count in 1..120) key to count else null
|
||||
}.toMap()
|
||||
}
|
||||
return GatewayPetInfo(
|
||||
enabled = true,
|
||||
slug = slug,
|
||||
displayName = response.stringField("displayName")?.take(256) ?: slug,
|
||||
mime = mime,
|
||||
spritesheet = spritesheet,
|
||||
spritesheetRevision = revision,
|
||||
spritesheetUnchanged = unchanged,
|
||||
frameWidth = requiredDimension("frameW", 4096),
|
||||
frameHeight = requiredDimension("frameH", 4096),
|
||||
framesPerState = requiredDimension("framesPerState", 120),
|
||||
framesByState = frameCounts("framesByState"),
|
||||
framesByRow = frameCounts("framesByRow"),
|
||||
loopMs = requiredDimension("loopMs", 60_000),
|
||||
scale = response.stringField("scale")?.toFloatOrNull()?.coerceIn(0.1f, 3f) ?: 1f,
|
||||
stateRows = rows,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseGatewayPetGallery(response: JsonObject): GatewayPetGallery {
|
||||
val enabled = (response["enabled"] as? JsonPrimitive)?.booleanOrNull
|
||||
?: throw GatewayRpcException("pet.gallery returned an invalid response")
|
||||
val active = response.stringField("active")?.takeIf(PETDEX_SLUG::matches)
|
||||
val rawPets = (response["pets"] as? JsonArray).orEmpty()
|
||||
if (rawPets.size > MAX_PET_GALLERY_ITEMS) {
|
||||
throw GatewayRpcException("pet.gallery returned too many pets")
|
||||
}
|
||||
val pets = rawPets.mapNotNull { element ->
|
||||
val item = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = item.stringField("slug")?.takeIf(PETDEX_SLUG::matches) ?: return@mapNotNull null
|
||||
val rawUrl = item.stringField("spritesheetUrl")?.trim().orEmpty()
|
||||
GatewayPetGalleryItem(
|
||||
slug = slug,
|
||||
displayName = item.stringField("displayName")?.take(256)?.ifBlank { slug } ?: slug,
|
||||
installed = (item["installed"] as? JsonPrimitive)?.booleanOrNull == true,
|
||||
spritesheetUrl = rawUrl.takeIf { it.isNotEmpty() && isTrustedPetdexAssetUrl(it) },
|
||||
curated = (item["curated"] as? JsonPrimitive)?.booleanOrNull == true,
|
||||
generated = (item["generated"] as? JsonPrimitive)?.booleanOrNull == true,
|
||||
)
|
||||
}
|
||||
return GatewayPetGallery(enabled = enabled, active = active, pets = pets)
|
||||
}
|
||||
|
||||
data class GatewayCompressResult(
|
||||
val status: String,
|
||||
val output: String? = null,
|
||||
|
||||
@@ -6,6 +6,9 @@ import android.util.Log
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import com.hermesandroid.relay.network.shared.bearerAuthorization
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
|
||||
import com.hermesandroid.relay.network.upstream.models.HermesSseEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
@@ -431,16 +434,20 @@ private class RetryingEventSource(
|
||||
*/
|
||||
class HermesApiClient(
|
||||
baseUrl: String,
|
||||
private val apiKey: String,
|
||||
apiKey: String,
|
||||
httpClient: OkHttpClient? = null,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
},
|
||||
okHttpClient: OkHttpClient? = null,
|
||||
) {
|
||||
@Volatile
|
||||
private var lastCapabilities: ServerCapabilities? = null
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
private val apiCredential = runCatching {
|
||||
normalizeCredentialForHeader(apiKey, "API credential")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HermesApiClient"
|
||||
@@ -480,7 +487,7 @@ class HermesApiClient(
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val client: OkHttpClient = httpClient ?: OkHttpClient.Builder()
|
||||
private val client: OkHttpClient = httpClient ?: okHttpClient ?: OkHttpClient.Builder()
|
||||
.readTimeout(5, TimeUnit.MINUTES)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
@@ -539,6 +546,8 @@ class HermesApiClient(
|
||||
HealthCheckResult.Unhealthy("Server not found — check the hostname")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
HealthCheckResult.Unhealthy("Connection timed out — is the server running?")
|
||||
} catch (e: InvalidCredentialException) {
|
||||
HealthCheckResult.Unhealthy(e.message ?: "Invalid API credential")
|
||||
} catch (e: IOException) {
|
||||
val msg = e.message ?: ""
|
||||
when {
|
||||
@@ -2022,9 +2031,8 @@ class HermesApiClient(
|
||||
|
||||
private fun authRequest(url: String): Request.Builder {
|
||||
val builder = Request.Builder().url(url)
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
val credential = apiCredential.getOrElse { throw it }
|
||||
builder.bearerAuthorization(credential, "API credential")
|
||||
return builder
|
||||
}
|
||||
|
||||
@@ -2041,10 +2049,12 @@ class HermesApiClient(
|
||||
*/
|
||||
private fun authRequestOrNull(url: String): Request.Builder? {
|
||||
val builder = buildApiRequestOrNull(url) ?: return null
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
return builder
|
||||
return runCatching {
|
||||
builder.bearerAuthorization(
|
||||
apiCredential.getOrElse { throw it },
|
||||
"API credential",
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2062,7 +2072,8 @@ class HermesApiClient(
|
||||
|
||||
/** Human message for a base URL that fails to parse (#131). */
|
||||
private fun invalidBaseUrlMessage(): String =
|
||||
"Invalid server address ($baseUrl) — edit the connection's API URL or re-pair."
|
||||
apiCredential.exceptionOrNull()?.message
|
||||
?: "Invalid server address ($baseUrl) — edit the connection's API URL or re-pair."
|
||||
|
||||
/**
|
||||
* Make attachment drops on the SSE fallback transports explicit
|
||||
|
||||
+30
-6
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import com.hermesandroid.relay.network.shared.bearerAuthorization
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
@@ -75,11 +77,13 @@ class EncryptedNativeDashboardTokenStore(
|
||||
|
||||
override fun load(): NativeDashboardTokens? =
|
||||
store.getString(TOKEN_KEY)?.let { raw ->
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||
runCatching {
|
||||
json.decodeFromString<NativeDashboardTokens>(raw).normalizedForStorage()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens.normalizedForStorage()))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
@@ -140,7 +144,16 @@ class NativeDashboardAuthClient(
|
||||
.addQueryParameter("code_challenge_method", "S256")
|
||||
.addQueryParameter("redirect_uri", redirectUri)
|
||||
.addQueryParameter("state", state)
|
||||
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||
// Match the official Desktop client for Nous-hosted gateways: the
|
||||
// gateway selects its single native-eligible provider. The provider
|
||||
// name advertised to UI clients is presentation/configuration data,
|
||||
// not a stable native-broker identifier. Other providers retain the
|
||||
// explicit selector for direct client use and tests.
|
||||
.apply {
|
||||
provider
|
||||
?.takeIf { it.isNotBlank() && !it.equals("nous", ignoreCase = true) }
|
||||
?.let { addQueryParameter("provider", it) }
|
||||
}
|
||||
.build()
|
||||
.toString()
|
||||
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||
@@ -441,7 +454,7 @@ class DashboardBearerAuth(
|
||||
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||
val request = tokens?.let {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${it.accessToken}")
|
||||
.bearerAuthorization(it.accessToken, "Dashboard credential")
|
||||
.build()
|
||||
} ?: chain.request()
|
||||
return chain.proceed(request)
|
||||
@@ -455,9 +468,12 @@ class DashboardBearerAuth(
|
||||
forceRefresh = true,
|
||||
failedAccessToken = failedAccessToken,
|
||||
) ?: return null
|
||||
val next = "Bearer ${tokens.accessToken}"
|
||||
val accessToken = normalizeCredentialForHeader(tokens.accessToken, "Dashboard credential")
|
||||
val next = "Bearer $accessToken"
|
||||
if (next == previous) return null
|
||||
return response.request.newBuilder().header("Authorization", next).build()
|
||||
return response.request.newBuilder()
|
||||
.bearerAuthorization(accessToken, "Dashboard credential")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun usableTokens(
|
||||
@@ -487,6 +503,14 @@ class DashboardBearerAuth(
|
||||
}
|
||||
}
|
||||
|
||||
private fun NativeDashboardTokens.normalizedForStorage(): NativeDashboardTokens = copy(
|
||||
accessToken = normalizeCredentialForHeader(accessToken, "Dashboard credential")
|
||||
.also { require(it.isNotEmpty()) { "Dashboard credential is empty" } },
|
||||
refreshToken = if (refreshToken.isEmpty()) "" else {
|
||||
normalizeCredentialForHeader(refreshToken, "Dashboard refresh credential")
|
||||
},
|
||||
)
|
||||
|
||||
internal class NativeDashboardAuthHttpException(
|
||||
val statusCode: Int,
|
||||
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||
|
||||
+59
-3
@@ -22,7 +22,7 @@ private const val CALLBACK_PATH = "/callback"
|
||||
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
private const val ACCEPT_POLL_MILLIS = 500
|
||||
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
|
||||
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 5 * 60 * 1000L
|
||||
internal val NATIVE_SIGN_IN_RETURN_URI = "${BuildConfig.APPLICATION_ID}://return"
|
||||
|
||||
private enum class CallbackPage(
|
||||
@@ -46,6 +46,48 @@ private enum class CallbackPage(
|
||||
message = "No session details were saved from this attempt.",
|
||||
guidance = "Return to Hermes Relay and start sign-in again.",
|
||||
),
|
||||
AuthorizationRejected(
|
||||
modifier = "failure",
|
||||
eyebrow = "Provider sign-in",
|
||||
title = "Sign-in was not completed",
|
||||
message = "The provider returned without an approved authorization for Hermes.",
|
||||
guidance = "Return to Hermes Relay and start again if you still want to sign in.",
|
||||
),
|
||||
CodeRejected(
|
||||
modifier = "failure",
|
||||
eyebrow = "Hosted Hermes callback",
|
||||
title = "Hermes rejected the sign-in code",
|
||||
message = "Google sign-in finished, but hosted Hermes could not exchange its one-time callback code for a session.",
|
||||
guidance = "Return to Hermes Relay and start a fresh sign-in attempt.",
|
||||
),
|
||||
GatewayUnavailable(
|
||||
modifier = "failure",
|
||||
eyebrow = "Hosted Hermes callback",
|
||||
title = "Hosted Hermes could not finish sign-in",
|
||||
message = "The callback reached Hermes Relay, but the hosted Hermes sign-in service was unavailable.",
|
||||
guidance = "Return to Hermes Relay, wait a moment, and try again.",
|
||||
),
|
||||
TransportFailure(
|
||||
modifier = "failure",
|
||||
eyebrow = "Secure sign-in connection",
|
||||
title = "Could not reach hosted Hermes",
|
||||
message = "Google sign-in finished, but the secure connection back to hosted Hermes was interrupted.",
|
||||
guidance = "Return to Hermes Relay and retry on a stable connection.",
|
||||
),
|
||||
ResponseUnsupported(
|
||||
modifier = "failure",
|
||||
eyebrow = "Hosted Hermes callback",
|
||||
title = "Hermes returned an unsupported session",
|
||||
message = "The hosted gateway answered, but its sign-in response was not compatible with this app.",
|
||||
guidance = "Return to Hermes Relay and check for app and hosted Hermes updates.",
|
||||
),
|
||||
SessionStorageFailure(
|
||||
modifier = "failure",
|
||||
eyebrow = "Secure session storage",
|
||||
title = "The session could not be saved",
|
||||
message = "Google sign-in finished, but Android could not securely save the Hermes session on this device.",
|
||||
guidance = "Return to Hermes Relay and try again. If it repeats, check the app's diagnostics.",
|
||||
),
|
||||
Rejected(
|
||||
modifier = "rejected",
|
||||
eyebrow = "Protected callback",
|
||||
@@ -194,14 +236,14 @@ class NativeDashboardSignInCoordinator(
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
page = CallbackPage.Failure,
|
||||
page = CallbackPage.AuthorizationRejected,
|
||||
)
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
page = CallbackPage.Failure,
|
||||
page = callbackFailurePage(error),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
@@ -296,6 +338,20 @@ class NativeDashboardSignInCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun callbackFailurePage(error: Throwable): CallbackPage {
|
||||
val stage = nativeDashboardSignInFailureStage(error)
|
||||
return when {
|
||||
stage == "token_http_400" -> CallbackPage.CodeRejected
|
||||
stage == "callback_error" -> CallbackPage.AuthorizationRejected
|
||||
stage == "token_http_429" || stage.startsWith("token_http_5") ->
|
||||
CallbackPage.GatewayUnavailable
|
||||
stage == "token_shape" -> CallbackPage.ResponseUnsupported
|
||||
stage == "token_store" -> CallbackPage.SessionStorageFailure
|
||||
stage.startsWith("token_transport") -> CallbackPage.TransportFailure
|
||||
else -> CallbackPage.Failure
|
||||
}
|
||||
}
|
||||
|
||||
private fun callbackPageHtml(page: CallbackPage): String = """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
+100
-2
@@ -1,19 +1,24 @@
|
||||
package com.hermesandroid.relay.network.upstream.models
|
||||
|
||||
import com.hermesandroid.relay.data.MessageReaction
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
@@ -97,6 +102,36 @@ object FlexibleLongSerializer : KSerializer<Long?> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard versions may expose SQLite JSON columns either as an object or as
|
||||
* their raw JSON string. Normalize both shapes so persisted presentation data
|
||||
* (notably message reactions) survives a history reload on every supported
|
||||
* upstream version.
|
||||
*/
|
||||
object FlexibleJsonObjectSerializer : KSerializer<JsonObject?> {
|
||||
override val descriptor = JsonObject.serializer().descriptor
|
||||
|
||||
override fun deserialize(decoder: Decoder): JsonObject? {
|
||||
return try {
|
||||
val jsonDecoder = decoder as? JsonDecoder ?: return null
|
||||
when (val element = jsonDecoder.decodeJsonElement()) {
|
||||
is JsonObject -> element
|
||||
is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }
|
||||
?.let { Json.parseToJsonElement(it) as? JsonObject }
|
||||
else -> null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: JsonObject?) {
|
||||
val jsonEncoder = encoder as? JsonEncoder
|
||||
?: throw SerializationException("FlexibleJsonObjectSerializer requires JSON")
|
||||
jsonEncoder.encodeJsonElement(value ?: JsonNull)
|
||||
}
|
||||
}
|
||||
|
||||
/** Timestamp serializer for Hermes session metadata.
|
||||
*
|
||||
* Upstream currently returns epoch seconds for `started_at` / `last_active`;
|
||||
@@ -134,6 +169,32 @@ object FlexibleTimestampSerializer : KSerializer<Double?> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean serializer for session flags backed by SQLite integer columns.
|
||||
*
|
||||
* Older Dashboard responses can expose those columns as `0` / `1` instead of
|
||||
* JSON booleans. Accept the equivalent primitive forms without making arbitrary
|
||||
* numbers or strings truthy, and always serialize back to a real JSON boolean.
|
||||
*/
|
||||
object FlexibleBooleanSerializer : KSerializer<Boolean> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("FlexibleBoolean", PrimitiveKind.BOOLEAN)
|
||||
|
||||
override fun deserialize(decoder: Decoder): Boolean {
|
||||
val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeBoolean()
|
||||
val element = jsonDecoder.decodeJsonElement()
|
||||
val value = (element as? JsonPrimitive)?.content?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true", "1" -> true
|
||||
"false", "0" -> false
|
||||
else -> throw SerializationException("Expected a boolean-compatible value, got $element")
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Boolean) {
|
||||
encoder.encodeBoolean(value)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD responses ---
|
||||
|
||||
@Serializable
|
||||
@@ -187,9 +248,16 @@ data class SessionItem(
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
|
||||
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
|
||||
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@SerialName("has_model_config")
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
val hasModelConfig: Boolean = false,
|
||||
/** Durable flags returned by current Dashboard and API-server session resources. */
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
val pinned: Boolean = false,
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
val archived: Boolean = false,
|
||||
/** Optional workspace metadata added by newer Dashboard session lists. */
|
||||
val cwd: String? = null,
|
||||
@@ -327,7 +395,9 @@ data class MessageItem(
|
||||
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,
|
||||
@SerialName("display_metadata")
|
||||
@Serializable(with = FlexibleJsonObjectSerializer::class)
|
||||
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
|
||||
@@ -335,11 +405,24 @@ data class MessageItem(
|
||||
val reasoning: String? = null,
|
||||
@SerialName("reasoning_content") val reasoningContent: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Dashboard history uses the SQLite row id as numeric `id`; Gateway
|
||||
* history exposes the same value explicitly as `row_id`. Match Desktop by
|
||||
* accepting either representation so persisted rows remain directly
|
||||
* reactable after reload.
|
||||
*/
|
||||
val resolvedRowId: Long?
|
||||
get() = rowId ?: id?.toLongOrNull()
|
||||
|
||||
/** Reasoning text under whichever field name the server used. */
|
||||
val resolvedReasoning: String?
|
||||
get() = reasoning?.takeIf { it.isNotBlank() }
|
||||
?: reasoningContent?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Persisted tapbacks stored by Hermes in display_metadata.reactions. */
|
||||
val reactions: List<MessageReaction>
|
||||
get() = parseMessageReactions(displayMetadata?.get("reactions"))
|
||||
|
||||
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
|
||||
val contentText: String?
|
||||
get() = when (content) {
|
||||
@@ -367,6 +450,21 @@ data class MessageItem(
|
||||
}
|
||||
}
|
||||
|
||||
fun parseMessageReactions(element: JsonElement?): List<MessageReaction> =
|
||||
(element as? JsonArray).orEmpty().mapNotNull { raw ->
|
||||
val reaction = raw as? JsonObject ?: return@mapNotNull null
|
||||
val emoji = (reaction["emoji"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
val author = (reaction["author"] as? JsonPrimitive)?.content
|
||||
?.takeIf { it == "user" || it == "agent" }
|
||||
?: return@mapNotNull null
|
||||
MessageReaction(
|
||||
emoji = emoji,
|
||||
author = author,
|
||||
at = (reaction["at"] as? JsonPrimitive)?.doubleOrNull ?: 0.0,
|
||||
)
|
||||
}
|
||||
|
||||
// --- SSE streaming events from /api/sessions/{id}/chat/stream ---
|
||||
//
|
||||
// Hermes WebAPI event types (from server source):
|
||||
|
||||
@@ -90,6 +90,29 @@ object ReliabilityCenter {
|
||||
writer.execute { runCatching { target.append(report) } }
|
||||
}
|
||||
|
||||
/** Persist a bounded, content-free checkpoint before a user-requested chat reset. */
|
||||
fun recordSessionCheckpoint(context: Context, evidence: SessionResetEvidence) {
|
||||
initialize(context)
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId("checkpoint"),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = Instant.now().toString(),
|
||||
kind = ReliabilityKind.SessionCheckpoint,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Info,
|
||||
summary = "Chat context reset",
|
||||
recovery = "The prior context remains on Hermes when it had a stored session.",
|
||||
reportRecommended = false,
|
||||
technicalDetail = evidence.technicalDetail(),
|
||||
routeRole = evidence.transport,
|
||||
environment = environment(),
|
||||
)
|
||||
// A pre-reset checkpoint is useful only if it lands before state is
|
||||
// replaced. The store is tiny and atomically rewritten, so persist it
|
||||
// synchronously instead of queueing behind later failures.
|
||||
runCatching { store?.append(report) }
|
||||
}
|
||||
|
||||
fun reports(context: Context): List<ReliabilityReport> {
|
||||
initialize(context)
|
||||
return store?.readAll().orEmpty()
|
||||
|
||||
@@ -15,6 +15,7 @@ const val RELIABILITY_SCHEMA_VERSION = 1
|
||||
enum class ReliabilityKind {
|
||||
FatalCrash,
|
||||
AnrSignal,
|
||||
SessionCheckpoint,
|
||||
RecoverableProductError,
|
||||
Connectivity,
|
||||
Authentication,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
/**
|
||||
* Content-free evidence captured immediately before Android replaces a chat
|
||||
* context. Deliberately excludes prompts, message text, IDs, profile names,
|
||||
* URLs, paths, attachments, and tool arguments/results.
|
||||
*/
|
||||
data class SessionResetEvidence(
|
||||
val reason: String,
|
||||
val transport: String,
|
||||
val messageCount: Int,
|
||||
val toolCount: Int,
|
||||
val queuedCount: Int,
|
||||
val pendingAttachmentCount: Int,
|
||||
val hadStoredSession: Boolean,
|
||||
val turnActive: Boolean,
|
||||
val askPending: Boolean,
|
||||
) {
|
||||
fun technicalDetail(): String = buildString {
|
||||
appendLine("reason=${reason.safeToken()}")
|
||||
appendLine("transport=${transport.safeToken()}")
|
||||
appendLine("messages=${messageCount.coerceAtLeast(0)}")
|
||||
appendLine("tools=${toolCount.coerceAtLeast(0)}")
|
||||
appendLine("queued=${queuedCount.coerceAtLeast(0)}")
|
||||
appendLine("pending_attachments=${pendingAttachmentCount.coerceAtLeast(0)}")
|
||||
appendLine("had_stored_session=$hadStoredSession")
|
||||
appendLine("turn_active=$turnActive")
|
||||
append("ask_pending=$askPending")
|
||||
}
|
||||
|
||||
private fun String.safeToken(): String =
|
||||
lowercase().replace(Regex("[^a-z0-9_.-]"), "_").take(40).ifBlank { "unknown" }
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModelStore
|
||||
import com.hermesandroid.relay.HermesRelayApp
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.PersistentChatComposerDraftStore
|
||||
import java.io.File
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@@ -70,7 +72,13 @@ class HermesProcessRuntime internal constructor(
|
||||
}
|
||||
|
||||
val chatViewModel: ChatViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
viewModelProvider[ChatViewModel::class.java]
|
||||
viewModelProvider[ChatViewModel::class.java].also { chat ->
|
||||
chat.installComposerDraftStore(
|
||||
PersistentChatComposerDraftStore(
|
||||
File(application.noBackupFilesDir, "chat-composer-drafts"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val voiceViewModel: VoiceViewModel by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
|
||||
@@ -176,7 +176,9 @@ internal class HermesRuntimeBinder(
|
||||
chat.setDisplayProfileProvider { connection.effectiveDisplayProfile.value }
|
||||
chat.setDisplayAliasProvider { connection.profileDisplayAlias.value }
|
||||
chat.setProfileSessionLister { connection.listProfileScopedSessions() }
|
||||
chat.setProfileMessageLoaderWithMode(connection::loadProfileScopedMessages)
|
||||
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
|
||||
connection.loadProfileScopedMessages(profileName, sessionId, mode)
|
||||
}
|
||||
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
|
||||
chat.profileSessionDeleter = connection::deleteProfileScopedSession
|
||||
chat.profileSessionRenamer = connection::renameProfileScopedSession
|
||||
|
||||
@@ -96,6 +96,7 @@ import com.hermesandroid.relay.ui.components.avatar.LocalFloatingPet
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalPetPlaybackSpeed
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalPetStabilize
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetLoader
|
||||
import com.hermesandroid.relay.ui.components.avatar.toAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.resolveBackgroundAvatar
|
||||
import com.hermesandroid.relay.ui.components.FloatingPetCompanion
|
||||
@@ -116,6 +117,7 @@ import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateAvailableBanner
|
||||
import com.hermesandroid.relay.ui.components.HostResourcePressureBanner
|
||||
import com.hermesandroid.relay.ui.components.rememberUpdateAvailability
|
||||
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewDialog
|
||||
@@ -741,8 +743,12 @@ fun RelayApp() {
|
||||
) {
|
||||
value = withContext(Dispatchers.IO) { PetLoader.loadPets(sphereContext) }
|
||||
}
|
||||
val activeFloatingPet = remember(floatingPetId, availablePets) {
|
||||
availablePets.firstOrNull { it.id == floatingPetId }
|
||||
val hermesPetState by connectionViewModel.hermesPetState.collectAsState()
|
||||
val upstreamProfilePet = remember(hermesPetState.active) {
|
||||
hermesPetState.active?.toAvatar()
|
||||
}
|
||||
val activeFloatingPet = remember(floatingPetId, availablePets, upstreamProfilePet) {
|
||||
availablePets.firstOrNull { it.id == floatingPetId } ?: upstreamProfilePet
|
||||
}
|
||||
var floatingPetMenuExpanded by remember(activeFloatingPet?.id) { mutableStateOf(false) }
|
||||
val activeBackgroundAvatar = remember(backgroundAvatarId, availablePets) {
|
||||
@@ -887,6 +893,7 @@ fun RelayApp() {
|
||||
// ChatViewModel isn't available where ConnectionViewModel builds the
|
||||
// handler, so the session sink is set here at the app root where both
|
||||
// ViewModels are in scope.
|
||||
val proactiveSummaryResources = LocalContext.current.resources
|
||||
LaunchedEffect(connectionViewModel, chatViewModel) {
|
||||
connectionViewModel.proactiveMessageHandler.toSession = { msg ->
|
||||
val text = buildString {
|
||||
@@ -895,6 +902,15 @@ fun RelayApp() {
|
||||
}
|
||||
chatViewModel.injectProactiveMessage(text)
|
||||
}
|
||||
connectionViewModel.proactiveMessageHandler.onBacklogDelivered = { count ->
|
||||
UiMessageBus.info(
|
||||
proactiveSummaryResources.getQuantityString(
|
||||
R.plurals.proactive_messages_arrived_while_away,
|
||||
count,
|
||||
count,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Agent Thread reply path: a send from the chat composer while a
|
||||
// source=phone Thread is open routes over the relay proactive
|
||||
// channel (continues the gateway phone session) instead of a normal
|
||||
@@ -906,7 +922,8 @@ fun RelayApp() {
|
||||
chatViewModel.onProactiveReplyAck(clientMsgId, status)
|
||||
}
|
||||
// Unified Threads: render an inbound agent message inline in the open
|
||||
// Thread (suppressing the notification/inbox) when it belongs there.
|
||||
// Thread when it belongs there. Surfacing semantics still decide
|
||||
// independently whether a system notification is also required.
|
||||
connectionViewModel.proactiveMessageHandler.injectIntoThread = { msg ->
|
||||
chatViewModel.injectThreadMessage(msg)
|
||||
}
|
||||
@@ -1383,6 +1400,22 @@ fun RelayApp() {
|
||||
// user always knows the chat is sample data with no live server, and
|
||||
// can exit into the real Connect flow with one tap.
|
||||
val showDemoBanner = isDemoMode && !voiceUiState.voiceMode
|
||||
val hostResourcePressure by connectionViewModel.hostResourcePressure.collectAsState()
|
||||
val showHostResourcePressure = hostResourcePressure.needsAttention &&
|
||||
!isDemoMode && !voiceUiState.voiceMode && !showStartupSphere
|
||||
val hostResourcePressureText = buildList {
|
||||
if (hostResourcePressure.lastBootSuspectedOom) {
|
||||
add(stringResource(R.string.host_resource_recent_oom))
|
||||
}
|
||||
when (hostResourcePressure.memoryPressure) {
|
||||
"critical" -> add(stringResource(R.string.host_resource_memory_critical))
|
||||
"elevated" -> add(stringResource(R.string.host_resource_memory_elevated))
|
||||
}
|
||||
when (hostResourcePressure.diskPressure) {
|
||||
"critical" -> add(stringResource(R.string.host_resource_disk_critical))
|
||||
"elevated" -> add(stringResource(R.string.host_resource_disk_elevated))
|
||||
}
|
||||
}.distinct().joinToString(" ")
|
||||
// Transient info/status banner (UiMessageBus) — thin, takes its own
|
||||
// space, auto-dismisses. Folded into the inset accounting below so a
|
||||
// child TopAppBar doesn't double-pad when this banner owns the top edge.
|
||||
@@ -1489,6 +1522,18 @@ fun RelayApp() {
|
||||
DemoModeBanner(onConnect = exitDemoToConnect)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showHostResourcePressure,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
) {
|
||||
HostResourcePressureBanner(
|
||||
text = hostResourcePressureText,
|
||||
critical = hostResourcePressure.critical,
|
||||
includeStatusBarPadding = !showUnattendedBanner && !showDemoBanner,
|
||||
)
|
||||
}
|
||||
|
||||
// Connection status intentionally has NO top-of-screen surface (no
|
||||
// banner, no strip, no float). Chat/agent status rides the chat header
|
||||
// subtitle; the relay socket rides the bottom RelayStatusStrip cue. See
|
||||
@@ -1499,7 +1544,7 @@ fun RelayApp() {
|
||||
// that banner already padded the top — avoid double padding).
|
||||
MessageBannerHost(
|
||||
includeStatusBarPadding =
|
||||
!showUnattendedBanner && !showDemoBanner,
|
||||
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
|
||||
)
|
||||
|
||||
// The update banner AND the connection-status indicator now render as
|
||||
@@ -1539,7 +1584,7 @@ fun RelayApp() {
|
||||
// The connection-status toast is now a floating overlay and
|
||||
// doesn't occupy space above the Scaffold, so it no longer
|
||||
// participates in the top-inset accounting.
|
||||
if (showUnattendedBanner || showDemoBanner || connectionChipVisible ||
|
||||
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure || connectionChipVisible ||
|
||||
showMessageBanner
|
||||
) {
|
||||
Modifier.consumeWindowInsets(WindowInsets.statusBars)
|
||||
|
||||
+12
-2
@@ -821,12 +821,21 @@ private fun ManualUrlSubsection(
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val apiKeyPresent by connectionViewModel.authManager.apiKeyPresent.collectAsState()
|
||||
val savedApiKeyError by connectionViewModel.authManager.apiKeyError.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
|
||||
// Keyed on the backing URL so a connection switch refreshes the input.
|
||||
var apiUrlInput by remember(apiServerUrl) { mutableStateOf(apiServerUrl) }
|
||||
var apiKeyInput by remember { mutableStateOf("") }
|
||||
var apiKeyVisible by remember { mutableStateOf(false) }
|
||||
val apiKeySingleLineError = stringResource(R.string.api_credential_single_line_error)
|
||||
val inputApiKeyError = remember(apiKeyInput, apiKeySingleLineError) {
|
||||
if (apiKeyInput.trim(' ', '\t').any { it < '!' || it > '~' }) {
|
||||
apiKeySingleLineError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
var relayUrlInput by remember(relayUrl) { mutableStateOf(relayUrl) }
|
||||
var isTestingApi by remember { mutableStateOf(false) }
|
||||
var apiVoiceSetupResult by remember {
|
||||
@@ -900,13 +909,14 @@ private fun ManualUrlSubsection(
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
if (apiKeyPresent && apiKeyInput.isBlank()) {
|
||||
inputApiKeyError ?: savedApiKeyError ?: if (apiKeyPresent && apiKeyInput.isBlank()) {
|
||||
apiKeyStoredHint
|
||||
} else {
|
||||
apiKeyNeededHint
|
||||
},
|
||||
)
|
||||
},
|
||||
isError = inputApiKeyError != null || (apiKeyInput.isBlank() && savedApiKeyError != null),
|
||||
singleLine = true,
|
||||
visualTransformation = if (apiKeyVisible) {
|
||||
VisualTransformation.None
|
||||
@@ -962,7 +972,7 @@ private fun ManualUrlSubsection(
|
||||
).show()
|
||||
}
|
||||
},
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
|
||||
) {
|
||||
Text(saveAndTestText)
|
||||
}
|
||||
|
||||
@@ -3,105 +3,422 @@ package com.hermesandroid.relay.ui.components
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.toAvatar
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Per-profile agent-icon picker — the visual twin of the local-name (alias) row.
|
||||
* The chosen image is copied into app storage and shown beside the agent's name
|
||||
* in chat. Client-side only: never sent to Hermes. Keyed per `(connection,
|
||||
* profile)` by [ConnectionViewModel.setProfileIcon] / `ProfileIconStore`.
|
||||
*/
|
||||
/** Shared Hermes identity and a separately-scoped phone presentation override. */
|
||||
@Composable
|
||||
fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
|
||||
val iconPath by connectionViewModel.profileIcon.collectAsState()
|
||||
val localIconPath by connectionViewModel.localProfileIcon.collectAsState()
|
||||
val serverAvatarPath by connectionViewModel.serverProfileAvatar.collectAsState()
|
||||
val useLocalOverride by connectionViewModel.useLocalProfileIconOverride.collectAsState()
|
||||
val hostImportState by connectionViewModel.hostProfileIconImportState.collectAsState()
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? -> uri?.let { connectionViewModel.setProfileIcon(it) } }
|
||||
val sharedState by connectionViewModel.sharedProfileAvatarState.collectAsState()
|
||||
val hermesPetState by connectionViewModel.hermesPetState.collectAsState()
|
||||
val hermesPetAvatar = remember(hermesPetState.active) { hermesPetState.active?.toAvatar() }
|
||||
var confirmSharedRemoval by remember { mutableStateOf(false) }
|
||||
var showHermesPetPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
LaunchedEffect(Unit) { connectionViewModel.refreshHermesPet() }
|
||||
|
||||
val sharedLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri: Uri? -> uri?.let(connectionViewModel::setSharedProfileAvatar) }
|
||||
val localLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri: Uri? -> uri?.let(connectionViewModel::setProfileIcon) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
AvatarSourceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val path = iconPath
|
||||
if (!path.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(path),
|
||||
contentDescription = stringResource(R.string.agent_icon_title),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
HermesPetPreview(hermesPetAvatar)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_hermes_pet_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
hermesPetState.supported == false -> stringResource(R.string.agent_icon_hermes_pet_unsupported)
|
||||
hermesPetState.active != null -> stringResource(
|
||||
R.string.agent_icon_hermes_pet_active,
|
||||
hermesPetState.active?.displayName.orEmpty(),
|
||||
)
|
||||
else -> stringResource(R.string.agent_icon_hermes_pet_empty)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (hermesPetState.loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
} else if (hermesPetState.supported != false) {
|
||||
Switch(
|
||||
checked = hermesPetState.active != null,
|
||||
onCheckedChange = { enabled ->
|
||||
if (enabled) {
|
||||
showHermesPetPicker = true
|
||||
connectionViewModel.loadHermesPetGallery()
|
||||
} else {
|
||||
connectionViewModel.disableHermesPet()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { launcher.launch(arrayOf("image/*")) }) {
|
||||
Text(if (iconPath.isNullOrBlank()) stringResource(R.string.agent_icon_set) else stringResource(R.string.agent_icon_change))
|
||||
}
|
||||
if (!iconPath.isNullOrBlank()) {
|
||||
TextButton(onClick = { connectionViewModel.clearProfileIcon() }) {
|
||||
Text(stringResource(R.string.agent_icon_clear))
|
||||
if (hermesPetState.supported != false) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showHermesPetPicker = true
|
||||
connectionViewModel.loadHermesPetGallery()
|
||||
},
|
||||
enabled = !hermesPetState.loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.agent_icon_hermes_pet_browse))
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { connectionViewModel.importProfileIconFromHost() },
|
||||
enabled = !hostImportState.loading,
|
||||
) {
|
||||
Text(
|
||||
if (hostImportState.loading) {
|
||||
stringResource(R.string.agent_icon_importing_host)
|
||||
} else {
|
||||
stringResource(R.string.agent_icon_import_host)
|
||||
}
|
||||
)
|
||||
}
|
||||
hostImportState.error?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
text = stringResource(R.string.agent_icon_hermes_pet_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
||||
AvatarSourceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AvatarPreview(serverAvatarPath)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_shared_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = if (serverAvatarPath.isNullOrBlank()) {
|
||||
stringResource(R.string.agent_icon_shared_empty)
|
||||
} else {
|
||||
stringResource(R.string.agent_icon_shared_active)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
sharedLauncher.launch(arrayOf("image/*"))
|
||||
},
|
||||
enabled = !sharedState.loading,
|
||||
) {
|
||||
Text(stringResource(R.string.agent_icon_change_shared))
|
||||
}
|
||||
if (!serverAvatarPath.isNullOrBlank()) {
|
||||
TextButton(
|
||||
onClick = { confirmSharedRemoval = true },
|
||||
enabled = !sharedState.loading,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_remove_shared),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
sharedState.error?.let { AvatarError(it) }
|
||||
}
|
||||
|
||||
AvatarSourceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AvatarPreview(localIconPath)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_phone_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_phone_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = useLocalOverride,
|
||||
onCheckedChange = connectionViewModel::setUseLocalProfileIconOverride,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { localLauncher.launch(arrayOf("image/*")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = useLocalOverride,
|
||||
) {
|
||||
Text(
|
||||
if (localIconPath.isNullOrBlank()) {
|
||||
stringResource(R.string.agent_icon_choose_phone)
|
||||
} else {
|
||||
stringResource(R.string.agent_icon_change_phone)
|
||||
},
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = connectionViewModel::importProfileIconFromHost,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = useLocalOverride && !hostImportState.loading,
|
||||
) {
|
||||
Text(
|
||||
if (hostImportState.loading) {
|
||||
stringResource(R.string.agent_icon_importing_host)
|
||||
} else {
|
||||
stringResource(R.string.agent_icon_import_host)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!localIconPath.isNullOrBlank()) {
|
||||
TextButton(
|
||||
onClick = connectionViewModel::clearProfileIcon,
|
||||
enabled = useLocalOverride,
|
||||
) {
|
||||
Text(stringResource(R.string.agent_icon_remove_phone))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_animation_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
hostImportState.error?.let { AvatarError(it) }
|
||||
hermesPetState.error?.let { AvatarError(it) }
|
||||
}
|
||||
|
||||
if (confirmSharedRemoval) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmSharedRemoval = false },
|
||||
title = { Text(stringResource(R.string.agent_icon_remove_shared_title)) },
|
||||
text = { Text(stringResource(R.string.agent_icon_remove_shared_message)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmSharedRemoval = false
|
||||
connectionViewModel.clearSharedProfileAvatar()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_remove_shared),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmSharedRemoval = false }) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showHermesPetPicker) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHermesPetPicker = false },
|
||||
title = { Text(stringResource(R.string.agent_icon_hermes_pet_picker_title)) },
|
||||
text = {
|
||||
if (hermesPetState.galleryLoading && hermesPetState.gallery.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
||||
if (hermesPetState.gallery.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_icon_hermes_pet_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(hermesPetState.gallery, key = { it.slug }) { pet ->
|
||||
LaunchedEffect(pet.slug, pet.spritesheetUrl) {
|
||||
connectionViewModel.loadHermesPetThumbnail(pet)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
showHermesPetPicker = false
|
||||
connectionViewModel.selectHermesPet(pet.slug)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PetGalleryPreview(hermesPetState.thumbnails[pet.slug])
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(pet.displayName, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = when {
|
||||
pet.slug == hermesPetState.active?.slug -> stringResource(R.string.agent_icon_hermes_pet_selected)
|
||||
pet.installed -> stringResource(R.string.agent_icon_hermes_pet_installed)
|
||||
else -> stringResource(R.string.agent_icon_hermes_pet_adopt)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showHermesPetPicker = false }) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarSourceCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarPreview(path: String?) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!path.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HermesPetPreview(pet: PetAvatar?) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
pet?.Render(
|
||||
state = AvatarRenderState(state = SphereState.Idle),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PetGalleryPreview(dataUri: String?) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (dataUri != null) {
|
||||
AsyncImage(
|
||||
model = dataUri,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().padding(3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarError(message: String) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
@@ -94,7 +93,7 @@ import kotlinx.coroutines.delay
|
||||
*/
|
||||
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
|
||||
|
||||
private val ChatComposerShape = RoundedCornerShape(18.dp)
|
||||
private val ChatComposerShape = RoundedCornerShape(26.dp)
|
||||
private val ChatInputChipShape = RoundedCornerShape(12.dp)
|
||||
internal const val CHAT_INPUT_FIELD_TEST_TAG = "chat-input-field"
|
||||
|
||||
@@ -187,9 +186,12 @@ fun ChatInputBar(
|
||||
modifier: Modifier = Modifier,
|
||||
surfaceModifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
submitEnabled: Boolean = true,
|
||||
physicalEnterSends: Boolean = true,
|
||||
largePasteThreshold: Int? = null,
|
||||
onLargePaste: (String) -> Unit = {},
|
||||
) {
|
||||
val canSubmit = enabled && trailing in setOf(
|
||||
val canSubmit = enabled && submitEnabled && trailing in setOf(
|
||||
ChatInputTrailing.SEND,
|
||||
ChatInputTrailing.STEER,
|
||||
ChatInputTrailing.QUEUE,
|
||||
@@ -315,7 +317,7 @@ fun ChatInputBar(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||
.then(surfaceModifier),
|
||||
) {
|
||||
Column {
|
||||
@@ -341,15 +343,24 @@ fun ChatInputBar(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
|
||||
onValueChange = { updated ->
|
||||
val converted = largePasteThreshold
|
||||
?.let { threshold -> detectLargeTextInsertion(value, updated, threshold) }
|
||||
if (converted != null) {
|
||||
onValueChange(converted.remainingText)
|
||||
onLargePaste(converted.insertedText)
|
||||
} else if (updated.length <= charLimit) {
|
||||
onValueChange(updated)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.heightIn(min = 30.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp)
|
||||
// Keep directional keys inside the editor. Compose's
|
||||
// BasicTextField owns normal caret/selection movement;
|
||||
// cancelling focus traversal prevents a boundary arrow
|
||||
@@ -382,10 +393,10 @@ fun ChatInputBar(
|
||||
enabled = enabled,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Send,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = { if (canSubmit) onSend() },
|
||||
// The field is multiline and already has a dedicated
|
||||
// send button. Leave the software keyboard action as
|
||||
// Return; hardware Enter remains handled above.
|
||||
imeAction = ImeAction.Default,
|
||||
),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@@ -408,7 +419,7 @@ fun ChatInputBar(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp),
|
||||
.heightIn(min = 44.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -521,12 +532,12 @@ fun ChatInputBar(
|
||||
when (state) {
|
||||
ChatInputTrailing.SEND -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
enabled = canSubmit,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_input_send_message),
|
||||
tint = if (enabled) MaterialTheme.colorScheme.primary
|
||||
tint = if (canSubmit) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -580,7 +591,7 @@ fun ChatInputBar(
|
||||
|
||||
ChatInputTrailing.STEER -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
enabled = canSubmit,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
@@ -591,7 +602,7 @@ fun ChatInputBar(
|
||||
|
||||
ChatInputTrailing.QUEUE -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
enabled = canSubmit,
|
||||
) {
|
||||
Box {
|
||||
Icon(
|
||||
@@ -620,6 +631,39 @@ fun ChatInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
internal data class LargeTextInsertion(
|
||||
val insertedText: String,
|
||||
val remainingText: String,
|
||||
)
|
||||
|
||||
/** Finds one large contiguous edit without requiring clipboard access or retaining clipboard data. */
|
||||
internal fun detectLargeTextInsertion(
|
||||
previous: String,
|
||||
updated: String,
|
||||
threshold: Int,
|
||||
): LargeTextInsertion? {
|
||||
if (threshold <= 0 || updated == previous) return null
|
||||
val prefixLength = previous.commonPrefixWith(updated).length
|
||||
val maxSuffixLength = minOf(
|
||||
previous.length - prefixLength,
|
||||
updated.length - prefixLength,
|
||||
)
|
||||
var suffixLength = 0
|
||||
while (
|
||||
suffixLength < maxSuffixLength &&
|
||||
previous[previous.lastIndex - suffixLength] == updated[updated.lastIndex - suffixLength]
|
||||
) {
|
||||
suffixLength++
|
||||
}
|
||||
val insertedEnd = updated.length - suffixLength
|
||||
val inserted = updated.substring(prefixLength, insertedEnd)
|
||||
if (inserted.length < threshold) return null
|
||||
return LargeTextInsertion(
|
||||
insertedText = inserted,
|
||||
remainingText = updated.removeRange(prefixLength, insertedEnd),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputPickerChip(
|
||||
control: ChatInputPickerControl,
|
||||
|
||||
@@ -2249,6 +2249,11 @@ private fun StandardEntryStep(
|
||||
val apiError = apiUrlSchemeError(apiUrl, context)
|
||||
val tailscaleError = optionalHttpUrlError(tailscaleApiUrl, context)
|
||||
val dashboardError = optionalHttpUrlError(dashboardUrl, context)
|
||||
val apiKeyError = if (apiKey.trim(' ', '\t').any { it < '!' || it > '~' }) {
|
||||
stringResource(R.string.api_credential_single_line_error)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var advancedExpanded by remember { mutableStateOf(false) }
|
||||
var scanBusy by remember { mutableStateOf(false) }
|
||||
var scanResults by remember { mutableStateOf<List<HermesLanDiscoveryResult>>(emptyList()) }
|
||||
@@ -2263,6 +2268,7 @@ private fun StandardEntryStep(
|
||||
apiError == null &&
|
||||
tailscaleError == null &&
|
||||
dashboardError == null &&
|
||||
apiKeyError == null &&
|
||||
!isConnecting
|
||||
val defaultDashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl)
|
||||
val effectiveDashboardUrl = dashboardUrl
|
||||
@@ -2407,8 +2413,9 @@ private fun StandardEntryStep(
|
||||
label = { Text(stringResource(R.string.cw_api_key_label)) },
|
||||
placeholder = { Text(stringResource(R.string.cw_api_key_placeholder)) },
|
||||
singleLine = true,
|
||||
isError = apiKeyError != null,
|
||||
supportingText = {
|
||||
Text(stringResource(R.string.cw_api_key_hint))
|
||||
Text(apiKeyError ?: stringResource(R.string.cw_api_key_hint))
|
||||
},
|
||||
visualTransformation = if (apiKeyVisible) {
|
||||
VisualTransformation.None
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.WarningAmber
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Persistent, server-authored resource-pressure warning for the active Hermes host. */
|
||||
@Composable
|
||||
fun HostResourcePressureBanner(
|
||||
text: String,
|
||||
critical: Boolean,
|
||||
includeStatusBarPadding: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val background = if (critical) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
}
|
||||
val foreground = if (critical) {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.WarningAmber,
|
||||
contentDescription = null,
|
||||
tint = foreground,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
color = foreground,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,13 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -51,6 +55,8 @@ import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.markdownDimens
|
||||
import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
|
||||
import com.mikepenz.markdown.model.StreamingMarkdownState
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
@@ -66,6 +72,23 @@ fun MarkdownContent(
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ConfiguredMarkdownContent(
|
||||
content = content,
|
||||
textColor = textColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfiguredMarkdownContent(
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
content: String? = null,
|
||||
streamingState: StreamingMarkdownState? = null,
|
||||
) {
|
||||
require((content == null) != (streamingState == null)) {
|
||||
"Exactly one Markdown source must be provided"
|
||||
}
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
@@ -75,109 +98,108 @@ fun MarkdownContent(
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
Markdown(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
// Code surfaces must contrast against the bubble (which is itself
|
||||
// surfaceVariant for assistant turns) or code reads as invisible. The
|
||||
// block uses the lowest container (a darker inset in dark themes, a
|
||||
// clean white inset in light), inline code a subtle raised step.
|
||||
colors = markdownColor(
|
||||
text = textColor,
|
||||
codeBackground = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
),
|
||||
// Chat-tuned type ramp. Left unset, the mikepenz M3 defaults map headings
|
||||
// to DISPLAY roles (in this app's scale h1=displayLarge 57sp, h2=displayMedium
|
||||
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
||||
// live font-picker still applies) and is capped so the largest heading is
|
||||
// proportionate to the 15sp body, matching Discord / GitHub-mobile
|
||||
// in-message headings.
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h2 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h3 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold, color = textColor,
|
||||
),
|
||||
h4 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, color = textColor,
|
||||
),
|
||||
h5 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h6 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||
color = textColor.copy(alpha = 0.85f),
|
||||
),
|
||||
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
|
||||
// The library default 'text'/list role is bodyLarge (16sp), while
|
||||
// bodyMedium was previously 14sp and unnecessarily small for long chat.
|
||||
paragraph = chatBodyStyle,
|
||||
text = chatBodyStyle,
|
||||
bullet = chatBodyStyle,
|
||||
ordered = chatBodyStyle,
|
||||
list = chatBodyStyle,
|
||||
quote = chatBodyStyle.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = textColor.copy(alpha = 0.9f),
|
||||
),
|
||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||
// + the tinted chip already signal "code" without also shrinking it, and
|
||||
// the loose 0.4sp default tracking is reset to 0 for tighter token runs.
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = textColor,
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace, color = textColor,
|
||||
),
|
||||
// Links get an accent color + underline so they read as tappable on the
|
||||
// muted assistant bubble (the default textLink is body-colored).
|
||||
textLink = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tables get a phone-friendly minimum measure. The stock renderer uses
|
||||
// one-line cells; our table component below keeps the same AST/inline
|
||||
// annotator path but permits wrapping and exposes horizontal overflow.
|
||||
dimens = markdownDimens(
|
||||
tableCellWidth = 110.dp,
|
||||
tableCellPadding = 12.dp,
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
SafeMarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
SafeMarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true
|
||||
)
|
||||
},
|
||||
table = { WideMarkdownTable(it) },
|
||||
),
|
||||
extendedSpans = markdownExtendedSpans {
|
||||
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
|
||||
}
|
||||
// Code surfaces must contrast against the assistant bubble. Keeping these
|
||||
// exact values shared between static and streaming renderers prevents a
|
||||
// typography/color change when a live turn completes.
|
||||
val colors = markdownColor(
|
||||
text = textColor,
|
||||
codeBackground = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
val typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h2 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h3 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold, color = textColor,
|
||||
),
|
||||
h4 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, color = textColor,
|
||||
),
|
||||
h5 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h6 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||
color = textColor.copy(alpha = 0.85f),
|
||||
),
|
||||
paragraph = chatBodyStyle,
|
||||
text = chatBodyStyle,
|
||||
bullet = chatBodyStyle,
|
||||
ordered = chatBodyStyle,
|
||||
list = chatBodyStyle,
|
||||
quote = chatBodyStyle.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = textColor.copy(alpha = 0.9f),
|
||||
),
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = textColor,
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = textColor,
|
||||
),
|
||||
textLink = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
),
|
||||
)
|
||||
val dimens = markdownDimens(tableCellWidth = 110.dp, tableCellPadding = 12.dp)
|
||||
val components = markdownComponents(
|
||||
codeBlock = {
|
||||
SafeMarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true,
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
SafeMarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true,
|
||||
)
|
||||
},
|
||||
table = { WideMarkdownTable(it) },
|
||||
)
|
||||
val extendedSpans = markdownExtendedSpans {
|
||||
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
|
||||
}
|
||||
|
||||
if (streamingState != null) {
|
||||
Markdown(
|
||||
streamingMarkdownState = streamingState,
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
dimens = dimens,
|
||||
components = components,
|
||||
extendedSpans = extendedSpans,
|
||||
)
|
||||
} else {
|
||||
Markdown(
|
||||
content = checkNotNull(content),
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
dimens = dimens,
|
||||
components = components,
|
||||
extendedSpans = extendedSpans,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,39 +313,63 @@ private fun WideMarkdownTable(model: MarkdownComponentModel) {
|
||||
fun StreamingMarkdownContent(
|
||||
content: String,
|
||||
textColor: Color,
|
||||
isStreaming: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (isStreaming) {
|
||||
// Keep one stable layout node for the entire live turn. Promoting each
|
||||
// blank-terminated paragraph into Markdown replaced the Text subtree
|
||||
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
|
||||
// which looked like the whole chat reloaded. Updating this Text value
|
||||
// only remeasures the growing bubble. Full Markdown is parsed once the
|
||||
// owning row releases its stable live-tail layout.
|
||||
Text(
|
||||
// CommonMark ignores blank lines before the first block. The live
|
||||
// Text renderer must do the same or a response whose transport
|
||||
// prefix contains newlines appears to start several lines down.
|
||||
// Preserve indentation on the first non-blank line so indented
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
),
|
||||
color = textColor,
|
||||
)
|
||||
} else {
|
||||
MarkdownContent(
|
||||
content = content,
|
||||
var generation by remember { mutableIntStateOf(0) }
|
||||
key(generation) {
|
||||
NativeStreamingMarkdownGeneration(
|
||||
content = content.withoutLeadingBlankLines(),
|
||||
textColor = textColor,
|
||||
modifier = modifier,
|
||||
onResetRequired = { generation += 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NativeStreamingMarkdownGeneration(
|
||||
content: String,
|
||||
textColor: Color,
|
||||
modifier: Modifier,
|
||||
onResetRequired: () -> Unit,
|
||||
) {
|
||||
val streamingState = rememberStreamingMarkdownState()
|
||||
LaunchedEffect(content, streamingState) {
|
||||
val plan = planStreamingMarkdownAppend(
|
||||
renderedContent = streamingState.content.toString(),
|
||||
nextContent = content,
|
||||
)
|
||||
if (plan.resetRequired) {
|
||||
onResetRequired()
|
||||
} else if (plan.delta.isNotEmpty()) {
|
||||
streamingState.append(plan.delta)
|
||||
}
|
||||
}
|
||||
|
||||
ConfiguredMarkdownContent(
|
||||
streamingState = streamingState,
|
||||
textColor = textColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
internal data class StreamingMarkdownAppendPlan(
|
||||
val resetRequired: Boolean,
|
||||
val delta: String,
|
||||
)
|
||||
|
||||
internal fun planStreamingMarkdownAppend(
|
||||
renderedContent: String,
|
||||
nextContent: String,
|
||||
): StreamingMarkdownAppendPlan = if (nextContent.startsWith(renderedContent)) {
|
||||
StreamingMarkdownAppendPlan(
|
||||
resetRequired = false,
|
||||
delta = nextContent.substring(renderedContent.length),
|
||||
)
|
||||
} else {
|
||||
StreamingMarkdownAppendPlan(resetRequired = true, delta = "")
|
||||
}
|
||||
|
||||
internal fun String.withoutLeadingBlankLines(): String {
|
||||
var contentStart = 0
|
||||
while (contentStart < length) {
|
||||
|
||||
@@ -42,6 +42,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -252,6 +255,7 @@ private fun MessageRow(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
.heightIn(min = ROW_MIN_HEIGHT_DP.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -27,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -34,7 +34,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.text.selection.DisableSelection
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
@@ -43,6 +42,7 @@ import androidx.compose.material.icons.filled.FormatQuote
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -90,6 +90,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
|
||||
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -100,13 +101,6 @@ fun MessageBubble(
|
||||
showThinking: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
/**
|
||||
* Keeps the current live tail on its stable Text layout while a final
|
||||
* streaming frame commits. The owning list releases it immediately after
|
||||
* completion so the same row transitions to full Markdown with its bottom
|
||||
* anchor preserved.
|
||||
*/
|
||||
retainStreamingLayout: Boolean = false,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Select this message as a structured composer quote. Null hides Quote.
|
||||
@@ -455,11 +449,51 @@ fun MessageBubble(
|
||||
val showEditAction = onEditMessage != null && isUser
|
||||
val showSpeakAction = shouldShowSpeakResponseAction(message, onSpeakMessage != null)
|
||||
val showStopSpeakingAction = shouldShowStopSpeakingAction(message, onStopSpeaking != null)
|
||||
val selectedUserReaction = message.reactions.firstOrNull { it.author == "user" }?.emoji
|
||||
if (onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
if (onReact != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
MESSAGE_REACTIONS.forEach { emoji ->
|
||||
IconButton(
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onReact(emoji)
|
||||
},
|
||||
modifier = Modifier.background(
|
||||
color = if (selectedUserReaction == emoji) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "React with $emoji"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.msg_bubble_copy)) },
|
||||
onClick = {
|
||||
@@ -515,32 +549,26 @@ fun MessageBubble(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onReact != null && selectedUserReaction != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove reaction") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onReact(null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.padding(
|
||||
bottom = if (message.reactions.isNotEmpty()) 8.dp else 0.dp,
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isUser && !isSystem &&
|
||||
(message.isStreaming || retainStreamingLayout)
|
||||
) {
|
||||
// The frame-paced text node is already measured at its
|
||||
// new size. Animate and clip the owning surface so a
|
||||
// newly wrapped line is revealed inside the expanding
|
||||
// bubble instead of drawing below the previous bounds
|
||||
// for one frame. TopStart keeps existing prose fixed.
|
||||
Modifier.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 72,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
alignment = Alignment.TopStart,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
Modifier.leftEdgeGlow(
|
||||
@@ -558,7 +586,7 @@ fun MessageBubble(
|
||||
// same tactile confirm every chat app fires.
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (
|
||||
onQuoteMessage != null || showEditAction || showSpeakAction ||
|
||||
onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction ||
|
||||
showStopSpeakingAction
|
||||
) {
|
||||
showMessageActions = true
|
||||
@@ -613,57 +641,29 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Keep one plain Text node stable only while content is
|
||||
// incomplete (plus the final committed live frame).
|
||||
// Completion releases this flag and selects the full
|
||||
// Markdown tree on the same stable message row.
|
||||
// The renderer owns one incremental AST for the entire
|
||||
// visible lifetime of this row. Stable and provisional
|
||||
// blocks therefore use the same typography/components;
|
||||
// completion is data state, not a renderer swap.
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor,
|
||||
isStreaming = message.isStreaming || retainStreamingLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compose's SelectionManager assumes that selectable IDs
|
||||
// captured by a drag remain registered. Reset its owner when
|
||||
// a live Text node becomes a Markdown tree, or settled
|
||||
// Markdown content changes its node topology, so a handle
|
||||
// cannot keep pointing at a removed selectable.
|
||||
if (showSpeakAction || showStopSpeakingAction) {
|
||||
DisableSelection { messageTextContent() }
|
||||
} else {
|
||||
key(
|
||||
messageSelectionTopologyKey(
|
||||
isPlainText = isUser || isSystem,
|
||||
isStreaming = message.isStreaming,
|
||||
retainStreamingLayout = retainStreamingLayout,
|
||||
markdownBody = markdownBody,
|
||||
),
|
||||
) {
|
||||
SelectionContainer { messageTextContent() }
|
||||
}
|
||||
// Voice actions live outside the message body and must never
|
||||
// replace its selection owner. Speak becomes available exactly
|
||||
// at completion; branching on it here recreated the complete
|
||||
// incremental Markdown state for one frame.
|
||||
key(
|
||||
messageSelectionTopologyKey(
|
||||
isPlainText = isUser || isSystem,
|
||||
),
|
||||
) {
|
||||
SelectionContainer { messageTextContent() }
|
||||
}
|
||||
if (onReact != null) {
|
||||
listOf("👍", "❤️", "😂").forEach { emoji ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("React $emoji") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onReact(emoji)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove reaction") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onReact(null)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (onSessionReference != null && sessionReferences.isNotEmpty()) {
|
||||
sessionReferences.forEach { reference ->
|
||||
TextButton(
|
||||
@@ -788,26 +788,39 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
val hasTokenUsage = !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
// Reserve the footer from the first streaming frame so
|
||||
// completion is a color-only transition and cannot resize the
|
||||
// row. Hide the reserved timestamp from accessibility until it
|
||||
// becomes visible.
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
modifier = if (message.isStreaming) {
|
||||
Modifier.clearAndSetSemantics { }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
modifier = if (message.isStreaming) {
|
||||
Modifier.clearAndSetSemantics { }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (hasTokenUsage) {
|
||||
TokenDisplay(
|
||||
inputTokens = message.inputTokens,
|
||||
outputTokens = message.outputTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status — only on agent-Thread reply bubbles (a user
|
||||
@@ -828,16 +841,30 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
// Token display (assistant messages only)
|
||||
if (!isUser && (message.inputTokens != null || message.outputTokens != null)) {
|
||||
// Non-tail historical fragments have no reserved timestamp row.
|
||||
// Preserve their existing standalone token metadata layout.
|
||||
if (!isLastInGroup && hasTokenUsage) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
TokenDisplay(
|
||||
inputTokens = message.inputTokens,
|
||||
outputTokens = message.outputTokens
|
||||
outputTokens = message.outputTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message.reactions.isNotEmpty()) {
|
||||
MessageReactionBadge(
|
||||
reactions = message.reactions.map { it.emoji },
|
||||
onOpen = onReact?.let { { showMessageActions = true } },
|
||||
modifier = Modifier
|
||||
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart)
|
||||
.offset(
|
||||
x = if (isUser) (-10).dp else 10.dp,
|
||||
y = 9.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val inlineActions: @Composable () -> Unit = {
|
||||
MessageInlineActions(
|
||||
showQuote = onQuoteMessage != null,
|
||||
@@ -890,6 +917,41 @@ fun MessageBubble(
|
||||
} // end CompositionLocalProvider(LocalMediaBlurMode)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun MessageReactionBadge(
|
||||
reactions: List<String>,
|
||||
onOpen: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = "Reactions: ${reactions.joinToString(" ")}"
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (onOpen != null) {
|
||||
Modifier.combinedClickable(onClick = onOpen, onLongClick = onOpen)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
|
||||
) {
|
||||
reactions.forEach { emoji ->
|
||||
Text(text = emoji, fontSize = 14.sp, lineHeight = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageInlineActions(
|
||||
showQuote: Boolean,
|
||||
@@ -966,14 +1028,9 @@ internal data class MessageSelectionTopologyKey(
|
||||
|
||||
internal fun messageSelectionTopologyKey(
|
||||
isPlainText: Boolean,
|
||||
isStreaming: Boolean,
|
||||
retainStreamingLayout: Boolean,
|
||||
markdownBody: String,
|
||||
): MessageSelectionTopologyKey = when {
|
||||
isPlainText -> MessageSelectionTopologyKey(renderer = "plain", markdownBody = null)
|
||||
isStreaming || retainStreamingLayout ->
|
||||
MessageSelectionTopologyKey(renderer = "live", markdownBody = null)
|
||||
else -> MessageSelectionTopologyKey(renderer = "markdown", markdownBody = markdownBody)
|
||||
else -> MessageSelectionTopologyKey(renderer = "streaming-markdown", markdownBody = null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,6 +73,7 @@ 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 com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
@@ -300,6 +301,11 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
val normalizedKey = normalizeCredentialForHeader(
|
||||
decodedWithAliases.key,
|
||||
"API credential",
|
||||
)
|
||||
val decodedWithSafeCredential = decodedWithAliases.copy(key = normalizedKey)
|
||||
|
||||
// TODO(security): verify `decoded.sig` against the server's HMAC
|
||||
// secret once the pairing protocol exposes a public verification
|
||||
@@ -310,12 +316,12 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
// Synthesize a single priority-0 candidate from the top-level fields
|
||||
// when the wire payload didn't carry an explicit `endpoints` array.
|
||||
// v3+ payloads with an explicit array pass through untouched.
|
||||
if (decodedWithAliases.endpoints.isNullOrEmpty()) {
|
||||
decodedWithAliases.copy(
|
||||
endpoints = listOf(synthesizeLegacyEndpoint(decodedWithAliases)),
|
||||
if (decodedWithSafeCredential.endpoints.isNullOrEmpty()) {
|
||||
decodedWithSafeCredential.copy(
|
||||
endpoints = listOf(synthesizeLegacyEndpoint(decodedWithSafeCredential)),
|
||||
)
|
||||
} else {
|
||||
decodedWithAliases
|
||||
decodedWithSafeCredential
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
@@ -396,10 +402,13 @@ private fun payloadFromApiUrl(
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val normalizedKey = runCatching {
|
||||
normalizeCredentialForHeader(apiKey, "API credential")
|
||||
}.getOrNull() ?: return null
|
||||
val payload = HermesPairingPayload(
|
||||
host = host,
|
||||
port = if (uri.port > 0) uri.port else 8642,
|
||||
key = apiKey.trim(),
|
||||
key = normalizedKey,
|
||||
tls = tls,
|
||||
dashboardUrl = dashboardUrl?.trim()?.takeIf { it.isNotBlank() },
|
||||
relay = null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import java.util.Locale
|
||||
|
||||
internal enum class SessionDrawerGrouping {
|
||||
None,
|
||||
Updated,
|
||||
Project,
|
||||
Status,
|
||||
Profile,
|
||||
}
|
||||
|
||||
internal enum class SessionDrawerOrdering {
|
||||
Updated,
|
||||
Created,
|
||||
Title,
|
||||
Status,
|
||||
Tokens,
|
||||
Cost,
|
||||
}
|
||||
|
||||
internal enum class SessionDrawerStatus {
|
||||
NeedsInput,
|
||||
Working,
|
||||
Idle,
|
||||
}
|
||||
|
||||
internal enum class SessionDrawerPrState {
|
||||
Open,
|
||||
Draft,
|
||||
Merged,
|
||||
Closed,
|
||||
None,
|
||||
}
|
||||
|
||||
internal data class SessionDrawerViewOptions(
|
||||
val grouping: SessionDrawerGrouping = SessionDrawerGrouping.None,
|
||||
val ordering: SessionDrawerOrdering = SessionDrawerOrdering.Updated,
|
||||
val statuses: Set<SessionDrawerStatus> = emptySet(),
|
||||
val profiles: Set<String> = emptySet(),
|
||||
val projects: Set<String> = emptySet(),
|
||||
val pullRequests: Set<SessionDrawerPrState> = emptySet(),
|
||||
val showProfile: Boolean = false,
|
||||
val showUpdated: Boolean = true,
|
||||
val showTokens: Boolean = false,
|
||||
val showCost: Boolean = false,
|
||||
)
|
||||
|
||||
internal data class SessionDrawerGroup(
|
||||
val key: String,
|
||||
val label: String?,
|
||||
val rows: List<ProfileSessionRow>,
|
||||
)
|
||||
|
||||
internal fun sessionRowKey(row: ProfileSessionRow): String =
|
||||
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
|
||||
internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
val raw = (session.gitRepoRoot ?: session.workingDirectory)
|
||||
?.trim()
|
||||
?.trimEnd('/', '\\')
|
||||
.orEmpty()
|
||||
if (raw.isBlank()) return "No project"
|
||||
return raw.substringAfterLast('/').substringAfterLast('\\').ifBlank { raw }
|
||||
}
|
||||
|
||||
internal fun sessionDrawerStatus(
|
||||
row: ProfileSessionRow,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
): SessionDrawerStatus = when (
|
||||
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
|
||||
) {
|
||||
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
|
||||
SessionActivityState.Working -> SessionDrawerStatus.Working
|
||||
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
|
||||
session.pullRequestNumber == null -> SessionDrawerPrState.None
|
||||
session.pullRequestDraft -> SessionDrawerPrState.Draft
|
||||
session.pullRequestState.equals("merged", ignoreCase = true) -> SessionDrawerPrState.Merged
|
||||
session.pullRequestState.equals("closed", ignoreCase = true) -> SessionDrawerPrState.Closed
|
||||
else -> SessionDrawerPrState.Open
|
||||
}
|
||||
|
||||
internal fun filterAndSortSessionRows(
|
||||
rows: List<ProfileSessionRow>,
|
||||
options: SessionDrawerViewOptions,
|
||||
activityStates: Map<String, SessionActivityState> = emptyMap(),
|
||||
): List<ProfileSessionRow> {
|
||||
val filtered = rows.asSequence()
|
||||
.filter { options.statuses.isEmpty() || sessionDrawerStatus(it, activityStates) in options.statuses }
|
||||
.filter { options.profiles.isEmpty() || it.profile in options.profiles }
|
||||
.filter { options.projects.isEmpty() || sessionProjectLabel(it.session) in options.projects }
|
||||
.filter { options.pullRequests.isEmpty() || sessionDrawerPrState(it.session) in options.pullRequests }
|
||||
.toList()
|
||||
val statusRank = mapOf(
|
||||
SessionDrawerStatus.NeedsInput to 0,
|
||||
SessionDrawerStatus.Working to 1,
|
||||
SessionDrawerStatus.Idle to 2,
|
||||
)
|
||||
val comparator = when (options.ordering) {
|
||||
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
|
||||
SessionDrawerOrdering.Created -> compareByDescending { it.session.startTimestamp }
|
||||
SessionDrawerOrdering.Title -> compareBy { it.session.title.orEmpty().lowercase(Locale.ROOT) }
|
||||
SessionDrawerOrdering.Status -> compareBy { statusRank.getValue(sessionDrawerStatus(it, activityStates)) }
|
||||
SessionDrawerOrdering.Tokens -> compareByDescending { it.session.totalTokens }
|
||||
SessionDrawerOrdering.Cost -> compareByDescending { it.session.costUsd }
|
||||
}
|
||||
return filtered.sortedWith(
|
||||
compareByDescending<ProfileSessionRow> { it.session.pinned }
|
||||
.then(comparator)
|
||||
.thenBy { it.session.title.orEmpty().lowercase(Locale.ROOT) },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun groupSessionRows(
|
||||
rows: List<ProfileSessionRow>,
|
||||
grouping: SessionDrawerGrouping,
|
||||
activityStates: Map<String, SessionActivityState> = emptyMap(),
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): List<SessionDrawerGroup> {
|
||||
if (rows.isEmpty()) return emptyList()
|
||||
val grouped = rows.groupBy { row ->
|
||||
when (grouping) {
|
||||
SessionDrawerGrouping.None -> null
|
||||
SessionDrawerGrouping.Updated -> updatedBucket(row.session.activityTimestamp, nowMillis)
|
||||
SessionDrawerGrouping.Project -> sessionProjectLabel(row.session)
|
||||
SessionDrawerGrouping.Status -> sessionDrawerStatus(row, activityStates).displayLabel
|
||||
SessionDrawerGrouping.Profile -> row.profile
|
||||
}
|
||||
}
|
||||
val groups = grouped.map { (label, groupRows) ->
|
||||
SessionDrawerGroup(key = "${grouping.name}:$label", label = label, rows = groupRows)
|
||||
}
|
||||
return if (grouping == SessionDrawerGrouping.Project) {
|
||||
groups.sortedWith(
|
||||
compareBy<SessionDrawerGroup> { it.label != "No project" }
|
||||
.thenByDescending { group -> group.rows.maxOfOrNull { it.session.activityTimestamp } ?: 0L }
|
||||
.thenBy { it.label.orEmpty().lowercase(Locale.ROOT) },
|
||||
)
|
||||
} else {
|
||||
groups
|
||||
}
|
||||
}
|
||||
|
||||
private val SessionDrawerStatus.displayLabel: String
|
||||
get() = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
private fun updatedBucket(timestamp: Long, nowMillis: Long): String {
|
||||
if (timestamp <= 0L) return "Older"
|
||||
val age = (nowMillis - timestamp).coerceAtLeast(0L)
|
||||
return when {
|
||||
age < DAY_MILLIS -> "Today"
|
||||
age < 2 * DAY_MILLIS -> "Yesterday"
|
||||
age < 7 * DAY_MILLIS -> "Last 7 days"
|
||||
else -> "Older"
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAY_MILLIS = 24L * 60L * 60L * 1_000L
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hermesandroid.relay.ui.components.avatar
|
||||
|
||||
import com.hermesandroid.relay.viewmodel.connection.ProfileController
|
||||
import java.io.File
|
||||
|
||||
/** Adapt upstream `pet.info` geometry to the existing bounded sprite renderer. */
|
||||
fun ProfileController.HermesPetPresentation.toAvatar(): PetAvatar? {
|
||||
val sheet = File(spritesheetPath)
|
||||
if (!sheet.isFile || frameWidth <= 0 || frameHeight <= 0 || framesPerState <= 0) return null
|
||||
val fps = (framesPerState * 1000f / loopMs.coerceAtLeast(1)).coerceIn(1f, 60f)
|
||||
val clips = stateRows.mapIndexedNotNull { row, name ->
|
||||
val normalized = name.trim().takeIf { it.isNotEmpty() } ?: return@mapIndexedNotNull null
|
||||
val count = (framesByRow[normalized] ?: framesByState[normalized] ?: framesPerState)
|
||||
.coerceIn(1, framesPerState)
|
||||
normalized to PetClipSpec(
|
||||
sheet = sheet.name,
|
||||
frameWidth = frameWidth,
|
||||
frameHeight = frameHeight,
|
||||
frameCount = count,
|
||||
startFrame = row * framesPerState,
|
||||
fps = fps,
|
||||
)
|
||||
}.toMap()
|
||||
if (clips["idle"] == null) return null
|
||||
return runCatching {
|
||||
PetSpec(
|
||||
id = "hermes:$slug",
|
||||
label = displayName,
|
||||
description = "Profile-scoped Hermes animated pet",
|
||||
reactive = PetReactiveSpec(voice = false, tools = true, intensity = true),
|
||||
states = clips,
|
||||
).toAvatar(sheet.parentFile ?: return null)
|
||||
}.getOrNull()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -444,6 +444,34 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val convertLargePastesToAttachments by
|
||||
connectionViewModel.convertLargePastesToAttachments.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_large_pastes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_large_pastes_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = convertLargePastesToAttachments,
|
||||
onCheckedChange = {
|
||||
connectionViewModel.setConvertLargePastesToAttachments(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val physicalKeyboardEnterBehavior by
|
||||
connectionViewModel.physicalKeyboardEnterBehavior.collectAsState()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
+276
-21
@@ -76,6 +76,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
@@ -107,15 +108,20 @@ 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.data.GatewayProfileAuthChoice
|
||||
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
|
||||
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
|
||||
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.CronCreationDraft
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.parseFiniteRepeat
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayMetricCard
|
||||
@@ -388,6 +394,7 @@ private fun dashboardPayloadKey(
|
||||
/** Section-level (not per-item) affordances rendered at the top of a tab. */
|
||||
private enum class DashboardSectionAction {
|
||||
ChangeMainModel,
|
||||
CreateCron,
|
||||
CreateProfile,
|
||||
BrowseSkillsHub,
|
||||
UpdateSkillsHub,
|
||||
@@ -443,6 +450,17 @@ private data class PendingDashboardAction(
|
||||
val action: DashboardItemAction,
|
||||
)
|
||||
|
||||
internal fun shouldUseLegacyDashboardProfileCreate(
|
||||
request: GatewayProfileCreateRequest,
|
||||
failure: Throwable,
|
||||
explicitlyAllowed: Boolean,
|
||||
): Boolean =
|
||||
explicitlyAllowed && failure is GatewayProfileManagementUnsupportedException &&
|
||||
request.authChoice == GatewayProfileAuthChoice.Shared &&
|
||||
(request.cloneFrom == null || request.cloneFrom == "default") &&
|
||||
!request.cloneAll && !request.noSkills && request.soul == null &&
|
||||
request.model == null && request.provider == null
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardManagementScreen(
|
||||
@@ -485,6 +503,7 @@ fun DashboardManagementScreen(
|
||||
var inputAction by remember { mutableStateOf<PendingDashboardAction?>(null) }
|
||||
var modelPickerTarget by remember { mutableStateOf<ModelPickerTarget?>(null) }
|
||||
var showCreateProfile by remember { mutableStateOf(false) }
|
||||
var showCreateCron by remember { mutableStateOf(false) }
|
||||
var expensiveModelConfirm by remember { mutableStateOf<ExpensiveModelConfirm?>(null) }
|
||||
var showSkillsHub by remember { mutableStateOf(false) }
|
||||
var soulEditor by remember { mutableStateOf<SoulEditorState?>(null) }
|
||||
@@ -882,35 +901,83 @@ fun DashboardManagementScreen(
|
||||
}
|
||||
|
||||
fun submitCreateProfile(
|
||||
name: String,
|
||||
description: String,
|
||||
cloneFromDefault: Boolean,
|
||||
request: GatewayProfileCreateRequest,
|
||||
mcpServers: List<String>,
|
||||
allowLegacyDashboard: Boolean,
|
||||
) {
|
||||
if (dashboardUrl.isBlank() || actionInFlight) return
|
||||
if (actionInFlight) return
|
||||
val actionPayloadKey = payloadKey
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
scope.launch {
|
||||
val result = try {
|
||||
withDashboardClient(clientFactory) { client ->
|
||||
client.createProfile(
|
||||
name = name,
|
||||
cloneFromDefault = cloneFromDefault,
|
||||
description = description.takeIf { it.isNotBlank() },
|
||||
mcpServers = mcpServers,
|
||||
val gatewayClient = connectionViewModel.activeGatewayChatClient()
|
||||
val gatewayResult = gatewayClient
|
||||
?.createProfile(request)
|
||||
?: Result.failure(GatewayProfileManagementUnsupportedException("profiles.create"))
|
||||
val result: Result<String> = gatewayResult.fold(
|
||||
onSuccess = { created ->
|
||||
val partial = created.partialMessages(request).toMutableList()
|
||||
if (mcpServers.isNotEmpty()) {
|
||||
val configured = gatewayClient
|
||||
?.describeProfile(created.name)
|
||||
?.mapCatching {
|
||||
gatewayClient.configureProfile(
|
||||
created.name,
|
||||
com.hermesandroid.relay.data.GatewayProfilePatch(
|
||||
enabledMcpServers = mcpServers,
|
||||
),
|
||||
).getOrThrow()
|
||||
}
|
||||
if (
|
||||
configured == null || configured.isFailure ||
|
||||
com.hermesandroid.relay.data.GatewayProfileSection.McpServers in
|
||||
configured.getOrThrow().failed
|
||||
) {
|
||||
partial += context.getString(R.string.dashboard_profile_mcp_partial)
|
||||
}
|
||||
}
|
||||
Result.success(
|
||||
if (partial.isEmpty()) {
|
||||
context.getString(R.string.dashboard_profile_created, created.name)
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.dashboard_profile_created_partial,
|
||||
created.name,
|
||||
partial.joinToString("; "),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
onFailure = { gatewayFailure ->
|
||||
// The authenticated Dashboard route remains the compatibility
|
||||
// create surface for old hosts, but only for the legacy shared
|
||||
// default. Never erase an explicit isolation choice.
|
||||
if (shouldUseLegacyDashboardProfileCreate(request, gatewayFailure, allowLegacyDashboard)) {
|
||||
try {
|
||||
withDashboardClient(clientFactory) { client ->
|
||||
client.createProfile(
|
||||
name = request.name,
|
||||
cloneFromDefault = request.cloneFrom != null,
|
||||
description = request.description,
|
||||
mcpServers = mcpServers,
|
||||
)
|
||||
}.map { context.getString(R.string.dashboard_profile_created_legacy, request.name) }
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
} else {
|
||||
Result.failure(gatewayFailure)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
actionMessage = result.fold(
|
||||
onSuccess = {
|
||||
onSuccess = { message ->
|
||||
showCreateProfile = false
|
||||
refreshingPayloads[actionPayloadKey] = true
|
||||
forceReloadKey = actionPayloadKey
|
||||
reloadNonce += 1
|
||||
context.getString(R.string.dashboard_profile_created, name)
|
||||
connectionViewModel.refreshGatewayProfiles()
|
||||
message
|
||||
},
|
||||
onFailure = { err -> err.message ?: context.getString(R.string.dashboard_profile_create_failed) },
|
||||
)
|
||||
@@ -918,6 +985,29 @@ fun DashboardManagementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun submitCreateCron(draft: CronCreationDraft) {
|
||||
if (actionInFlight) return
|
||||
val actionPayloadKey = payloadKey
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
scope.launch {
|
||||
val result = connectionViewModel.activeGatewayChatClient()
|
||||
?.createCronJob(draft.copy(profile = effectiveProfileName))
|
||||
?: Result.failure(IllegalStateException(context.getString(R.string.dashboard_cron_gateway_required)))
|
||||
actionMessage = result.fold(
|
||||
onSuccess = {
|
||||
showCreateCron = false
|
||||
refreshingPayloads[actionPayloadKey] = true
|
||||
forceReloadKey = actionPayloadKey
|
||||
reloadNonce += 1
|
||||
context.getString(R.string.dashboard_cron_created, draft.name.trim())
|
||||
},
|
||||
onFailure = { err -> err.message ?: context.getString(R.string.dashboard_cron_create_failed) },
|
||||
)
|
||||
actionInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
fun runServerBackup() {
|
||||
if (dashboardUrl.isBlank() || actionInFlight) return
|
||||
actionInFlight = true
|
||||
@@ -1146,11 +1236,17 @@ fun DashboardManagementScreen(
|
||||
var newProfileDescription by remember { mutableStateOf("") }
|
||||
var newProfileMcpServers by remember { mutableStateOf("") }
|
||||
var cloneFromDefault by remember { mutableStateOf(true) }
|
||||
var noSkills by remember { mutableStateOf(false) }
|
||||
var authChoice by remember { mutableStateOf(GatewayProfileAuthChoice.Shared) }
|
||||
var allowLegacyDashboard by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreateProfile = false },
|
||||
title = { Text(stringResource(R.string.dashboard_new_profile_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newProfileName,
|
||||
onValueChange = { newProfileName = it },
|
||||
@@ -1177,28 +1273,97 @@ fun DashboardManagementScreen(
|
||||
) {
|
||||
Checkbox(
|
||||
checked = cloneFromDefault,
|
||||
onCheckedChange = { cloneFromDefault = it },
|
||||
onCheckedChange = {
|
||||
cloneFromDefault = it
|
||||
if (it) noSkills = false
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_clone_from_default),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = noSkills,
|
||||
enabled = !cloneFromDefault,
|
||||
onCheckedChange = { noSkills = it },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_profile_no_skills),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_profile_auth_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
listOf(
|
||||
GatewayProfileAuthChoice.Shared to R.string.dashboard_profile_auth_shared,
|
||||
GatewayProfileAuthChoice.Copied to R.string.dashboard_profile_auth_copied,
|
||||
GatewayProfileAuthChoice.Isolated to R.string.dashboard_profile_auth_isolated,
|
||||
).forEach { (choice, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
authChoice = choice
|
||||
if (choice != GatewayProfileAuthChoice.Shared) allowLegacyDashboard = false
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = authChoice == choice,
|
||||
onClick = {
|
||||
authChoice = choice
|
||||
if (choice != GatewayProfileAuthChoice.Shared) allowLegacyDashboard = false
|
||||
},
|
||||
)
|
||||
Text(text = stringResource(label), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_profile_auth_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = allowLegacyDashboard,
|
||||
enabled = authChoice == GatewayProfileAuthChoice.Shared,
|
||||
onCheckedChange = { allowLegacyDashboard = it },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_profile_allow_legacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
submitCreateProfile(
|
||||
name = newProfileName.trim(),
|
||||
description = newProfileDescription.trim(),
|
||||
cloneFromDefault = cloneFromDefault,
|
||||
request = GatewayProfileCreateRequest(
|
||||
name = newProfileName.trim(),
|
||||
description = newProfileDescription.trim().takeIf(String::isNotEmpty),
|
||||
cloneFrom = "default".takeIf { cloneFromDefault },
|
||||
noSkills = noSkills,
|
||||
authChoice = authChoice,
|
||||
),
|
||||
mcpServers = newProfileMcpServers
|
||||
.split(',', '\n')
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(64),
|
||||
allowLegacyDashboard = allowLegacyDashboard,
|
||||
)
|
||||
},
|
||||
enabled = newProfileName.isNotBlank() && !actionInFlight,
|
||||
@@ -1210,6 +1375,14 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showCreateCron) {
|
||||
CronCreationDialog(
|
||||
actionInFlight = actionInFlight,
|
||||
onDismiss = { showCreateCron = false },
|
||||
onCreate = ::submitCreateCron,
|
||||
)
|
||||
}
|
||||
|
||||
expensiveModelConfirm?.let { confirm ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { expensiveModelConfirm = null },
|
||||
@@ -1529,6 +1702,8 @@ fun DashboardManagementScreen(
|
||||
when (sectionAction) {
|
||||
DashboardSectionAction.ChangeMainModel ->
|
||||
modelPickerTarget = ModelPickerTarget.Main
|
||||
DashboardSectionAction.CreateCron ->
|
||||
showCreateCron = true
|
||||
DashboardSectionAction.CreateProfile ->
|
||||
showCreateProfile = true
|
||||
DashboardSectionAction.BrowseSkillsHub ->
|
||||
@@ -2587,6 +2762,7 @@ private fun LoadedBody(
|
||||
// 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 actionLabelNewSchedule = stringResource(R.string.dashboard_section_action_new_schedule)
|
||||
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)
|
||||
@@ -2614,6 +2790,9 @@ private fun LoadedBody(
|
||||
DashboardManagementSection.Profiles -> listOf(
|
||||
DashboardSectionAction.CreateProfile to actionLabelNewProfile,
|
||||
)
|
||||
DashboardManagementSection.Cron -> listOf(
|
||||
DashboardSectionAction.CreateCron to actionLabelNewSchedule,
|
||||
)
|
||||
DashboardManagementSection.Skills -> listOf(
|
||||
DashboardSectionAction.BrowseSkillsHub to actionLabelBrowseHub,
|
||||
DashboardSectionAction.UpdateSkillsHub to actionLabelUpdateInstalled,
|
||||
@@ -3176,6 +3355,82 @@ private fun ActionMessageCard(message: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CronCreationDialog(
|
||||
actionInFlight: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (CronCreationDraft) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var schedule by remember { mutableStateOf("") }
|
||||
var prompt by remember { mutableStateOf("") }
|
||||
var repeatText by remember { mutableStateOf("") }
|
||||
val repeat = parseFiniteRepeat(repeatText)
|
||||
val draft = repeat.getOrNull()?.let {
|
||||
CronCreationDraft(name = name, schedule = schedule, prompt = prompt, repeat = it)
|
||||
} ?: if (repeat.isSuccess) {
|
||||
CronCreationDraft(name = name, schedule = schedule, prompt = prompt)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val canCreate = draft?.validated()?.isSuccess == true && !actionInFlight
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.dashboard_cron_create_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.dashboard_cron_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = schedule,
|
||||
onValueChange = { schedule = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.dashboard_cron_schedule)) },
|
||||
supportingText = { Text(stringResource(R.string.dashboard_cron_schedule_hint)) },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = prompt,
|
||||
onValueChange = { prompt = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.dashboard_cron_prompt)) },
|
||||
minLines = 3,
|
||||
maxLines = 6,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = repeatText,
|
||||
onValueChange = { repeatText = it.take(4) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.dashboard_cron_repeat)) },
|
||||
supportingText = {
|
||||
Text(
|
||||
repeat.exceptionOrNull()?.message
|
||||
?: stringResource(R.string.dashboard_cron_repeat_help),
|
||||
)
|
||||
},
|
||||
isError = repeat.isFailure,
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { draft?.let(onCreate) },
|
||||
enabled = canCreate,
|
||||
) { Text(stringResource(R.string.dashboard_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dashboard_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardDetailDialog(
|
||||
detail: DashboardDetailResult,
|
||||
|
||||
@@ -264,14 +264,37 @@ fun DashboardSignInScreen(
|
||||
operation = "dashboard_native_pkce",
|
||||
)
|
||||
Log.w(NATIVE_DASHBOARD_AUTH_LOG_TAG, failureDetail)
|
||||
actionMessage = nativeDashboardSignInActionMessage(
|
||||
failureStage = failureStage,
|
||||
errorMessage = error.message,
|
||||
fallbackMessage = resources.getString(R.string.dashboard_signin_failed),
|
||||
transportRetryMessage = resources.getString(
|
||||
actionMessage = when (nativeDashboardSignInMessageKind(failureStage)) {
|
||||
NativeDashboardSignInMessageKind.CallbackRejected -> resources.getString(
|
||||
R.string.dashboard_native_signin_callback_rejected,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.CodeRejected -> resources.getString(
|
||||
R.string.dashboard_native_signin_code_rejected,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.GatewayRejected -> resources.getString(
|
||||
R.string.dashboard_native_signin_gateway_rejected,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.RateLimited -> resources.getString(
|
||||
R.string.dashboard_native_signin_rate_limited,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.GatewayUnavailable -> resources.getString(
|
||||
R.string.dashboard_native_signin_gateway_unavailable,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.ResponseUnsupported -> resources.getString(
|
||||
R.string.dashboard_native_signin_response_unsupported,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.AttemptInactive -> resources.getString(
|
||||
R.string.dashboard_native_signin_attempt_inactive,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.SecureStorage -> resources.getString(
|
||||
R.string.dashboard_native_signin_storage_failed,
|
||||
)
|
||||
NativeDashboardSignInMessageKind.Transport -> resources.getString(
|
||||
R.string.dashboard_native_signin_transport_retry,
|
||||
),
|
||||
)
|
||||
)
|
||||
NativeDashboardSignInMessageKind.Generic -> error.message
|
||||
?: resources.getString(R.string.dashboard_signin_failed)
|
||||
}
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
@@ -362,17 +385,34 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun nativeDashboardSignInActionMessage(
|
||||
failureStage: String,
|
||||
errorMessage: String?,
|
||||
fallbackMessage: String,
|
||||
transportRetryMessage: String,
|
||||
): String = if (failureStage.startsWith("token_transport")) {
|
||||
transportRetryMessage
|
||||
} else {
|
||||
errorMessage ?: fallbackMessage
|
||||
internal enum class NativeDashboardSignInMessageKind {
|
||||
CallbackRejected,
|
||||
CodeRejected,
|
||||
GatewayRejected,
|
||||
RateLimited,
|
||||
GatewayUnavailable,
|
||||
ResponseUnsupported,
|
||||
AttemptInactive,
|
||||
SecureStorage,
|
||||
Transport,
|
||||
Generic,
|
||||
}
|
||||
|
||||
internal fun nativeDashboardSignInMessageKind(failureStage: String): NativeDashboardSignInMessageKind =
|
||||
when {
|
||||
failureStage == "callback_error" -> NativeDashboardSignInMessageKind.CallbackRejected
|
||||
failureStage == "token_http_400" -> NativeDashboardSignInMessageKind.CodeRejected
|
||||
failureStage == "token_http_401" || failureStage == "token_http_403" ->
|
||||
NativeDashboardSignInMessageKind.GatewayRejected
|
||||
failureStage == "token_http_429" -> NativeDashboardSignInMessageKind.RateLimited
|
||||
failureStage.startsWith("token_http_5") -> NativeDashboardSignInMessageKind.GatewayUnavailable
|
||||
failureStage == "token_shape" -> NativeDashboardSignInMessageKind.ResponseUnsupported
|
||||
failureStage == "inactive_generation" -> NativeDashboardSignInMessageKind.AttemptInactive
|
||||
failureStage == "token_store" -> NativeDashboardSignInMessageKind.SecureStorage
|
||||
failureStage.startsWith("token_transport") -> NativeDashboardSignInMessageKind.Transport
|
||||
else -> NativeDashboardSignInMessageKind.Generic
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
|
||||
Column(
|
||||
|
||||
@@ -127,6 +127,7 @@ fun ProfileInspectorScreen(
|
||||
val skillsState by viewModel.skillsState.collectAsState()
|
||||
val source by viewModel.source.collectAsState()
|
||||
val gatewayDescription by viewModel.gatewayDescription.collectAsState()
|
||||
val gatewayWritable by viewModel.gatewayWritable.collectAsState()
|
||||
|
||||
// Lazy first-load on screen entry. Keyed on the profile name so a
|
||||
// re-entry for a different profile (unlikely but possible via deep
|
||||
@@ -248,7 +249,7 @@ fun ProfileInspectorScreen(
|
||||
InspectorSection.Config -> ConfigPane(
|
||||
state = configState,
|
||||
onRetry = { viewModel.refreshSection(InspectorSection.Config) },
|
||||
gatewayEditable = source == ProfileInspectorSource.Gateway,
|
||||
gatewayEditable = source == ProfileInspectorSource.Gateway && gatewayWritable,
|
||||
editing = viewModel.configEditing.collectAsState().value,
|
||||
descriptionDraft = viewModel.configDescriptionDraft.collectAsState().value,
|
||||
providerDraft = viewModel.configProviderDraft.collectAsState().value,
|
||||
@@ -273,6 +274,7 @@ fun ProfileInspectorScreen(
|
||||
onDraftChange = { viewModel.updateSoulDraft(it) },
|
||||
onSave = { viewModel.saveSoulEdit() },
|
||||
onCancelEdit = { viewModel.cancelSoulEdit() },
|
||||
editable = source != ProfileInspectorSource.Gateway || gatewayWritable,
|
||||
)
|
||||
InspectorSection.Memory -> MemoryPane(
|
||||
state = memoryState,
|
||||
@@ -298,7 +300,7 @@ fun ProfileInspectorScreen(
|
||||
onToggleSkill = { name, enabled ->
|
||||
viewModel.toggleSkill(name, enabled)
|
||||
},
|
||||
gatewayNative = source == ProfileInspectorSource.Gateway,
|
||||
gatewayNative = source == ProfileInspectorSource.Gateway && gatewayWritable,
|
||||
skillDrafts = viewModel.skillDrafts.collectAsState().value,
|
||||
toolsets = gatewayDescription?.toolsets.orEmpty(),
|
||||
toolsetDrafts = viewModel.toolsetDrafts.collectAsState().value,
|
||||
@@ -835,6 +837,7 @@ private fun SoulPane(
|
||||
onDraftChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancelEdit: () -> Unit,
|
||||
editable: Boolean,
|
||||
) {
|
||||
PaneShell(state = state, onRetry = onRetry) { response ->
|
||||
Column(
|
||||
@@ -886,11 +889,13 @@ private fun SoulPane(
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onBeginEdit) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.profile_inspector_edit_soul),
|
||||
)
|
||||
if (editable) {
|
||||
IconButton(onClick = onBeginEdit) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.profile_inspector_edit_soul),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
@@ -910,14 +915,14 @@ private fun SoulPane(
|
||||
MonospaceEditor(
|
||||
content = draft,
|
||||
onContentChange = onDraftChange,
|
||||
enabled = !saving,
|
||||
enabled = editable && !saving,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
EditorBottomBar(
|
||||
saving = saving,
|
||||
canSave = true,
|
||||
canSave = editable,
|
||||
onSave = onSave,
|
||||
onCancel = onCancelEdit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val PROFILE_SATURATION = 0.68f
|
||||
private const val PROFILE_LIGHTNESS = 0.58f
|
||||
|
||||
/** The same evenly-spaced 12-hue picker model used by Hermes Desktop. */
|
||||
val ProfileAccentSwatches: List<String> = (0 until 12).map { index ->
|
||||
hslColor(index * 30f, PROFILE_SATURATION, PROFILE_LIGHTNESS).toRgbHex()
|
||||
}
|
||||
|
||||
/** Desktop-compatible deterministic profile identity color; default remains neutral. */
|
||||
fun resolveProfileAccent(name: String?, overrides: Map<String, String>): Color? {
|
||||
val key = name?.trim().orEmpty()
|
||||
if (key.isBlank() || key.equals("default", ignoreCase = true)) return null
|
||||
return accentColor(overrides[key]) ?: deterministicProfileAccent(key)
|
||||
}
|
||||
|
||||
internal fun deterministicProfileAccent(name: String): Color {
|
||||
var hash = 0u
|
||||
name.forEach { character -> hash = hash * 31u + character.code.toUInt() }
|
||||
return hslColor((hash % 360u).toFloat(), PROFILE_SATURATION, PROFILE_LIGHTNESS)
|
||||
}
|
||||
|
||||
private fun hslColor(hue: Float, saturation: Float, lightness: Float): Color {
|
||||
val chroma = (1f - abs(2f * lightness - 1f)) * saturation
|
||||
val section = (hue / 60f) % 6f
|
||||
val x = chroma * (1f - abs(section % 2f - 1f))
|
||||
val (red, green, blue) = when {
|
||||
section < 1f -> Triple(chroma, x, 0f)
|
||||
section < 2f -> Triple(x, chroma, 0f)
|
||||
section < 3f -> Triple(0f, chroma, x)
|
||||
section < 4f -> Triple(0f, x, chroma)
|
||||
section < 5f -> Triple(x, 0f, chroma)
|
||||
else -> Triple(chroma, 0f, x)
|
||||
}
|
||||
val match = lightness - chroma / 2f
|
||||
return Color(red + match, green + match, blue + match)
|
||||
}
|
||||
|
||||
private fun Color.toRgbHex(): String = String.format(
|
||||
Locale.ROOT,
|
||||
"#%02X%02X%02X",
|
||||
(red * 255f).roundToInt(),
|
||||
(green * 255f).roundToInt(),
|
||||
(blue * 255f).roundToInt(),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
|
||||
internal data class BoundedBase64Payload(
|
||||
val base64: String,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
|
||||
internal class AttachmentTooLargeException(
|
||||
val limitBytes: Long,
|
||||
) : Exception("attachment exceeds the $limitBytes byte limit")
|
||||
|
||||
/**
|
||||
* Base64-encode [input] while enforcing [limitBytes] before an oversized body
|
||||
* is buffered. The picker previously called `readBytes()` and checked the cap
|
||||
* afterwards, allowing a content provider to exhaust the app heap first.
|
||||
*/
|
||||
internal fun readBase64Bounded(
|
||||
input: InputStream,
|
||||
limitBytes: Long,
|
||||
): BoundedBase64Payload {
|
||||
require(limitBytes >= 0L) { "limitBytes must be non-negative" }
|
||||
val initialCapacity = minOf(limitBytes, 64L * 1024L).toInt()
|
||||
val encoded = ByteArrayOutputStream(initialCapacity)
|
||||
var count = 0L
|
||||
Base64.getEncoder().wrap(encoded).use { base64Out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val remaining = limitBytes - count
|
||||
val requested = minOf(buffer.size.toLong(), remaining + 1L).toInt()
|
||||
val read = input.read(buffer, 0, requested)
|
||||
if (read < 0) break
|
||||
if (read == 0) continue
|
||||
if (read.toLong() > remaining) throw AttachmentTooLargeException(limitBytes)
|
||||
base64Out.write(buffer, 0, read)
|
||||
count += read
|
||||
}
|
||||
}
|
||||
return BoundedBase64Payload(
|
||||
base64 = encoded.toString(Charsets.US_ASCII.name()),
|
||||
sizeBytes = count,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,10 +95,12 @@ import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayPetGalleryItem
|
||||
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.network.shared.ProfileApiUrlResolver
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.network.relay.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
|
||||
@@ -156,6 +158,31 @@ internal data class RelayUiInputs(
|
||||
val configured: Boolean,
|
||||
)
|
||||
|
||||
data class HostResourcePressureStatus(
|
||||
val memoryPressure: String? = null,
|
||||
val memoryAvailableMb: Int? = null,
|
||||
val diskPressure: String? = null,
|
||||
val diskFreeMb: Int? = null,
|
||||
val lastBootSuspectedOom: Boolean = false,
|
||||
) {
|
||||
val needsAttention: Boolean
|
||||
get() = memoryPressure in setOf("elevated", "critical") ||
|
||||
diskPressure in setOf("elevated", "critical") ||
|
||||
lastBootSuspectedOom
|
||||
|
||||
val critical: Boolean
|
||||
get() = memoryPressure == "critical" || diskPressure == "critical" || lastBootSuspectedOom
|
||||
}
|
||||
|
||||
internal fun DashboardStatus.hostResourcePressure(): HostResourcePressureStatus =
|
||||
HostResourcePressureStatus(
|
||||
memoryPressure = memory?.pressure,
|
||||
memoryAvailableMb = memory?.systemAvailableMb,
|
||||
diskPressure = disk?.pressure,
|
||||
diskFreeMb = disk?.freeMb,
|
||||
lastBootSuspectedOom = memory?.lastBootSuspectedOom == true,
|
||||
)
|
||||
|
||||
internal fun RelayUiInputs.requiresReconnectGrace(): Boolean =
|
||||
configured &&
|
||||
url.isNotBlank() &&
|
||||
@@ -765,6 +792,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
},
|
||||
rebuildChatApiClient = { rebuildChatApiClient() },
|
||||
relayHttpClient = relayHttpClient,
|
||||
gatewayClientProvider = { upstreamTransport.activeGatewayChatClient() },
|
||||
)
|
||||
|
||||
// --- Relay connection state ---
|
||||
@@ -1071,6 +1099,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val standardVoiceAvailability: StateFlow<StandardVoiceAvailability> =
|
||||
_standardVoiceAvailability.asStateFlow()
|
||||
|
||||
private val _hostResourcePressure = MutableStateFlow(HostResourcePressureStatus())
|
||||
val hostResourcePressure: StateFlow<HostResourcePressureStatus> =
|
||||
_hostResourcePressure.asStateFlow()
|
||||
|
||||
private val _standardAudioApiReachable = MutableStateFlow(false)
|
||||
val standardAudioApiReachable: StateFlow<Boolean> = _standardAudioApiReachable.asStateFlow()
|
||||
|
||||
@@ -1571,11 +1603,30 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? =
|
||||
profileController.listAllProfileSessions(limit)
|
||||
|
||||
suspend fun deleteSession(profileName: String, sessionId: String): Boolean =
|
||||
profileController.deleteSession(profileName, sessionId)
|
||||
|
||||
suspend fun renameSession(profileName: String, sessionId: String, title: String): Boolean =
|
||||
profileController.renameSession(profileName, sessionId, title)
|
||||
|
||||
suspend fun setSessionPinned(profileName: String, sessionId: String, pinned: Boolean): Boolean =
|
||||
profileController.setSessionPinned(profileName, sessionId, pinned)
|
||||
|
||||
suspend fun setSessionArchived(profileName: String, sessionId: String, archived: Boolean): Boolean =
|
||||
profileController.setSessionArchived(profileName, sessionId, archived)
|
||||
|
||||
suspend fun loadProfileScopedMessages(
|
||||
sessionId: String,
|
||||
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
|
||||
): Result<List<MessageItem>>? = profileController.loadProfileScopedMessages(sessionId, mode)
|
||||
|
||||
suspend fun loadProfileScopedMessages(
|
||||
profileName: String?,
|
||||
sessionId: String,
|
||||
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
|
||||
): Result<List<MessageItem>>? =
|
||||
profileController.loadProfileScopedMessages(profileName, sessionId, mode)
|
||||
|
||||
/**
|
||||
* Delete a session scoped to the ACTIVE PROFILE via the dashboard
|
||||
* `DELETE /api/sessions/{id}?profile=` surface — the write twin of
|
||||
@@ -1624,6 +1675,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun setProfileHidden(profileName: String?, hidden: Boolean) =
|
||||
profileController.setProfileHidden(profileName, hidden)
|
||||
|
||||
fun setProfileColor(profileName: String, colorHex: String?) =
|
||||
profileController.setProfileColor(profileName, colorHex)
|
||||
|
||||
fun resetProfilePresentation() = profileController.resetProfilePresentation()
|
||||
|
||||
/**
|
||||
@@ -1640,6 +1694,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/** The active profile's local agent-icon path (client-side, never sent to Hermes). */
|
||||
val profileIcon: StateFlow<String?> get() = profileController.profileIcon
|
||||
val localProfileIcon: StateFlow<String?> get() = profileController.localProfileIcon
|
||||
val serverProfileAvatar: StateFlow<String?> get() = profileController.serverProfileAvatar
|
||||
val useLocalProfileIconOverride: StateFlow<Boolean>
|
||||
get() = profileController.useLocalProfileIconOverride
|
||||
val sharedProfileAvatarState: StateFlow<ProfileController.SharedAvatarState>
|
||||
get() = profileController.sharedAvatarState
|
||||
val hermesPetState: StateFlow<ProfileController.HermesPetState>
|
||||
get() = profileController.hermesPetState
|
||||
|
||||
/** A local icon path for a specific profile identity on the active connection. */
|
||||
fun profileIconFlow(profileName: String?) = profileController.profileIconFlow(profileName)
|
||||
@@ -1649,10 +1711,32 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
fun setProfileIcon(uri: Uri) = profileController.setProfileIcon(uri)
|
||||
|
||||
fun setSharedProfileAvatar(uri: Uri) = profileController.setSharedProfileAvatar(uri)
|
||||
|
||||
fun setUseLocalProfileIconOverride(enabled: Boolean) =
|
||||
profileController.setUseLocalProfileIconOverride(enabled)
|
||||
|
||||
fun importProfileIconFromHost() = profileController.importProfileIconFromHost()
|
||||
|
||||
fun clearProfileIcon() = profileController.clearProfileIcon()
|
||||
|
||||
fun uploadLocalProfileIconToHermes() = profileController.uploadLocalProfileIconToHermes()
|
||||
|
||||
fun clearSharedProfileAvatar() = profileController.clearSharedProfileAvatar()
|
||||
|
||||
fun refreshHermesPet() = profileController.refreshHermesPet()
|
||||
|
||||
fun loadHermesPetGallery() = profileController.loadHermesPetGallery()
|
||||
|
||||
fun selectHermesPet(slug: String) = profileController.selectHermesPet(slug)
|
||||
|
||||
fun loadHermesPetThumbnail(pet: GatewayPetGalleryItem) =
|
||||
profileController.loadHermesPetThumbnail(pet)
|
||||
|
||||
fun disableHermesPet() = profileController.disableHermesPet()
|
||||
|
||||
fun refreshGatewayProfiles() = profileController.refreshGatewayProfiles()
|
||||
|
||||
fun selectProfile(profile: Profile?) = profileController.selectProfile(profile)
|
||||
|
||||
// --- Profile lock (per-connection pin to one profile) ------------------
|
||||
@@ -2204,6 +2288,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
val convertLargePastesToAttachments: StateFlow<Boolean> =
|
||||
chatInputPreferencesRepository.convertLargePastesToAttachments
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setConvertLargePastesToAttachments(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
chatInputPreferencesRepository.setConvertLargePastesToAttachments(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-complete notification (default ON). RelayApp mirrors this into
|
||||
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
|
||||
// + the POST_NOTIFICATIONS runtime request on first enable.
|
||||
@@ -2363,6 +2457,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
receivedAt = msg.sentAt ?: System.currentTimeMillis(),
|
||||
chatId = msg.chatId,
|
||||
connectionId = connectionStore.activeConnectionId.value,
|
||||
arrivedWhileAway = msg.arrivedWhileAway,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -4236,6 +4331,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.collectLatest {
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
|
||||
_standardAudioApiReachable.value = false
|
||||
_hostResourcePressure.value = HostResourcePressureStatus()
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
probeStandardVoice()
|
||||
@@ -4535,6 +4631,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
if (connectionId == null || dashboardUrl.isNullOrBlank()) {
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
|
||||
_standardAudioApiReachable.value = false
|
||||
_hostResourcePressure.value = HostResourcePressureStatus()
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
return
|
||||
@@ -4553,11 +4650,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
updateDashboardTopology(connectionId, null)
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
|
||||
_standardAudioApiReachable.value = false
|
||||
_hostResourcePressure.value = HostResourcePressureStatus()
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unreachable)
|
||||
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
|
||||
return
|
||||
}
|
||||
_hostResourcePressure.value = status.hostResourcePressure()
|
||||
updateDashboardTopology(connectionId, status)
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
val authed = !status.authRequired || session?.authenticated == true
|
||||
@@ -4595,6 +4694,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
android.util.Log.w("ConnectionVM", "probeStandardVoice failed: ${e.message}")
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
|
||||
_standardAudioApiReachable.value = false
|
||||
_hostResourcePressure.value = HostResourcePressureStatus()
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unreachable)
|
||||
} finally {
|
||||
@@ -5314,6 +5414,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
routeCandidatesOverride: List<EndpointCandidate>? = null,
|
||||
onResult: (StandardApiSetupResult) -> Unit,
|
||||
) {
|
||||
val normalizedApiKey = runCatching {
|
||||
normalizeCredentialForHeader(apiKey, "API credential")
|
||||
}.getOrElse {
|
||||
onResult(
|
||||
StandardApiSetupResult(
|
||||
ok = false,
|
||||
message = it.message ?: "Invalid API credential",
|
||||
apiReachable = false,
|
||||
relayPaired = authState.value is AuthState.Paired,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
// Bare host/IP input gets http:// and the surface's default port —
|
||||
// typing `192.168.1.10` or a Tailscale `100.x.y.z` without a scheme
|
||||
// is the common case and used to either block the wizard or silently
|
||||
@@ -5375,8 +5488,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
relayUrl = routeRelayUrl,
|
||||
routeCandidates = routeCandidates,
|
||||
)
|
||||
if (apiKey.isNotBlank()) {
|
||||
authManager.setApiKey(apiKey.trim())
|
||||
if (normalizedApiKey.isNotBlank()) {
|
||||
authManager.setApiKey(normalizedApiKey)
|
||||
}
|
||||
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
@@ -5579,6 +5692,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
manualRelayUrlOverride: String?,
|
||||
onResult: (ApiVoiceSetupResult) -> Unit,
|
||||
) {
|
||||
val normalizedApiKey = runCatching {
|
||||
normalizeCredentialForHeader(apiKey, "API credential")
|
||||
}.getOrElse {
|
||||
onResult(
|
||||
ApiVoiceSetupResult(
|
||||
apiReachable = false,
|
||||
relayUrl = null,
|
||||
relayAutoDerived = false,
|
||||
voiceConfigReachable = false,
|
||||
voiceConfigError = it.message,
|
||||
voiceRoute = "none",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
val trimmedApiUrl = apiUrl.trim()
|
||||
val trimmedOverride = manualRelayUrlOverride?.trim().orEmpty()
|
||||
val derivedRelayUrl = RelayUrlDeriver.deriveFromApiUrl(trimmedApiUrl)
|
||||
@@ -5597,8 +5725,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
if (apiKey.isNotBlank()) {
|
||||
authManager.setApiKey(apiKey.trim())
|
||||
if (normalizedApiKey.isNotBlank()) {
|
||||
authManager.setApiKey(normalizedApiKey)
|
||||
}
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_API_SERVER_URL] = trimmedApiUrl
|
||||
@@ -5677,8 +5805,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
fun updateApiKey(key: String) {
|
||||
viewModelScope.launch {
|
||||
authManager.setApiKey(key)
|
||||
rebuildApiClient()
|
||||
runCatching {
|
||||
authManager.setApiKey(key)
|
||||
rebuildApiClient()
|
||||
}.onFailure {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "API credential was not saved",
|
||||
detail = it.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ class ProfileInspectorViewModel(
|
||||
|
||||
private val _gatewayDescription = MutableStateFlow<GatewayProfileDescription?>(null)
|
||||
val gatewayDescription: StateFlow<GatewayProfileDescription?> = _gatewayDescription.asStateFlow()
|
||||
private val _gatewayWritable = MutableStateFlow(false)
|
||||
val gatewayWritable: StateFlow<Boolean> = _gatewayWritable.asStateFlow()
|
||||
private var gatewayConfigureUnsupported = false
|
||||
|
||||
private val _configState = MutableStateFlow<LoadState<ProfileConfigResponse>>(LoadState.Idle)
|
||||
val configState: StateFlow<LoadState<ProfileConfigResponse>> = _configState.asStateFlow()
|
||||
@@ -351,6 +354,7 @@ class ProfileInspectorViewModel(
|
||||
if (configResult.isSuccess || soulResult.isSuccess || skillsResult.isSuccess) {
|
||||
_source.value = ProfileInspectorSource.Relay
|
||||
_gatewayDescription.value = null
|
||||
_gatewayWritable.value = false
|
||||
}
|
||||
val fallbackMessage = gatewayError
|
||||
?.takeUnless { it is GatewayProfileEditorUnsupportedException }
|
||||
@@ -375,7 +379,8 @@ class ProfileInspectorViewModel(
|
||||
private fun applyGatewayDescription(description: GatewayProfileDescription) {
|
||||
if (description.name != profileName) return
|
||||
_gatewayDescription.value = description
|
||||
_skillToggleSupported.value = true
|
||||
_gatewayWritable.value = !gatewayConfigureUnsupported
|
||||
_skillToggleSupported.value = !gatewayConfigureUnsupported
|
||||
_configState.value = LoadState.Loaded(description.toConfigResponse())
|
||||
_soulState.value = LoadState.Loaded(description.toSoulResponse())
|
||||
_skillsState.value = LoadState.Loaded(description.toSkillsResponse())
|
||||
@@ -393,6 +398,11 @@ class ProfileInspectorViewModel(
|
||||
viewModelScope.launch {
|
||||
val configured = client.configureProfile(profileName, patch)
|
||||
if (configured.isFailure) {
|
||||
if (configured.exceptionOrNull() is GatewayProfileEditorUnsupportedException) {
|
||||
gatewayConfigureUnsupported = true
|
||||
_gatewayWritable.value = false
|
||||
_skillToggleSupported.value = false
|
||||
}
|
||||
clearSavingFlags()
|
||||
_editEvents.tryEmit(EditEvent.Error(configured.exceptionOrNull()?.message ?: "Save failed"))
|
||||
return@launch
|
||||
|
||||
+717
-28
@@ -4,9 +4,13 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfileDisplayAliasStore
|
||||
import com.hermesandroid.relay.data.ProfileIconStore
|
||||
import com.hermesandroid.relay.data.prepareProfileAvatar
|
||||
import com.hermesandroid.relay.data.profileAvatarMime
|
||||
import com.hermesandroid.relay.data.preferredProfileIcon
|
||||
import com.hermesandroid.relay.data.ProfileLockStore
|
||||
import com.hermesandroid.relay.data.ProfilePresentation
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
@@ -17,6 +21,10 @@ import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardProfileScope
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayPetGalleryItem
|
||||
import com.hermesandroid.relay.network.upstream.GatewayPetInfo
|
||||
import com.hermesandroid.relay.network.upstream.GatewayRpcException
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.SessionMessageLoadMode
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -39,7 +47,46 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal fun isCurrentProfileAvatarRefresh(
|
||||
requestConnectionId: String,
|
||||
activeConnectionId: String?,
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
): Boolean = requestConnectionId == activeConnectionId && requestGeneration == currentGeneration
|
||||
|
||||
internal fun mergeGatewayProfileRoster(
|
||||
gateway: List<Profile>,
|
||||
fallback: List<Profile>,
|
||||
): List<Profile> = gateway.map { authoritative ->
|
||||
val extension = fallback.firstOrNull { it.name == authoritative.name }
|
||||
?: return@map authoritative
|
||||
authoritative.copy(
|
||||
systemMessage = extension.systemMessage,
|
||||
gatewayRunning = extension.gatewayRunning,
|
||||
hasSoul = extension.hasSoul,
|
||||
apiServerEnabled = extension.apiServerEnabled,
|
||||
apiServerUrl = extension.apiServerUrl,
|
||||
apiServerHost = extension.apiServerHost,
|
||||
apiServerPort = extension.apiServerPort,
|
||||
apiServerKeyPresent = extension.apiServerKeyPresent,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun selectProfileRoster(
|
||||
gatewayAuthoritative: Boolean,
|
||||
gateway: List<Profile>,
|
||||
fallback: List<Profile>,
|
||||
): List<Profile> = if (gatewayAuthoritative) {
|
||||
mergeGatewayProfileRoster(gateway, fallback)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the **agent-profiles cluster** of
|
||||
@@ -93,6 +140,8 @@ class ProfileController(
|
||||
private val rebuildChatApiClient: suspend () -> Unit,
|
||||
/** Optional Relay client used only for importing an icon from the host. */
|
||||
private val relayHttpClient: RelayHttpClient? = null,
|
||||
/** Current per-connection Gateway client; profile management stays upstream-owned. */
|
||||
private val gatewayClientProvider: () -> GatewayChatClient? = { null },
|
||||
) {
|
||||
|
||||
// Server-advertised named agent configs, flattened to a StateFlow the
|
||||
@@ -101,15 +150,24 @@ class ProfileController(
|
||||
// underlying AuthManager instance is replaced and the public flow needs to
|
||||
// repoint at the new manager's backing state.
|
||||
private val _dashboardProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||
private val _gatewayProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||
private val _gatewayRosterAuthoritative = MutableStateFlow(false)
|
||||
private val avatarRefreshGeneration = AtomicLong(0L)
|
||||
private val petRefreshGeneration = AtomicLong(0L)
|
||||
private val petGalleryGeneration = AtomicLong(0L)
|
||||
private val petThumbnailRequests = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val agentProfiles: StateFlow<List<Profile>> = combine(
|
||||
authManagerFlow.flatMapLatest { it.agentProfiles },
|
||||
_dashboardProfiles,
|
||||
) { relay, dashboard ->
|
||||
// Prefer the relay's list when it has entries (richer runtime metadata);
|
||||
// fall back to the dashboard list so a dashboard-only connection still
|
||||
// sees its server profiles in the chat picker.
|
||||
relay.ifEmpty { dashboard }
|
||||
_gatewayProfiles,
|
||||
_gatewayRosterAuthoritative,
|
||||
) { relay, dashboard, gateway, gatewayAuthoritative ->
|
||||
// Current Gateway rows are authoritative for shared profile metadata and
|
||||
// avatar presence. Relay-only runtime/API routing fields are joined by
|
||||
// exact name so adopting the roster never drops an isolated profile route.
|
||||
val fallback = relay.ifEmpty { dashboard }
|
||||
selectProfileRoster(gatewayAuthoritative, gateway, fallback)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, authManagerFlow.value.agentProfiles.value)
|
||||
|
||||
private val _selectedProfile = MutableStateFlow<Profile?>(null)
|
||||
@@ -257,8 +315,8 @@ class ProfileController(
|
||||
|
||||
val profileIconStore: ProfileIconStore = ProfileIconStore(context)
|
||||
|
||||
/** The active profile's local agent-icon path (twin of [profileDisplayAlias]). */
|
||||
val profileIcon: StateFlow<String?> = combine(
|
||||
/** The active profile's device-local fallback icon. */
|
||||
val localProfileIcon: StateFlow<String?> = combine(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
) { connectionId, profile ->
|
||||
@@ -271,11 +329,63 @@ class ProfileController(
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val activeServerAvatarIdentity: Flow<Pair<String?, String?>> = combine(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, selected, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
selected?.name ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
}
|
||||
|
||||
/** Cached bytes fetched from Hermes; always preferred over the local fallback. */
|
||||
val serverProfileAvatar: StateFlow<String?> = activeServerAvatarIdentity
|
||||
.flatMapLatest { (connectionId, profileName) ->
|
||||
if (connectionId == null || profileName.isNullOrBlank()) flowOf(null)
|
||||
else profileIconStore.serverAvatarFlow(connectionId, profileName)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/** Whether this phone should prefer its local image over Hermes' shared avatar. */
|
||||
val useLocalProfileIconOverride: StateFlow<Boolean> = combine(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
) { connectionId, profile ->
|
||||
connectionId to AgentDisplay.profileRequestName(profile?.name)
|
||||
}.flatMapLatest { (connectionId, profileName) ->
|
||||
if (connectionId == null) flowOf(false)
|
||||
else profileIconStore.localOverrideFlow(connectionId, profileName)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
val profileIcon: StateFlow<String?> = combine(
|
||||
serverProfileAvatar,
|
||||
localProfileIcon,
|
||||
useLocalProfileIconOverride,
|
||||
) { server, local, localOverride -> preferredProfileIcon(server, local, localOverride) }
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/** Local icon for any profile identity on the active connection. */
|
||||
fun profileIconFlow(profileName: String?): Flow<String?> = activeConnectionId
|
||||
.flatMapLatest { connectionId ->
|
||||
if (connectionId == null) flowOf(null)
|
||||
else profileIconStore.iconFlow(connectionId, profileName)
|
||||
fun profileIconFlow(profileName: String?): Flow<String?> = combine(
|
||||
activeConnectionId,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
profileName ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
}
|
||||
.flatMapLatest { (connectionId, serverProfileName) ->
|
||||
if (connectionId == null) return@flatMapLatest flowOf(null)
|
||||
val local = profileIconStore.iconFlow(connectionId, profileName)
|
||||
val localOverride = profileIconStore.localOverrideFlow(connectionId, profileName)
|
||||
if (serverProfileName.isNullOrBlank()) local else combine(
|
||||
profileIconStore.serverAvatarFlow(connectionId, serverProfileName),
|
||||
local,
|
||||
localOverride,
|
||||
) { server, fallback, override -> preferredProfileIcon(server, fallback, override) }
|
||||
}
|
||||
|
||||
data class HostIconImportState(
|
||||
@@ -287,6 +397,44 @@ class ProfileController(
|
||||
val hostIconImportState: StateFlow<HostIconImportState> =
|
||||
_hostIconImportState.asStateFlow()
|
||||
|
||||
data class SharedAvatarState(
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val _sharedAvatarState = MutableStateFlow(SharedAvatarState())
|
||||
val sharedAvatarState: StateFlow<SharedAvatarState> = _sharedAvatarState.asStateFlow()
|
||||
|
||||
data class HermesPetPresentation(
|
||||
val connectionId: String,
|
||||
val profileName: String?,
|
||||
val slug: String,
|
||||
val displayName: String,
|
||||
val spritesheetPath: String,
|
||||
val spritesheetRevision: String,
|
||||
val frameWidth: Int,
|
||||
val frameHeight: Int,
|
||||
val framesPerState: Int,
|
||||
val framesByState: Map<String, Int>,
|
||||
val framesByRow: Map<String, Int>,
|
||||
val loopMs: Int,
|
||||
val scale: Float,
|
||||
val stateRows: List<String>,
|
||||
)
|
||||
|
||||
data class HermesPetState(
|
||||
val supported: Boolean? = null,
|
||||
val loading: Boolean = false,
|
||||
val galleryLoading: Boolean = false,
|
||||
val active: HermesPetPresentation? = null,
|
||||
val gallery: List<GatewayPetGalleryItem> = emptyList(),
|
||||
val thumbnails: Map<String, String> = emptyMap(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val _hermesPetState = MutableStateFlow(HermesPetState())
|
||||
val hermesPetState: StateFlow<HermesPetState> = _hermesPetState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Load the host's agent profiles from the dashboard `/api/profiles` into
|
||||
* [agentProfiles] (merged in the combine above). Lets the chat agent sheet
|
||||
@@ -302,6 +450,8 @@ class ProfileController(
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
refreshGatewayProfiles(connectionId)
|
||||
refreshHermesPet(connectionId)
|
||||
val client = dashboardClientFactory(connectionId, dashboardUrl)
|
||||
val defaultScope = client.getActiveProfileScope().getOrNull()
|
||||
if (activeConnectionId.value != connectionId) return@launch
|
||||
@@ -324,6 +474,258 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshGatewayProfiles() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
scope.launch {
|
||||
refreshGatewayProfiles(connectionId)
|
||||
refreshHermesPet(connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshGatewayProfiles(connectionId: String) {
|
||||
val gateway = gatewayClientProvider() ?: return
|
||||
val generation = avatarRefreshGeneration.incrementAndGet()
|
||||
gateway.listProfiles().onSuccess { profiles ->
|
||||
if (!isCurrentProfileAvatarRefresh(connectionId, activeConnectionId.value, generation, avatarRefreshGeneration.get())) return@onSuccess
|
||||
_gatewayProfiles.value = profiles
|
||||
_gatewayRosterAuthoritative.value = true
|
||||
profiles.forEach { profile ->
|
||||
if (!profile.hasAvatar) {
|
||||
clearServerAvatarCache(connectionId, profile.name, generation)
|
||||
} else {
|
||||
gateway.getProfileAvatar(profile.name).onSuccess { asset ->
|
||||
if (!isCurrentProfileAvatarRefresh(connectionId, activeConnectionId.value, generation, avatarRefreshGeneration.get())) {
|
||||
return@onSuccess
|
||||
}
|
||||
if (asset == null) {
|
||||
clearServerAvatarCache(connectionId, profile.name, generation)
|
||||
} else {
|
||||
val path = copyServerAvatarBytes(connectionId, profile.name, asset.data, asset.mime)
|
||||
if (path != null && avatarRefreshGeneration.get() == generation) {
|
||||
profileIconStore.setServerAvatar(connectionId, profile.name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { failure ->
|
||||
if (
|
||||
failure is GatewayProfileManagementUnsupportedException &&
|
||||
isCurrentProfileAvatarRefresh(
|
||||
connectionId,
|
||||
activeConnectionId.value,
|
||||
generation,
|
||||
avatarRefreshGeneration.get(),
|
||||
)
|
||||
) {
|
||||
_gatewayProfiles.value = emptyList()
|
||||
_gatewayRosterAuthoritative.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearServerAvatarCache(connectionId: String, profileName: String, generation: Long) {
|
||||
if (avatarRefreshGeneration.get() != generation) return
|
||||
profileIconStore.serverAvatarFlow(connectionId, profileName).first()?.let { runCatching { File(it).delete() } }
|
||||
profileIconStore.setServerAvatar(connectionId, profileName, null)
|
||||
}
|
||||
|
||||
/** Refresh the active profile's upstream sprite contract without re-sending an unchanged sheet. */
|
||||
fun refreshHermesPet() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
scope.launch { refreshHermesPet(connectionId) }
|
||||
}
|
||||
|
||||
private suspend fun refreshHermesPet(connectionId: String) {
|
||||
val gateway = gatewayClientProvider() ?: run {
|
||||
_hermesPetState.value = HermesPetState(supported = null)
|
||||
return
|
||||
}
|
||||
val profileName = resolveSessionProfileName()
|
||||
val generation = petRefreshGeneration.incrementAndGet()
|
||||
val previous = _hermesPetState.value.active?.takeIf {
|
||||
it.connectionId == connectionId && it.profileName == profileName
|
||||
}
|
||||
_hermesPetState.value = _hermesPetState.value.copy(loading = true, error = null)
|
||||
gateway.petInfo(profile = profileName, knownRevision = previous?.spritesheetRevision).fold(
|
||||
onSuccess = { info ->
|
||||
if (!isCurrentPetRefresh(connectionId, generation)) return@fold
|
||||
if (!info.enabled) {
|
||||
_hermesPetState.value = HermesPetState(supported = true)
|
||||
return@fold
|
||||
}
|
||||
val presentation = cacheHermesPet(connectionId, profileName, info, previous)
|
||||
_hermesPetState.value = if (presentation == null) {
|
||||
HermesPetState(
|
||||
supported = true,
|
||||
error = "Hermes returned an animated pet that this phone could not cache",
|
||||
)
|
||||
} else {
|
||||
_hermesPetState.value.copy(
|
||||
supported = true,
|
||||
loading = false,
|
||||
active = presentation,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { failure ->
|
||||
if (!isCurrentPetRefresh(connectionId, generation)) return@fold
|
||||
_hermesPetState.value = if ((failure as? GatewayRpcException)?.code == -32601) {
|
||||
HermesPetState(supported = false)
|
||||
} else {
|
||||
_hermesPetState.value.copy(
|
||||
loading = false,
|
||||
error = failure.message ?: "Could not load the Hermes animated pet",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Load the profile-scoped upstream gallery only when the user opens the picker. */
|
||||
fun loadHermesPetGallery() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val gateway = gatewayClientProvider() ?: return
|
||||
val profileName = resolveSessionProfileName()
|
||||
val generation = petGalleryGeneration.incrementAndGet()
|
||||
scope.launch {
|
||||
_hermesPetState.value = _hermesPetState.value.copy(galleryLoading = true, error = null)
|
||||
gateway.petGallery(profile = profileName).fold(
|
||||
onSuccess = { gallery ->
|
||||
if (!isCurrentPetGallery(connectionId, profileName, generation)) return@fold
|
||||
_hermesPetState.value = _hermesPetState.value.copy(
|
||||
supported = true,
|
||||
galleryLoading = false,
|
||||
gallery = gallery.pets,
|
||||
error = null,
|
||||
)
|
||||
},
|
||||
onFailure = { failure ->
|
||||
if (!isCurrentPetGallery(connectionId, profileName, generation)) return@fold
|
||||
_hermesPetState.value = _hermesPetState.value.copy(
|
||||
galleryLoading = false,
|
||||
error = failure.message ?: "Could not load the Hermes pet gallery",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectHermesPet(slug: String) = mutateHermesPet { gateway, profile ->
|
||||
gateway.selectPet(slug, profile)
|
||||
}
|
||||
|
||||
/** Lazily fetch one upstream-cropped idle frame for a visible gallery row. */
|
||||
fun loadHermesPetThumbnail(pet: GatewayPetGalleryItem) {
|
||||
if (_hermesPetState.value.thumbnails.containsKey(pet.slug)) return
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val gateway = gatewayClientProvider() ?: return
|
||||
val profileName = resolveSessionProfileName()
|
||||
val requestKey = "$connectionId\u0000${AgentDisplay.profileSessionKey(profileName)}\u0000${pet.slug}"
|
||||
if (!petThumbnailRequests.add(requestKey)) return
|
||||
scope.launch {
|
||||
try {
|
||||
gateway.petThumbnail(
|
||||
slug = pet.slug,
|
||||
spritesheetUrl = pet.spritesheetUrl,
|
||||
profile = profileName,
|
||||
).onSuccess { dataUri ->
|
||||
if (
|
||||
dataUri != null && activeConnectionId.value == connectionId &&
|
||||
resolveSessionProfileName() == profileName
|
||||
) {
|
||||
_hermesPetState.value = _hermesPetState.value.copy(
|
||||
thumbnails = _hermesPetState.value.thumbnails + (pet.slug to dataUri),
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
petThumbnailRequests.remove(requestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableHermesPet() = mutateHermesPet { gateway, profile ->
|
||||
gateway.disablePet(profile)
|
||||
}
|
||||
|
||||
private fun mutateHermesPet(
|
||||
mutation: suspend (GatewayChatClient, String?) -> Result<Unit>,
|
||||
) {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val gateway = gatewayClientProvider() ?: return
|
||||
val profileName = resolveSessionProfileName()
|
||||
scope.launch {
|
||||
_hermesPetState.value = _hermesPetState.value.copy(loading = true, error = null)
|
||||
mutation(gateway, profileName).fold(
|
||||
onSuccess = { refreshHermesPet(connectionId) },
|
||||
onFailure = { failure ->
|
||||
_hermesPetState.value = _hermesPetState.value.copy(
|
||||
loading = false,
|
||||
error = failure.message ?: "Could not update the Hermes animated pet",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentPetRefresh(connectionId: String, generation: Long): Boolean =
|
||||
activeConnectionId.value == connectionId && petRefreshGeneration.get() == generation
|
||||
|
||||
private fun isCurrentPetGallery(connectionId: String, profileName: String?, generation: Long): Boolean =
|
||||
activeConnectionId.value == connectionId &&
|
||||
resolveSessionProfileName() == profileName &&
|
||||
petGalleryGeneration.get() == generation
|
||||
|
||||
private suspend fun cacheHermesPet(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
info: GatewayPetInfo,
|
||||
previous: HermesPetPresentation?,
|
||||
): HermesPetPresentation? = withContext(Dispatchers.IO) {
|
||||
val slug = info.slug ?: return@withContext null
|
||||
val revision = info.spritesheetRevision ?: return@withContext null
|
||||
val path = if (info.spritesheetUnchanged && previous?.spritesheetRevision == revision) {
|
||||
previous.spritesheetPath.takeIf { File(it).isFile }
|
||||
} else {
|
||||
val bytes = info.spritesheet ?: return@withContext null
|
||||
val dir = File(context.filesDir, "hermes-profile-pets").apply { mkdirs() }
|
||||
val key = MessageDigest.getInstance("SHA-256")
|
||||
.digest("$connectionId\u0000${AgentDisplay.profileSessionKey(profileName)}".toByteArray())
|
||||
.take(12)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
val extension = if (info.mime == "image/webp") "webp" else "png"
|
||||
val target = File(dir, "$key.$extension")
|
||||
val pending = File(dir, "$key.$extension.tmp")
|
||||
runCatching {
|
||||
pending.writeBytes(bytes)
|
||||
if (!pending.renameTo(target)) {
|
||||
target.writeBytes(bytes)
|
||||
pending.delete()
|
||||
}
|
||||
target.absolutePath
|
||||
}.getOrNull()
|
||||
} ?: return@withContext null
|
||||
|
||||
HermesPetPresentation(
|
||||
connectionId = connectionId,
|
||||
profileName = profileName,
|
||||
slug = slug,
|
||||
displayName = info.displayName ?: slug,
|
||||
spritesheetPath = path,
|
||||
spritesheetRevision = revision,
|
||||
frameWidth = info.frameWidth,
|
||||
frameHeight = info.frameHeight,
|
||||
framesPerState = info.framesPerState,
|
||||
framesByState = info.framesByState,
|
||||
framesByRow = info.framesByRow,
|
||||
loopMs = info.loopMs,
|
||||
scale = info.scale,
|
||||
stateRows = info.stateRows,
|
||||
)
|
||||
}
|
||||
|
||||
/** Effective profile namespace for Gateway and profile-scoped session I/O. */
|
||||
fun resolveSessionProfileName(selectedProfileName: String? = _selectedProfile.value?.name): String? =
|
||||
AgentDisplay.effectiveSessionProfileName(
|
||||
@@ -350,6 +752,38 @@ class ProfileController(
|
||||
return dashboardClientFactory(connectionId, dashboardUrl).listAllProfileSessions(limit)
|
||||
}
|
||||
|
||||
suspend fun deleteSession(profileName: String, sessionId: String): Boolean {
|
||||
val connectionId = activeConnectionId.value ?: return false
|
||||
val dashboardUrl = activeDashboardUrlProvider() ?: return false
|
||||
return dashboardClientFactory(connectionId, dashboardUrl)
|
||||
.deleteSession(sessionId, profileName)
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
suspend fun renameSession(profileName: String, sessionId: String, title: String): Boolean {
|
||||
val connectionId = activeConnectionId.value ?: return false
|
||||
val dashboardUrl = activeDashboardUrlProvider() ?: return false
|
||||
return dashboardClientFactory(connectionId, dashboardUrl)
|
||||
.renameSession(sessionId, title, profileName)
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
suspend fun setSessionPinned(profileName: String, sessionId: String, pinned: Boolean): Boolean {
|
||||
val connectionId = activeConnectionId.value ?: return false
|
||||
val dashboardUrl = activeDashboardUrlProvider() ?: return false
|
||||
return dashboardClientFactory(connectionId, dashboardUrl)
|
||||
.setSessionPinned(sessionId, pinned, profileName)
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
suspend fun setSessionArchived(profileName: String, sessionId: String, archived: Boolean): Boolean {
|
||||
val connectionId = activeConnectionId.value ?: return false
|
||||
val dashboardUrl = activeDashboardUrlProvider() ?: return false
|
||||
return dashboardClientFactory(connectionId, dashboardUrl)
|
||||
.setSessionArchived(sessionId, archived, profileName)
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's transcript, scoped to the active profile via the dashboard
|
||||
* `/api/sessions/{id}/messages?profile=`. Returns `null` off the dashboard
|
||||
@@ -358,10 +792,19 @@ class ProfileController(
|
||||
suspend fun loadProfileScopedMessages(
|
||||
sessionId: String,
|
||||
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
|
||||
): Result<List<MessageItem>>? = loadProfileScopedMessages(
|
||||
profileName = resolveSessionProfileName(),
|
||||
sessionId = sessionId,
|
||||
mode = mode,
|
||||
)
|
||||
|
||||
suspend fun loadProfileScopedMessages(
|
||||
profileName: String?,
|
||||
sessionId: String,
|
||||
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
|
||||
): Result<List<MessageItem>>? {
|
||||
val connectionId = activeConnectionId.value ?: return null
|
||||
val dashboardUrl = activeDashboardUrlProvider() ?: return null
|
||||
val profileName = resolveSessionProfileName()
|
||||
return dashboardClientFactory(connectionId, dashboardUrl)
|
||||
.getSessionMessages(sessionId, profileName, mode)
|
||||
}
|
||||
@@ -431,6 +874,7 @@ class ProfileController(
|
||||
scope.launch {
|
||||
val path = copyIcon(connectionId, profileName, uri) ?: return@launch
|
||||
profileIconStore.setIcon(connectionId, profileName, path)
|
||||
profileIconStore.setLocalOverride(connectionId, profileName, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +900,7 @@ class ProfileController(
|
||||
)
|
||||
} else {
|
||||
profileIconStore.setIcon(connectionId, profileName, path)
|
||||
profileIconStore.setLocalOverride(connectionId, profileName, true)
|
||||
_hostIconImportState.value = HostIconImportState()
|
||||
}
|
||||
},
|
||||
@@ -479,23 +924,120 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
fun setUseLocalProfileIconOverride(enabled: Boolean) {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
|
||||
scope.launch { profileIconStore.setLocalOverride(connectionId, profileName, enabled) }
|
||||
}
|
||||
|
||||
/** Upload a selected static image directly to Hermes without changing this phone's override. */
|
||||
fun setSharedProfileAvatar(uri: Uri) {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val profileName = resolveSharedAssetProfileName()
|
||||
val gateway = gatewayClientProvider()
|
||||
if (profileName.isNullOrBlank() || gateway == null) {
|
||||
_sharedAvatarState.value = SharedAvatarState(error = "Shared avatars require a current Hermes Gateway")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
_sharedAvatarState.value = SharedAvatarState(loading = true)
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
prepareProfileAvatar(context, uri, GatewayChatClient.PROFILE_AVATAR_MAX_BYTES)
|
||||
}
|
||||
if (bytes == null) {
|
||||
_sharedAvatarState.value = SharedAvatarState(error = "That image could not be prepared for Hermes")
|
||||
return@launch
|
||||
}
|
||||
gateway.setProfileAvatar(profileName, bytes).fold(
|
||||
onSuccess = {
|
||||
cacheAcknowledgedSharedAvatar(connectionId, profileName, bytes)
|
||||
_sharedAvatarState.value = SharedAvatarState()
|
||||
},
|
||||
onFailure = { failure ->
|
||||
_sharedAvatarState.value = SharedAvatarState(
|
||||
error = failure.message ?: "Shared avatar upload failed",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit migration: upload the current device-local fallback to Hermes. */
|
||||
fun uploadLocalProfileIconToHermes() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val profileName = resolveSharedAssetProfileName()
|
||||
if (profileName.isNullOrBlank()) {
|
||||
_sharedAvatarState.value = SharedAvatarState(error = "Hermes profile identity is not available")
|
||||
return
|
||||
}
|
||||
val gateway = gatewayClientProvider()
|
||||
if (gateway == null) {
|
||||
_sharedAvatarState.value = SharedAvatarState(error = "Shared avatars require a current Hermes Gateway")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
_sharedAvatarState.value = SharedAvatarState(loading = true)
|
||||
val path = profileIconStore.iconFlow(
|
||||
connectionId,
|
||||
AgentDisplay.profileRequestName(_selectedProfile.value?.name),
|
||||
).first()
|
||||
val file = path?.let(::File)
|
||||
val bytes = when {
|
||||
file == null || !file.isFile -> null
|
||||
file.length() > GatewayChatClient.PROFILE_AVATAR_MAX_BYTES -> null
|
||||
else -> withContext(Dispatchers.IO) { runCatching { file.readBytes() }.getOrNull() }
|
||||
}
|
||||
if (bytes == null) {
|
||||
_sharedAvatarState.value = SharedAvatarState(error = "Choose a local PNG, JPEG, or WebP under 2 MB first")
|
||||
return@launch
|
||||
}
|
||||
gateway.setProfileAvatar(profileName, bytes).fold(
|
||||
onSuccess = {
|
||||
cacheAcknowledgedSharedAvatar(connectionId, profileName, bytes)
|
||||
_sharedAvatarState.value = SharedAvatarState()
|
||||
},
|
||||
onFailure = { failure ->
|
||||
_sharedAvatarState.value = SharedAvatarState(
|
||||
error = failure.message ?: "Shared avatar upload failed",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear only Hermes' shared asset; the device-local fallback remains untouched. */
|
||||
fun clearSharedProfileAvatar() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val profileName = resolveSharedAssetProfileName()
|
||||
val gateway = gatewayClientProvider()
|
||||
if (profileName.isNullOrBlank() || gateway == null) {
|
||||
_sharedAvatarState.value = SharedAvatarState(error = "Shared avatars require a current Hermes Gateway")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
_sharedAvatarState.value = SharedAvatarState(loading = true)
|
||||
gateway.clearProfileAvatar(profileName).fold(
|
||||
onSuccess = {
|
||||
val generation = avatarRefreshGeneration.incrementAndGet()
|
||||
clearServerAvatarCache(connectionId, profileName, generation)
|
||||
_gatewayProfiles.value = _gatewayProfiles.value.map { profile ->
|
||||
if (profile.name == profileName) profile.copy(hasAvatar = false) else profile
|
||||
}
|
||||
_sharedAvatarState.value = SharedAvatarState()
|
||||
},
|
||||
onFailure = { failure ->
|
||||
_sharedAvatarState.value = SharedAvatarState(
|
||||
error = failure.message ?: "Shared avatar clear failed",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy [uri]'s image bytes into app-internal storage; returns the path or null. */
|
||||
private suspend fun copyIcon(connectionId: String, profileName: String?, uri: Uri): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dir = File(context.filesDir, "profile-icons").apply { mkdirs() }
|
||||
val key = AgentDisplay.profileSessionKey(profileName)
|
||||
val safe = "${connectionId}_$key"
|
||||
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' }
|
||||
.joinToString("")
|
||||
val out = File(dir, "$safe.png")
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
out.outputStream().use { input.copyTo(it) }
|
||||
} ?: return@withContext null
|
||||
out.absolutePath
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
readBoundedUri(uri, LOCAL_PROFILE_ICON_MAX_BYTES)?.let { bytes ->
|
||||
copyIconBytes(connectionId, profileName, bytes)
|
||||
}
|
||||
|
||||
private suspend fun copyIconBytes(
|
||||
@@ -504,17 +1046,131 @@ class ProfileController(
|
||||
bytes: ByteArray,
|
||||
): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val extension = localImageExtension(bytes) ?: return@withContext null
|
||||
val dir = File(context.filesDir, "profile-icons").apply { mkdirs() }
|
||||
val key = AgentDisplay.profileSessionKey(profileName)
|
||||
val safe = "${connectionId}_$key"
|
||||
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' }
|
||||
.joinToString("")
|
||||
File(dir, "$safe.png").also { it.writeBytes(bytes) }.absolutePath
|
||||
val target = File(dir, "$safe.$extension")
|
||||
target.writeBytes(bytes)
|
||||
LOCAL_PROFILE_ICON_EXTENSIONS
|
||||
.filterNot(extension::equals)
|
||||
.forEach { stale -> runCatching { File(dir, "$safe.$stale").delete() } }
|
||||
target.absolutePath
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readBoundedUri(uri: Uri, maxBytes: Int): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val output = ByteArrayOutputStream(minOf(maxBytes, 64 * 1024))
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > maxBytes) return@use null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun localImageExtension(bytes: ByteArray): String? = when {
|
||||
bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals(
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
) -> "png"
|
||||
bytes.size >= 3 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() &&
|
||||
bytes[2] == 0xff.toByte() -> "jpg"
|
||||
bytes.size >= 6 && (
|
||||
bytes.copyOfRange(0, 6).contentEquals("GIF87a".toByteArray()) ||
|
||||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".toByteArray())
|
||||
) -> "gif"
|
||||
bytes.size >= 12 && bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
|
||||
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) -> "webp"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun copyServerAvatarBytes(
|
||||
connectionId: String,
|
||||
profileName: String,
|
||||
bytes: ByteArray,
|
||||
mime: String,
|
||||
): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val extension = when (mime) {
|
||||
"image/png" -> "png"
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/webp" -> "webp"
|
||||
else -> return@withContext null
|
||||
}
|
||||
val dir = File(context.filesDir, "profile-avatars-server").apply { mkdirs() }
|
||||
val safe = MessageDigest.getInstance("SHA-256")
|
||||
.digest("$connectionId\u0000${AgentDisplay.profileSessionKey(profileName)}".toByteArray())
|
||||
.joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }
|
||||
val target = File(dir, "$safe.$extension")
|
||||
val temp = File(dir, "$safe.$extension.tmp")
|
||||
temp.writeBytes(bytes)
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
target.toPath(),
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
} catch (_: java.nio.file.AtomicMoveNotSupportedException) {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
target.toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}
|
||||
listOf("png", "jpg", "webp")
|
||||
.filterNot(extension::equals)
|
||||
.forEach { stale -> runCatching { File(dir, "$safe.$stale").delete() } }
|
||||
target.absolutePath
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cacheAcknowledgedSharedAvatar(
|
||||
connectionId: String,
|
||||
profileName: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
if (activeConnectionId.value != connectionId) return
|
||||
val mime = profileAvatarMime(bytes) ?: return
|
||||
val generation = avatarRefreshGeneration.incrementAndGet()
|
||||
val path = copyServerAvatarBytes(connectionId, profileName, bytes, mime) ?: return
|
||||
if (!isCurrentProfileAvatarRefresh(
|
||||
connectionId,
|
||||
activeConnectionId.value,
|
||||
generation,
|
||||
avatarRefreshGeneration.get(),
|
||||
)
|
||||
) return
|
||||
profileIconStore.setServerAvatar(connectionId, profileName, path)
|
||||
_gatewayProfiles.value = _gatewayProfiles.value.map { profile ->
|
||||
if (profile.name == profileName) profile.copy(hasAvatar = true) else profile
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSharedAssetProfileName(): String? =
|
||||
resolveSessionProfileName()
|
||||
?: _gatewayProfiles.value.firstOrNull(Profile::isDefault)?.name
|
||||
|
||||
private companion object {
|
||||
const val LOCAL_PROFILE_ICON_MAX_BYTES = 8_000_000
|
||||
val LOCAL_PROFILE_ICON_EXTENSIONS = listOf("png", "jpg", "gif", "webp")
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored lock-token for a (possibly null) profile. Server default —
|
||||
* A null selection maps to [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY]; a
|
||||
@@ -583,6 +1239,24 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist or clear a local cosmetic accent for a named profile. */
|
||||
fun setProfileColor(profileName: String, colorHex: String?) {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val key = profileName.trim()
|
||||
if (key.isBlank() || key.equals("default", ignoreCase = true)) return
|
||||
scope.launch {
|
||||
profilePresentationWriteMutex.withLock {
|
||||
val updated = profilePresentationStore
|
||||
.presentationFlow(connectionId)
|
||||
.first()
|
||||
.colors
|
||||
.toMutableMap()
|
||||
.apply { if (colorHex == null) remove(key) else put(key, colorHex) }
|
||||
profilePresentationStore.setColors(connectionId, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetProfilePresentation() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
scope.launch {
|
||||
@@ -600,6 +1274,11 @@ class ProfileController(
|
||||
* [selectProfile] is the gated public entry point.
|
||||
*/
|
||||
private fun applyProfileSelection(normalizedProfile: Profile?) {
|
||||
_sharedAvatarState.value = SharedAvatarState()
|
||||
_hostIconImportState.value = HostIconImportState()
|
||||
petRefreshGeneration.incrementAndGet()
|
||||
petGalleryGeneration.incrementAndGet()
|
||||
_hermesPetState.value = HermesPetState()
|
||||
_selectedProfile.value = normalizedProfile
|
||||
setLastSessionId(null)
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
@@ -608,6 +1287,7 @@ class ProfileController(
|
||||
scope.launch {
|
||||
profileSelectionStore.setSelectedProfile(connectionId, normalizedProfile?.name)
|
||||
rebuildChatApiClient()
|
||||
refreshHermesPet(connectionId)
|
||||
}
|
||||
refreshLastSessionForProfile(connectionId, normalizedProfile?.name)
|
||||
}
|
||||
@@ -781,6 +1461,12 @@ class ProfileController(
|
||||
// pending persisted name can't resolve against the previous connection's
|
||||
// profiles before the new connection's list arrives.
|
||||
_dashboardProfiles.value = emptyList()
|
||||
_gatewayProfiles.value = emptyList()
|
||||
_gatewayRosterAuthoritative.value = false
|
||||
avatarRefreshGeneration.incrementAndGet()
|
||||
petRefreshGeneration.incrementAndGet()
|
||||
petGalleryGeneration.incrementAndGet()
|
||||
_hermesPetState.value = HermesPetState()
|
||||
}
|
||||
|
||||
/** Clear just the in-memory selection + pending state (resetAppData). */
|
||||
@@ -790,6 +1476,9 @@ class ProfileController(
|
||||
_pendingSelectedProfileName.value = null
|
||||
_serverDefaultProfileScope.value = null
|
||||
_serverDefaultProfileSettled.value = false
|
||||
petRefreshGeneration.incrementAndGet()
|
||||
petGalleryGeneration.incrementAndGet()
|
||||
_hermesPetState.value = HermesPetState()
|
||||
}
|
||||
|
||||
fun clearSelectedProfile() {
|
||||
|
||||
+20
-13
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligib
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import com.hermesandroid.relay.network.shutdownOffMainThread
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -80,6 +81,10 @@ class UpstreamTransportController(
|
||||
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
|
||||
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
|
||||
{ _, _ -> null },
|
||||
private val dashboardHttpClientFactory:
|
||||
(DashboardCookieStore, DashboardBearerAuth?) -> okhttp3.OkHttpClient = { cookieStore, bearerAuth ->
|
||||
DashboardApiClient.defaultClient(cookieStore, bearerAuth)
|
||||
},
|
||||
) {
|
||||
|
||||
// --- Per-connection dashboard cookie stores ----------------------------
|
||||
@@ -151,9 +156,9 @@ class UpstreamTransportController(
|
||||
* scoping, the gateway client, standard-API setup probe) route through.
|
||||
*/
|
||||
fun dashboardClientFor(connectionId: String, dashboardUrl: String): DashboardApiClient {
|
||||
val base = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
|
||||
val base = dashboardHttpClientFactory(
|
||||
dashboardCookieStoreFor(connectionId),
|
||||
bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
|
||||
)
|
||||
return DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
@@ -167,9 +172,9 @@ class UpstreamTransportController(
|
||||
* no active connection (the standard-voice probe path).
|
||||
*/
|
||||
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient {
|
||||
val base = DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let {
|
||||
val base = dashboardHttpClientFactory(
|
||||
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
activeConnectionIdProvider()?.let {
|
||||
bearerAuthForTrustedDashboard(it, dashboardUrl)
|
||||
},
|
||||
)
|
||||
@@ -219,9 +224,9 @@ class UpstreamTransportController(
|
||||
disposeDashboardHttpClient(client)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
val base = DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
|
||||
val base = dashboardHttpClientFactory(
|
||||
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
activeConnectionIdProvider()?.let { activeId ->
|
||||
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
|
||||
},
|
||||
)
|
||||
@@ -250,10 +255,12 @@ class UpstreamTransportController(
|
||||
}
|
||||
|
||||
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
runCatching { client.cache?.close() }
|
||||
client.dispatcher.executorService.shutdown()
|
||||
shutdownOffMainThread("DashboardHttpClient-shutdown") {
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
runCatching { client.cache?.close() }
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway availability ----------------------------------------------
|
||||
|
||||
@@ -694,6 +694,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Mostre suas mensagens recentes como chips tocáveis acima do editor para enviá-las novamente. Desativado por padrão.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Manter o teclado aberto ao enviar</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Permaneça no editor após enviar. Desative para fechar o teclado depois de cada mensagem enviada.</string>
|
||||
<string name="chat_settings_large_pastes">Converter colagens grandes em anexos</string>
|
||||
<string name="chat_settings_large_pastes_desc">Transforma colagens com 5.000 caracteres ou mais em anexos de texto revisáveis. Ativado por padrão.</string>
|
||||
<string name="chat_large_paste_attached">Colagem grande adicionada como anexo de texto</string>
|
||||
<string name="chat_large_paste_too_large">O texto colado excede o limite de anexos de %1$d MB</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">Tecla Enter do teclado físico</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">Escolha se Enter envia ou insere uma nova linha. Ctrl+Enter sempre envia.</string>
|
||||
<string name="chat_settings_insert_newline">Inserir nova linha</string>
|
||||
@@ -1722,6 +1726,17 @@
|
||||
<!-- Section action buttons -->
|
||||
<string name="dashboard_section_action_change_main_model">Alterar modelo principal</string>
|
||||
<string name="dashboard_section_action_new_profile">Novo perfil</string>
|
||||
<string name="dashboard_section_action_new_schedule">Novo agendamento</string>
|
||||
<string name="dashboard_cron_create_title">Criar agendamento</string>
|
||||
<string name="dashboard_cron_name">Nome</string>
|
||||
<string name="dashboard_cron_schedule">Agendamento</string>
|
||||
<string name="dashboard_cron_schedule_hint">Exemplos: every 2h, 0 9 * * 1-5 ou 2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">Instruções da tarefa</string>
|
||||
<string name="dashboard_cron_repeat">Parar após esta quantidade de execuções (opcional)</string>
|
||||
<string name="dashboard_cron_repeat_help">Deixe em branco para manter o comportamento normal. Agendamentos finitos aceitam de 1 a 999 execuções.</string>
|
||||
<string name="dashboard_cron_gateway_required">A criação de agendamentos requer uma conexão atual com o Hermes Gateway.</string>
|
||||
<string name="dashboard_cron_created">Agendamento “%1$s” criado</string>
|
||||
<string name="dashboard_cron_create_failed">Não foi possível criar o agendamento</string>
|
||||
<string name="dashboard_section_action_browse_hub">Explorar a central</string>
|
||||
<string name="dashboard_section_action_update_installed">Atualizar instaladas</string>
|
||||
<!-- Action labels (rendered via kind → string) -->
|
||||
@@ -1863,6 +1878,16 @@
|
||||
<string name="dashboard_skills_update_failed">Falha ao iniciar a atualização de habilidades</string>
|
||||
<!-- Profile create -->
|
||||
<string name="dashboard_profile_created">Perfil %1$s criado</string>
|
||||
<string name="dashboard_profile_created_partial">O perfil %1$s foi criado, mas %2$s. Revise antes de usar.</string>
|
||||
<string name="dashboard_profile_created_legacy">O perfil %1$s foi criado pela rota antiga do Painel. Revise as credenciais antes de usar.</string>
|
||||
<string name="dashboard_profile_no_skills">Não adicionar as habilidades incluídas</string>
|
||||
<string name="dashboard_profile_auth_title">Autenticação</string>
|
||||
<string name="dashboard_profile_auth_shared">Compartilhar login (recomendado) — um conjunto de tokens renováveis</string>
|
||||
<string name="dashboard_profile_auth_copied">Copiar credenciais atuais — instantâneo separado que pode ficar desatualizado</string>
|
||||
<string name="dashboard_profile_auth_isolated">Isolado — não copiar credenciais nem padrões do provedor</string>
|
||||
<string name="dashboard_profile_auth_help">O Hermes aplica esta escolha no servidor. Senhas e tokens nunca são retornados ao Android.</string>
|
||||
<string name="dashboard_profile_allow_legacy">Se a criação pelo Gateway não estiver disponível, permitir a rota antiga do Painel. O comportamento das credenciais depende do host e deve ser revisado.</string>
|
||||
<string name="dashboard_profile_mcp_partial">A seleção de servidores MCP não foi aplicada</string>
|
||||
<string name="dashboard_profile_create_failed">Falha ao criar o perfil</string>
|
||||
<!-- Stats for nerds -->
|
||||
<string name="stats_overview">Visão geral</string>
|
||||
@@ -2070,6 +2095,7 @@
|
||||
<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 direta</string>
|
||||
<string name="active_section_api_key_stored_hint">A chave está armazenada com segurança.</string>
|
||||
<string name="api_credential_single_line_error">As credenciais da API devem estar em uma única linha.</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>
|
||||
<string name="active_section_api_server">Servidor API</string>
|
||||
@@ -2466,7 +2492,35 @@
|
||||
<plurals name="tool_run_using_tools"><item quantity="one">Usando %d ferramenta</item><item quantity="many">Usando %d ferramentas</item><item quantity="other">Usando %d ferramentas</item></plurals>
|
||||
<plurals name="tool_run_used_tools"><item quantity="one">Usou %d ferramenta</item><item quantity="many">Usou %d ferramentas</item><item quantity="other">Usou %d ferramentas</item></plurals>
|
||||
<!-- AgentIconRow -->
|
||||
<string name="agent_icon_title">Ícone do agente</string>
|
||||
<string name="agent_icon_title">Visual do agente</string>
|
||||
<string name="agent_icon_hermes_pet_title">Mascote animado do Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_description">Vinculado ao perfil e compartilhado com as interfaces upstream do Hermes. Reage à atividade do agente e aparece como companheiro no celular.</string>
|
||||
<string name="agent_icon_hermes_pet_active">Ativo: %1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">Nenhum mascote upstream selecionado</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">Atualize o Hermes para gerenciar mascotes animados aqui</string>
|
||||
<string name="agent_icon_hermes_pet_browse">Explorar mascotes do Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">Escolha um mascote do Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_selected">Selecionado</string>
|
||||
<string name="agent_icon_hermes_pet_installed">Instalado</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">Adotar do Petdex</string>
|
||||
<string name="agent_icon_shared_title">Compartilhado no Hermes</string>
|
||||
<string name="agent_icon_shared_active">Avatar compartilhado</string>
|
||||
<string name="agent_icon_shared_empty">Nenhum avatar compartilhado</string>
|
||||
<string name="agent_icon_change_shared">Alterar avatar compartilhado</string>
|
||||
<string name="agent_icon_remove_shared">Remover</string>
|
||||
<string name="agent_icon_remove_shared_title">Remover avatar compartilhado?</string>
|
||||
<string name="agent_icon_remove_shared_message">Isso remove o avatar do Hermes e de outros clientes. A imagem deste celular não será afetada.</string>
|
||||
<string name="agent_icon_phone_title">Somente neste celular</string>
|
||||
<string name="agent_icon_phone_description">Substitua o avatar compartilhado somente neste celular sem alterar o Hermes.</string>
|
||||
<string name="agent_icon_choose_phone">Escolher imagem do celular</string>
|
||||
<string name="agent_icon_change_phone">Alterar imagem do celular</string>
|
||||
<string name="agent_icon_remove_phone">Remover imagem do celular</string>
|
||||
<string name="agent_icon_animation_note">Substituições animadas em GIF e WebP são reproduzidas somente neste celular. Use o mascote animado do Hermes acima para uma animação upstream vinculada ao perfil.</string>
|
||||
<string name="agent_icon_set_local">Escolher imagem reserva do dispositivo</string>
|
||||
<string name="agent_icon_change_local">Alterar imagem reserva do dispositivo</string>
|
||||
<string name="agent_icon_clear_local">Limpar imagem reserva do dispositivo</string>
|
||||
<string name="agent_icon_upload_shared">Enviar ícone do dispositivo ao Hermes</string>
|
||||
<string name="agent_icon_clear_shared">Limpar avatar compartilhado do Hermes</string>
|
||||
<string name="agent_icon_set">Escolher arquivo</string>
|
||||
<string name="agent_icon_change">Escolher arquivo</string>
|
||||
<string name="agent_icon_clear">Limpar</string>
|
||||
@@ -3304,6 +3358,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
|
||||
<string name="dashboard_native_signin_transport_retry">A conexão segura foi interrompida. Toque no botão de login para tentar novamente.</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">O provedor não aprovou este login. Se você cancelou, nenhuma ação é necessária; caso contrário, inicie uma nova tentativa.</string>
|
||||
<string name="dashboard_native_signin_code_rejected">O login com o Google terminou, mas o Hermes hospedado rejeitou o código de retorno de uso único antes de criar uma sessão. Inicie uma nova tentativa.</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">O Hermes hospedado recusou a troca da sessão. Verifique se o agente hospedado está atualizado e inicie o login novamente.</string>
|
||||
<string name="dashboard_native_signin_rate_limited">O Hermes hospedado recebeu tentativas de login demais. Aguarde um momento e tente novamente.</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">O login com o Google terminou, mas o serviço de login do Hermes hospedado está indisponível. Aguarde um momento e tente novamente.</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">O Hermes hospedado retornou uma resposta de login que este app não pode usar. Verifique se há atualizações do Hermes Relay e do agente hospedado.</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">Esta tentativa de login foi cancelada ou substituída. Feche a aba antiga e comece novamente.</string>
|
||||
<string name="dashboard_native_signin_storage_failed">O login com o Google terminou, mas o Android não conseguiu salvar a sessão do Hermes com segurança. Tente novamente; se o problema se repetir, abra Diagnósticos para ver a etapa da falha.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
|
||||
@@ -3884,6 +3946,35 @@
|
||||
<string name="dashboard_whatsapp_saved">WhatsApp ativado; o Hermes iniciou a reinicialização do gateway.</string>
|
||||
<string name="drawer_all_profiles">Todos os perfis</string>
|
||||
<string name="drawer_no_profile_sessions">Nenhuma sessão de perfil correspondente.</string>
|
||||
<string name="drawer_customize_sessions">Personalizar sessões</string>
|
||||
<string name="drawer_group_by">Agrupar por</string>
|
||||
<string name="drawer_order_by">Ordenar por</string>
|
||||
<string name="drawer_show_metadata">Mostrar nas linhas</string>
|
||||
<string name="drawer_show_details">Mostrar detalhes</string>
|
||||
<string name="drawer_filters">Filtros</string>
|
||||
<string name="drawer_project_home">Início</string>
|
||||
<string name="drawer_expand_project">Expandir %1$s</string>
|
||||
<string name="drawer_collapse_project">Recolher %1$s</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="one">%1$d sessão</item>
|
||||
<item quantity="other">%1$d sessões</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="one">%1$d perfil</item>
|
||||
<item quantity="other">%1$d perfis</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">Status</string>
|
||||
<string name="drawer_filter_profile">Perfil</string>
|
||||
<string name="drawer_filter_project">Projeto</string>
|
||||
<string name="drawer_filter_pull_request">Pull request</string>
|
||||
<string name="drawer_option_updated">Atualizado</string>
|
||||
<string name="drawer_option_profile">Perfil</string>
|
||||
<string name="drawer_option_tokens">Tokens</string>
|
||||
<string name="drawer_option_cost">Custo</string>
|
||||
<string name="drawer_reset_filters">Redefinir</string>
|
||||
<string name="drawer_profile_colors">Cores dos perfis</string>
|
||||
<string name="drawer_profile_color_auto">Automática</string>
|
||||
<string name="drawer_profile_color_set">Definir a cor do perfil %1$s como %2$s</string>
|
||||
<string name="drawer_close">Fechar</string>
|
||||
<string name="profile_inspector_gateway_settings">Configurações do perfil do Gateway</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">Salvo diretamente pelo Hermes para este perfil selecionado.</string>
|
||||
@@ -3910,4 +4001,14 @@
|
||||
<string name="secure_link_fallback_ready">Se o Secure Link não estiver disponível, o Hermes poderá tentar as rotas alternativas aprovadas abaixo.</string>
|
||||
<string name="secure_link_no_fallback">Nenhuma rota alternativa está incluída. Os serviços protegidos permanecerão offline se o Secure Link não estiver disponível.</string>
|
||||
<string name="secure_link_auth_note">A proteção do transporte não ignora a autenticação. O pareamento do Relay, as credenciais da API e o login no Painel continuam sendo exigidos por serviço.</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="one">%1$d mensagem chegou enquanto você estava ausente</item>
|
||||
<item quantity="other">%1$d mensagens chegaram enquanto você estava ausente</item>
|
||||
</plurals>
|
||||
<string name="chat_model_confirmation_title">Confirmar seleção do modelo</string>
|
||||
<string name="host_resource_recent_oom">O Hermes reiniciou recentemente, provavelmente por falta de memória.</string>
|
||||
<string name="host_resource_memory_critical">A memória do servidor está criticamente baixa; chats ativos podem parar.</string>
|
||||
<string name="host_resource_memory_elevated">A memória do servidor está baixa.</string>
|
||||
<string name="host_resource_disk_critical">O armazenamento do servidor está criticamente baixo; mensagens e configurações podem não ser salvas.</string>
|
||||
<string name="host_resource_disk_elevated">O armazenamento do servidor está baixo.</string>
|
||||
</resources>
|
||||
|
||||
@@ -736,6 +736,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">在输入栏上方显示最近消息为可点击的标签以便重发。默认关闭。</string>
|
||||
<string name="chat_settings_keep_keyboard_open">发送时保持键盘打开</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">发送后留在输入栏。关闭则在每条消息发送后收起键盘。</string>
|
||||
<string name="chat_settings_large_pastes">将大型粘贴内容转换为附件</string>
|
||||
<string name="chat_settings_large_pastes_desc">将 5,000 个或更多字符的粘贴内容转换为可检查的文本附件。默认开启。</string>
|
||||
<string name="chat_large_paste_attached">大型粘贴内容已添加为文本附件</string>
|
||||
<string name="chat_large_paste_too_large">粘贴的文本超过 %1$d MB 附件限制</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">实体键盘 Enter 键</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">选择按 Enter 是发送消息还是插入新行。Ctrl+Enter 始终发送。</string>
|
||||
<string name="chat_settings_insert_newline">插入新行</string>
|
||||
@@ -1796,6 +1800,17 @@
|
||||
<!-- Section action buttons -->
|
||||
<string name="dashboard_section_action_change_main_model">更改主模型</string>
|
||||
<string name="dashboard_section_action_new_profile">新建配置文件</string>
|
||||
<string name="dashboard_section_action_new_schedule">新建计划</string>
|
||||
<string name="dashboard_cron_create_title">创建计划</string>
|
||||
<string name="dashboard_cron_name">名称</string>
|
||||
<string name="dashboard_cron_schedule">计划</string>
|
||||
<string name="dashboard_cron_schedule_hint">示例:every 2h、0 9 * * 1-5 或 2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">任务说明</string>
|
||||
<string name="dashboard_cron_repeat">运行指定次数后停止(可选)</string>
|
||||
<string name="dashboard_cron_repeat_help">留空以保留计划的正常行为。有限计划可设置 1–999 次运行。</string>
|
||||
<string name="dashboard_cron_gateway_required">创建计划需要当前 Hermes Gateway 连接。</string>
|
||||
<string name="dashboard_cron_created">已创建计划“%1$s”</string>
|
||||
<string name="dashboard_cron_create_failed">无法创建计划</string>
|
||||
<string name="dashboard_section_action_browse_hub">浏览中心</string>
|
||||
<string name="dashboard_section_action_update_installed">更新已安装</string>
|
||||
|
||||
@@ -1950,6 +1965,16 @@
|
||||
|
||||
<!-- Profile create -->
|
||||
<string name="dashboard_profile_created">已创建配置文件 %1$s</string>
|
||||
<string name="dashboard_profile_created_partial">配置文件 %1$s 已创建,但%2$s。使用前请检查。</string>
|
||||
<string name="dashboard_profile_created_legacy">配置文件 %1$s 已通过旧版控制面板路由创建。使用前请检查其凭据。</string>
|
||||
<string name="dashboard_profile_no_skills">不添加内置技能</string>
|
||||
<string name="dashboard_profile_auth_title">身份验证</string>
|
||||
<string name="dashboard_profile_auth_shared">共享登录(推荐)— 共用一个可刷新的令牌池</string>
|
||||
<string name="dashboard_profile_auth_copied">复制当前凭据 — 独立快照可能会过期</string>
|
||||
<string name="dashboard_profile_auth_isolated">隔离 — 不复制凭据或提供商默认值</string>
|
||||
<string name="dashboard_profile_auth_help">Hermes 在服务器上应用此选择。密码和令牌绝不会返回到 Android。</string>
|
||||
<string name="dashboard_profile_allow_legacy">如果无法通过 Gateway 创建,则允许使用旧版控制面板路由。凭据行为取决于该主机,必须进行检查。</string>
|
||||
<string name="dashboard_profile_mcp_partial">未应用 MCP 服务器选择</string>
|
||||
<string name="dashboard_profile_create_failed">创建配置文件失败</string>
|
||||
|
||||
<!-- Stats for nerds -->
|
||||
@@ -2163,6 +2188,7 @@
|
||||
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
|
||||
<string name="active_section_api_key_optional">直接 API 的 API 密钥</string>
|
||||
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
|
||||
<string name="api_credential_single_line_error">API 凭据必须为单行内容。</string>
|
||||
<string name="active_section_api_reachable_voice_review">API 可达——请检查语音配置</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API 可达——Relay 语音已配置</string>
|
||||
<string name="active_section_api_server">API 服务器</string>
|
||||
@@ -2576,7 +2602,35 @@
|
||||
<plurals name="tool_run_used_tools"><item quantity="other">已使用 %d 个工具</item></plurals>
|
||||
|
||||
<!-- AgentIconRow -->
|
||||
<string name="agent_icon_title">代理图标</string>
|
||||
<string name="agent_icon_title">代理外观</string>
|
||||
<string name="agent_icon_hermes_pet_title">Hermes 动画宠物</string>
|
||||
<string name="agent_icon_hermes_pet_description">按配置文件保存,并与上游 Hermes 界面共享。它会响应代理活动,并作为手机伴侣显示。</string>
|
||||
<string name="agent_icon_hermes_pet_active">已启用:%1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">未选择上游宠物</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">请更新 Hermes 以在此管理动画宠物</string>
|
||||
<string name="agent_icon_hermes_pet_browse">浏览 Hermes 宠物</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">选择 Hermes 宠物</string>
|
||||
<string name="agent_icon_hermes_pet_selected">已选择</string>
|
||||
<string name="agent_icon_hermes_pet_installed">已安装</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">从 Petdex 领养</string>
|
||||
<string name="agent_icon_shared_title">在 Hermes 中共享</string>
|
||||
<string name="agent_icon_shared_active">共享头像</string>
|
||||
<string name="agent_icon_shared_empty">无共享头像</string>
|
||||
<string name="agent_icon_change_shared">更改共享头像</string>
|
||||
<string name="agent_icon_remove_shared">移除</string>
|
||||
<string name="agent_icon_remove_shared_title">移除共享头像?</string>
|
||||
<string name="agent_icon_remove_shared_message">这会从 Hermes 和其他客户端移除头像,但不会影响本手机上的图像。</string>
|
||||
<string name="agent_icon_phone_title">仅限本手机</string>
|
||||
<string name="agent_icon_phone_description">仅在本手机上覆盖共享头像,不更改 Hermes。</string>
|
||||
<string name="agent_icon_choose_phone">选择手机图像</string>
|
||||
<string name="agent_icon_change_phone">更改手机图像</string>
|
||||
<string name="agent_icon_remove_phone">移除手机图像</string>
|
||||
<string name="agent_icon_animation_note">GIF 和 WebP 动画替代仅在本手机上播放。如需按配置文件管理的上游动画,请使用上方的 Hermes 动画宠物。</string>
|
||||
<string name="agent_icon_set_local">选择设备备用图像</string>
|
||||
<string name="agent_icon_change_local">更改设备备用图像</string>
|
||||
<string name="agent_icon_clear_local">清除设备备用图像</string>
|
||||
<string name="agent_icon_upload_shared">将设备图标上传到 Hermes</string>
|
||||
<string name="agent_icon_clear_shared">清除共享的 Hermes 头像</string>
|
||||
<string name="agent_icon_set">选择文件</string>
|
||||
<string name="agent_icon_change">选择文件</string>
|
||||
<string name="agent_icon_clear">清除</string>
|
||||
@@ -3397,6 +3451,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
|
||||
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
|
||||
<string name="dashboard_native_signin_transport_retry">安全连接已中断。请点按登录按钮重试。</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">提供商未批准本次登录。如果是你取消的,则无需操作;否则请重新开始登录。</string>
|
||||
<string name="dashboard_native_signin_code_rejected">Google 登录已完成,但托管的 Hermes 在创建会话前拒绝了一次性回调代码。请重新开始登录。</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">托管的 Hermes 拒绝了会话交换。请确认托管代理已更新,然后重新开始登录。</string>
|
||||
<string name="dashboard_native_signin_rate_limited">托管的 Hermes 收到了过多登录尝试。请稍等片刻后重试。</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">Google 登录已完成,但托管的 Hermes 登录服务不可用。请稍等片刻后重试。</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">托管的 Hermes 返回了此应用无法使用的登录响应。请检查 Hermes Relay 和托管代理的更新。</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">本次登录尝试已取消或被替换。请关闭旧浏览器标签页并重新开始。</string>
|
||||
<string name="dashboard_native_signin_storage_failed">Google 登录已完成,但 Android 无法安全保存 Hermes 会话。请重试;如果问题重复出现,请打开“诊断”查看失败阶段。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
|
||||
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
|
||||
@@ -3972,6 +4034,33 @@
|
||||
<string name="dashboard_whatsapp_saved">WhatsApp 已启用;Hermes 已开始重启网关。</string>
|
||||
<string name="drawer_all_profiles">所有配置文件</string>
|
||||
<string name="drawer_no_profile_sessions">没有匹配的配置文件会话。</string>
|
||||
<string name="drawer_customize_sessions">自定义会话</string>
|
||||
<string name="drawer_group_by">分组方式</string>
|
||||
<string name="drawer_order_by">排序方式</string>
|
||||
<string name="drawer_show_metadata">在行中显示</string>
|
||||
<string name="drawer_show_details">显示详细信息</string>
|
||||
<string name="drawer_filters">筛选器</string>
|
||||
<string name="drawer_project_home">主页</string>
|
||||
<string name="drawer_expand_project">展开%1$s</string>
|
||||
<string name="drawer_collapse_project">折叠%1$s</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="other">%1$d 个会话</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="other">%1$d 个配置文件</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">状态</string>
|
||||
<string name="drawer_filter_profile">配置文件</string>
|
||||
<string name="drawer_filter_project">项目</string>
|
||||
<string name="drawer_filter_pull_request">拉取请求</string>
|
||||
<string name="drawer_option_updated">更新时间</string>
|
||||
<string name="drawer_option_profile">配置文件</string>
|
||||
<string name="drawer_option_tokens">令牌</string>
|
||||
<string name="drawer_option_cost">费用</string>
|
||||
<string name="drawer_reset_filters">重置</string>
|
||||
<string name="drawer_profile_colors">配置文件颜色</string>
|
||||
<string name="drawer_profile_color_auto">自动</string>
|
||||
<string name="drawer_profile_color_set">将 %1$s 配置文件颜色设为 %2$s</string>
|
||||
<string name="drawer_close">关闭</string>
|
||||
<string name="profile_inspector_gateway_settings">Gateway 配置文件设置</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">通过 Hermes 直接保存到当前所选配置文件。</string>
|
||||
@@ -3998,4 +4087,13 @@
|
||||
<string name="secure_link_fallback_ready">如果 Secure Link 不可用,Hermes 可以尝试下面已批准的备用路由。</string>
|
||||
<string name="secure_link_no_fallback">未包含备用路由。如果 Secure Link 不可用,受保护的服务将保持离线。</string>
|
||||
<string name="secure_link_auth_note">传输保护不会绕过身份验证。Relay 配对、API 凭据和控制面板登录仍会按服务分别强制执行。</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="other">离开期间收到了 %1$d 条消息</item>
|
||||
</plurals>
|
||||
<string name="chat_model_confirmation_title">确认模型选择</string>
|
||||
<string name="host_resource_recent_oom">Hermes 最近可能因内存不足而重启。</string>
|
||||
<string name="host_resource_memory_critical">主机内存严重不足,进行中的聊天可能会停止。</string>
|
||||
<string name="host_resource_memory_elevated">主机内存不足。</string>
|
||||
<string name="host_resource_disk_critical">主机存储空间严重不足,消息和设置可能无法保存。</string>
|
||||
<string name="host_resource_disk_elevated">主机存储空间不足。</string>
|
||||
</resources>
|
||||
|
||||
@@ -736,6 +736,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Letzte Nachrichten als antippbare Chips über dem Eingabefeld anzeigen, um sie erneut zu senden. Standardmäßig aus.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Tastatur nach dem Senden geöffnet lassen</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Nach dem Senden im Eingabefeld bleiben. Ausschalten, um die Tastatur nach jeder gesendeten Nachricht zu schließen.</string>
|
||||
<string name="chat_settings_large_pastes">Große Einfügungen in Anhänge umwandeln</string>
|
||||
<string name="chat_settings_large_pastes_desc">Wandelt Einfügungen mit mindestens 5.000 Zeichen in überprüfbare Textanhänge um. Standardmäßig aktiviert.</string>
|
||||
<string name="chat_large_paste_attached">Große Einfügung als Textanhang hinzugefügt</string>
|
||||
<string name="chat_large_paste_too_large">Der eingefügte Text überschreitet das Anhangslimit von %1$d MB</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">Eingabetaste der physischen Tastatur</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">Wähle, ob die Eingabetaste sendet oder eine neue Zeile einfügt. Strg+Eingabetaste sendet immer.</string>
|
||||
<string name="chat_settings_insert_newline">Neue Zeile einfügen</string>
|
||||
@@ -1799,6 +1803,17 @@
|
||||
<!-- Section action buttons -->
|
||||
<string name="dashboard_section_action_change_main_model">Hauptmodell ändern</string>
|
||||
<string name="dashboard_section_action_new_profile">Neues Profil</string>
|
||||
<string name="dashboard_section_action_new_schedule">Neuer Zeitplan</string>
|
||||
<string name="dashboard_cron_create_title">Zeitplan erstellen</string>
|
||||
<string name="dashboard_cron_name">Name</string>
|
||||
<string name="dashboard_cron_schedule">Zeitplan</string>
|
||||
<string name="dashboard_cron_schedule_hint">Beispiele: every 2h, 0 9 * * 1-5 oder 2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">Aufgabenanweisungen</string>
|
||||
<string name="dashboard_cron_repeat">Nach dieser Anzahl von Ausführungen stoppen (optional)</string>
|
||||
<string name="dashboard_cron_repeat_help">Leer lassen, um das normale Verhalten des Zeitplans beizubehalten. Endliche Zeitpläne akzeptieren 1–999 Ausführungen.</string>
|
||||
<string name="dashboard_cron_gateway_required">Zum Erstellen eines Zeitplans ist eine aktuelle Hermes-Gateway-Verbindung erforderlich.</string>
|
||||
<string name="dashboard_cron_created">Zeitplan „%1$s“ erstellt</string>
|
||||
<string name="dashboard_cron_create_failed">Zeitplan konnte nicht erstellt werden</string>
|
||||
<string name="dashboard_section_action_browse_hub">Sammlung durchsuchen</string>
|
||||
<string name="dashboard_section_action_update_installed">Installierte aktualisieren</string>
|
||||
|
||||
@@ -1953,6 +1968,16 @@
|
||||
|
||||
<!-- Profile create -->
|
||||
<string name="dashboard_profile_created">Profil %1$s erstellt</string>
|
||||
<string name="dashboard_profile_created_partial">Profil %1$s wurde erstellt, aber %2$s. Vor der Verwendung prüfen.</string>
|
||||
<string name="dashboard_profile_created_legacy">Profil %1$s wurde über die ältere Dashboard-Route erstellt. Zugangsdaten vor der Verwendung prüfen.</string>
|
||||
<string name="dashboard_profile_no_skills">Gebündelte Skills nicht hinzufügen</string>
|
||||
<string name="dashboard_profile_auth_title">Authentifizierung</string>
|
||||
<string name="dashboard_profile_auth_shared">Anmeldung teilen (empfohlen) — ein aktualisierbarer Token-Pool</string>
|
||||
<string name="dashboard_profile_auth_copied">Aktuelle Zugangsdaten kopieren — separater Snapshot, der veralten kann</string>
|
||||
<string name="dashboard_profile_auth_isolated">Isoliert — keine Zugangsdaten oder Anbieterstandards kopieren</string>
|
||||
<string name="dashboard_profile_auth_help">Hermes wendet diese Auswahl auf dem Server an. Passwörter und Token werden nie an Android zurückgegeben.</string>
|
||||
<string name="dashboard_profile_allow_legacy">Wenn die Gateway-Erstellung nicht verfügbar ist, die ältere Dashboard-Route zulassen. Das Verhalten der Zugangsdaten hängt vom Host ab und muss geprüft werden.</string>
|
||||
<string name="dashboard_profile_mcp_partial">Die MCP-Serverauswahl wurde nicht angewendet</string>
|
||||
<string name="dashboard_profile_create_failed">Profil konnte nicht erstellt werden</string>
|
||||
|
||||
<!-- Stats for nerds -->
|
||||
@@ -2166,6 +2191,7 @@
|
||||
<string name="active_section_api_key_not_configured">API-Schlüssel nicht konfiguriert</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="api_credential_single_line_error">API-Anmeldedaten müssen in einer einzigen Zeile stehen.</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>
|
||||
<string name="active_section_api_server">API-Server</string>
|
||||
@@ -2579,7 +2605,35 @@
|
||||
<plurals name="tool_run_used_tools"><item quantity="one">%d Werkzeug verwendet</item><item quantity="other">%d Werkzeuge verwendet</item></plurals>
|
||||
|
||||
<!-- AgentIconRow -->
|
||||
<string name="agent_icon_title">Agentensymbol</string>
|
||||
<string name="agent_icon_title">Agentendarstellung</string>
|
||||
<string name="agent_icon_hermes_pet_title">Animiertes Hermes-Haustier</string>
|
||||
<string name="agent_icon_hermes_pet_description">Profilbezogen und mit Upstream-Hermes-Oberflächen geteilt. Es reagiert auf Agentenaktivität und erscheint als Begleiter auf dem Smartphone.</string>
|
||||
<string name="agent_icon_hermes_pet_active">Aktiv: %1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">Kein Upstream-Haustier ausgewählt</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">Hermes aktualisieren, um animierte Haustiere hier zu verwalten</string>
|
||||
<string name="agent_icon_hermes_pet_browse">Hermes-Haustiere durchsuchen</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">Hermes-Haustier auswählen</string>
|
||||
<string name="agent_icon_hermes_pet_selected">Ausgewählt</string>
|
||||
<string name="agent_icon_hermes_pet_installed">Installiert</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">Aus Petdex übernehmen</string>
|
||||
<string name="agent_icon_shared_title">In Hermes geteilt</string>
|
||||
<string name="agent_icon_shared_active">Geteilter Avatar</string>
|
||||
<string name="agent_icon_shared_empty">Kein geteilter Avatar</string>
|
||||
<string name="agent_icon_change_shared">Geteilten Avatar ändern</string>
|
||||
<string name="agent_icon_remove_shared">Entfernen</string>
|
||||
<string name="agent_icon_remove_shared_title">Geteilten Avatar entfernen?</string>
|
||||
<string name="agent_icon_remove_shared_message">Dadurch wird der Avatar aus Hermes und anderen Clients entfernt. Das Bild auf diesem Smartphone bleibt erhalten.</string>
|
||||
<string name="agent_icon_phone_title">Nur dieses Smartphone</string>
|
||||
<string name="agent_icon_phone_description">Den geteilten Avatar nur auf diesem Smartphone überschreiben, ohne Hermes zu ändern.</string>
|
||||
<string name="agent_icon_choose_phone">Smartphone-Bild auswählen</string>
|
||||
<string name="agent_icon_change_phone">Smartphone-Bild ändern</string>
|
||||
<string name="agent_icon_remove_phone">Smartphone-Bild entfernen</string>
|
||||
<string name="agent_icon_animation_note">Animierte GIF- und WebP-Überschreibungen werden nur auf diesem Smartphone abgespielt. Für eine profilbezogene Upstream-Animation oben ein animiertes Hermes-Haustier verwenden.</string>
|
||||
<string name="agent_icon_set_local">Geräte-Ersatzbild auswählen</string>
|
||||
<string name="agent_icon_change_local">Geräte-Ersatzbild ändern</string>
|
||||
<string name="agent_icon_clear_local">Geräte-Ersatzbild löschen</string>
|
||||
<string name="agent_icon_upload_shared">Gerätesymbol zu Hermes hochladen</string>
|
||||
<string name="agent_icon_clear_shared">Geteilten Hermes-Avatar löschen</string>
|
||||
<string name="agent_icon_set">Datei auswählen</string>
|
||||
<string name="agent_icon_change">Datei auswählen</string>
|
||||
<string name="agent_icon_clear">Löschen</string>
|
||||
@@ -3464,6 +3518,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
|
||||
<string name="dashboard_native_signin_transport_retry">Die sichere Verbindung wurde unterbrochen. Tippe auf die Anmeldeschaltfläche, um es erneut zu versuchen.</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">Der Anbieter hat diese Anmeldung nicht genehmigt. Wenn du abgebrochen hast, ist keine Aktion nötig; andernfalls starte einen neuen Anmeldeversuch.</string>
|
||||
<string name="dashboard_native_signin_code_rejected">Die Google-Anmeldung wurde abgeschlossen, aber das gehostete Hermes hat den einmaligen Rückrufcode abgelehnt, bevor eine Sitzung erstellt werden konnte. Starte einen neuen Anmeldeversuch.</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">Das gehostete Hermes hat den Sitzungsaustausch abgelehnt. Prüfe, ob der gehostete Agent aktuell ist, und starte die Anmeldung erneut.</string>
|
||||
<string name="dashboard_native_signin_rate_limited">Das gehostete Hermes hat zu viele Anmeldeversuche erhalten. Warte einen Moment und versuche es erneut.</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">Die Google-Anmeldung wurde abgeschlossen, aber der Anmeldedienst des gehosteten Hermes ist nicht verfügbar. Warte einen Moment und versuche es erneut.</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">Das gehostete Hermes hat eine Anmeldeantwort zurückgegeben, die diese App nicht verwenden kann. Suche nach Updates für Hermes Relay und den gehosteten Agent.</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">Dieser Anmeldeversuch wurde abgebrochen oder ersetzt. Schließe den alten Browser-Tab und starte erneut.</string>
|
||||
<string name="dashboard_native_signin_storage_failed">Die Google-Anmeldung wurde abgeschlossen, aber Android konnte die Hermes-Sitzung nicht sicher speichern. Versuche es erneut. Falls der Fehler wiederholt auftritt, öffne die Diagnose für die Fehlerstufe.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
|
||||
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
|
||||
@@ -4044,6 +4106,35 @@
|
||||
<string name="dashboard_whatsapp_saved">WhatsApp aktiviert; Hermes hat einen Gateway-Neustart gestartet.</string>
|
||||
<string name="drawer_all_profiles">Alle Profile</string>
|
||||
<string name="drawer_no_profile_sessions">Keine passenden Profilsitzungen.</string>
|
||||
<string name="drawer_customize_sessions">Sitzungen anpassen</string>
|
||||
<string name="drawer_group_by">Gruppieren nach</string>
|
||||
<string name="drawer_order_by">Sortieren nach</string>
|
||||
<string name="drawer_show_metadata">In Zeilen anzeigen</string>
|
||||
<string name="drawer_show_details">Details anzeigen</string>
|
||||
<string name="drawer_filters">Filter</string>
|
||||
<string name="drawer_project_home">Startseite</string>
|
||||
<string name="drawer_expand_project">%1$s erweitern</string>
|
||||
<string name="drawer_collapse_project">%1$s reduzieren</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="one">%1$d Sitzung</item>
|
||||
<item quantity="other">%1$d Sitzungen</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="one">%1$d Profil</item>
|
||||
<item quantity="other">%1$d Profile</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">Status</string>
|
||||
<string name="drawer_filter_profile">Profil</string>
|
||||
<string name="drawer_filter_project">Projekt</string>
|
||||
<string name="drawer_filter_pull_request">Pull Request</string>
|
||||
<string name="drawer_option_updated">Aktualisiert</string>
|
||||
<string name="drawer_option_profile">Profil</string>
|
||||
<string name="drawer_option_tokens">Tokens</string>
|
||||
<string name="drawer_option_cost">Kosten</string>
|
||||
<string name="drawer_reset_filters">Zurücksetzen</string>
|
||||
<string name="drawer_profile_colors">Profilfarben</string>
|
||||
<string name="drawer_profile_color_auto">Automatisch</string>
|
||||
<string name="drawer_profile_color_set">Profilfarbe von %1$s auf %2$s setzen</string>
|
||||
<string name="drawer_close">Schließen</string>
|
||||
<string name="profile_inspector_gateway_settings">Gateway-Profileinstellungen</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">Wird für dieses ausgewählte Profil direkt über Hermes gespeichert.</string>
|
||||
@@ -4070,4 +4161,14 @@
|
||||
<string name="secure_link_fallback_ready">Wenn Secure Link nicht verfügbar ist, kann Hermes die genehmigten Ausweichrouten unten versuchen.</string>
|
||||
<string name="secure_link_no_fallback">Keine Ausweichroute enthalten. Geschützte Dienste bleiben offline, wenn Secure Link nicht verfügbar ist.</string>
|
||||
<string name="secure_link_auth_note">Transportschutz umgeht keine Authentifizierung. Relay-Kopplung, API-Zugangsdaten und Dashboard-Anmeldung werden weiterhin pro Dienst erzwungen.</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="one">%1$d Nachricht ist eingegangen, während du weg warst</item>
|
||||
<item quantity="other">%1$d Nachrichten sind eingegangen, während du weg warst</item>
|
||||
</plurals>
|
||||
<string name="chat_model_confirmation_title">Modellauswahl bestätigen</string>
|
||||
<string name="host_resource_recent_oom">Hermes wurde kürzlich wahrscheinlich wegen Speichermangels neu gestartet.</string>
|
||||
<string name="host_resource_memory_critical">Der Arbeitsspeicher des Hosts ist äußerst knapp; aktive Chats können beendet werden.</string>
|
||||
<string name="host_resource_memory_elevated">Der Arbeitsspeicher des Hosts wird knapp.</string>
|
||||
<string name="host_resource_disk_critical">Der Speicherplatz des Hosts ist äußerst knapp; Nachrichten und Einstellungen können möglicherweise nicht gespeichert werden.</string>
|
||||
<string name="host_resource_disk_elevated">Der Speicherplatz des Hosts wird knapp.</string>
|
||||
</resources>
|
||||
|
||||
@@ -661,6 +661,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Muestre sus mensajes recientes como chips que se pueden tocar encima del redactor para enviarlos nuevamente. Desactivado de forma predeterminada.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Mantener el teclado abierto al enviar</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Permanece en el compositor después del envío. Desactívelo para descartar el teclado después de cada mensaje enviado.</string>
|
||||
<string name="chat_settings_large_pastes">Convertir pegados grandes en archivos adjuntos</string>
|
||||
<string name="chat_settings_large_pastes_desc">Convierte pegados de 5.000 caracteres o más en archivos de texto revisables. Activado de forma predeterminada.</string>
|
||||
<string name="chat_large_paste_attached">El pegado grande se añadió como archivo de texto</string>
|
||||
<string name="chat_large_paste_too_large">El texto pegado supera el límite de archivos adjuntos de %1$d MB</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">Tecla Intro del teclado físico</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">Elige si Intro envía o inserta una línea nueva. Ctrl+Intro siempre envía.</string>
|
||||
<string name="chat_settings_insert_newline">Insertar línea nueva</string>
|
||||
@@ -1646,6 +1650,17 @@
|
||||
<string name="dashboard_empty_section">No se devolvió ningún %1$s</string>
|
||||
<string name="dashboard_section_action_change_main_model">Cambiar modelo principal</string>
|
||||
<string name="dashboard_section_action_new_profile">Nuevo perfil</string>
|
||||
<string name="dashboard_section_action_new_schedule">Nueva programación</string>
|
||||
<string name="dashboard_cron_create_title">Crear programación</string>
|
||||
<string name="dashboard_cron_name">Nombre</string>
|
||||
<string name="dashboard_cron_schedule">Programación</string>
|
||||
<string name="dashboard_cron_schedule_hint">Ejemplos: every 2h, 0 9 * * 1-5 o 2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">Instrucciones de la tarea</string>
|
||||
<string name="dashboard_cron_repeat">Detener después de esta cantidad de ejecuciones (opcional)</string>
|
||||
<string name="dashboard_cron_repeat_help">Déjalo en blanco para conservar el comportamiento normal. Las programaciones finitas aceptan entre 1 y 999 ejecuciones.</string>
|
||||
<string name="dashboard_cron_gateway_required">La creación de programaciones requiere una conexión actual con Hermes Gateway.</string>
|
||||
<string name="dashboard_cron_created">Programación «%1$s» creada</string>
|
||||
<string name="dashboard_cron_create_failed">No se pudo crear la programación</string>
|
||||
<string name="dashboard_section_action_browse_hub">Centro de exploración</string>
|
||||
<string name="dashboard_section_action_update_installed">Actualización instalada</string>
|
||||
<string name="dashboard_action_set">Colocar</string>
|
||||
@@ -1774,6 +1789,16 @@
|
||||
<string name="dashboard_skills_update_started">La actualización de habilidades comenzó en el servidor: actualice las habilidades en un minuto</string>
|
||||
<string name="dashboard_skills_update_failed">La actualización de habilidades no pudo iniciarse</string>
|
||||
<string name="dashboard_profile_created">Perfil %1$s creado</string>
|
||||
<string name="dashboard_profile_created_partial">Se creó el perfil %1$s, pero %2$s. Revísalo antes de usarlo.</string>
|
||||
<string name="dashboard_profile_created_legacy">El perfil %1$s se creó mediante la ruta antigua del panel. Revisa sus credenciales antes de usarlo.</string>
|
||||
<string name="dashboard_profile_no_skills">No añadir las habilidades incluidas</string>
|
||||
<string name="dashboard_profile_auth_title">Autenticación</string>
|
||||
<string name="dashboard_profile_auth_shared">Compartir inicio de sesión (recomendado) — un grupo de tokens renovables</string>
|
||||
<string name="dashboard_profile_auth_copied">Copiar credenciales actuales — instantánea separada que puede quedar desactualizada</string>
|
||||
<string name="dashboard_profile_auth_isolated">Aislado — no copiar credenciales ni valores predeterminados del proveedor</string>
|
||||
<string name="dashboard_profile_auth_help">Hermes aplica esta opción en el servidor. Las contraseñas y los tokens nunca se devuelven a Android.</string>
|
||||
<string name="dashboard_profile_allow_legacy">Si la creación mediante Gateway no está disponible, permitir la ruta antigua del panel. El comportamiento de las credenciales depende del host y debe revisarse.</string>
|
||||
<string name="dashboard_profile_mcp_partial">No se aplicó la selección de servidores MCP</string>
|
||||
<string name="dashboard_profile_create_failed">Error al crear el perfil</string>
|
||||
<string name="stats_overview">Descripción general</string>
|
||||
<string name="stats_reset">Reiniciar</string>
|
||||
@@ -1975,6 +2000,7 @@
|
||||
<string name="active_section_api_key_not_configured">Clave API no configurada</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="api_credential_single_line_error">Las credenciales de API deben estar en una sola línea.</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>
|
||||
<string name="active_section_api_server">Servidor API</string>
|
||||
@@ -2350,7 +2376,35 @@
|
||||
<plurals name="tool_run_used_device"><item quantity="one">Se ejecutó %d acción del dispositivo</item><item quantity="other">Se ejecutaron %d acciones del dispositivo</item></plurals>
|
||||
<plurals name="tool_run_using_tools"><item quantity="one">Usando %d herramienta</item><item quantity="other">Usando %d herramientas</item></plurals>
|
||||
<plurals name="tool_run_used_tools"><item quantity="one">Se usó %d herramienta</item><item quantity="other">Se usaron %d herramientas</item></plurals>
|
||||
<string name="agent_icon_title">Icono de agente</string>
|
||||
<string name="agent_icon_title">Aspecto del agente</string>
|
||||
<string name="agent_icon_hermes_pet_title">Mascota animada de Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_description">Se guarda por perfil y se comparte con las superficies upstream de Hermes. Reacciona a la actividad del agente y aparece como acompañante en el teléfono.</string>
|
||||
<string name="agent_icon_hermes_pet_active">Activa: %1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">No hay mascota upstream seleccionada</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">Actualiza Hermes para gestionar aquí mascotas animadas</string>
|
||||
<string name="agent_icon_hermes_pet_browse">Explorar mascotas de Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">Elige una mascota de Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_selected">Seleccionada</string>
|
||||
<string name="agent_icon_hermes_pet_installed">Instalada</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">Adoptar desde Petdex</string>
|
||||
<string name="agent_icon_shared_title">Compartido en Hermes</string>
|
||||
<string name="agent_icon_shared_active">Avatar compartido</string>
|
||||
<string name="agent_icon_shared_empty">Sin avatar compartido</string>
|
||||
<string name="agent_icon_change_shared">Cambiar avatar compartido</string>
|
||||
<string name="agent_icon_remove_shared">Quitar</string>
|
||||
<string name="agent_icon_remove_shared_title">¿Quitar el avatar compartido?</string>
|
||||
<string name="agent_icon_remove_shared_message">Esto quita el avatar de Hermes y de otros clientes. La imagen del teléfono no se modifica.</string>
|
||||
<string name="agent_icon_phone_title">Solo este teléfono</string>
|
||||
<string name="agent_icon_phone_description">Reemplaza el avatar compartido solo en este teléfono sin cambiar Hermes.</string>
|
||||
<string name="agent_icon_choose_phone">Elegir imagen del teléfono</string>
|
||||
<string name="agent_icon_change_phone">Cambiar imagen del teléfono</string>
|
||||
<string name="agent_icon_remove_phone">Quitar imagen del teléfono</string>
|
||||
<string name="agent_icon_animation_note">Las sustituciones GIF y WebP animadas se reproducen solo en este teléfono. Usa arriba una mascota animada de Hermes para una animación upstream por perfil.</string>
|
||||
<string name="agent_icon_set_local">Elegir imagen alternativa del dispositivo</string>
|
||||
<string name="agent_icon_change_local">Cambiar imagen alternativa del dispositivo</string>
|
||||
<string name="agent_icon_clear_local">Borrar imagen alternativa del dispositivo</string>
|
||||
<string name="agent_icon_upload_shared">Subir icono del dispositivo a Hermes</string>
|
||||
<string name="agent_icon_clear_shared">Borrar avatar compartido de Hermes</string>
|
||||
<string name="agent_icon_set">Elegir archivo</string>
|
||||
<string name="agent_icon_change">Elegir archivo</string>
|
||||
<string name="agent_icon_clear">Claro</string>
|
||||
@@ -3149,6 +3203,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
|
||||
<string name="dashboard_native_signin_transport_retry">Se interrumpió la conexión segura. Toca el botón de inicio de sesión para volver a intentarlo.</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">El proveedor no aprobó este inicio de sesión. Si lo cancelaste, no tienes que hacer nada; de lo contrario, inicia un intento nuevo.</string>
|
||||
<string name="dashboard_native_signin_code_rejected">El inicio de sesión con Google terminó, pero Hermes alojado rechazó el código de devolución de un solo uso antes de crear una sesión. Inicia un intento nuevo.</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">Hermes alojado rechazó el intercambio de sesión. Comprueba que el agente alojado esté actualizado y vuelve a iniciar sesión.</string>
|
||||
<string name="dashboard_native_signin_rate_limited">Hermes alojado recibió demasiados intentos de inicio de sesión. Espera un momento y vuelve a intentarlo.</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">El inicio de sesión con Google terminó, pero el servicio de acceso de Hermes alojado no está disponible. Espera un momento y vuelve a intentarlo.</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">Hermes alojado devolvió una respuesta de inicio de sesión que esta aplicación no puede usar. Busca actualizaciones de Hermes Relay y del agente alojado.</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">Este intento de inicio de sesión se canceló o fue reemplazado. Cierra la pestaña anterior y empieza de nuevo.</string>
|
||||
<string name="dashboard_native_signin_storage_failed">El inicio de sesión con Google terminó, pero Android no pudo guardar de forma segura la sesión de Hermes. Vuelve a intentarlo; si se repite, abre Diagnósticos para ver la fase del error.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
|
||||
@@ -3729,6 +3791,35 @@
|
||||
<string name="dashboard_whatsapp_saved">WhatsApp activado; Hermes inició un reinicio del gateway.</string>
|
||||
<string name="drawer_all_profiles">Todos los perfiles</string>
|
||||
<string name="drawer_no_profile_sessions">No hay sesiones de perfil coincidentes.</string>
|
||||
<string name="drawer_customize_sessions">Personalizar sesiones</string>
|
||||
<string name="drawer_group_by">Agrupar por</string>
|
||||
<string name="drawer_order_by">Ordenar por</string>
|
||||
<string name="drawer_show_metadata">Mostrar en las filas</string>
|
||||
<string name="drawer_show_details">Mostrar detalles</string>
|
||||
<string name="drawer_filters">Filtros</string>
|
||||
<string name="drawer_project_home">Inicio</string>
|
||||
<string name="drawer_expand_project">Expandir %1$s</string>
|
||||
<string name="drawer_collapse_project">Contraer %1$s</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="one">%1$d sesión</item>
|
||||
<item quantity="other">%1$d sesiones</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="one">%1$d perfil</item>
|
||||
<item quantity="other">%1$d perfiles</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">Estado</string>
|
||||
<string name="drawer_filter_profile">Perfil</string>
|
||||
<string name="drawer_filter_project">Proyecto</string>
|
||||
<string name="drawer_filter_pull_request">Solicitud de incorporación</string>
|
||||
<string name="drawer_option_updated">Actualizado</string>
|
||||
<string name="drawer_option_profile">Perfil</string>
|
||||
<string name="drawer_option_tokens">Tokens</string>
|
||||
<string name="drawer_option_cost">Coste</string>
|
||||
<string name="drawer_reset_filters">Restablecer</string>
|
||||
<string name="drawer_profile_colors">Colores de perfil</string>
|
||||
<string name="drawer_profile_color_auto">Automático</string>
|
||||
<string name="drawer_profile_color_set">Establecer el color del perfil %1$s en %2$s</string>
|
||||
<string name="drawer_close">Cerrar</string>
|
||||
<string name="profile_inspector_gateway_settings">Ajustes del perfil de Gateway</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">Se guarda directamente mediante Hermes para este perfil seleccionado.</string>
|
||||
@@ -3755,4 +3846,14 @@
|
||||
<string name="secure_link_fallback_ready">Si Secure Link no está disponible, Hermes puede probar las rutas alternativas aprobadas que aparecen abajo.</string>
|
||||
<string name="secure_link_no_fallback">No se incluye ninguna ruta alternativa. Los servicios protegidos permanecerán sin conexión si Secure Link no está disponible.</string>
|
||||
<string name="secure_link_auth_note">La protección del transporte no omite la autenticación. El emparejamiento de Relay, las credenciales de API y el inicio de sesión del Panel se siguen aplicando por servicio.</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="one">Ha llegado %1$d mensaje mientras no estabas</item>
|
||||
<item quantity="other">Han llegado %1$d mensajes mientras no estabas</item>
|
||||
</plurals>
|
||||
<string name="chat_model_confirmation_title">Confirmar selección de modelo</string>
|
||||
<string name="host_resource_recent_oom">Hermes se reinició recientemente, probablemente por falta de memoria.</string>
|
||||
<string name="host_resource_memory_critical">La memoria del servidor está casi agotada; los chats activos podrían detenerse.</string>
|
||||
<string name="host_resource_memory_elevated">La memoria del servidor se está agotando.</string>
|
||||
<string name="host_resource_disk_critical">El almacenamiento del servidor está casi agotado; puede que los mensajes y ajustes no se guarden.</string>
|
||||
<string name="host_resource_disk_elevated">El almacenamiento del servidor se está agotando.</string>
|
||||
</resources>
|
||||
|
||||
@@ -736,6 +736,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">最近のメッセージをコンポーザーの上にタップ可能なチップとして表示し、再送信します。デフォルトではオフです。</string>
|
||||
<string name="chat_settings_keep_keyboard_open">送信時にキーボードを開いたままにする</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">送信後はコンポーザー内に留まります。メッセージを送信するたびにキーボードを閉じるには、オフにします。</string>
|
||||
<string name="chat_settings_large_pastes">大きな貼り付けを添付ファイルに変換</string>
|
||||
<string name="chat_settings_large_pastes_desc">5,000 文字以上の貼り付けを確認可能なテキスト添付ファイルに変換します。デフォルトでオンです。</string>
|
||||
<string name="chat_large_paste_attached">大きな貼り付けをテキスト添付ファイルとして追加しました</string>
|
||||
<string name="chat_large_paste_too_large">貼り付けたテキストが %1$d MB の添付ファイル上限を超えています</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">物理キーボードの Enter キー</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">Enter で送信するか改行するかを選択します。Ctrl+Enter は常に送信します。</string>
|
||||
<string name="chat_settings_insert_newline">改行を挿入</string>
|
||||
@@ -1812,6 +1816,17 @@
|
||||
<!-- Section action buttons -->
|
||||
<string name="dashboard_section_action_change_main_model">メイン機種変更</string>
|
||||
<string name="dashboard_section_action_new_profile">新しいプロフィール</string>
|
||||
<string name="dashboard_section_action_new_schedule">新しいスケジュール</string>
|
||||
<string name="dashboard_cron_create_title">スケジュールを作成</string>
|
||||
<string name="dashboard_cron_name">名前</string>
|
||||
<string name="dashboard_cron_schedule">スケジュール</string>
|
||||
<string name="dashboard_cron_schedule_hint">例: every 2h、0 9 * * 1-5、2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">タスクの指示</string>
|
||||
<string name="dashboard_cron_repeat">この実行回数の後に停止(任意)</string>
|
||||
<string name="dashboard_cron_repeat_help">通常の動作を維持する場合は空欄にします。有限スケジュールには1~999回を指定できます。</string>
|
||||
<string name="dashboard_cron_gateway_required">スケジュールの作成には、現在のHermes Gateway接続が必要です。</string>
|
||||
<string name="dashboard_cron_created">スケジュール「%1$s」を作成しました</string>
|
||||
<string name="dashboard_cron_create_failed">スケジュールを作成できませんでした</string>
|
||||
<string name="dashboard_section_action_browse_hub">ハブを参照する</string>
|
||||
<string name="dashboard_section_action_update_installed">アップデートがインストールされました</string>
|
||||
|
||||
@@ -1966,6 +1981,16 @@
|
||||
|
||||
<!-- Profile create -->
|
||||
<string name="dashboard_profile_created">プロファイル %1$s が作成されました</string>
|
||||
<string name="dashboard_profile_created_partial">プロファイル %1$s は作成されましたが、%2$s。使用前に確認してください。</string>
|
||||
<string name="dashboard_profile_created_legacy">プロファイル %1$s は旧 Dashboard ルートで作成されました。使用前に認証情報を確認してください。</string>
|
||||
<string name="dashboard_profile_no_skills">同梱スキルを追加しない</string>
|
||||
<string name="dashboard_profile_auth_title">認証</string>
|
||||
<string name="dashboard_profile_auth_shared">サインインを共有(推奨)— 更新可能なトークンプールを共有</string>
|
||||
<string name="dashboard_profile_auth_copied">現在の認証情報をコピー — 別スナップショットのため古くなる場合があります</string>
|
||||
<string name="dashboard_profile_auth_isolated">分離 — 認証情報やプロバイダー既定値をコピーしない</string>
|
||||
<string name="dashboard_profile_auth_help">Hermes がサーバー上でこの選択を適用します。パスワードやトークンが Android に返されることはありません。</string>
|
||||
<string name="dashboard_profile_allow_legacy">Gateway で作成できない場合、旧 Dashboard ルートを許可します。認証情報の動作はホストによって異なるため確認が必要です。</string>
|
||||
<string name="dashboard_profile_mcp_partial">MCP サーバーの選択は適用されませんでした</string>
|
||||
<string name="dashboard_profile_create_failed">プロファイルの作成に失敗しました</string>
|
||||
|
||||
<!-- Stats for nerds -->
|
||||
@@ -2179,6 +2204,7 @@
|
||||
<string name="active_section_api_key_not_configured">API キーが構成されていません</string>
|
||||
<string name="active_section_api_key_optional">直接 API の API キー</string>
|
||||
<string name="active_section_api_key_stored_hint">鍵は安全に保管されます。</string>
|
||||
<string name="api_credential_single_line_error">API 認証情報は 1 行で入力してください。</string>
|
||||
<string name="active_section_api_reachable_voice_review">API に到達可能 — 音声設定を確認してください</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API 到達可能 — Relay 音声が設定されています</string>
|
||||
<string name="active_section_api_server">API サーバー</string>
|
||||
@@ -2590,7 +2616,35 @@
|
||||
<plurals name="tool_run_used_tools"><item quantity="other">%d 個のツールを使用</item></plurals>
|
||||
|
||||
<!-- AgentIconRow -->
|
||||
<string name="agent_icon_title">エージェントアイコン</string>
|
||||
<string name="agent_icon_title">エージェントの外観</string>
|
||||
<string name="agent_icon_hermes_pet_title">Hermes アニメーションペット</string>
|
||||
<string name="agent_icon_hermes_pet_description">プロファイル単位で upstream Hermes の各画面と共有されます。エージェントの活動に反応し、スマートフォンのコンパニオンとして表示されます。</string>
|
||||
<string name="agent_icon_hermes_pet_active">有効: %1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">upstream ペットが選択されていません</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">ここでアニメーションペットを管理するには Hermes を更新してください</string>
|
||||
<string name="agent_icon_hermes_pet_browse">Hermes ペットを探す</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">Hermes ペットを選択</string>
|
||||
<string name="agent_icon_hermes_pet_selected">選択中</string>
|
||||
<string name="agent_icon_hermes_pet_installed">インストール済み</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">Petdex から迎える</string>
|
||||
<string name="agent_icon_shared_title">Hermes 全体で共有</string>
|
||||
<string name="agent_icon_shared_active">共有アバター</string>
|
||||
<string name="agent_icon_shared_empty">共有アバターなし</string>
|
||||
<string name="agent_icon_change_shared">共有アバターを変更</string>
|
||||
<string name="agent_icon_remove_shared">削除</string>
|
||||
<string name="agent_icon_remove_shared_title">共有アバターを削除しますか?</string>
|
||||
<string name="agent_icon_remove_shared_message">Hermes と他のクライアントからアバターを削除します。この端末の画像には影響しません。</string>
|
||||
<string name="agent_icon_phone_title">この端末のみ</string>
|
||||
<string name="agent_icon_phone_description">Hermes を変更せず、この端末だけ共有アバターを上書きします。</string>
|
||||
<string name="agent_icon_choose_phone">端末の画像を選択</string>
|
||||
<string name="agent_icon_change_phone">端末の画像を変更</string>
|
||||
<string name="agent_icon_remove_phone">端末の画像を削除</string>
|
||||
<string name="agent_icon_animation_note">アニメーション GIF と WebP の上書きはこの端末でのみ再生されます。プロファイル単位の upstream アニメーションには、上の Hermes アニメーションペットを使用してください。</string>
|
||||
<string name="agent_icon_set_local">端末の予備画像を選択</string>
|
||||
<string name="agent_icon_change_local">端末の予備画像を変更</string>
|
||||
<string name="agent_icon_clear_local">端末の予備画像を消去</string>
|
||||
<string name="agent_icon_upload_shared">端末アイコンを Hermes にアップロード</string>
|
||||
<string name="agent_icon_clear_shared">共有 Hermes アバターを消去</string>
|
||||
<string name="agent_icon_set">ファイルを選択</string>
|
||||
<string name="agent_icon_change">ファイルを選択</string>
|
||||
<string name="agent_icon_clear">クリア</string>
|
||||
@@ -3463,6 +3517,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
|
||||
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
|
||||
<string name="dashboard_native_signin_transport_retry">安全な接続が中断されました。サインインボタンをタップして、もう一度お試しください。</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">プロバイダーがこのサインインを承認しませんでした。キャンセルした場合は操作不要です。それ以外の場合は、新しいサインインを開始してください。</string>
|
||||
<string name="dashboard_native_signin_code_rejected">Google サインインは完了しましたが、ホストされた Hermes がセッション作成前に一回限りのコールバックコードを拒否しました。新しいサインインを開始してください。</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">ホストされた Hermes がセッション交換を拒否しました。ホスト側エージェントが最新であることを確認し、サインインをやり直してください。</string>
|
||||
<string name="dashboard_native_signin_rate_limited">ホストされた Hermes が短時間に多すぎるサインイン試行を受け取りました。しばらく待ってから再試行してください。</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">Google サインインは完了しましたが、ホストされた Hermes のサインインサービスを利用できません。しばらく待ってから再試行してください。</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">ホストされた Hermes から、このアプリでは使用できないサインイン応答が返されました。Hermes Relay とホスト側エージェントの更新を確認してください。</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">このサインイン試行はキャンセルされたか置き換えられました。古いブラウザタブを閉じて、やり直してください。</string>
|
||||
<string name="dashboard_native_signin_storage_failed">Google サインインは完了しましたが、Android が Hermes セッションを安全に保存できませんでした。再試行し、繰り返す場合は診断で失敗段階を確認してください。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
|
||||
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
|
||||
@@ -4043,6 +4105,35 @@
|
||||
<string name="dashboard_whatsapp_saved">WhatsApp を有効にしました。Hermes がゲートウェイの再起動を開始しました。</string>
|
||||
<string name="drawer_all_profiles">すべてのプロファイル</string>
|
||||
<string name="drawer_no_profile_sessions">一致するプロファイルセッションはありません。</string>
|
||||
<string name="drawer_customize_sessions">セッションをカスタマイズ</string>
|
||||
<string name="drawer_group_by">グループ化</string>
|
||||
<string name="drawer_order_by">並び順</string>
|
||||
<string name="drawer_show_metadata">行に表示</string>
|
||||
<string name="drawer_show_details">詳細を表示</string>
|
||||
<string name="drawer_filters">フィルター</string>
|
||||
<string name="drawer_project_home">ホーム</string>
|
||||
<string name="drawer_expand_project">%1$sを展開</string>
|
||||
<string name="drawer_collapse_project">%1$sを折りたたむ</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="one">%1$d件のセッション</item>
|
||||
<item quantity="other">%1$d件のセッション</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="one">%1$d件のプロファイル</item>
|
||||
<item quantity="other">%1$d件のプロファイル</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">ステータス</string>
|
||||
<string name="drawer_filter_profile">プロフィール</string>
|
||||
<string name="drawer_filter_project">プロジェクト</string>
|
||||
<string name="drawer_filter_pull_request">プルリクエスト</string>
|
||||
<string name="drawer_option_updated">更新日時</string>
|
||||
<string name="drawer_option_profile">プロフィール</string>
|
||||
<string name="drawer_option_tokens">トークン</string>
|
||||
<string name="drawer_option_cost">コスト</string>
|
||||
<string name="drawer_reset_filters">リセット</string>
|
||||
<string name="drawer_profile_colors">プロフィールの色</string>
|
||||
<string name="drawer_profile_color_auto">自動</string>
|
||||
<string name="drawer_profile_color_set">%1$s のプロフィール色を %2$s に設定</string>
|
||||
<string name="drawer_close">閉じる</string>
|
||||
<string name="profile_inspector_gateway_settings">Gateway プロファイル設定</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">選択中のプロファイルに Hermes 経由で直接保存します。</string>
|
||||
@@ -4069,4 +4160,13 @@
|
||||
<string name="secure_link_fallback_ready">Secure Link を利用できない場合、Hermes は以下の承認済み代替ルートを試行できます。</string>
|
||||
<string name="secure_link_no_fallback">代替ルートは含まれていません。Secure Link を利用できない場合、保護対象サービスはオフラインのままです。</string>
|
||||
<string name="secure_link_auth_note">トランスポート保護によって認証が省略されることはありません。Relay のペアリング、API 資格情報、ダッシュボードへのログインはサービスごとに引き続き適用されます。</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="other">離れている間に%1$d件のメッセージが届きました</item>
|
||||
</plurals>
|
||||
<string name="chat_model_confirmation_title">モデル選択を確認</string>
|
||||
<string name="host_resource_recent_oom">Hermes は最近、メモリ不足が原因と思われる再起動を行いました。</string>
|
||||
<string name="host_resource_memory_critical">ホストのメモリが極端に不足しています。進行中のチャットが停止する可能性があります。</string>
|
||||
<string name="host_resource_memory_elevated">ホストのメモリが不足しています。</string>
|
||||
<string name="host_resource_disk_critical">ホストのストレージが極端に不足しています。メッセージや設定を保存できない可能性があります。</string>
|
||||
<string name="host_resource_disk_elevated">ホストのストレージが不足しています。</string>
|
||||
</resources>
|
||||
|
||||
@@ -704,6 +704,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Показывать ваши недавние сообщения как нажимаемые чипы над композитором, чтобы отправить их снова. По умолчанию выключено.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Оставить клавиатуру открытой при отправке</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Оставаться в композиторе после отправки. Выключите, чтобы скрыть клавиатуру после каждого отправленного сообщения.</string>
|
||||
<string name="chat_settings_large_pastes">Преобразовывать большие вставки во вложения</string>
|
||||
<string name="chat_settings_large_pastes_desc">Преобразует вставки длиной от 5 000 символов в текстовые вложения для проверки. Включено по умолчанию.</string>
|
||||
<string name="chat_large_paste_attached">Большая вставка добавлена как текстовое вложение</string>
|
||||
<string name="chat_large_paste_too_large">Вставленный текст превышает лимит вложения %1$d МБ</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">Клавиша Enter на физической клавиатуре</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">Выберите, отправляет ли Enter сообщение или добавляет новую строку. Ctrl+Enter всегда отправляет.</string>
|
||||
<string name="chat_settings_insert_newline">Вставить новую строку</string>
|
||||
@@ -1822,6 +1826,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">Безопасный вход в браузере требует HTTPS-адреса панели управления.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Безопасный вход в браузере недоступен для этого подключения. Обновите подключение и попробуйте снова.</string>
|
||||
<string name="dashboard_native_signin_transport_retry">Защищённое соединение было прервано. Нажмите кнопку входа, чтобы повторить попытку.</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">Провайдер не подтвердил этот вход. Если вы отменили его, ничего делать не нужно; иначе начните новую попытку входа.</string>
|
||||
<string name="dashboard_native_signin_code_rejected">Вход через Google завершён, но размещённый Hermes отклонил одноразовый код обратного вызова до создания сеанса. Начните новую попытку входа.</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">Размещённый Hermes отклонил обмен сеанса. Убедитесь, что размещённый агент обновлён, и начните вход заново.</string>
|
||||
<string name="dashboard_native_signin_rate_limited">Размещённый Hermes получил слишком много попыток входа. Подождите немного и повторите попытку.</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">Вход через Google завершён, но служба входа размещённого Hermes недоступна. Подождите немного и повторите попытку.</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">Размещённый Hermes вернул ответ входа, который это приложение не может использовать. Проверьте обновления Hermes Relay и размещённого агента.</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">Эта попытка входа была отменена или заменена. Закройте старую вкладку и начните заново.</string>
|
||||
<string name="dashboard_native_signin_storage_failed">Вход через Google завершён, но Android не смог безопасно сохранить сеанс Hermes. Повторите попытку; если ошибка повторится, откройте диагностику, чтобы увидеть этап сбоя.</string>
|
||||
<string name="dashboard_close_signin">Закрыть вход</string>
|
||||
<string name="dashboard_error_title">Панель управления недоступна</string>
|
||||
<string name="dashboard_error_target">Не удалось загрузить из %1$s</string>
|
||||
@@ -1831,6 +1843,17 @@
|
||||
<string name="dashboard_empty_section">Не возвращено %1$s</string>
|
||||
<string name="dashboard_section_action_change_main_model">Изменить основную модель</string>
|
||||
<string name="dashboard_section_action_new_profile">Новый профиль</string>
|
||||
<string name="dashboard_section_action_new_schedule">Новое расписание</string>
|
||||
<string name="dashboard_cron_create_title">Создать расписание</string>
|
||||
<string name="dashboard_cron_name">Название</string>
|
||||
<string name="dashboard_cron_schedule">Расписание</string>
|
||||
<string name="dashboard_cron_schedule_hint">Примеры: every 2h, 0 9 * * 1-5 или 2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">Инструкции задачи</string>
|
||||
<string name="dashboard_cron_repeat">Остановить после указанного числа запусков (необязательно)</string>
|
||||
<string name="dashboard_cron_repeat_help">Оставьте пустым для обычного поведения расписания. Для конечных расписаний можно указать от 1 до 999 запусков.</string>
|
||||
<string name="dashboard_cron_gateway_required">Для создания расписания требуется текущее подключение к Hermes Gateway.</string>
|
||||
<string name="dashboard_cron_created">Расписание «%1$s» создано</string>
|
||||
<string name="dashboard_cron_create_failed">Не удалось создать расписание</string>
|
||||
<string name="dashboard_section_action_browse_hub">Просмотр центра</string>
|
||||
<string name="dashboard_section_action_update_installed">Обновить установленное</string>
|
||||
<string name="dashboard_action_set">Установить</string>
|
||||
@@ -1955,6 +1978,16 @@
|
||||
<string name="dashboard_skills_update_started">Обновление навыка начато на сервере — обновите Навыки через минуту</string>
|
||||
<string name="dashboard_skills_update_failed">Обновление навыка не удалось</string>
|
||||
<string name="dashboard_profile_created">Профиль %1$s создан</string>
|
||||
<string name="dashboard_profile_created_partial">Профиль %1$s создан, но %2$s. Проверьте его перед использованием.</string>
|
||||
<string name="dashboard_profile_created_legacy">Профиль %1$s создан через старый маршрут панели. Проверьте его учётные данные перед использованием.</string>
|
||||
<string name="dashboard_profile_no_skills">Не добавлять встроенные навыки</string>
|
||||
<string name="dashboard_profile_auth_title">Аутентификация</string>
|
||||
<string name="dashboard_profile_auth_shared">Общий вход (рекомендуется) — единый пул обновляемых токенов</string>
|
||||
<string name="dashboard_profile_auth_copied">Копировать текущие учётные данные — отдельный снимок может устареть</string>
|
||||
<string name="dashboard_profile_auth_isolated">Изолированный — не копировать учётные данные и настройки провайдера</string>
|
||||
<string name="dashboard_profile_auth_help">Hermes применяет этот выбор на сервере. Пароли и токены никогда не возвращаются в Android.</string>
|
||||
<string name="dashboard_profile_allow_legacy">Если создание через Gateway недоступно, разрешить старый маршрут панели. Поведение учётных данных зависит от хоста и требует проверки.</string>
|
||||
<string name="dashboard_profile_mcp_partial">Выбор серверов MCP не был применён</string>
|
||||
<string name="dashboard_profile_create_failed">Создание профиля не удалось</string>
|
||||
<string name="stats_overview">Обзор</string>
|
||||
<string name="stats_reset">Сброс</string>
|
||||
@@ -2158,6 +2191,7 @@
|
||||
<string name="active_section_api_key_not_configured">Ключ API не настроен</string>
|
||||
<string name="active_section_api_key_optional">Ключ API для прямого API</string>
|
||||
<string name="active_section_api_key_stored_hint">Ключ хранится безопасно.</string>
|
||||
<string name="api_credential_single_line_error">Учетные данные API должны быть указаны в одной строке.</string>
|
||||
<string name="active_section_api_reachable_voice_review">API доступен — проверьте настройку голоса</string>
|
||||
<string name="active_section_api_relay_voice_reachable">API доступен — голос Relay настроен</string>
|
||||
<string name="active_section_api_server">Сервер API</string>
|
||||
@@ -2553,7 +2587,35 @@
|
||||
<plurals name="tool_run_used_device"><item quantity="one">Выполнено %d действие устройства</item><item quantity="few">Выполнено %d действия устройства</item><item quantity="many">Выполнено %d действий устройства</item><item quantity="other">Выполнено %d действий устройства</item></plurals>
|
||||
<plurals name="tool_run_using_tools"><item quantity="one">Используется %d инструмент</item><item quantity="few">Используется %d инструмента</item><item quantity="many">Используется %d инструментов</item><item quantity="other">Используется %d инструментов</item></plurals>
|
||||
<plurals name="tool_run_used_tools"><item quantity="one">Использован %d инструмент</item><item quantity="few">Использовано %d инструмента</item><item quantity="many">Использовано %d инструментов</item><item quantity="other">Использовано %d инструментов</item></plurals>
|
||||
<string name="agent_icon_title">Иконка агента</string>
|
||||
<string name="agent_icon_title">Внешний вид агента</string>
|
||||
<string name="agent_icon_hermes_pet_title">Анимированный питомец Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_description">Привязан к профилю и доступен в upstream-интерфейсах Hermes. Реагирует на действия агента и отображается как спутник на телефоне.</string>
|
||||
<string name="agent_icon_hermes_pet_active">Активен: %1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">Upstream-питомец не выбран</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">Обновите Hermes, чтобы управлять анимированными питомцами здесь</string>
|
||||
<string name="agent_icon_hermes_pet_browse">Выбрать питомца Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">Выберите питомца Hermes</string>
|
||||
<string name="agent_icon_hermes_pet_selected">Выбран</string>
|
||||
<string name="agent_icon_hermes_pet_installed">Установлен</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">Добавить из Petdex</string>
|
||||
<string name="agent_icon_shared_title">Общий в Hermes</string>
|
||||
<string name="agent_icon_shared_active">Общий аватар</string>
|
||||
<string name="agent_icon_shared_empty">Нет общего аватара</string>
|
||||
<string name="agent_icon_change_shared">Изменить общий аватар</string>
|
||||
<string name="agent_icon_remove_shared">Удалить</string>
|
||||
<string name="agent_icon_remove_shared_title">Удалить общий аватар?</string>
|
||||
<string name="agent_icon_remove_shared_message">Аватар будет удалён из Hermes и других клиентов. Изображение на телефоне не изменится.</string>
|
||||
<string name="agent_icon_phone_title">Только на этом телефоне</string>
|
||||
<string name="agent_icon_phone_description">Заменить общий аватар только на этом телефоне, не изменяя Hermes.</string>
|
||||
<string name="agent_icon_choose_phone">Выбрать изображение на телефоне</string>
|
||||
<string name="agent_icon_change_phone">Изменить изображение на телефоне</string>
|
||||
<string name="agent_icon_remove_phone">Удалить изображение с телефона</string>
|
||||
<string name="agent_icon_animation_note">Анимированные GIF- и WebP-переопределения работают только на этом телефоне. Для профильной upstream-анимации используйте анимированного питомца Hermes выше.</string>
|
||||
<string name="agent_icon_set_local">Выбрать резервное изображение на устройстве</string>
|
||||
<string name="agent_icon_change_local">Изменить резервное изображение на устройстве</string>
|
||||
<string name="agent_icon_clear_local">Удалить резервное изображение с устройства</string>
|
||||
<string name="agent_icon_upload_shared">Загрузить значок устройства в Hermes</string>
|
||||
<string name="agent_icon_clear_shared">Удалить общий аватар Hermes</string>
|
||||
<string name="agent_icon_set">Выбрать файл</string>
|
||||
<string name="agent_icon_change">Выбрать файл</string>
|
||||
<string name="agent_icon_clear">Очистить</string>
|
||||
@@ -3765,6 +3827,39 @@
|
||||
<string name="dashboard_whatsapp_saved">WhatsApp включён; Hermes начал перезапуск шлюза.</string>
|
||||
<string name="drawer_all_profiles">Все профили</string>
|
||||
<string name="drawer_no_profile_sessions">Нет подходящих сеансов профиля.</string>
|
||||
<string name="drawer_customize_sessions">Настроить сеансы</string>
|
||||
<string name="drawer_group_by">Группировать по</string>
|
||||
<string name="drawer_order_by">Сортировать по</string>
|
||||
<string name="drawer_show_metadata">Показывать в строках</string>
|
||||
<string name="drawer_show_details">Показывать подробности</string>
|
||||
<string name="drawer_filters">Фильтры</string>
|
||||
<string name="drawer_project_home">Главная</string>
|
||||
<string name="drawer_expand_project">Развернуть %1$s</string>
|
||||
<string name="drawer_collapse_project">Свернуть %1$s</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="one">%1$d сеанс</item>
|
||||
<item quantity="few">%1$d сеанса</item>
|
||||
<item quantity="many">%1$d сеансов</item>
|
||||
<item quantity="other">%1$d сеанса</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="one">%1$d профиль</item>
|
||||
<item quantity="few">%1$d профиля</item>
|
||||
<item quantity="many">%1$d профилей</item>
|
||||
<item quantity="other">%1$d профиля</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">Статус</string>
|
||||
<string name="drawer_filter_profile">Профиль</string>
|
||||
<string name="drawer_filter_project">Проект</string>
|
||||
<string name="drawer_filter_pull_request">Запрос на слияние</string>
|
||||
<string name="drawer_option_updated">Обновлено</string>
|
||||
<string name="drawer_option_profile">Профиль</string>
|
||||
<string name="drawer_option_tokens">Токены</string>
|
||||
<string name="drawer_option_cost">Стоимость</string>
|
||||
<string name="drawer_reset_filters">Сбросить</string>
|
||||
<string name="drawer_profile_colors">Цвета профилей</string>
|
||||
<string name="drawer_profile_color_auto">Автоматически</string>
|
||||
<string name="drawer_profile_color_set">Установить цвет профиля %1$s: %2$s</string>
|
||||
<string name="drawer_close">Закрыть</string>
|
||||
<string name="profile_inspector_gateway_settings">Настройки профиля Gateway</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">Сохраняются напрямую через Hermes для выбранного профиля.</string>
|
||||
@@ -3791,4 +3886,16 @@
|
||||
<string name="secure_link_fallback_ready">Если Secure Link недоступен, Hermes может попробовать одобренные резервные маршруты ниже.</string>
|
||||
<string name="secure_link_no_fallback">Резервный маршрут не указан. Защищённые сервисы останутся офлайн, если Secure Link недоступен.</string>
|
||||
<string name="secure_link_auth_note">Защита транспорта не отменяет аутентификацию. Сопряжение Relay, учётные данные API и вход в Панель по-прежнему проверяются отдельно для каждого сервиса.</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="one">Пока вас не было, пришло %1$d сообщение</item>
|
||||
<item quantity="few">Пока вас не было, пришло %1$d сообщения</item>
|
||||
<item quantity="many">Пока вас не было, пришло %1$d сообщений</item>
|
||||
<item quantity="other">Пока вас не было, пришло %1$d сообщения</item>
|
||||
</plurals>
|
||||
<string name="chat_model_confirmation_title">Подтвердите выбор модели</string>
|
||||
<string name="host_resource_recent_oom">Hermes недавно перезапустился, вероятно, из-за нехватки памяти.</string>
|
||||
<string name="host_resource_memory_critical">Память хоста почти исчерпана; активные чаты могут остановиться.</string>
|
||||
<string name="host_resource_memory_elevated">Память хоста заканчивается.</string>
|
||||
<string name="host_resource_disk_critical">Хранилище хоста почти заполнено; сообщения и настройки могут не сохраниться.</string>
|
||||
<string name="host_resource_disk_elevated">Хранилище хоста заканчивается.</string>
|
||||
</resources>
|
||||
|
||||
@@ -774,6 +774,10 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Show your recent messages as tappable chips above the composer to send them again. Off by default.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Keep keyboard open on send</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
|
||||
<string name="chat_settings_large_pastes">Convert large pastes to attachments</string>
|
||||
<string name="chat_settings_large_pastes_desc">Turn pastes of 5,000 or more characters into reviewable text attachments. On by default.</string>
|
||||
<string name="chat_large_paste_attached">Large paste added as a text attachment</string>
|
||||
<string name="chat_large_paste_too_large">Pasted text exceeds the %1$d MB attachment limit</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">Physical keyboard Enter key</string>
|
||||
<string name="chat_settings_physical_keyboard_enter_desc">Choose whether Enter sends or inserts a new line. Ctrl+Enter always sends.</string>
|
||||
<string name="chat_settings_insert_newline">Insert new line</string>
|
||||
@@ -1494,6 +1498,10 @@
|
||||
<string name="proactive_about">About</string>
|
||||
<string name="proactive_about_desc">When on, your phone tells the relay it\'s open to agent-initiated messages. The agent delivers them over the same paired connection the relay already uses — no new permissions beyond notifications.</string>
|
||||
<string name="proactive_about_server_note">Your server must also enable the phone platform (set PHONE_ENABLED on the server). Until both sides are on and the phone is paired, nothing is pushed.</string>
|
||||
<plurals name="proactive_messages_arrived_while_away">
|
||||
<item quantity="one">%1$d message arrived while you were away</item>
|
||||
<item quantity="other">%1$d messages arrived while you were away</item>
|
||||
</plurals>
|
||||
|
||||
<!-- ProfileInspectorScreen -->
|
||||
<string name="profile_inspector_back">Back</string>
|
||||
@@ -1974,6 +1982,14 @@
|
||||
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
|
||||
<string name="dashboard_native_signin_transport_retry">The secure connection was interrupted. Tap the sign-in button to try again.</string>
|
||||
<string name="dashboard_native_signin_callback_rejected">The provider did not approve this sign-in. If you cancelled, no action is needed; otherwise start a fresh sign-in attempt.</string>
|
||||
<string name="dashboard_native_signin_code_rejected">Google sign-in finished, but hosted Hermes rejected the one-time callback code before it could create a session. Start a fresh sign-in attempt.</string>
|
||||
<string name="dashboard_native_signin_gateway_rejected">Hosted Hermes refused the session exchange. Check that the hosted agent is current, then start sign-in again.</string>
|
||||
<string name="dashboard_native_signin_rate_limited">Hosted Hermes received too many sign-in attempts. Wait a moment, then try again.</string>
|
||||
<string name="dashboard_native_signin_gateway_unavailable">Google sign-in finished, but the hosted Hermes sign-in service is unavailable. Wait a moment, then try again.</string>
|
||||
<string name="dashboard_native_signin_response_unsupported">Hosted Hermes returned a sign-in response this app could not use. Check for Hermes Relay and hosted agent updates.</string>
|
||||
<string name="dashboard_native_signin_attempt_inactive">This sign-in attempt was cancelled or replaced. Close the old browser tab and start again.</string>
|
||||
<string name="dashboard_native_signin_storage_failed">Google sign-in finished, but Android could not securely save the Hermes session. Try again; if it repeats, open Diagnostics for the failure stage.</string>
|
||||
<string name="dashboard_close_signin">Close sign-in</string>
|
||||
|
||||
<!-- Error body -->
|
||||
@@ -1989,6 +2005,17 @@
|
||||
<!-- Section action buttons -->
|
||||
<string name="dashboard_section_action_change_main_model">Change main model</string>
|
||||
<string name="dashboard_section_action_new_profile">New profile</string>
|
||||
<string name="dashboard_section_action_new_schedule">New schedule</string>
|
||||
<string name="dashboard_cron_create_title">Create schedule</string>
|
||||
<string name="dashboard_cron_name">Name</string>
|
||||
<string name="dashboard_cron_schedule">Schedule</string>
|
||||
<string name="dashboard_cron_schedule_hint">Examples: every 2h, 0 9 * * 1-5, or 2026-08-20 09:00</string>
|
||||
<string name="dashboard_cron_prompt">Task instructions</string>
|
||||
<string name="dashboard_cron_repeat">Stop after this many runs (optional)</string>
|
||||
<string name="dashboard_cron_repeat_help">Leave blank to keep the schedule’s normal behavior. Finite schedules accept 1–999 runs.</string>
|
||||
<string name="dashboard_cron_gateway_required">Schedule creation requires a current Hermes Gateway connection.</string>
|
||||
<string name="dashboard_cron_created">Created schedule “%1$s”</string>
|
||||
<string name="dashboard_cron_create_failed">Couldn’t create schedule</string>
|
||||
<string name="dashboard_section_action_browse_hub">Browse hub</string>
|
||||
<string name="dashboard_section_action_update_installed">Update installed</string>
|
||||
<string name="dashboard_section_action_server_backup">Create server backup</string>
|
||||
@@ -2188,6 +2215,16 @@
|
||||
|
||||
<!-- Profile create -->
|
||||
<string name="dashboard_profile_created">Profile %1$s created</string>
|
||||
<string name="dashboard_profile_created_partial">Profile %1$s created, but %2$s. Review it before use.</string>
|
||||
<string name="dashboard_profile_created_legacy">Profile %1$s created through the older Dashboard route. Review its credentials before use.</string>
|
||||
<string name="dashboard_profile_no_skills">Do not seed bundled skills</string>
|
||||
<string name="dashboard_profile_auth_title">Authentication</string>
|
||||
<string name="dashboard_profile_auth_shared">Share sign-in (recommended) — one refreshable token pool</string>
|
||||
<string name="dashboard_profile_auth_copied">Copy current credentials — separate snapshot that can drift</string>
|
||||
<string name="dashboard_profile_auth_isolated">Isolated — copy no credentials or provider defaults</string>
|
||||
<string name="dashboard_profile_auth_help">Hermes applies this choice on the server. Passwords and tokens are never returned to Android.</string>
|
||||
<string name="dashboard_profile_allow_legacy">If Gateway create is unavailable, allow the older Dashboard route. Credential behavior depends on that host and must be reviewed.</string>
|
||||
<string name="dashboard_profile_mcp_partial">MCP server selection was not applied</string>
|
||||
<string name="dashboard_profile_create_failed">Profile create failed</string>
|
||||
|
||||
<!-- Stats for nerds -->
|
||||
@@ -2401,6 +2438,7 @@
|
||||
<string name="active_section_api_key_not_configured">API key not configured</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="api_credential_single_line_error">API credentials must be a single line.</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>
|
||||
<string name="active_section_api_server">API Server</string>
|
||||
@@ -2835,7 +2873,35 @@
|
||||
<plurals name="tool_run_used_tools"><item quantity="one">Used %d tool</item><item quantity="other">Used %d tools</item></plurals>
|
||||
|
||||
<!-- AgentIconRow -->
|
||||
<string name="agent_icon_title">Agent icon</string>
|
||||
<string name="agent_icon_title">Agent visuals</string>
|
||||
<string name="agent_icon_hermes_pet_title">Hermes animated pet</string>
|
||||
<string name="agent_icon_hermes_pet_description">Profile-scoped and shared with upstream Hermes surfaces. It reacts to agent activity and appears as the phone companion.</string>
|
||||
<string name="agent_icon_hermes_pet_active">Active: %1$s</string>
|
||||
<string name="agent_icon_hermes_pet_empty">No upstream pet selected</string>
|
||||
<string name="agent_icon_hermes_pet_unsupported">Update Hermes to manage animated pets here</string>
|
||||
<string name="agent_icon_hermes_pet_browse">Browse Hermes pets</string>
|
||||
<string name="agent_icon_hermes_pet_picker_title">Choose a Hermes pet</string>
|
||||
<string name="agent_icon_hermes_pet_selected">Selected</string>
|
||||
<string name="agent_icon_hermes_pet_installed">Installed</string>
|
||||
<string name="agent_icon_hermes_pet_adopt">Adopt from Petdex</string>
|
||||
<string name="agent_icon_shared_title">Shared across Hermes</string>
|
||||
<string name="agent_icon_shared_active">Shared avatar</string>
|
||||
<string name="agent_icon_shared_empty">No shared avatar</string>
|
||||
<string name="agent_icon_change_shared">Change shared avatar</string>
|
||||
<string name="agent_icon_remove_shared">Remove</string>
|
||||
<string name="agent_icon_remove_shared_title">Remove shared avatar?</string>
|
||||
<string name="agent_icon_remove_shared_message">This removes the avatar from Hermes and other clients. Your phone image is not affected.</string>
|
||||
<string name="agent_icon_phone_title">This phone only</string>
|
||||
<string name="agent_icon_phone_description">Override the shared avatar on this phone without changing Hermes.</string>
|
||||
<string name="agent_icon_choose_phone">Choose phone image</string>
|
||||
<string name="agent_icon_change_phone">Change phone image</string>
|
||||
<string name="agent_icon_remove_phone">Remove phone image</string>
|
||||
<string name="agent_icon_animation_note">Animated GIF and WebP overrides play only on this phone. Use Hermes animated pets above for an upstream-managed, profile-scoped animation.</string>
|
||||
<string name="agent_icon_set_local">Choose device fallback</string>
|
||||
<string name="agent_icon_change_local">Change device fallback</string>
|
||||
<string name="agent_icon_clear_local">Clear device fallback</string>
|
||||
<string name="agent_icon_upload_shared">Upload device icon to Hermes</string>
|
||||
<string name="agent_icon_clear_shared">Clear shared Hermes avatar</string>
|
||||
<string name="agent_icon_set">Choose file</string>
|
||||
<string name="agent_icon_change">Choose file</string>
|
||||
<string name="agent_icon_clear">Clear</string>
|
||||
@@ -4063,8 +4129,37 @@
|
||||
<string name="support_bundle_copied">Support information copied</string>
|
||||
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
|
||||
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
|
||||
<string name="drawer_all_profiles">All profiles</string>
|
||||
<string name="drawer_all_profiles">All Profiles</string>
|
||||
<string name="drawer_no_profile_sessions">No matching profile sessions.</string>
|
||||
<string name="drawer_customize_sessions">Customize sessions</string>
|
||||
<string name="drawer_group_by">Group by</string>
|
||||
<string name="drawer_order_by">Order by</string>
|
||||
<string name="drawer_show_metadata">Show on rows</string>
|
||||
<string name="drawer_show_details">Show details</string>
|
||||
<string name="drawer_filters">Filters</string>
|
||||
<string name="drawer_project_home">Home</string>
|
||||
<string name="drawer_expand_project">Expand %1$s</string>
|
||||
<string name="drawer_collapse_project">Collapse %1$s</string>
|
||||
<plurals name="drawer_project_session_count">
|
||||
<item quantity="one">%1$d session</item>
|
||||
<item quantity="other">%1$d sessions</item>
|
||||
</plurals>
|
||||
<plurals name="drawer_profile_count">
|
||||
<item quantity="one">%1$d profile</item>
|
||||
<item quantity="other">%1$d profiles</item>
|
||||
</plurals>
|
||||
<string name="drawer_filter_status">Status</string>
|
||||
<string name="drawer_filter_profile">Profile</string>
|
||||
<string name="drawer_filter_project">Project</string>
|
||||
<string name="drawer_filter_pull_request">Pull request</string>
|
||||
<string name="drawer_option_updated">Updated</string>
|
||||
<string name="drawer_option_profile">Profile</string>
|
||||
<string name="drawer_option_tokens">Tokens</string>
|
||||
<string name="drawer_option_cost">Cost</string>
|
||||
<string name="drawer_reset_filters">Reset</string>
|
||||
<string name="drawer_profile_colors">Profile colors</string>
|
||||
<string name="drawer_profile_color_auto">Auto</string>
|
||||
<string name="drawer_profile_color_set">Set %1$s profile color to %2$s</string>
|
||||
<string name="drawer_close">Close</string>
|
||||
<string name="profile_inspector_gateway_settings">Gateway profile settings</string>
|
||||
<string name="profile_inspector_gateway_settings_hint">Saved directly through upstream Hermes for this selected profile.</string>
|
||||
@@ -4076,4 +4171,10 @@
|
||||
<string name="profile_inspector_toolsets">Toolsets</string>
|
||||
<string name="profile_inspector_toolsets_hint">Choose which upstream tool groups this profile can use.</string>
|
||||
<string name="profile_inspector_tool_count">%1$d tools</string>
|
||||
<string name="chat_model_confirmation_title">Confirm model selection</string>
|
||||
<string name="host_resource_recent_oom">Hermes recently restarted after likely running out of memory.</string>
|
||||
<string name="host_resource_memory_critical">Host memory is critically low; active chats may stop.</string>
|
||||
<string name="host_resource_memory_elevated">Host memory is running low.</string>
|
||||
<string name="host_resource_disk_critical">Host storage is critically low; messages and settings may fail to save.</string>
|
||||
<string name="host_resource_disk_elevated">Host storage is running low.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class AuthManagerBackupCredentialTest {
|
||||
@Test
|
||||
fun backupValidationRejectsMultilineCredentialsBeforeRestore() {
|
||||
for (secrets in listOf(
|
||||
ConnectionAuthSecrets(sessionToken = "first\nsecond"),
|
||||
ConnectionAuthSecrets(refreshToken = "first\r\nsecond"),
|
||||
ConnectionAuthSecrets(apiKey = "first second"),
|
||||
ConnectionAuthSecrets(profileApiKeys = mapOf("work" to "first\tsecond")),
|
||||
)) {
|
||||
assertThrows(InvalidCredentialException::class.java) {
|
||||
AuthManager.validateStoredSecrets(secrets)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,4 +336,23 @@ class AuthManagerProfilesParseTest {
|
||||
assertFalse(parsed[0].apiServerKeyPresent)
|
||||
assertFalse(parsed[0].hasIsolatedApi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesBoundedSharedProfileMetadata() {
|
||||
val parsed = AuthManager.parseAgentProfiles(buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("name", "operator")
|
||||
put("model", "gpt")
|
||||
put("provider", "openai")
|
||||
put("is_default", true)
|
||||
put("has_avatar", true)
|
||||
put("ui_meta", buildJsonObject { put("accent", "#ff5500") })
|
||||
})
|
||||
}).single()
|
||||
|
||||
assertEquals("openai", parsed.provider)
|
||||
assertTrue(parsed.isDefault)
|
||||
assertTrue(parsed.hasAvatar)
|
||||
assertEquals("#ff5500", (parsed.uiMeta["accent"] as JsonPrimitive).content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class ChatComposerDraftStoreTest {
|
||||
private val store = InMemoryChatComposerDraftStore()
|
||||
|
||||
@Test
|
||||
fun draftsAreIsolatedByCompleteOwnerIdentity() {
|
||||
fun draftsAreIsolatedByCompleteOwnerIdentity() = runBlocking {
|
||||
val original = key(connection = "connection-a", profile = "coder", session = "session-a")
|
||||
val otherConnection = original.copy(connectionId = "connection-b")
|
||||
val otherProfile = original.copy(profileId = "default")
|
||||
@@ -26,7 +26,7 @@ class ChatComposerDraftStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesSelectionContextAndPendingAttachments() {
|
||||
fun preservesSelectionContextAndPendingAttachments() = runBlocking {
|
||||
val attachment = Attachment(
|
||||
contentType = "image/jpeg",
|
||||
content = "base64-payload",
|
||||
@@ -57,7 +57,7 @@ class ChatComposerDraftStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateUsesCurrentSessionDraftWithoutTouchingAnotherSession() {
|
||||
fun updateUsesCurrentSessionDraftWithoutTouchingAnotherSession() = runBlocking {
|
||||
val first = key(session = "first")
|
||||
val second = key(session = "second")
|
||||
store.save(first, ChatComposerDraft(text = "one"))
|
||||
@@ -71,7 +71,7 @@ class ChatComposerDraftStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSelectionIsClampedAndOrderedWhenSaved() {
|
||||
fun invalidSelectionIsClampedAndOrderedWhenSaved() = runBlocking {
|
||||
store.save(
|
||||
key(),
|
||||
ChatComposerDraft(text = "hello", selectionStart = 99, selectionEnd = -4),
|
||||
@@ -101,7 +101,7 @@ class ChatComposerDraftStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeSessionClearsEveryDraftSlotOnlyForExactNamespace() {
|
||||
fun removeSessionClearsEveryDraftSlotOnlyForExactNamespace() = runBlocking {
|
||||
val primary = key()
|
||||
val alternate = primary.copy(draftId = "alternate")
|
||||
val otherProfile = primary.copy(profileId = "other")
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -14,6 +15,20 @@ import org.junit.Test
|
||||
|
||||
class ChatInputPreferencesTest {
|
||||
|
||||
@Test
|
||||
fun `large paste conversion defaults on and round trips`() = runTest {
|
||||
val store = InMemoryChatInputDataStore()
|
||||
val repository = ChatInputPreferencesRepository(store)
|
||||
|
||||
assertEquals(true, repository.convertLargePastesToAttachments.first())
|
||||
|
||||
repository.setConvertLargePastesToAttachments(false)
|
||||
assertEquals(false, repository.convertLargePastesToAttachments.first())
|
||||
|
||||
repository.setConvertLargePastesToAttachments(true)
|
||||
assertEquals(true, repository.convertLargePastesToAttachments.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enter defaults to send and unknown values fall back safely`() = runTest {
|
||||
assertEquals(
|
||||
@@ -35,13 +50,20 @@ class ChatInputPreferencesTest {
|
||||
@Test
|
||||
fun `all enter behaviors round trip without replacing unrelated settings`() = runTest {
|
||||
val unrelatedKey = stringPreferencesKey("unrelated_chat_input_test")
|
||||
val store = InMemoryChatInputDataStore(mutablePreferencesOf(unrelatedKey to "keep-me"))
|
||||
val unrelatedBoolean = booleanPreferencesKey("unrelated_chat_input_boolean_test")
|
||||
val store = InMemoryChatInputDataStore(
|
||||
mutablePreferencesOf(
|
||||
unrelatedKey to "keep-me",
|
||||
unrelatedBoolean to true,
|
||||
),
|
||||
)
|
||||
val repository = ChatInputPreferencesRepository(store)
|
||||
|
||||
PhysicalKeyboardEnterBehavior.entries.forEach { behavior ->
|
||||
repository.setPhysicalKeyboardEnterBehavior(behavior)
|
||||
assertEquals(behavior, repository.physicalKeyboardEnterBehavior.first())
|
||||
assertEquals("keep-me", store.data.first()[unrelatedKey])
|
||||
assertEquals(true, store.data.first()[unrelatedBoolean])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.util.Base64
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatLargePasteTest {
|
||||
|
||||
@Test
|
||||
fun `text transport materializes large paste and keeps ordinary attachments`() {
|
||||
val paste = largePasteAttachment("line one\nline two")
|
||||
val image = Attachment("image/png", "aW1hZ2U=", "image.png", 5)
|
||||
|
||||
val prepared = prepareTextTransportAttachments(
|
||||
message = "Please review this context",
|
||||
attachments = listOf(paste, image),
|
||||
)
|
||||
|
||||
assertTrue(prepared.message.contains("line one\nline two"))
|
||||
assertTrue(prepared.message.contains("pasted-text.txt"))
|
||||
assertEquals(listOf(image), prepared.attachments)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large paste attachment is utf8 text with stable metadata`() {
|
||||
val attachment = largePasteAttachment("héllo")
|
||||
|
||||
assertEquals("text/plain; charset=utf-8", attachment.contentType)
|
||||
assertEquals("pasted-text.txt", attachment.fileName)
|
||||
assertEquals(6L, attachment.fileSize)
|
||||
assertTrue(attachment.isLargePaste)
|
||||
assertEquals("héllo", String(Base64.getDecoder().decode(attachment.content), Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,18 @@ import org.junit.Test
|
||||
*/
|
||||
class ChatMessageTest {
|
||||
|
||||
@Test
|
||||
fun messageReaction_replacesAndRetractsTheUsersTapback() {
|
||||
val agent = MessageReaction("🔥", "agent", 1.0)
|
||||
val heart = applyMessageReaction(listOf(agent), "❤️", at = 2.0)
|
||||
assertEquals(listOf(agent, MessageReaction("❤️", "user", 2.0)), heart)
|
||||
|
||||
val replaced = applyMessageReaction(heart, "😂", at = 3.0)
|
||||
assertEquals(listOf(agent, MessageReaction("😂", "user", 3.0)), replaced)
|
||||
|
||||
assertEquals(listOf(agent), applyMessageReaction(replaced, "😂", at = 4.0))
|
||||
}
|
||||
|
||||
// --- ChatMessage creation with defaults ---
|
||||
|
||||
@Test
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.util.Base64
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PersistentChatComposerDraftStoreTest {
|
||||
|
||||
@Test
|
||||
fun `draft and attachment survive store recreation`() = runTest {
|
||||
val root = Files.createTempDirectory("composer-drafts").toFile()
|
||||
try {
|
||||
val key = ChatComposerDraftKey("connection", "profile", "session")
|
||||
val attachment = Attachment(
|
||||
contentType = "text/plain; charset=utf-8",
|
||||
content = Base64.getEncoder().encodeToString("large pasted text".toByteArray()),
|
||||
fileName = "pasted-text.txt",
|
||||
fileSize = 17,
|
||||
isLargePaste = true,
|
||||
)
|
||||
PersistentChatComposerDraftStore(root).save(
|
||||
key,
|
||||
ChatComposerDraft(
|
||||
text = "survives process death",
|
||||
selectionStart = 4,
|
||||
selectionEnd = 9,
|
||||
context = ChatComposerDraftContext(quotedMessageId = "quote"),
|
||||
attachments = listOf(attachment),
|
||||
),
|
||||
)
|
||||
|
||||
val restored = PersistentChatComposerDraftStore(root).snapshot(key)
|
||||
|
||||
assertEquals("survives process death", restored.text)
|
||||
assertEquals(4, restored.selectionStart)
|
||||
assertEquals(9, restored.selectionEnd)
|
||||
assertEquals("quote", restored.context.quotedMessageId)
|
||||
assertEquals(listOf(attachment), restored.attachments)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session removal is exact and clears persisted blobs when unreferenced`() = runTest {
|
||||
val root = Files.createTempDirectory("composer-drafts").toFile()
|
||||
try {
|
||||
val store = PersistentChatComposerDraftStore(root)
|
||||
val removed = ChatComposerDraftKey("connection", "profile", "removed")
|
||||
val kept = ChatComposerDraftKey("connection", "profile", "kept")
|
||||
val attachment = Attachment(
|
||||
contentType = "text/plain",
|
||||
content = Base64.getEncoder().encodeToString("private draft".toByteArray()),
|
||||
fileName = "notes.txt",
|
||||
fileSize = 13,
|
||||
)
|
||||
store.save(removed, ChatComposerDraft(text = "remove", attachments = listOf(attachment)))
|
||||
store.save(kept, ChatComposerDraft(text = "keep"))
|
||||
|
||||
store.removeSession("connection", "profile", "removed")
|
||||
|
||||
assertTrue(store.snapshot(removed).isEmpty)
|
||||
assertEquals("keep", store.snapshot(kept).text)
|
||||
assertTrue(root.walkTopDown().none { it.isFile && it.extension == "blob" })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preparing large paste restores as a ready attachment`() = runTest {
|
||||
val root = Files.createTempDirectory("composer-drafts").toFile()
|
||||
try {
|
||||
val key = ChatComposerDraftKey("connection", "profile", "session")
|
||||
PersistentChatComposerDraftStore(root).save(
|
||||
key,
|
||||
ChatComposerDraft(
|
||||
attachments = listOf(
|
||||
Attachment(
|
||||
contentType = "text/plain; charset=utf-8",
|
||||
content = "",
|
||||
fileName = "pasted-text.txt",
|
||||
fileSize = 14,
|
||||
state = AttachmentState.LOADING,
|
||||
isLargePaste = true,
|
||||
composerId = "preparing",
|
||||
composerRawText = "unfinished app",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val restored = PersistentChatComposerDraftStore(root)
|
||||
.snapshot(key)
|
||||
.attachments
|
||||
.single()
|
||||
|
||||
assertEquals(AttachmentState.LOADED, restored.state)
|
||||
assertEquals("preparing", restored.composerId)
|
||||
assertEquals(
|
||||
"unfinished app",
|
||||
String(Base64.getDecoder().decode(restored.content), Charsets.UTF_8),
|
||||
)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProfileAvatarImageTest {
|
||||
@Test
|
||||
fun `oversized bitmap is encoded within upstream limit`() {
|
||||
val bitmap = Bitmap.createBitmap(1600, 1200, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(0xff336699.toInt())
|
||||
}
|
||||
|
||||
val encoded = encodeProfileAvatar(bitmap, 80_000)
|
||||
|
||||
assertNotNull(encoded)
|
||||
assertTrue(encoded!!.size <= 80_000)
|
||||
assertTrue(profileAvatarMime(encoded) in setOf("image/png", "image/jpeg"))
|
||||
val decoded = BitmapFactory.decodeByteArray(encoded, 0, encoded.size)
|
||||
assertTrue(maxOf(decoded.width, decoded.height) <= 1024)
|
||||
decoded.recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile avatar mime recognizes upstream formats`() {
|
||||
assertEquals("image/jpeg", profileAvatarMime(byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte())))
|
||||
assertEquals(
|
||||
"image/webp",
|
||||
profileAvatarMime("RIFF1234WEBP".toByteArray()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@@ -53,6 +55,42 @@ class ProfileIconStoreTest {
|
||||
assertEquals("/files/c.png", store.iconFlow("conn-2", null).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverAvatarsKeepDuplicateProfileNamesConnectionScoped() = runBlocking {
|
||||
store.setServerAvatar("conn-1", "operator", "/server/a.png")
|
||||
store.setServerAvatar("conn-2", "operator", "/server/b.png")
|
||||
|
||||
assertEquals("/server/a.png", store.serverAvatarFlow("conn-1", "operator").first())
|
||||
assertEquals("/server/b.png", store.serverAvatarFlow("conn-2", "operator").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearingServerAvatarDoesNotOverwriteLocalFallback() = runBlocking {
|
||||
store.setIcon("conn-1", "operator", "/local/icon.png")
|
||||
store.setServerAvatar("conn-1", "operator", "/server/avatar.png")
|
||||
store.setServerAvatar("conn-1", "operator", null)
|
||||
|
||||
assertNull(store.serverAvatarFlow("conn-1", "operator").first())
|
||||
assertEquals("/local/icon.png", store.iconFlow("conn-1", "operator").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localOverrideIsScopedAndDefaultsOff() = runBlocking {
|
||||
assertFalse(store.localOverrideFlow("conn-1", "operator").first())
|
||||
store.setLocalOverride("conn-1", "operator", true)
|
||||
|
||||
assertTrue(store.localOverrideFlow("conn-1", "operator").first())
|
||||
assertFalse(store.localOverrideFlow("conn-2", "operator").first())
|
||||
assertFalse(store.localOverrideFlow("conn-1", "other").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preferredIconUsesLocalOnlyWhenOverrideHasAnImage() {
|
||||
assertEquals("/server.png", preferredProfileIcon("/server.png", "/local.gif", false))
|
||||
assertEquals("/local.gif", preferredProfileIcon("/server.png", "/local.gif", true))
|
||||
assertEquals("/server.png", preferredProfileIcon("/server.png", null, true))
|
||||
}
|
||||
|
||||
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow<Preferences>(emptyPreferences())
|
||||
|
||||
|
||||
@@ -25,9 +25,14 @@ class ProfilePresentationStoreTest {
|
||||
fun orderAndHiddenProfilesRoundTripPerConnection() = runBlocking {
|
||||
store.setOrder("one", listOf("beta", "alpha"))
|
||||
store.setHidden("one", setOf("gamma"))
|
||||
store.setColors("one", mapOf("alpha" to "#356CFF"))
|
||||
|
||||
assertEquals(
|
||||
ProfilePresentation(order = listOf("beta", "alpha"), hidden = setOf("gamma")),
|
||||
ProfilePresentation(
|
||||
order = listOf("beta", "alpha"),
|
||||
hidden = setOf("gamma"),
|
||||
colors = mapOf("alpha" to "#356CFF"),
|
||||
),
|
||||
store.presentationFlow("one").first(),
|
||||
)
|
||||
assertEquals(ProfilePresentation(), store.presentationFlow("two").first())
|
||||
@@ -104,6 +109,7 @@ class ProfilePresentationStoreTest {
|
||||
fun clearRemovesOnlyOneConnectionsPreferences() = runBlocking {
|
||||
store.setOrder("one", listOf("beta"))
|
||||
store.setHidden("one", setOf("alpha"))
|
||||
store.setColors("one", mapOf("beta" to "#ED4E8B"))
|
||||
store.setOrder("two", listOf("gamma"))
|
||||
|
||||
store.clear("one")
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.ConnectionManager
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
|
||||
import io.mockk.mockk
|
||||
import java.util.concurrent.AbstractExecutorService
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Owner-level coverage for every production ConnectionPool.evictAll() teardown. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class NetworkShutdownOwnerTest {
|
||||
|
||||
@Test
|
||||
fun hermesApiClient_shutdownLeavesMainThread() {
|
||||
val teardown = TrackingExecutorService()
|
||||
val client = HermesApiClient(
|
||||
baseUrl = "https://hermes.example.test",
|
||||
apiKey = "test-key",
|
||||
okHttpClient = clientWith(teardown),
|
||||
)
|
||||
|
||||
client.shutdown()
|
||||
|
||||
teardown.assertShutdownOffMainThread()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardApiClient_shutdownLeavesMainThread() {
|
||||
val teardown = TrackingExecutorService()
|
||||
val client = DashboardApiClient(
|
||||
baseUrl = "https://hermes.example.test",
|
||||
okHttpClient = clientWith(teardown),
|
||||
)
|
||||
|
||||
client.shutdown()
|
||||
|
||||
teardown.assertShutdownOffMainThread()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionManager_shutdownLeavesMainThread() {
|
||||
val teardown = TrackingExecutorService()
|
||||
val manager = ConnectionManager(
|
||||
multiplexer = ChannelMultiplexer(),
|
||||
okHttpClientFactory = { clientWith(teardown) },
|
||||
)
|
||||
|
||||
manager.shutdown()
|
||||
|
||||
teardown.assertShutdownOffMainThread()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upstreamTransportController_connectionSwitchResetLeavesMainThread() {
|
||||
val dashboardUrl = "https://hermes.example.test"
|
||||
val teardown = TrackingExecutorService()
|
||||
val controller = UpstreamTransportController(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
activeConnectionIdProvider = { null },
|
||||
dashboardUrlProvider = { dashboardUrl },
|
||||
gatewayKeepAliveProvider = { false },
|
||||
dashboardHttpClientFactory = { _, _ -> clientWith(teardown) },
|
||||
)
|
||||
|
||||
controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
controller.resetGatewayForConnectionSwitch()
|
||||
|
||||
teardown.assertShutdownOffMainThread()
|
||||
}
|
||||
|
||||
private fun clientWith(executor: ExecutorService): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.dispatcher(Dispatcher(executor))
|
||||
.build()
|
||||
|
||||
private class TrackingExecutorService : AbstractExecutorService() {
|
||||
private val shutdown = AtomicBoolean(false)
|
||||
private val shutdownLatch = CountDownLatch(1)
|
||||
private val shutdownThread = AtomicReference<Thread>()
|
||||
|
||||
override fun shutdown() {
|
||||
shutdownThread.compareAndSet(null, Thread.currentThread())
|
||||
shutdown.set(true)
|
||||
shutdownLatch.countDown()
|
||||
}
|
||||
|
||||
override fun shutdownNow(): MutableList<Runnable> {
|
||||
shutdown()
|
||||
return mutableListOf()
|
||||
}
|
||||
|
||||
override fun isShutdown(): Boolean = shutdown.get()
|
||||
|
||||
override fun isTerminated(): Boolean = shutdown.get()
|
||||
|
||||
override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean =
|
||||
shutdownLatch.await(timeout, unit)
|
||||
|
||||
override fun execute(command: Runnable) = command.run()
|
||||
|
||||
fun assertShutdownOffMainThread() {
|
||||
assertSame(Looper.myLooper(), Looper.getMainLooper())
|
||||
assertTrue("owner did not shut its OkHttp dispatcher down", shutdownLatch.await(5, TimeUnit.SECONDS))
|
||||
assertNotSame(
|
||||
"owner performed OkHttp teardown on the main thread",
|
||||
Looper.getMainLooper().thread,
|
||||
shutdownThread.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ProactiveMessageHandlerTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkObject(ProactiveMessageNotifier)
|
||||
every {
|
||||
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
|
||||
} just Runs
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkObject(ProactiveMessageNotifier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification surfacing notifies even when matching Thread is open`() {
|
||||
val persisted = mutableListOf<ProactiveMessage>()
|
||||
val handler = ProactiveMessageHandler(context, toInbox = persisted::add).apply {
|
||||
injectIntoThread = { true }
|
||||
}
|
||||
|
||||
handler.onMessage(messageEnvelope(surfacing = "notification"))
|
||||
|
||||
assertEquals(1, persisted.size)
|
||||
verify(exactly = 1) {
|
||||
ProactiveMessageNotifier.notify(context, "Hermes", "ready", "m-1", "phone")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inbox surfacing persists silently`() {
|
||||
val handler = ProactiveMessageHandler(context, toInbox = {}).apply {
|
||||
injectIntoThread = { true }
|
||||
}
|
||||
|
||||
handler.onMessage(messageEnvelope(surfacing = "inbox"))
|
||||
|
||||
verify(exactly = 0) {
|
||||
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session surfacing suppresses notification only when a destination accepts`() {
|
||||
val accepted = ProactiveMessageHandler(context, toInbox = {}).apply {
|
||||
injectIntoThread = { false }
|
||||
toSession = { true }
|
||||
}
|
||||
accepted.onMessage(messageEnvelope(surfacing = "session"))
|
||||
verify(exactly = 0) {
|
||||
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
|
||||
}
|
||||
|
||||
val unavailable = ProactiveMessageHandler(context, toInbox = {}).apply {
|
||||
injectIntoThread = { false }
|
||||
toSession = { false }
|
||||
}
|
||||
unavailable.onMessage(messageEnvelope(surfacing = "session"))
|
||||
verify(exactly = 1) {
|
||||
ProactiveMessageNotifier.notify(context, "Hermes", "ready", "m-1", "phone")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued delivery is persisted with provenance and summarized once`() {
|
||||
val persisted = mutableListOf<ProactiveMessage>()
|
||||
val summaries = mutableListOf<Int>()
|
||||
val handler = ProactiveMessageHandler(context, toInbox = persisted::add).apply {
|
||||
onBacklogDelivered = summaries::add
|
||||
}
|
||||
|
||||
handler.onMessage(messageEnvelope(surfacing = "inbox", queuedDelivery = true))
|
||||
handler.onMessage(
|
||||
Envelope(
|
||||
channel = "proactive",
|
||||
type = "proactive.backlog.complete",
|
||||
payload = buildJsonObject { put("count", JsonPrimitive(1)) },
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(persisted.single().arrivedWhileAway)
|
||||
assertEquals(listOf(1), summaries)
|
||||
}
|
||||
|
||||
private fun messageEnvelope(
|
||||
surfacing: String,
|
||||
queuedDelivery: Boolean = false,
|
||||
) = Envelope(
|
||||
channel = "proactive",
|
||||
type = "phone.message",
|
||||
payload = buildJsonObject {
|
||||
put("message_id", JsonPrimitive("m-1"))
|
||||
put("chat_id", JsonPrimitive("phone"))
|
||||
put("text", JsonPrimitive("ready"))
|
||||
put("title", JsonPrimitive("Hermes"))
|
||||
put("surfacing", JsonPrimitive(surfacing))
|
||||
if (queuedDelivery) put("queued_delivery", JsonPrimitive(true))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import okhttp3.Request
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialHeadersTest {
|
||||
@Test
|
||||
fun harmlessOuterHorizontalWhitespaceIsNormalized() {
|
||||
val request = Request.Builder()
|
||||
.url("https://example.test/")
|
||||
.bearerAuthorization(" \tcredential-value\t ", "API credential")
|
||||
.build()
|
||||
|
||||
assertEquals("Bearer credential-value", request.header("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multilineAndEmbeddedWhitespaceAreRejectedWithoutCredentialDisclosure() {
|
||||
for (raw in listOf("first\nsecond", "first\r\nsecond", "first second", "first\tsecond")) {
|
||||
val failure = assertThrows(InvalidCredentialException::class.java) {
|
||||
Request.Builder()
|
||||
.url("https://example.test/")
|
||||
.bearerAuthorization(raw, "API credential")
|
||||
}
|
||||
assertEquals(
|
||||
"Invalid API credential — enter or import a single-line value.",
|
||||
failure.message,
|
||||
)
|
||||
assertFalse(failure.message.orEmpty().contains("first"))
|
||||
assertFalse(failure.message.orEmpty().contains("second"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,6 +975,49 @@ class ChatHandlerTest {
|
||||
assertEquals(42L, handler.messages.value.last().rowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_hydratesPersistedReactionsAndLetsServerWin() {
|
||||
fun metadata(emoji: String) = buildJsonObject {
|
||||
put("reactions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("emoji", emoji)
|
||||
put("author", "user")
|
||||
put("at", 1_700_000_000.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "assistant-1",
|
||||
rowId = 42L,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Reply"),
|
||||
displayMetadata = metadata("❤️"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("❤️", handler.messages.value.single().reactions.single().emoji)
|
||||
|
||||
handler.mutateMessage("assistant-1") { message ->
|
||||
message.copy(reactions = listOf(com.hermesandroid.relay.data.MessageReaction("👍", "user", 2.0)))
|
||||
}
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "assistant-1",
|
||||
rowId = 42L,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Reply"),
|
||||
displayMetadata = metadata("😂"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("😂", handler.messages.value.single().reactions.single().emoji)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rebindSurvivorUserRowIds_replacesPrefixAndClearsUnboundTurns() {
|
||||
handler.loadMessageHistory(
|
||||
@@ -994,6 +1037,37 @@ class ChatHandlerTest {
|
||||
assertEquals(listOf("user-1", "assistant-1", "user-2"), messages.map { it.uiKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasSafeGatewayRewindAddress_allowsWhollyLegacyHistory() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "legacy-1", role = "user", content = JsonPrimitive("First")),
|
||||
MessageItem(id = "legacy-2", role = "user", content = JsonPrimitive("Second")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(handler.hasSafeGatewayRewindAddress("legacy-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasSafeGatewayRewindAddress_requiresSelectedIdInDurableHistory() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "legacy-1", role = "user", content = JsonPrimitive("First")),
|
||||
MessageItem(
|
||||
id = "durable-2",
|
||||
rowId = 73L,
|
||||
role = "user",
|
||||
content = JsonPrimitive("Second"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(handler.hasSafeGatewayRewindAddress("legacy-1"))
|
||||
assertTrue(handler.hasSafeGatewayRewindAddress("durable-2"))
|
||||
assertFalse(handler.hasSafeGatewayRewindAddress("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileInterimMessage_collapsesProvisionalFinalBubble() {
|
||||
handler.addPlaceholderMessage(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CronManagementTest {
|
||||
@Test
|
||||
fun `finite repeat accepts bounded whole numbers and optional blank`() {
|
||||
assertNull(parseFiniteRepeat(" ").getOrThrow())
|
||||
assertEquals(2, parseFiniteRepeat("2").getOrThrow())
|
||||
assertEquals(999, parseFiniteRepeat("999").getOrThrow())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finite repeat never coerces unsafe values to unlimited`() {
|
||||
assertTrue(parseFiniteRepeat("0").isFailure)
|
||||
assertTrue(parseFiniteRepeat("-2").isFailure)
|
||||
assertTrue(parseFiniteRepeat("1000").isFailure)
|
||||
assertTrue(parseFiniteRepeat("two").isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft validation trims fields and preserves explicit profile`() {
|
||||
val result = CronCreationDraft(
|
||||
name = " Morning brief ",
|
||||
schedule = " every 1d ",
|
||||
prompt = " Summarize updates ",
|
||||
repeat = 3,
|
||||
profile = " work ",
|
||||
).validated().getOrThrow()
|
||||
|
||||
assertEquals("Morning brief", result.name)
|
||||
assertEquals("every 1d", result.schedule)
|
||||
assertEquals("Summarize updates", result.prompt)
|
||||
assertEquals("work", result.profile)
|
||||
}
|
||||
}
|
||||
+31
@@ -35,6 +35,8 @@ class DashboardStatusTopologyTest {
|
||||
assertEquals(emptyList<String>(), status.profiles)
|
||||
assertEquals(false, status.componentHealth.supported)
|
||||
assertEquals(emptyList<String>(), status.componentHealth.components.map { it.name })
|
||||
assertNull(status.memory)
|
||||
assertNull(status.disk)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -70,4 +72,33 @@ class DashboardStatusTopologyTest {
|
||||
assertEquals(3, platforms.configured)
|
||||
assertEquals(1, platforms.connected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseStatus_readsAuthoritativeResourcePressureBlocks() {
|
||||
val status = DashboardApiClient.parseStatus(
|
||||
json.decodeFromString<JsonObject>(
|
||||
"""{
|
||||
"auth_required": false,
|
||||
"memory": {
|
||||
"pressure": "critical",
|
||||
"system_available_mb": 48,
|
||||
"last_boot_suspected_oom": true,
|
||||
"boot_id": "2026-08-15T10:00:00Z"
|
||||
},
|
||||
"disk": {
|
||||
"pressure": "elevated",
|
||||
"free_mb": 384,
|
||||
"used_percent": 92.5
|
||||
}
|
||||
}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("critical", status.memory?.pressure)
|
||||
assertEquals(48, status.memory?.systemAvailableMb)
|
||||
assertEquals(true, status.memory?.lastBootSuspectedOom)
|
||||
assertEquals("elevated", status.disk?.pressure)
|
||||
assertEquals(384, status.disk?.freeMb)
|
||||
assertEquals(92.5, status.disk?.usedPercent)
|
||||
}
|
||||
}
|
||||
|
||||
+662
-19
@@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -60,6 +61,13 @@ class GatewayClientHarness(
|
||||
var failTicketMint = false
|
||||
var resumeFails = false
|
||||
|
||||
/** Authoritative profile owner echoed by session results; null follows request. */
|
||||
@Volatile
|
||||
var sessionProfileOverride: String? = null
|
||||
|
||||
@Volatile
|
||||
var omitSessionProfileMetadata = false
|
||||
|
||||
@Volatile
|
||||
var recoveryRunning = false
|
||||
|
||||
@@ -108,6 +116,13 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var approvalMode = "smart"
|
||||
|
||||
@Volatile
|
||||
var modelConfirmationMessage: String? = null
|
||||
val modelsRequiringConfirmation: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
@Volatile
|
||||
var createdSessionProfileName: String? = null
|
||||
|
||||
/** Config keys rejected with the older-gateway unknown-key response. */
|
||||
val unsupportedConfigKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
@@ -124,12 +139,74 @@ class GatewayClientHarness(
|
||||
put("dataUri", "data:image/png;base64,iVBORw0KGgo=")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var petInfoPayload: JsonObject = buildJsonObject {
|
||||
put("enabled", true)
|
||||
put("slug", "boba")
|
||||
put("displayName", "Boba")
|
||||
put("mime", "image/png")
|
||||
put("spritesheetBase64", "iVBORw0KGgo=")
|
||||
put("spritesheetRevision", "123:8")
|
||||
put("frameW", 192)
|
||||
put("frameH", 208)
|
||||
put("framesPerState", 8)
|
||||
put("framesByState", buildJsonObject { put("idle", 6); put("run", 8) })
|
||||
put("framesByRow", buildJsonObject { put("idle", 6); put("running", 8) })
|
||||
put("loopMs", 1100)
|
||||
put("scale", 0.75)
|
||||
put("stateRows", JsonArray(listOf(JsonPrimitive("idle"), JsonPrimitive("running"))))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var petGalleryPayload: JsonObject = buildJsonObject {
|
||||
put("enabled", true)
|
||||
put("active", "boba")
|
||||
put("pets", JsonArray(listOf(buildJsonObject {
|
||||
put("slug", "boba")
|
||||
put("displayName", "Boba")
|
||||
put("installed", true)
|
||||
put("spritesheetUrl", "https://assets.petdex.dev/pets/boba/sprites.png")
|
||||
put("curated", true)
|
||||
put("generated", false)
|
||||
})))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var fileAttachPayload: JsonObject = buildJsonObject {
|
||||
put("attached", true)
|
||||
put("ref_text", "@file:notes.txt")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profilesListPayload: JsonObject = buildJsonObject {
|
||||
put("profiles", JsonArray(listOf(buildJsonObject {
|
||||
put("name", "operator")
|
||||
put("model", "gpt-5.6")
|
||||
put("provider", "openai")
|
||||
put("description", "Android operator")
|
||||
put("skill_count", 3)
|
||||
put("has_avatar", true)
|
||||
put("ui_meta", buildJsonObject { put("accent", "#ff5500") })
|
||||
})))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profileCreatePayload: JsonObject = buildJsonObject {
|
||||
put("ok", true)
|
||||
put("name", "operator")
|
||||
put("soul_written", true)
|
||||
put("model_set", true)
|
||||
put("mirrored", buildJsonObject {
|
||||
put("env", true)
|
||||
put("auth", "shared")
|
||||
put("model_inherited", false)
|
||||
put("voice", true)
|
||||
})
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profileGetAssetPayload: JsonObject = buildJsonObject { put("found", false) }
|
||||
|
||||
/** Methods answered with JSON-RPC -32601 — exercises the legacy-name fallback. */
|
||||
val methodNotFound: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
@@ -209,12 +286,26 @@ class GatewayClientHarness(
|
||||
"session.create" -> buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
put("stored_session_id", "20260612_120000_abc123")
|
||||
if (!omitSessionProfileMetadata) {
|
||||
put("info", buildJsonObject {
|
||||
put(
|
||||
"profile_name",
|
||||
createdSessionProfileName
|
||||
?: sessionProfileOverride
|
||||
?: (params["profile"] as? JsonPrimitive)?.contentOrNull
|
||||
?: "default",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
"session.resume" ->
|
||||
if (resumeFails) null
|
||||
else {
|
||||
val storedId = (params["session_id"] as? JsonPrimitive)?.contentOrNull
|
||||
recoveryPayload(resumeLiveSessionIds[storedId] ?: "live-resumed")
|
||||
recoveryPayload(
|
||||
resumeLiveSessionIds[storedId] ?: "live-resumed",
|
||||
(params["profile"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
"session.activate" -> recoveryPayload(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
|
||||
@@ -273,10 +364,14 @@ class GatewayClientHarness(
|
||||
"image.attach_bytes", "image.attach.bytes" -> buildJsonObject {
|
||||
put("attached", true)
|
||||
put("count", 1)
|
||||
put("path", "/session/images/upload.png")
|
||||
}
|
||||
"pdf.attach" -> buildJsonObject {
|
||||
put("attached", true)
|
||||
put("pages", 1)
|
||||
put("pages", JsonArray(listOf(buildJsonObject {
|
||||
put("path", "/session/images/page-1.png")
|
||||
put("page", 1)
|
||||
})))
|
||||
}
|
||||
"file.attach" -> fileAttachPayload
|
||||
"clarify.respond", "sudo.respond", "secret.respond" ->
|
||||
@@ -308,6 +403,22 @@ class GatewayClientHarness(
|
||||
})))
|
||||
put("toolsets_pinned", true)
|
||||
}
|
||||
"profiles.list" -> profilesListPayload
|
||||
"profiles.create" -> profileCreatePayload
|
||||
"profiles.get_asset" -> profileGetAssetPayload
|
||||
"profiles.set_asset" -> buildJsonObject {
|
||||
put("ok", true)
|
||||
put("asset", "avatar")
|
||||
val data = (params["data"] as? JsonPrimitive)?.contentOrNull
|
||||
put(
|
||||
"size",
|
||||
if ((params["clear"] as? JsonPrimitive)?.booleanOrNull == true) {
|
||||
0
|
||||
} else {
|
||||
java.util.Base64.getDecoder().decode(data.orEmpty().substringAfter(";base64,")).size
|
||||
},
|
||||
)
|
||||
}
|
||||
"profiles.configure" -> buildJsonObject {
|
||||
put("ok", false)
|
||||
put("applied", buildJsonObject {
|
||||
@@ -315,9 +426,14 @@ class GatewayClientHarness(
|
||||
if (params.containsKey("provider")) put("model", false)
|
||||
if (params.containsKey("disabled_skills")) put("skills", true)
|
||||
if (params.containsKey("enabled_toolsets")) put("toolsets", true)
|
||||
if (params.containsKey("enabled_mcp_servers")) put("mcp_servers", true)
|
||||
if (params.containsKey("ui_meta")) put("ui_meta", false)
|
||||
})
|
||||
}
|
||||
"pet.thumb" -> petThumbPayload
|
||||
"pet.info" -> petInfoPayload
|
||||
"pet.gallery" -> petGalleryPayload
|
||||
"pet.select", "pet.disable" -> buildJsonObject { put("ok", true) }
|
||||
"model.options" -> buildJsonObject {
|
||||
put("model", "gpt-5.5")
|
||||
put("provider", "openai")
|
||||
@@ -353,9 +469,22 @@ 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 ?: "")
|
||||
"model" -> {
|
||||
val confirmation = modelConfirmationMessage
|
||||
val requestedValue = (params["value"] as? JsonPrimitive)?.contentOrNull ?: ""
|
||||
val confirmed = (params["confirm_expensive_model"] as? JsonPrimitive)
|
||||
?.booleanOrNull == true
|
||||
if (confirmation != null && requestedValue in modelsRequiringConfirmation && !confirmed) {
|
||||
buildJsonObject {
|
||||
put("confirm_required", true)
|
||||
put("confirm_message", confirmation)
|
||||
}
|
||||
} else {
|
||||
buildJsonObject {
|
||||
put("key", "model")
|
||||
put("value", requestedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
"reasoning" -> {
|
||||
reasoningEffort = (params["value"] as? JsonPrimitive)?.contentOrNull ?: reasoningEffort
|
||||
@@ -403,12 +532,17 @@ class GatewayClientHarness(
|
||||
|
||||
private val autoRespondEnabled = autoRespond
|
||||
|
||||
private fun recoveryPayload(sessionId: String): JsonObject = buildJsonObject {
|
||||
private fun recoveryPayload(sessionId: String, requestedProfile: String? = null): JsonObject = buildJsonObject {
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
recoveryProject?.let { project ->
|
||||
put("info", buildJsonObject { put("project", project) })
|
||||
if (!omitSessionProfileMetadata || recoveryProject != null) {
|
||||
put("info", buildJsonObject {
|
||||
if (!omitSessionProfileMetadata) {
|
||||
put("profile_name", sessionProfileOverride ?: requestedProfile ?: "default")
|
||||
}
|
||||
recoveryProject?.let { put("project", it) }
|
||||
})
|
||||
}
|
||||
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
||||
if (recoveryRunning || recoveryInflightStreaming != null || recoveryInflightError != null) {
|
||||
@@ -628,6 +762,17 @@ class GatewayChatClientTest {
|
||||
turnIdleTimeoutMs = turnIdleTimeoutMs,
|
||||
)
|
||||
|
||||
private fun awaitCondition(
|
||||
timeoutMs: Long = 5_000L,
|
||||
condition: () -> Boolean,
|
||||
) {
|
||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
|
||||
while (!condition() && System.nanoTime() < deadline) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue("condition did not settle within ${timeoutMs}ms", condition())
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a client with shortened timeout seams. Mints a FRESH scope:
|
||||
* shutdown() cancels the scope's Job, and the replacement client must
|
||||
@@ -710,6 +855,198 @@ class GatewayChatClientTest {
|
||||
assertTrue(harness.rpcLog.none { it.first == "profiles.describe" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile configure method not found becomes sticky read only capability`() = runBlocking {
|
||||
client.describeProfile("operator").getOrThrow()
|
||||
harness.methodNotFound += "profiles.configure"
|
||||
|
||||
assertTrue(
|
||||
client.configureProfile(
|
||||
"operator",
|
||||
com.hermesandroid.relay.data.GatewayProfilePatch(description = "Updated"),
|
||||
).exceptionOrNull() is com.hermesandroid.relay.data.GatewayProfileEditorUnsupportedException,
|
||||
)
|
||||
harness.rpcLog.clear()
|
||||
assertTrue(
|
||||
client.configureProfile(
|
||||
"operator",
|
||||
com.hermesandroid.relay.data.GatewayProfilePatch(description = "Again"),
|
||||
).exceptionOrNull() is com.hermesandroid.relay.data.GatewayProfileEditorUnsupportedException,
|
||||
)
|
||||
assertTrue(harness.rpcLog.none { it.first == "profiles.configure" })
|
||||
assertEquals("operator", client.describeProfile("operator").getOrThrow().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile list requests bounded roster metadata without sessions`() = runBlocking {
|
||||
val profiles = client.listProfiles().getOrThrow()
|
||||
|
||||
assertEquals(1, profiles.size)
|
||||
assertEquals("operator", profiles.single().name)
|
||||
assertEquals("openai", profiles.single().provider)
|
||||
assertTrue(profiles.single().hasAvatar)
|
||||
assertEquals("#ff5500", (profiles.single().uiMeta["accent"] as JsonPrimitive).content)
|
||||
assertEquals(false, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile list drops oversized ui meta without dropping profile`() = runBlocking {
|
||||
harness.profilesListPayload = buildJsonObject {
|
||||
put("profiles", JsonArray(listOf(buildJsonObject {
|
||||
put("name", "operator")
|
||||
put("model", "gpt")
|
||||
put("ui_meta", buildJsonObject { put("blob", "x".repeat(65_537)) })
|
||||
})))
|
||||
}
|
||||
|
||||
val profile = client.listProfiles().getOrThrow().single()
|
||||
assertTrue(profile.uiMeta.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile create serializes every auth choice explicitly`() = runBlocking {
|
||||
val cases = listOf(
|
||||
com.hermesandroid.relay.data.GatewayProfileAuthChoice.Shared to (true to true),
|
||||
com.hermesandroid.relay.data.GatewayProfileAuthChoice.Copied to (true to false),
|
||||
com.hermesandroid.relay.data.GatewayProfileAuthChoice.Isolated to (false to false),
|
||||
)
|
||||
cases.forEach { (choice, _) ->
|
||||
client.createProfile(
|
||||
com.hermesandroid.relay.data.GatewayProfileCreateRequest(
|
||||
name = "operator",
|
||||
description = "Mobile operator",
|
||||
cloneFrom = "default",
|
||||
noSkills = false,
|
||||
soul = "# Operator",
|
||||
provider = "openai",
|
||||
model = "gpt-5.6",
|
||||
authChoice = choice,
|
||||
),
|
||||
).getOrThrow()
|
||||
}
|
||||
harness.awaitRpcCount("profiles.create", cases.size).zip(cases).forEach { (params, case) ->
|
||||
val expected = case.second
|
||||
assertEquals(expected.first, (params["mirror_credentials"] as JsonPrimitive).booleanOrNull)
|
||||
assertEquals(expected.second, (params["share_auth"] as JsonPrimitive).booleanOrNull)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile create exposes partial server results`() = runBlocking {
|
||||
harness.profileCreatePayload = buildJsonObject {
|
||||
put("ok", true)
|
||||
put("name", "operator")
|
||||
put("soul_written", false)
|
||||
put("model_set", false)
|
||||
put("mirrored", buildJsonObject {
|
||||
put("env", false)
|
||||
put("auth", false)
|
||||
put("voice", false)
|
||||
})
|
||||
}
|
||||
val request = com.hermesandroid.relay.data.GatewayProfileCreateRequest(
|
||||
name = "operator",
|
||||
soul = "# Operator",
|
||||
provider = "openai",
|
||||
model = "gpt-5.6",
|
||||
authChoice = com.hermesandroid.relay.data.GatewayProfileAuthChoice.Copied,
|
||||
)
|
||||
|
||||
val partial = client.createProfile(request).getOrThrow().partialMessages(request)
|
||||
assertTrue(partial.any { it.contains("SOUL") })
|
||||
assertTrue(partial.any { it.contains("model") })
|
||||
assertTrue(partial.any { it.contains("credential source") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile asset get distinguishes absent and validates bytes`() = runBlocking {
|
||||
assertNull(client.getProfileAvatar("operator").getOrThrow())
|
||||
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
harness.profileGetAssetPayload = buildJsonObject {
|
||||
put("found", true)
|
||||
put("mime", "image/png")
|
||||
put("size", png.size)
|
||||
put("data", "data:image/png;base64,${java.util.Base64.getEncoder().encodeToString(png)}")
|
||||
}
|
||||
|
||||
val asset = client.getProfileAvatar("operator").getOrThrow()
|
||||
assertEquals("image/png", asset?.mime)
|
||||
assertTrue(png.contentEquals(asset?.data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile asset get rejects malformed and magic mismatch responses`() = runBlocking {
|
||||
harness.profileGetAssetPayload = buildJsonObject {
|
||||
put("found", true)
|
||||
put("mime", "image/png")
|
||||
put("data", "data:image/png;base64,not-base64!")
|
||||
}
|
||||
assertTrue(client.getProfileAvatar("operator").isFailure)
|
||||
|
||||
harness.profileGetAssetPayload = buildJsonObject {
|
||||
put("found", true)
|
||||
put("mime", "image/png")
|
||||
put("data", "data:image/png;base64,${java.util.Base64.getEncoder().encodeToString("not an image".toByteArray())}")
|
||||
}
|
||||
assertTrue(client.getProfileAvatar("operator").isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile asset set accepts exact boundary rejects overflow and clears`() = runBlocking {
|
||||
val boundary = ByteArray(GatewayChatClient.PROFILE_AVATAR_MAX_BYTES)
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
|
||||
.copyInto(boundary)
|
||||
assertTrue(client.setProfileAvatar("operator", boundary).isSuccess)
|
||||
assertTrue(client.setProfileAvatar("operator", boundary.copyOf(boundary.size + 1)).isFailure)
|
||||
assertEquals(0, client.clearProfileAvatar("operator").getOrThrow())
|
||||
assertEquals(
|
||||
true,
|
||||
(harness.awaitRpcCount("profiles.set_asset", 2).last()["clear"] as JsonPrimitive).booleanOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile management method not found becomes sticky per operation`() = runBlocking {
|
||||
harness.methodNotFound += "profiles.list"
|
||||
assertTrue(client.listProfiles().exceptionOrNull() is com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException)
|
||||
harness.rpcLog.clear()
|
||||
assertTrue(client.listProfiles().isFailure)
|
||||
assertTrue(harness.rpcLog.none { it.first == "profiles.list" })
|
||||
|
||||
harness.methodNotFound += "profiles.get_asset"
|
||||
assertTrue(client.getProfileAvatar("operator").isFailure)
|
||||
harness.rpcLog.clear()
|
||||
assertTrue(client.getProfileAvatar("operator").isFailure)
|
||||
assertTrue(harness.rpcLog.none { it.first == "profiles.get_asset" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile configure reports ui meta partial apply`() = runBlocking {
|
||||
client.describeProfile("operator").getOrThrow()
|
||||
val result = client.configureProfile(
|
||||
"operator",
|
||||
com.hermesandroid.relay.data.GatewayProfilePatch(
|
||||
uiMeta = buildJsonObject { put("accent", "#ff5500") },
|
||||
),
|
||||
).getOrThrow()
|
||||
assertEquals(setOf(com.hermesandroid.relay.data.GatewayProfileSection.UiMeta), result.failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile ui meta rejects embedded image data`() = runBlocking {
|
||||
client.describeProfile("operator").getOrThrow()
|
||||
listOf("data:image/png;base64,AAAA", "iVBORw0KGgoAAAA", "UEsDBAAAA").forEach { embedded ->
|
||||
val result = client.configureProfile(
|
||||
"operator",
|
||||
com.hermesandroid.relay.data.GatewayProfilePatch(
|
||||
uiMeta = buildJsonObject { put("asset", embedded) },
|
||||
),
|
||||
)
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
assertTrue(harness.rpcLog.none { it.first == "profiles.configure" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `happy path - ticket, ready, create, submit, stream, complete`() {
|
||||
val r = Recorder()
|
||||
@@ -1537,6 +1874,34 @@ class GatewayChatClientTest {
|
||||
assertEquals("mizu", (create["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create rejects a different authoritative profile owner`() {
|
||||
val r = Recorder()
|
||||
client.sessionProfileProvider = { "mizu" }
|
||||
harness.sessionProfileOverride = "default"
|
||||
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
awaitCondition { r.preflightFailures.isNotEmpty() }
|
||||
|
||||
assertTrue(r.preflightFailures.single().contains("refusing to use it as selected profile 'mizu'"))
|
||||
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create rejects older gateway without profile ownership metadata`() {
|
||||
val r = Recorder()
|
||||
client.sessionProfileProvider = { "mizu" }
|
||||
harness.omitSessionProfileMetadata = true
|
||||
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
awaitCondition { r.preflightFailures.isNotEmpty() }
|
||||
|
||||
assertTrue(r.preflightFailures.single().contains("did not confirm profile ownership"))
|
||||
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create omits profile when none is selected`() {
|
||||
val r = Recorder()
|
||||
@@ -1549,12 +1914,10 @@ class GatewayChatClientTest {
|
||||
assertEquals(null, create["profile"])
|
||||
}
|
||||
|
||||
// --- Model-bound sessions (upstream tui_gateway: session.create honors
|
||||
// `model`/`provider` → the new session's model_override). Without this a
|
||||
// fresh chat ignores the in-chat picker and runs on the global default. ---
|
||||
// --- Confirmation-safe model sessions ---
|
||||
|
||||
@Test
|
||||
fun `session create binds the picked model and provider`() {
|
||||
fun `session create never binds an unconfirmed model or provider`() {
|
||||
val r = Recorder()
|
||||
client.sessionModelProvider = { GatewaySessionModel("grok-4.3", "xai") }
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
@@ -1562,12 +1925,12 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("grok-4.3", (create["model"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("xai", (create["provider"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(null, create["model"])
|
||||
assertEquals(null, create["provider"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create binds model without provider when provider is null`() {
|
||||
fun `session create never binds an unconfirmed providerless model`() {
|
||||
val r = Recorder()
|
||||
client.sessionModelProvider = { GatewaySessionModel("gpt-5.5", null) }
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
@@ -1575,7 +1938,7 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("gpt-5.5", (create["model"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(null, create["model"])
|
||||
assertEquals(null, create["provider"])
|
||||
}
|
||||
|
||||
@@ -1609,7 +1972,8 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("grok-4.3", (create["model"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(create.containsKey("model"))
|
||||
assertFalse(create.containsKey("provider"))
|
||||
assertEquals("high", (create["reasoning_effort"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(true, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
|
||||
}
|
||||
@@ -1657,7 +2021,8 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("gpt-5.5", (create["model"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(create.containsKey("model"))
|
||||
assertFalse(create.containsKey("provider"))
|
||||
assertFalse(create.containsKey("reasoning_effort"))
|
||||
assertFalse(create.containsKey("fast"))
|
||||
}
|
||||
@@ -1880,6 +2245,65 @@ class GatewayChatClientTest {
|
||||
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachment failure never falls through to a transport that drops the file`() {
|
||||
harness.methodNotFound.add("file.attach")
|
||||
val r = Recorder()
|
||||
val attachmentFailures = mutableListOf<String>()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "use both files",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
attachments = listOf(
|
||||
GatewayAttachment("shot.png", "QQ==", "png", "image/png", sizeBytes = 1),
|
||||
GatewayAttachment("notes.txt", "Qg==", "txt", "text/plain", sizeBytes = 1),
|
||||
),
|
||||
onAttachmentFailure = {
|
||||
attachmentFailures += it
|
||||
r.completeLatch.countDown()
|
||||
},
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(attachmentFailures.single().contains("file.attach"))
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
|
||||
val detach = harness.awaitRpc("image.detach")
|
||||
assertEquals("/session/images/upload.png", (detach["path"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachment sizes are derived from base64 and bounded before upload`() {
|
||||
assertEquals(1L, decodedBase64Size("QQ=="))
|
||||
assertEquals(2L, decodedBase64Size("QUI="))
|
||||
assertEquals(3L, decodedBase64Size("QUJD"))
|
||||
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "oversized",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
attachments = listOf(
|
||||
GatewayAttachment(
|
||||
name = "huge.png",
|
||||
base64 = "QQ==",
|
||||
ext = "png",
|
||||
contentType = "image/png",
|
||||
sizeBytes = 25L * 1024L * 1024L + 1L,
|
||||
),
|
||||
),
|
||||
onAttachmentFailure = { r.completeLatch.countDown() },
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(harness.rpcLog.none { it.first.startsWith("image.attach") })
|
||||
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
// --- Ask responders ---
|
||||
|
||||
@Test
|
||||
@@ -1978,6 +2402,43 @@ class GatewayChatClientTest {
|
||||
assertTrue(harness.ticketMints.get() >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cron creation uses upstream manage rpc with finite repeat and profile`() {
|
||||
val result = runBlocking {
|
||||
client.createCronJob(
|
||||
CronCreationDraft(
|
||||
name = "Two reports",
|
||||
schedule = "every 1h",
|
||||
prompt = "Send a concise report",
|
||||
repeat = 2,
|
||||
profile = "work",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val params = harness.awaitRpc("cron.manage")
|
||||
assertEquals("add", (params["action"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("Two reports", (params["name"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("every 1h", (params["schedule"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("Send a concise report", (params["prompt"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(2, (params["repeat"] as? JsonPrimitive)?.intOrNull)
|
||||
assertEquals("work", (params["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid finite repeat is rejected before opening gateway`() {
|
||||
val result = runBlocking {
|
||||
client.createCronJob(
|
||||
CronCreationDraft("Bad", "every 1h", "Run", repeat = 0),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(0, harness.ticketMints.get())
|
||||
assertTrue(harness.rpcLog.none { it.first == "cron.manage" })
|
||||
}
|
||||
|
||||
// --- Pet thumbnails ---
|
||||
|
||||
@Test
|
||||
@@ -1986,7 +2447,7 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.reactToNewest("assistant", "👍") }
|
||||
val result = runBlocking { client.reactToMessage(null, "assistant", "👍") }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val params = harness.awaitRpc("message.react")
|
||||
@@ -1997,6 +2458,20 @@ class GatewayChatClientTest {
|
||||
assertFalse("row_id" in params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message reaction targets durable row when history provides it`() {
|
||||
client.sendTurn("stored-1", "hi", null, Recorder().callbacks) {}
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.reactToMessage(42L, "assistant", "❤️") }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val params = harness.awaitRpc("message.react")
|
||||
assertEquals(42L, (params["row_id"] as? JsonPrimitive)?.longOrNull)
|
||||
assertFalse("newest_role" in params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet thumbnail connects on demand and sends upstream params`() {
|
||||
client.sessionProfileProvider = { "work" }
|
||||
@@ -2075,6 +2550,81 @@ class GatewayChatClientTest {
|
||||
assertTrue(wrongMediaType.exceptionOrNull() is GatewayRpcException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet info parses the upstream renderer payload with profile and revision`() = runBlocking {
|
||||
val info = client.petInfo(profile = "work", knownRevision = "old:1").getOrThrow()
|
||||
|
||||
val params = harness.awaitRpc("pet.info")
|
||||
assertEquals("work", (params["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("old:1", (params["knownRevision"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertTrue(info.enabled)
|
||||
assertEquals("boba", info.slug)
|
||||
assertEquals("123:8", info.spritesheetRevision)
|
||||
assertEquals(192, info.frameWidth)
|
||||
assertEquals(8, info.framesByRow["running"])
|
||||
assertArrayEquals(java.util.Base64.getDecoder().decode("iVBORw0KGgo="), info.spritesheet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet info accepts upstream unchanged response without duplicate sheet`() = runBlocking {
|
||||
harness.petInfoPayload = buildJsonObject {
|
||||
put("enabled", true)
|
||||
put("slug", "boba")
|
||||
put("displayName", "Boba")
|
||||
put("spritesheetRevision", "123:8")
|
||||
put("spritesheetUnchanged", true)
|
||||
put("frameW", 192)
|
||||
put("frameH", 208)
|
||||
put("framesPerState", 8)
|
||||
put("loopMs", 1100)
|
||||
put("stateRows", JsonArray(listOf(JsonPrimitive("idle"))))
|
||||
}
|
||||
|
||||
val info = client.petInfo(knownRevision = "123:8").getOrThrow()
|
||||
|
||||
assertTrue(info.spritesheetUnchanged)
|
||||
assertNull(info.spritesheet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet gallery and mutations preserve profile scope`() = runBlocking {
|
||||
val gallery = client.petGallery(profile = "work", localOnly = true).getOrThrow()
|
||||
assertEquals("boba", gallery.active)
|
||||
assertEquals("Boba", gallery.pets.single().displayName)
|
||||
val galleryParams = harness.awaitRpc("pet.gallery")
|
||||
assertEquals("work", (galleryParams["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertTrue((galleryParams["localOnly"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
|
||||
client.selectPet("boba", profile = "work").getOrThrow()
|
||||
val selectParams = harness.awaitRpc("pet.select")
|
||||
assertEquals("boba", (selectParams["slug"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("work", (selectParams["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
|
||||
client.disablePet(profile = "work").getOrThrow()
|
||||
val disableParams = harness.awaitRpc("pet.disable")
|
||||
assertEquals("work", (disableParams["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet contract rejects malformed renderer and unsafe mutation`() = runBlocking {
|
||||
harness.petInfoPayload = buildJsonObject {
|
||||
put("enabled", true)
|
||||
put("slug", "boba")
|
||||
put("spritesheetRevision", "1:1")
|
||||
put("spritesheetBase64", "not base64")
|
||||
put("mime", "image/png")
|
||||
put("frameW", 192)
|
||||
put("frameH", 208)
|
||||
put("framesPerState", 8)
|
||||
put("loopMs", 1100)
|
||||
put("stateRows", JsonArray(listOf(JsonPrimitive("idle"))))
|
||||
}
|
||||
|
||||
assertTrue(client.petInfo().isFailure)
|
||||
assertTrue(client.selectPet("../boba").exceptionOrNull() is IllegalArgumentException)
|
||||
assertEquals(0, harness.rpcLog.count { it.first == "pet.select" })
|
||||
}
|
||||
|
||||
// --- Reasoning config ---
|
||||
|
||||
@Test
|
||||
@@ -2302,6 +2852,44 @@ class GatewayChatClientTest {
|
||||
assertFalse(rpc.containsKey("scope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed model update echoes upstream confirmation field`() {
|
||||
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", confirmSelection = true)
|
||||
}
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val rpc = harness.awaitRpc("config.set")
|
||||
assertEquals("model", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(true, (rpc["confirm_expensive_model"] as? JsonPrimitive)?.booleanOrNull)
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same confirmed draft session is reusable across rapid model picks`() {
|
||||
client.sessionProfileProvider = { "mizu" }
|
||||
harness.createdSessionProfileName = "mizu"
|
||||
val results = runBlocking {
|
||||
val first = async(Dispatchers.IO) { client.prepareModelSelectionSession(null) }
|
||||
val second = async(Dispatchers.IO) { client.prepareModelSelectionSession(null) }
|
||||
listOf(first.await(), second.await())
|
||||
}
|
||||
|
||||
assertTrue(results.all { it.isSuccess })
|
||||
assertEquals(
|
||||
listOf("20260612_120000_abc123", "20260612_120000_abc123"),
|
||||
results.map { it.getOrThrow() },
|
||||
)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.create" })
|
||||
assertEquals(0, harness.rpcLog.count { it.first == "config.set" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fast update targets live session without global scope`() {
|
||||
val r = Recorder()
|
||||
@@ -2394,6 +2982,25 @@ class GatewayChatClientTest {
|
||||
assertEquals(listOf(101L, null, null), rebound.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `row id only rewind still carries explicit truncation consent`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = "stored-1",
|
||||
text = "edited message",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
truncateBeforeRowId = 73L,
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
|
||||
val submit = harness.awaitRpc("prompt.submit")
|
||||
assertFalse(submit.containsKey("truncate_before_user_ordinal"))
|
||||
assertEquals(73L, (submit["truncate_before_row_id"] as? JsonPrimitive)?.longOrNull)
|
||||
assertEquals(true, (submit["confirm_truncate"] as? JsonPrimitive)?.booleanOrNull)
|
||||
assertFalse(submit.containsKey("confirm_empty_truncate"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first user truncate carries empty-history confirmation`() {
|
||||
val r = Recorder()
|
||||
@@ -2665,6 +3272,41 @@ class GatewayChatClientTest {
|
||||
assertEquals(listOf("recovered"), recorder.textDeltas.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile mismatch clears buffered recovery events before retry`() = runBlocking {
|
||||
client.sessionProfileProvider = { "mizu" }
|
||||
harness.sessionProfileOverride = "default"
|
||||
harness.recoveryRunning = true
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val rejectedRecorder = Recorder()
|
||||
|
||||
val rejected = async(Dispatchers.IO) {
|
||||
client.recoverTurn("stored-42", null, rejectedRecorder.callbacks)
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "stale mismatch delta") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult("live-resumed"))
|
||||
|
||||
assertTrue(rejected.await().exceptionOrNull() is GatewayPreflightException)
|
||||
assertTrue(rejectedRecorder.textDeltas.isEmpty())
|
||||
|
||||
harness.suppressAckMethods -= "session.resume"
|
||||
harness.sessionProfileOverride = "mizu"
|
||||
val retryRecorder = Recorder()
|
||||
val retry = client.recoverTurn("stored-42", null, retryRecorder.callbacks).getOrThrow()
|
||||
|
||||
assertTrue(retry.running)
|
||||
assertTrue(retryRecorder.textDeltas.isEmpty())
|
||||
retry.handle?.detach()
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps queued-only resume live`() {
|
||||
harness.recoveryQueuedUser = "do this next"
|
||||
@@ -3130,6 +3772,7 @@ class GatewayChatClientTest {
|
||||
val backgroundEvents = ConcurrentLinkedQueue<GatewayBackgroundInteractionEvent>()
|
||||
client.setBackgroundInteractionListener(backgroundEvents::add)
|
||||
client.sessionProfileProvider = { "work" }
|
||||
harness.sessionProfileOverride = "work"
|
||||
client.sendTurn(null, "long task", null, original.callbacks) {
|
||||
original.preflightFailures += it
|
||||
}
|
||||
|
||||
@@ -109,6 +109,30 @@ class HermesApiClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedMultilineApiCredentialFailsBeforeAuthorizationHeaderConstruction() = runTest {
|
||||
val server = MockWebServer()
|
||||
server.start()
|
||||
try {
|
||||
val result = HermesApiClient(
|
||||
server.url("/").toString(),
|
||||
"first-line\nsecond-line",
|
||||
).checkHealthDetailed()
|
||||
|
||||
assertTrue(result is HealthCheckResult.Unhealthy)
|
||||
val message = (result as HealthCheckResult.Unhealthy).message
|
||||
assertEquals(
|
||||
"Invalid API credential — enter or import a single-line value.",
|
||||
message,
|
||||
)
|
||||
assertFalse(message.contains("first-line"))
|
||||
assertFalse(message.contains("second-line"))
|
||||
assertEquals(0, server.requestCount)
|
||||
} finally {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// --- HealthCheckResult sealed interface ---
|
||||
|
||||
@Test
|
||||
|
||||
+20
-1
@@ -76,13 +76,32 @@ class NativeDashboardAuthTest {
|
||||
assertEquals("/prefix/auth/native/authorize", url.path)
|
||||
assertEquals("S256", query["code_challenge_method"])
|
||||
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
|
||||
assertEquals("nous", query["provider"])
|
||||
assertEquals(null, query["provider"])
|
||||
assertTrue(query.getValue("state").length >= 32)
|
||||
assertEquals(43, query.getValue("code_challenge").length)
|
||||
assertFalse(query.getValue("code_challenge").contains('='))
|
||||
assertNotEquals(query["state"], query["code_challenge"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun beginAuthorization_keepsExplicitSelectorForNonNousProviders() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
|
||||
val authorization = client.beginAuthorization(
|
||||
redirectUri = "http://127.0.0.1:43123/callback",
|
||||
provider = "oidc",
|
||||
)
|
||||
|
||||
val query = java.net.URI(authorization.authorizationUrl).rawQuery
|
||||
.split("&")
|
||||
.associate {
|
||||
val pair = it.split("=", limit = 2)
|
||||
java.net.URLDecoder.decode(pair[0], "UTF-8") to
|
||||
java.net.URLDecoder.decode(pair[1], "UTF-8")
|
||||
}
|
||||
assertEquals("oidc", query["provider"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
|
||||
val location = "https://portal.nousresearch.com/oauth/authorize" +
|
||||
|
||||
+7
-3
@@ -124,8 +124,8 @@ class NativeDashboardSignInCoordinatorTest {
|
||||
)
|
||||
|
||||
assertTrue(response.startsWith("HTTP/1.1 400"))
|
||||
assertTrue(response.contains("Sign-in needs another try"))
|
||||
assertTrue(response.contains("No session details were saved"))
|
||||
assertTrue(response.contains("Hermes rejected the sign-in code"))
|
||||
assertTrue(response.contains("hosted Hermes could not exchange its one-time callback code"))
|
||||
assertTrue(response.contains("Referrer-Policy: no-referrer"))
|
||||
assertTrue(response.contains("X-Content-Type-Options: nosniff"))
|
||||
assertTrue(response.contains("Permissions-Policy:"))
|
||||
@@ -215,7 +215,11 @@ class NativeDashboardSignInCoordinatorTest {
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val authorizeQuery = query(authorize)
|
||||
assertEquals(provider, authorizeQuery["provider"])
|
||||
if (provider.equals("nous", ignoreCase = true)) {
|
||||
assertEquals(null, authorizeQuery["provider"])
|
||||
} else {
|
||||
assertEquals(provider, authorizeQuery["provider"])
|
||||
}
|
||||
assertEquals("S256", authorizeQuery["code_challenge_method"])
|
||||
val redirect = URI(authorizeQuery["redirect_uri"]!!)
|
||||
assertEquals("127.0.0.1", redirect.host)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user