Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5649d016 |
@@ -1 +0,0 @@
|
||||
*.sh text eol=lf
|
||||
@@ -5,14 +5,12 @@ on:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
@@ -51,8 +49,8 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify plugin-owned version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
- name: Verify server-owned version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# branch protection on `main` has a check name it can rely on, regardless
|
||||
# of which paths the PR touches.
|
||||
#
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-plugin.yml`,
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-server.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Hermes-Relay — Plugin CI Pipeline
|
||||
# Hermes-Relay — Python Server CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# plugin-affecting paths so Android-only changes don't spin up the
|
||||
# server-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> focused plugin tests
|
||||
# Pipeline: syntax-check -> focused server tests
|
||||
|
||||
name: CI — Plugin
|
||||
name: CI — Server
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -26,11 +26,9 @@ on:
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-plugin-version.sh"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-plugin.yml"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
@@ -48,20 +46,18 @@ on:
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-plugin-version.sh"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-plugin.yml"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-plugin-${{ github.ref }}
|
||||
group: ci-server-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Plugin — py_compile syntax sanity
|
||||
# Python Server — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
@@ -79,7 +75,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
- name: Syntax check (server/plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
@@ -91,18 +87,18 @@ jobs:
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Plugin version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
- name: Validate Server version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Plugin — focused route/auth/session tests
|
||||
# Python Server — focused route/auth/session tests
|
||||
#
|
||||
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
|
||||
# merge queue. Strict on main — the dev → main release-merge PR surfaces
|
||||
# any real failures before release.
|
||||
# ──────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Focused Plugin tests (Python)
|
||||
name: Focused Server tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -124,7 +120,7 @@ jobs:
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
@@ -25,16 +25,7 @@ jobs:
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
# Any dev -> main PR is, by the branching model, the aggregate release PR
|
||||
# (main only ever receives release merges from dev). Detect it by base+head
|
||||
# alone — a title-format match (e.g. "release:") is fragile and silently
|
||||
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
|
||||
# Bot-authored PRs such as Dependabot do not receive the same secret
|
||||
# surface as human-authored PRs, and Claude Code rejects bot actors unless
|
||||
# explicitly allow-listed. Keep the required check green with a no-op and
|
||||
# rely on the dependency CI/status checks for those PRs.
|
||||
IS_BOT_PR: ${{ github.event.pull_request.user.type == 'Bot' }}
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
|
||||
|
||||
steps:
|
||||
- name: Skip aggregate release PR review
|
||||
@@ -43,40 +34,14 @@ jobs:
|
||||
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
|
||||
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
|
||||
|
||||
- name: Skip bot-authored PR review
|
||||
if: env.IS_BOT_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for bot-authored PR."
|
||||
echo "Bot PRs are gated by Required checks plus their path-specific CI jobs."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Depth 2 includes the pull_request merge commit's first parent, which
|
||||
# lets the next step detect whether this PR changes the workflow file.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Claude review workflow changes
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
id: changed-workflow
|
||||
shell: bash
|
||||
run: |
|
||||
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 &&
|
||||
git diff --name-only HEAD^1 HEAD | grep -Fxq ".github/workflows/claude-code-review.yml"; then
|
||||
echo "claude_review_workflow=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "claude_review_workflow=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip Claude review workflow self-change
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review because this PR changes the review workflow itself."
|
||||
echo "The Claude action requires this workflow file to match the default branch before it can exchange the app token."
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow != 'true'
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
@@ -68,4 +68,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Release Android
|
||||
|
||||
@@ -151,38 +151,6 @@ jobs:
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
# Runs only when the Play service-account secret is configured AND this is
|
||||
# a stable tag (prereleases — versions containing a dash — are skipped so
|
||||
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
|
||||
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
|
||||
# and persists across steps in this job, so the AAB is release-signed.
|
||||
#
|
||||
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
|
||||
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
|
||||
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
|
||||
# build lands on the Production track as a DRAFT: CI does the upload, a human
|
||||
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle --track=production
|
||||
rm -f play-service-account.json
|
||||
|
||||
- name: Play upload skipped (no secret)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
|
||||
run: |
|
||||
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
|
||||
"GitHub Release artifacts are still published; upload to Play manually" \
|
||||
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Release CLI
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['cli-v*']
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-binaries
|
||||
name: desktop-cli-release
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-windows-tray-installer
|
||||
name: desktop-windows-tray-release
|
||||
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
@@ -171,13 +171,9 @@ jobs:
|
||||
- build-cli-binaries
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract CLI version
|
||||
- name: Extract desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -192,31 +188,54 @@ jobs:
|
||||
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
|
||||
cat release-assets/SHA256SUMS.txt
|
||||
|
||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
||||
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
sed -e "s/__VERSION__/${VERSION}/g" -e "s/__TAG__/${TAG}/g" \
|
||||
CLI_RELEASE_NOTES.md > cli_release_notes_rendered.md
|
||||
echo "=== rendered release body ===" && cat cli_release_notes_rendered.md
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
|
||||
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body_path: cli_release_notes_rendered.md
|
||||
body: |
|
||||
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
|
||||
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
release-assets/cli-binaries/hermes-relay-win-x64.exe
|
||||
release-assets/cli-binaries/hermes-relay-linux-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-tray-installer/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/desktop-cli-release/hermes-relay-win-x64.exe
|
||||
release-assets/desktop-cli-release/hermes-relay-linux-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-arm64
|
||||
release-assets/desktop-windows-tray-release/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -1,16 +1,16 @@
|
||||
name: Release Plugin
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-v*"
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Plugin release
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -20,15 +20,15 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Plugin version sync
|
||||
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
- name: Verify Server version sync
|
||||
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Plugin package
|
||||
name: Test Server package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
plugin/tests/test_session_grants.py
|
||||
|
||||
package:
|
||||
name: Build and publish Plugin package
|
||||
name: Build and publish Server package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -86,25 +86,32 @@ jobs:
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
# Render PLUGIN_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body, substituting the version token so the Install command stays
|
||||
# accurate without a manual edit. The file is the single source of the notes;
|
||||
# see RELEASE.md "Plugin / Python package release".
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
sed "s/__VERSION__/${VERSION}/g" PLUGIN_RELEASE_NOTES.md > release_notes_rendered.md
|
||||
echo "=== rendered release body ===" && cat release_notes_rendered.md
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
|
||||
tag_name: plugin-v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body_path: release_notes_rendered.md
|
||||
body: |
|
||||
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
|
||||
This release contains the server/Python plugin package.
|
||||
Android releases use `android-v*` tags. Desktop releases use
|
||||
`desktop-v*` tags. Historical server releases before this lane
|
||||
rename used `relay-v*` tags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==${{ needs.validate.outputs.version }}
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
@@ -26,7 +26,6 @@ local.properties
|
||||
/app/build/
|
||||
/relay-core/build/
|
||||
/relay-ui/build/
|
||||
/ui-preview/build/
|
||||
/quest/build/
|
||||
/app/release/
|
||||
*.apk
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Universal agent instructions for **Hermes-Relay**. This is the entry point for any
|
||||
coding agent (Claude Code, Codex, Cursor, etc.).
|
||||
|
||||
## Read this first
|
||||
|
||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
||||
then `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection —
|
||||
chat via the API server, standard voice via the Hermes dashboard — must work
|
||||
against unmodified upstream hermes-agent. Server-side needs go through upstream
|
||||
PRs or the optional relay plugin, never fork patches.
|
||||
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
|
||||
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||
|
||||
## Public-repo writing hygiene
|
||||
|
||||
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
|
||||
notes:
|
||||
|
||||
- **No personal names** — attribute impersonally; identity lives in git + the
|
||||
signing cert.
|
||||
- **No private infrastructure** — real hostnames/IPs, internal deployment names,
|
||||
`~/SYSTEM.md`. (Generic example IPs in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** ("I should have…", course
|
||||
corrections) — state the technical conclusion only.
|
||||
- **No internal jargon or fork/branch plumbing** in user-facing notes.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping; condense the version block to
|
||||
crisp public bullets at release-prep (see RELEASE.md §2 "Scrub for public
|
||||
distribution"). **DEVLOG** is a depersonalized, factual engineering log.
|
||||
+15
-89
@@ -6,89 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated Play Console upload on release.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, the release builds publish to GitHub Releases exactly as before.
|
||||
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
|
||||
- **Plugin: guided env-key setup.** The relay plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of hand-editing `.env`. The standard no-plugin path needs none.
|
||||
- **Plugin: native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
|
||||
- **`/relay` slash commands.** `relay status · devices · pair` usable mid-conversation from any platform (CLI / Discord / TUI).
|
||||
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
|
||||
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
|
||||
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
|
||||
|
||||
### Changed
|
||||
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
|
||||
|
||||
- **Release names normalized by surface.** Future GitHub Releases are named `Hermes-Relay-Android`, `Hermes-Relay-Plugin`, and `Hermes-Relay-CLI`, with future tags on `android-v*`, `plugin-v*`, and `cli-v*`. The CLI installer and updater still understand historical `desktop-v*` prereleases during the migration.
|
||||
- **Per-surface release notes.** Plugin and CLI GitHub Releases now use hand-written `PLUGIN_RELEASE_NOTES.md` / `CLI_RELEASE_NOTES.md` files (Summary + Added/Changed/Fixed + Install/Verify) — the same format as Android's `RELEASE_NOTES.md` — instead of static boilerplate baked into the workflow. The release workflows substitute the version into the install commands automatically.
|
||||
- **Settings screen overhaul (Android).** Status pills are now exception-only — they appear only when a surface needs attention and stay quiet when healthy. The Power tools section shows a single state-aware **Plugin active / required / offline** badge instead of an identical "Relay paired" chip on every card. Connections moved to the top (above the Hermes section), Diagnostics + Developer options moved into the App section, the status chips were restyled to match the app's translucent-bordered language, and the brand blue was deepened.
|
||||
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
|
||||
|
||||
### Fixed
|
||||
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
|
||||
|
||||
- **Force-close on connect when the stored credential keyset was corrupt.** A corrupt encrypted token store (which can happen after an app upgrade or device restore) threw during construction and crashed the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset on the spot, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
|
||||
- **Dashboard plugin: unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background. Solid button variants now keep their proper contrast colour.
|
||||
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
|
||||
- **Chat settings (Android).** The streaming-endpoint picker no longer wraps "Gateway"/"Sessions" onto a second line, and the system-prompt preview now reflects the enabled context toggles (foreground app, battery, safety rails) with representative placeholder values instead of looking inert.
|
||||
- **Dashboard plugin: buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button`/`Badge` use boolean variant flags (`outlined`/`ghost`/`invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter, and drops a label-hiding CSS reset.
|
||||
|
||||
## [1.0.0] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
|
||||
|
||||
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
|
||||
|
||||
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
|
||||
|
||||
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
|
||||
|
||||
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
|
||||
|
||||
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
|
||||
|
||||
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
|
||||
|
||||
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
|
||||
|
||||
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
|
||||
|
||||
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
|
||||
|
||||
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
|
||||
|
||||
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
|
||||
|
||||
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
|
||||
|
||||
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
|
||||
|
||||
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
|
||||
|
||||
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
|
||||
|
||||
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
|
||||
|
||||
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
|
||||
|
||||
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
|
||||
|
||||
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
|
||||
|
||||
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
|
||||
|
||||
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
|
||||
|
||||
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
|
||||
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
|
||||
|
||||
## [0.8.1] - 2026-05-26
|
||||
|
||||
@@ -116,7 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
|
||||
|
||||
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, plugin/Python releases use `server-v*`, and CLI releases continue on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Plugin vX.Y.Z`, and `Hermes-Relay-CLI vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
|
||||
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, server/Python releases use `server-v*`, and desktop continues on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Server vX.Y.Z`, and `Hermes-Relay-Desktop vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
|
||||
|
||||
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
|
||||
|
||||
@@ -208,7 +136,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
|
||||
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Bailey: *"This isn't cohesive — we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -218,9 +146,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
|
||||
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** Bailey saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9, `hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** Bailey on alpha.9 ran `hermes-relay update --check`, expected to see alpha.10, got "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
|
||||
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
|
||||
|
||||
@@ -242,9 +170,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke runs in `release-cli.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
|
||||
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke added to `release-desktop.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
|
||||
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
|
||||
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from the CLI release workflow, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
|
||||
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from release-desktop.yml, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
|
||||
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
|
||||
|
||||
### Added
|
||||
@@ -294,13 +222,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
|
||||
### Added
|
||||
|
||||
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
|
||||
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (deliberate: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
|
||||
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
|
||||
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
|
||||
@@ -677,7 +605,7 @@ sees the toggle, never installs the wake lock, and never invokes
|
||||
|
||||
### Added — Voice intent → server session sync (v0.4.1 fast-follow)
|
||||
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per Bailey's 2026-04-14 on-device repro).
|
||||
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
|
||||
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
|
||||
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
|
||||
@@ -1259,9 +1187,7 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...HEAD
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
|
||||
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
|
||||
|
||||
@@ -4,20 +4,18 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Standard chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
|
||||
|
||||
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Standard voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
**Current state:** v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (WS) -> Hermes dashboard (:9119) [standard gateway chat, live thinking]
|
||||
Phone (HTTP/SSE) -> Hermes API Server (:8642) [standard chat fallback, sessions, runs]
|
||||
Phone (HTTP) -> Hermes dashboard (:9119) [standard Manage + voice]
|
||||
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
```
|
||||
|
||||
The standard path must stay vanilla upstream only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; standard chat, Manage, and dashboard voice must not.
|
||||
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
|
||||
|
||||
### Upstream Hermes API Reference
|
||||
|
||||
@@ -31,54 +29,46 @@ The standard path must stay vanilla upstream only. API-server bearer auth and da
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
**Baseline upstream endpoints vs compatibility endpoints:**
|
||||
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
Upstream hermes-agent now has a native baseline for API Server session control and skill/toolset discovery:
|
||||
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
1. **Native upstream** — commit [`f7527b0`](https://github.com/NousResearch/hermes-agent/commit/f7527b0fdb54f01691547df03fc65a6d367f9fde), merged via PR [#33134](https://github.com/NousResearch/hermes-agent/pull/33134), salvaged the focused session-control work from closed PR [#29302](https://github.com/NousResearch/hermes-agent/pull/29302). It provides `/api/sessions/*`, session chat/stream, fork/messages, plus `/v1/skills` and `/v1/toolsets`.
|
||||
2. **Codename-11 `axiom` fork** — still carries compatibility/client-metadata routes that upstream does not provide yet: `/api/sessions/search`, `/api/memory`, `/api/skills` detail routes, `/api/config`, and `/api/available-models`.
|
||||
3. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file and injects only missing compatibility routes for older or partial upstream builds. It should remain per-route/per-feature, not all-or-nothing.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream OR fork OR bootstrap |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream OR fork OR bootstrap |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap only |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream OR fork only (NOT bootstrap) |
|
||||
| `GET /v1/skills` | Skill list metadata | Native upstream OR fork |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap only |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail routes | Fork OR bootstrap only; Android prefers `/v1/skills` first |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap only |
|
||||
| `GET /api/available-models` | Provider-aware model list | Fork OR bootstrap only |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
**Dashboard web server (separate surface — loopback-only):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, standard voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the standard fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — gateway/API chat, Manage, and standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
- **Bootstrap maintenance:** Do not remove `hermes_relay_bootstrap/` just because upstream has native sessions. It can start shrinking only after each Relay-consuming compatibility route has a native replacement or the Android/Desktop clients have migrated away from it.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -95,10 +85,6 @@ hermes-android/
|
||||
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
|
||||
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
|
||||
│ └── notifications/ # HermesNotificationCompanion
|
||||
├── relay-core/ ← [EXPERIMENTAL] Quest/XR shared core lib (com.axiomlabs.hermesrelay.core) — pairing, transport, terminal, voice, wire
|
||||
├── relay-ui/ ← [EXPERIMENTAL] Quest/XR shared Compose UI lib — sphere, terminal WebView, QR scanner
|
||||
├── quest/ ← [EXPERIMENTAL] Meta Spatial SDK Quest/XR app (gradle includeBuild; in development, not shipped)
|
||||
├── ui-preview/ ← Desktop Compose Hot Reload harness for PC UI iteration (NOT shipped; shares MorphingSphereCore)
|
||||
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
|
||||
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
|
||||
│ ├── src/
|
||||
@@ -122,7 +108,7 @@ hermes-android/
|
||||
│ ├── tools/ # android_navigate.py, android_notifications.py
|
||||
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
├── hermes_relay_bootstrap/ ← Legacy import shim for older startup hooks
|
||||
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla/partial upstream compatibility routes
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
@@ -136,17 +122,6 @@ hermes-android/
|
||||
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
|
||||
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
|
||||
|
||||
### Public-repo writing hygiene
|
||||
|
||||
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
|
||||
|
||||
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
|
||||
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
|
||||
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
|
||||
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
|
||||
|
||||
### Code Style — Android (Kotlin)
|
||||
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
|
||||
- **kotlinx.serialization** — not Gson. Type-safe, faster.
|
||||
@@ -173,14 +148,14 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -188,22 +163,14 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
|
||||
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
|
||||
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
|
||||
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
|
||||
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
|
||||
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
|
||||
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
|
||||
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
|
||||
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
@@ -242,16 +209,12 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
|
||||
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
|
||||
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
@@ -266,12 +229,9 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla/partial upstream compatibility routes; shrink per route group after native parity or client migration |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
@@ -317,16 +277,10 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| `release-desktop.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -393,12 +347,6 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
**Compat hook:** `hermes relay compat status/install/remove` manages only the
|
||||
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
|
||||
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
|
||||
package is only a legacy import shim. Standard chat, Manage, and dashboard voice
|
||||
must not depend on this hook.
|
||||
|
||||
**Key conventions:**
|
||||
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
|
||||
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
|
||||
@@ -417,25 +365,20 @@ must not depend on this hook.
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
||||
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
|
||||
- **`appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
|
||||
- **Version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
|
||||
- **Bump atomically:** `bash scripts/bump-version.sh <new-version>` — updates all three sources
|
||||
- **`appVersionCode` is monotonic** — always increment across prereleases
|
||||
- **Cut a release:** bump → commit → `git tag vMAJOR.MINOR.PATCH` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Standard upstream dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; preferred |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Standard upstream dashboard surface; do not proxy through Relay |
|
||||
| Standard voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Standard no-plugin voice; uses dashboard session from Manage |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Non-standard; bootstrap or fork |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
@@ -446,11 +389,9 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Hermes-Relay-CLI v__VERSION__
|
||||
|
||||
**Release Date:** <!-- YYYY-MM-DD -->
|
||||
**Since the previous CLI release:** <!-- one line: the theme of this release -->
|
||||
|
||||
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
|
||||
|
||||
<!--
|
||||
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
|
||||
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
|
||||
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
|
||||
leave those tokens in the Install section; do NOT hardcode versions there.
|
||||
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
|
||||
bullets in CHANGELOG.md's promoted version block.
|
||||
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
|
||||
• Keep the "Experimental phase" notice until the CLI reaches GA.
|
||||
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
|
||||
no fork-branch plumbing, no AI self-narration.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
-
|
||||
|
||||
### Changed
|
||||
-
|
||||
|
||||
### Fixed
|
||||
-
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=__TAG__`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
@@ -96,16 +96,6 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Changelog & writing conventions
|
||||
|
||||
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
|
||||
|
||||
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
|
||||
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
|
||||
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
|
||||
|
||||
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 16, 2026
|
||||
**Since the previous plugin release:** Easier setup and a fixed dashboard panel — plus mid-conversation `/relay` controls and a relay-status widget.
|
||||
|
||||
This release makes the relay plugin easier to install and live with. Setup now prompts for the optional voice-provider keys instead of asking you to hand-edit `.env`, tools-only hosts can install through the native `hermes plugins install` path, and the installer no longer breaks on `uv`-managed Hermes cores. The dashboard panel — which previously rendered as blank boxes on the host's design system — now displays correctly, and a header widget plus `/relay` slash commands surface relay state from anywhere. The standard no-plugin path needs none of this.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
- **Guided env-key setup.** The plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of requiring a hand-edited `.env`. The standard no-plugin path needs none.
|
||||
- **Native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
|
||||
- **`/relay` slash commands.** `relay status · devices · pair` are usable mid-conversation from any platform (CLI / Discord / TUI).
|
||||
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
|
||||
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
|
||||
|
||||
### Fixed
|
||||
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
|
||||
- **Dashboard buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button` / `Badge` use boolean variant flags (`outlined` / `ghost` / `invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter and drops a label-hiding CSS reset.
|
||||
- **Unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background; solid button variants now keep their proper contrast colour.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==__VERSION__
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
|
||||
relay/plugin releases used `relay-v*` tags.
|
||||
@@ -1,23 +1,21 @@
|
||||
<p align="center">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
|
||||
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Runs on your machine. Lives on your devices.</strong><br>
|
||||
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
|
||||
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
|
||||
</p>
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
||||
<strong>One Hermes agent. Two ways to use it.</strong><br>
|
||||
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
|
||||
use a server-deployed Hermes from your laptop as if it were running locally.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,204 +25,120 @@
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo.mp4" poster="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo_poster.jpg" autoplay loop muted playsinline width="280"></video>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
## Two surfaces, one pair
|
||||
|
||||
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
|
||||
|
||||
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
|
||||
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
|
||||
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
---
|
||||
|
||||
## Quick Start (Android)
|
||||
## Quick Start
|
||||
|
||||
Install → connect → talk, in about two minutes.
|
||||
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
|
||||
|
||||
### 1 · Install the app
|
||||
### 1a. Android app
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
<!-- TODO: Uncomment when Play Store listing is live
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
|
||||
-->
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and choose the newest Android release (`android-v*`; historical Android releases used bare `v*`)
|
||||
|
||||
### 2 · Have Hermes running
|
||||
#### Sideload APK (GitHub Releases)
|
||||
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is standard Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
1. Download the file ending in **`-sideload-release.apk`** from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
|
||||
3. Open the APK from your downloads and tap **Install**.
|
||||
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
### 1b. Desktop app + CLI (experimental)
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
|
||||
|
||||
### 3 · Connect and talk
|
||||
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
|
||||
- **Standard Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Standard Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|------|---------------|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole standard setup.
|
||||
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
Use the legacy installer instead if you also want the systemd user service,
|
||||
shell shims, and the full clone/update workflow:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
|
||||
and agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
optional legacy API compatibility hook when an older Hermes build needs it. Scan
|
||||
the QR from the phone's Connections screen — or use
|
||||
`hermes pair --register-code ABCD12` with the manual code from Android
|
||||
**Settings → Connections → Advanced**.
|
||||
|
||||
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
|
||||
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/05_commands.png" alt="Command palette" width="100%"><br><sub><b>Command palette</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections & routes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/08_settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — rides standard Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
|
||||
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
|
||||
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
|
||||
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
|
||||
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
|
||||
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
|
||||
**Install tray app** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI only** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay # interactive Hermes TUI in tmux
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
hermes-relay daemon # headless tool router (agent reaches you anytime)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
|
||||
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
|
||||
|
||||
## How It Works
|
||||
- **Docs**: [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
|
||||
```
|
||||
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, standard voice]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
|
||||
### 2. Install the server plugin (one-liner)
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
|
||||
back to the upstream API server SSE path with the API key. Manage and standard
|
||||
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
|
||||
install needs no plugin for either. The optional relay on `:8767` adds the power
|
||||
surfaces: terminal, bridge phone control, media handoff, machine tools, and
|
||||
relay-side voice, which is preferred automatically when paired. One QR can
|
||||
configure API, dashboard, and relay routes without merging their auth models.
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
|
||||
|
||||
## Documentation
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
|
||||
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
|
||||
|
||||
<br>
|
||||
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
|
||||
|
||||
If an AI assistant manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
|
||||
|
||||
### For AI Agents
|
||||
|
||||
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
@@ -232,92 +146,202 @@ Read the canonical setup recipe before acting:
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
|
||||
|
||||
</details>
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
|
||||
|
||||
| Surface | Channel | What | Status |
|
||||
|---------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
|
||||
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
|
||||
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
|
||||
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
|
||||
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
|
||||
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
|
||||
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
|
||||
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for the full list.
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
|
||||
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
|
||||
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
|
||||
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
|
||||
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
|
||||
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
|
||||
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Bridge safety rails** — Per-app blocklist (banking, payments, 2FA default-blocked), destructive-verb confirmation modal (send, pay, delete, transfer…), idle auto-disable timer, optional persistent-status overlay, full activity log
|
||||
- **Security & pairing** — QR-code pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
|
||||
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
|
||||
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
|
||||
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
|
||||
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
|
||||
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
|
||||
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
|
||||
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
|
||||
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Android:**
|
||||
|
||||
1. **Install the app** from the [link above](#1a-android-app)
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
**Desktop CLI:**
|
||||
|
||||
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
|
||||
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Server (:8767) [voice routes — API key or relay session]
|
||||
Phone (WSS/HTTP) --> Server (:8767) [terminal, bridge, media, sessions]
|
||||
Desktop CLI (WSS) --> Server (:8767) [tui, terminal, desktop tools]
|
||||
```
|
||||
|
||||
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, and Desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **File > Open** the repo root in Android Studio
|
||||
2. Wait for Gradle sync
|
||||
3. **Run** (Shift+F10) to deploy to emulator or device
|
||||
|
||||
### Dev Scripts
|
||||
|
||||
```bash
|
||||
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
scripts/dev.bat relay # Start Server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS Server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
|
||||
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
|
||||
├── user-docs/ # VitePress documentation site (Android + desktop sections)
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server / plugin** | Python 3.11+, aiohttp |
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
<details>
|
||||
<summary><b>Repository structure</b></summary>
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Hermes-Relay CLI thin-client (TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, machine tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/devops/ # Hermes agent skills (pairing, self-setup, CLI setup recipes)
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-plugin / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Running the server / plugin from a clone</b></summary>
|
||||
|
||||
<br>
|
||||
|
||||
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
|
||||
### Server (optional — bridge, terminal, TUI, media, and voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
python -m plugin.relay --no-ssl # or from a repo checkout
|
||||
# or from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
|
||||
# Docker:
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
|
||||
```
|
||||
|
||||
# Live-edit the plugin against a local Hermes:
|
||||
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
### Hermes Plugin (for contributors)
|
||||
|
||||
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
# Or symlink for live edits:
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
|
||||
</details>
|
||||
|
||||
## Built for Hermes Agent
|
||||
## Hermes Agent
|
||||
|
||||
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
|
||||
|
||||
## Found a bug? Let us know
|
||||
## Found a bug? Let us know!
|
||||
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+91
-199
@@ -13,23 +13,20 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
|
||||
Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
|
||||
for automation.
|
||||
Hermes-Relay now ships three independently versioned surfaces:
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-cli.yml` |
|
||||
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
|
||||
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
|
||||
|
||||
This split is intentional. The plugin carries relay features for both Android
|
||||
and CLI clients, so plugin fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
|
||||
Android releases before this naming split used bare `v*` tags. Historical
|
||||
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
|
||||
`desktop-v*` tags. New releases use the explicit tag prefixes above.
|
||||
This split is intentional. The server now carries features for both Android
|
||||
and desktop, so server fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
|
||||
Historical Android releases before this naming split used bare `v*` tags, and
|
||||
historical server releases used `relay-v*` tags. New releases use the explicit
|
||||
surface prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
@@ -73,47 +70,35 @@ bash scripts/bump-android-version.sh 0.6.2
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Plugin / Python package versioning
|
||||
### Server / Python package versioning
|
||||
|
||||
Plugin version metadata lives in these plugin-owned files and must stay in
|
||||
Server version metadata lives in these server-owned files and must stay in
|
||||
lockstep:
|
||||
|
||||
| File | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `pyproject.toml` | `version = "..."` | Python package metadata |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` and `/relay/info` |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` |
|
||||
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
|
||||
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Plugin releases via:
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-plugin-version-sync.py
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
Check all release tracks at once with:
|
||||
|
||||
```bash
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, plugin, and CLI versions
|
||||
side by side and validates that each track's own source files are internally
|
||||
consistent. It deliberately does not require all three tracks to share the same
|
||||
SemVer.
|
||||
|
||||
The `plugin-v*` release workflow validates the tag against the same metadata,
|
||||
runs plugin tests, builds a wheel and sdist, generates checksums, and
|
||||
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
|
||||
artifacts.
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs server tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
a GitHub Release with the package artifacts.
|
||||
|
||||
## Branching policy
|
||||
|
||||
@@ -171,15 +156,15 @@ Squash merges lose that detail and are **not** the house style.
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
plugin-owned version metadata, or `desktop/package.json`.
|
||||
server-owned version metadata, or `desktop/package.json`.
|
||||
If two feature branches both bumped a release version, they'd collide on
|
||||
version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
|
||||
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
||||
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use the existing
|
||||
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
@@ -188,7 +173,7 @@ use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
||||
Light branch protection is enabled:
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Plugin) before merge. Force push and
|
||||
here. PR must pass CI (Android + Server) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
@@ -290,34 +275,22 @@ for the full text.
|
||||
|
||||
### 3. Play Developer API service account (optional)
|
||||
|
||||
Required for automated upload (the `android-v*` workflow's Play step, or local
|
||||
`gradlew publishGooglePlayReleaseBundle`). Manual UI uploads work without this.
|
||||
Required only if you want `gradlew publishReleaseBundle` to upload directly
|
||||
to Play Console. Manual UI uploads work without this.
|
||||
|
||||
The service account is **created in Google Cloud Console** and then **authorized
|
||||
in Play Console** — two separate consoles. (Play Console's older "Setup > API
|
||||
access" page has been reorganized; there is no longer a "Setup" group. Use the
|
||||
paths below.)
|
||||
|
||||
1. **Create the service account (Google Cloud Console).** Open
|
||||
<https://console.cloud.google.com/iam-admin/serviceaccounts>, pick the project
|
||||
(any project works; if Play Console's **API access** page already names a linked
|
||||
project, use that one). **Create service account** → name it e.g.
|
||||
`hermes-relay-publisher` → **Done**. No project roles needed.
|
||||
2. **Create a JSON key.** On the new service account → **Keys** tab → **Add key >
|
||||
Create new key > JSON** → download. This file's *contents* are the secret.
|
||||
3. **Authorize it in Play Console.** Open the Play Console account-level left
|
||||
sidebar → **Users and permissions** → **Invite new users** → paste the service
|
||||
account's email (`...@...iam.gserviceaccount.com`). Under **App permissions**
|
||||
(for `com.axiomlabs.hermesrelay`) or **Account permissions**, grant the
|
||||
**Release** permissions — "Release apps to testing tracks" and "Release to
|
||||
production, exclude devices, and use Play App Signing" — plus "View app
|
||||
information". (Granting **Admin (all permissions)** also works but is broader
|
||||
than needed.) **Invite user**.
|
||||
4. **Use it.** For CI, paste the JSON contents into the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||
repo secret (step 4 / secrets table). For local publish, save the JSON as
|
||||
`play-service-account.json` in the repo root (already in `.gitignore`).
|
||||
5. Verify locally with `gradlew bootstrapGooglePlayReleaseResources` — succeeds
|
||||
without auth errors once permissions propagate (allow a few minutes).
|
||||
1. Open <https://console.cloud.google.com/> and select the project linked
|
||||
to your Play Console account (Play Console > Setup > API access shows
|
||||
which one).
|
||||
2. **IAM & Admin > Service Accounts > Create Service Account** (e.g.
|
||||
`hermes-relay-publisher`). No project roles needed.
|
||||
3. On the new service account, **Keys > Add key > Create new key > JSON**
|
||||
and download the file.
|
||||
4. In Play Console > **Setup > API access**, find the service account,
|
||||
click **Grant access**, and assign the **Release manager** role.
|
||||
5. Save the JSON as `play-service-account.json` in the repo root (already
|
||||
in `.gitignore`).
|
||||
6. Verify with `gradlew bootstrapReleasePlayResources` — should succeed
|
||||
without auth errors.
|
||||
|
||||
### 4. GitHub Actions secrets
|
||||
|
||||
@@ -377,12 +350,6 @@ the new app version and a higher `appVersionCode`.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
> Each surface has its own GitHub-Release-body file, all in the same format
|
||||
> (Summary + Added/Changed/Fixed + Install/Verify): `RELEASE_NOTES.md` (Android),
|
||||
> `PLUGIN_RELEASE_NOTES.md` (plugin), `CLI_RELEASE_NOTES.md` (CLI). This step covers
|
||||
> the Android artifacts; the plugin/CLI files are filled in their own release
|
||||
> sections below but follow the identical scrub and Keep-a-Changelog grouping.
|
||||
|
||||
- `CHANGELOG.md` — promote the accumulated `[Unreleased]` block to a
|
||||
versioned header. The block already exists: every feature PR has
|
||||
been appending to it. All you do here is:
|
||||
@@ -407,34 +374,7 @@ the new app version and a higher `appVersionCode`.
|
||||
(v0.4.0 shipped with 0.1.0 content until caught post-release).
|
||||
- `docs/play-store-listing.md` — Play Store listing copy. Update
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
This is a **public repo** and these four files are user-facing. Before
|
||||
promoting the `[Unreleased]` block and writing the notes, scrub the
|
||||
versioned CHANGELOG block and all three release-notes artifacts for
|
||||
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
|
||||
dev-log voice during the iteration phase — release-prep is where it
|
||||
becomes public copy. Check for and remove/rewrite:
|
||||
|
||||
- **Personal names / quoted asides** — `git grep -niE "bailey|: \"" CHANGELOG.md`
|
||||
on the new block. Attribute fixes impersonally ("a user reported"),
|
||||
not by name. (Author identity already lives in git + the signing cert.)
|
||||
- **Private infrastructure** — server hostnames/IPs, `~/SYSTEM.md`,
|
||||
internal deployment names, anything that should stay in the operator's
|
||||
environment and not the repo. `grep -niE "192\.168|10\.0\.|hermes-host|SYSTEM\.md"`.
|
||||
(Example IPs like `192.168.1.100` in install docs are fine.)
|
||||
- **Fork / branch plumbing + internal nicknames** — references to private
|
||||
fork branches, rollout channels, or in-team incident nicknames read as
|
||||
internal. Keep the *what changed*, drop the *where we staged it*.
|
||||
- **Personal example data** — genericize sample profile/agent names to
|
||||
neutral placeholders so the copy doesn't expose a specific setup.
|
||||
|
||||
The goal is that someone who has never seen the repo can read the block
|
||||
and the release notes and learn only what the software does.
|
||||
pasted into the Play Console "What's new" field.
|
||||
|
||||
### 3. Build and verify locally
|
||||
|
||||
@@ -485,94 +425,64 @@ Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
Server/Python version files are intentionally not part of an Android app
|
||||
release unless the server package itself is also being released.
|
||||
|
||||
### Plugin / Python package release
|
||||
### Server / Python package release
|
||||
|
||||
Use this when plugin or relay behavior changes independently of Android app
|
||||
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
||||
voice auth, dashboard plugin UI, or packaging changes.
|
||||
|
||||
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
|
||||
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
|
||||
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
|
||||
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
|
||||
Use this when Server behavior changes independently of Android app
|
||||
delivery, for example desktop channel support, bridge routes, pairing
|
||||
server fixes, voice auth, or packaging changes.
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md PLUGIN_RELEASE_NOTES.md
|
||||
git commit -m "release(plugin): plugin-v0.6.2"
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md
|
||||
git commit -m "release(server): server-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag plugin-v0.6.2
|
||||
git push origin plugin-v0.6.2
|
||||
git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
|
||||
validates all plugin-owned version metadata with
|
||||
`scripts/check-plugin-version-sync.py`. Run
|
||||
`python scripts/check-version-tracks.py` locally before tagging when a change
|
||||
touches more than one release surface. The workflow also runs plugin tests,
|
||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
||||
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
|
||||
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
|
||||
validates all server-owned version metadata with
|
||||
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
|
||||
package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
|
||||
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
|
||||
> automated for stable tags.** The release workflow runs
|
||||
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
|
||||
> Production **draft** — skip to the Play Console, confirm the draft, and click
|
||||
> **Start rollout**. The manual path below is the fallback when the secret is
|
||||
> unset (or for staging on a non-production track).
|
||||
|
||||
**Pick the track first.** The AAB is track-agnostic — the same
|
||||
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
|
||||
not habit:
|
||||
|
||||
- **Production** — the default for a stable GA release (`android-vX.Y.Z`). The
|
||||
listing is live, so this is where real releases land. The org account is
|
||||
D-U-N-S-verified, so the 14-day / 12-tester closed-testing gate does **not**
|
||||
apply — you can publish straight to Production.
|
||||
- **Open / Closed testing** — only when you actually want a public/private beta
|
||||
channel for this build.
|
||||
- **Internal testing** — only for a throwaway pre-release smoke check (e.g. a
|
||||
prerelease tag), not for a GA. Don't default here.
|
||||
|
||||
**Manual upload:**
|
||||
**Manual upload (default):**
|
||||
|
||||
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
|
||||
Release assets (for example, `hermes-relay-1.0.0-googlePlay-release.aab`),
|
||||
Release assets (for example, `hermes-relay-0.3.0-googlePlay-release.aab`),
|
||||
or use your local build at
|
||||
`app\build\outputs\bundle\googlePlayRelease\hermes-relay-<version>-googlePlay-release.aab`.
|
||||
2. In Play Console, open the track you chose above — for a GA that's
|
||||
**Release > Production**.
|
||||
2. In Play Console: **Release > Testing > Internal testing** (the 14-day
|
||||
closed-testing rule does NOT apply to this account — see "Google Play
|
||||
Console developer account" above).
|
||||
3. **Create new release** > upload the AAB.
|
||||
4. Paste the Play "What's new" from `docs/play-store-listing.md` (≤500 chars) into
|
||||
the release notes field. (`RELEASE_NOTES.md` is the GitHub-Release body, not the
|
||||
Play field — don't paste that; it's over the limit.)
|
||||
5. **Review release** > **Start rollout** (set the staged-rollout percentage if you
|
||||
want a gradual production ramp).
|
||||
4. Paste `RELEASE_NOTES.md` into the release notes field.
|
||||
5. **Review release** > **Start rollout.**
|
||||
|
||||
**Automated upload (if `play-service-account.json` is configured):**
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
gradlew publishReleaseBundle --track=production
|
||||
gradlew publishReleaseBundle
|
||||
```
|
||||
|
||||
The `play { }` block in `app/build.gradle.kts` defaults to the `internal` track
|
||||
with `DRAFT` status as a safety net for unattended runs, so pass `--track` explicitly
|
||||
for a real release: `--track=production` (GA), or `--track=alpha` (Closed) /
|
||||
`--track=beta` (Open) for a beta channel.
|
||||
Defaults to the `internal` track with `DRAFT` status (configured in the
|
||||
`play { }` block in `app/build.gradle.kts`). Override per-invocation with
|
||||
`--track=alpha` (= Closed testing), `--track=beta` (= Open testing), or
|
||||
`--track=production`.
|
||||
|
||||
To promote an existing release between tracks without rebuilding:
|
||||
|
||||
@@ -580,24 +490,18 @@ To promote an existing release between tracks without rebuilding:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
```
|
||||
|
||||
### 6. Tracks (a menu, not a mandatory ladder)
|
||||
### 6. Promote through tracks
|
||||
|
||||
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
|
||||
stable GA publishes **straight to Production** — there is no required promotion
|
||||
chain. The other tracks are opt-in tools, not steps you must climb:
|
||||
Typical path:
|
||||
|
||||
- **Production** — live on the Play Store. Where GA releases go.
|
||||
- **Open testing (beta)** — opt-in public beta channel.
|
||||
- **Closed testing (alpha)** — opt-in private beta (named tester lists).
|
||||
- **Internal testing** — throwaway smoke check (e.g. a prerelease tag), no tester
|
||||
or time minimum.
|
||||
1. **Internal testing** — personal smoke test (no tester or time minimum)
|
||||
2. **Closed testing (alpha)** — optional for staged rollout; Axiom-Labs'
|
||||
org account is exempt from the 14-day / 12-tester rule, so you can skip
|
||||
straight from Internal to Production if the build is ready
|
||||
3. **Open testing (beta)** — optional public beta
|
||||
4. **Production** — live on the Play Store
|
||||
|
||||
If you *do* stage through tracks, promote an existing release without rebuilding via
|
||||
the Play Console UI or:
|
||||
|
||||
```bat
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
```
|
||||
Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
### 7. After release
|
||||
|
||||
@@ -618,9 +522,9 @@ gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
|
||||
## CI Behavior
|
||||
|
||||
Android, Plugin, dashboard, and desktop now have separate CI/release lanes.
|
||||
Android, Server, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full server suite, and keeps
|
||||
plugin changes from forcing an Android app `versionCode` bump.
|
||||
server changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
@@ -640,25 +544,20 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
|
||||
On every push of a tag matching `plugin-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-server.yml`:
|
||||
|
||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`.
|
||||
2. Runs plugin syntax checks and the focused route/auth/session test slice.
|
||||
1. Validates the tag matches all server-owned version metadata checked by
|
||||
`scripts/check-server-version-sync.py`.
|
||||
2. Runs server syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `cli-v*`,
|
||||
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
|
||||
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
|
||||
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
|
||||
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
|
||||
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
|
||||
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||
Dashboard-only changes are covered by
|
||||
On every push of a tag matching `desktop-v*`,
|
||||
`.github/workflows/release-desktop.yml` builds and publishes the desktop
|
||||
CLI binaries. Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
runs the dashboard API tests, and verifies the modal CSS markers are present
|
||||
in the built bundle.
|
||||
@@ -671,13 +570,6 @@ in the built bundle.
|
||||
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
||||
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
|
||||
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
|
||||
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
|
||||
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
|
||||
tags only — prereleases are skipped). CI does the upload; you still click **Start
|
||||
rollout** in Play Console. If the secret is unset, the workflow skips the upload
|
||||
and you upload manually (§5) — nothing else changes.
|
||||
|
||||
## Hotfix Recipe
|
||||
|
||||
@@ -703,9 +595,9 @@ For an Android app hotfix:
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, and tag `plugin-v<next-version>`. Do not touch
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
|
||||
`main`, and tag `server-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
+14
-36
@@ -1,55 +1,33 @@
|
||||
# Hermes-Relay-Android v1.1.0
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** June 16, 2026
|
||||
**Since v1.0.0:** A settings + chat-UX overhaul — quieter status surfaces, a single state-aware plugin badge, and chat-settings polish — plus a force-close fix and release-pipeline upgrades.
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
v1.1.0 is a refinement release on top of the 1.0 milestone. Settings is calmer and easier to read: status pills now appear only when a surface needs attention, the Power tools section shows one **Plugin active / required / offline** badge instead of an identical chip on every card, and the most-used controls sit where you reach for them. Chat settings render correctly, the system-prompt preview reflects your toggles, and a crash that could hit right after a successful pair is gone.
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v1.1.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-1.1.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.1.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.1.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.1.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
## Fixed
|
||||
|
||||
### Settings screen overhaul
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
|
||||
Settings was reorganized around what you actually touch and quieted down everywhere else:
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
|
||||
- **Exception-only status pills.** Status pills now appear only when a surface needs attention and stay quiet when everything is healthy — no more a wall of green chips to read past.
|
||||
- **One state-aware plugin badge.** The Power tools section shows a single **Plugin active / required / offline** badge instead of an identical "Relay paired" chip repeated on every card.
|
||||
- **Layout that follows your reach.** Connections moved to the top (above the Hermes section), and Diagnostics + Developer options moved into the App section.
|
||||
- **Restyled to match the app.** The status chips now use the app's translucent-bordered language, and the brand blue was deepened.
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
|
||||
### Chat settings polish
|
||||
|
||||
- **Streaming-endpoint picker fixed.** The picker no longer wraps "Gateway" / "Sessions" onto a second line.
|
||||
- **Live system-prompt preview.** The system-prompt preview now reflects the context toggles you've enabled (foreground app, battery, safety rails) with representative placeholder values, instead of looking inert.
|
||||
|
||||
### Force-close fix
|
||||
|
||||
A corrupt encrypted token store — which can happen after an app upgrade or a device restore — used to throw during construction and crash the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset in place, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
|
||||
|
||||
### Release pipeline
|
||||
|
||||
- **Automated Play Console upload.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, releases publish to GitHub Releases exactly as before.
|
||||
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
- The force-close fix means devices that previously crashed on connect after an upgrade or restore will heal their token store automatically on first launch of this build — no manual re-pair required in most cases.
|
||||
- `appVersionCode` is **13**.
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
|
||||
+5
-5
@@ -14,7 +14,7 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
|
||||
|
||||
### Desktop track (parallel lane to Android) — **experimental**
|
||||
|
||||
Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `plugin-v*`). Historical alpha prereleases used `desktop-v*`, and the installer/updater keep a migration fallback. Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-cli.yml`](.github/workflows/release-cli.yml).
|
||||
Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
@@ -47,11 +47,11 @@ Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `p
|
||||
|
||||
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
|
||||
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary self-update path polls the GitHub Releases API, prefers `cli-v*`, falls back to historical `desktop-v*` prereleases during migration, compares to `readVersion()`, and downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
|
||||
- **Harden `release-cli.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
|
||||
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
|
||||
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
|
||||
@@ -101,7 +101,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**What the middleware can do (near-term, ships via install.sh).** New aiohttp middleware in `hermes_relay_bootstrap/_command_middleware.py`, installed at the same `_PatchedApplication.__setitem__` hook as the current route injection so it lands before `AppRunner.setup()` freezes the app. Filters by `request.path in ("/v1/runs", "/v1/chat/completions")` — zero-cost fast path for everything else. On chat paths: parses the body, lazy-imports `GATEWAY_KNOWN_COMMANDS` + `resolve_command()` + `gateway_help_lines()` from `hermes_cli.commands`, and splits on command type:
|
||||
- **Stateless commands** (`/help`, `/commands`, and any others the upstream Option B PR ends up supporting without router state) — actually dispatch, emit a synthetic SSE stream matching the runs handler's existing event shape so the Android client at `HermesApiClient.kt:655-715` renders it as a normal assistant turn.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (post-PR-#8556) or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
|
||||
**On no match** (unknown command, cli-only command, or plain text): falls through to `handler(request)` unchanged. Fork-detects the same way the existing injection does — if the upstream preprocessor PR lands first, the middleware no-ops.
|
||||
|
||||
@@ -109,7 +109,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
|
||||
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (post PR #8556).** Once PR #8556 merges and `/api/sessions/{id}/chat/stream` ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`. Blocked on #8556 landing.
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134 / commit `f7527b0`).** Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`.
|
||||
|
||||
## Future — v0.5+
|
||||
|
||||
|
||||
@@ -78,10 +78,10 @@ Things to look into:
|
||||
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
|
||||
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
|
||||
- **`hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Bootstrap injection shrink path** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. Upstream commit `f7527b0` via PR #33134 now covers baseline sessions/chat/fork/message history, and `/v1/skills` covers list metadata. Do **not** delete the bootstrap wholesale yet: Relay still depends on compatibility routes that upstream lacks or does not match (`/api/sessions/search`, `/api/memory`, `/api/config`, legacy `/api/skills` detail routes, `/api/available-models`, and voice aliases). Shrink per route group only after native parity or client migration.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Follow-up to the native session-control baseline from PR #33134 / commit `f7527b0`. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Blocked on PR #8556 merging. Once session primitives ship upstream, add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked by upstream PR #33134 / commit `f7527b0`. Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
When the answer becomes clearer, this section becomes either an ADR in `docs/decisions.md` or a Plan under `Plans/`.
|
||||
|
||||
|
||||
@@ -106,19 +106,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Structural guard: the sideload flavor is distributed via GitHub Releases /
|
||||
// F-Droid / ADB and must NEVER be uploaded to Play Console (it declares the
|
||||
// unattended Device Control surface Play forbids). gradle-play-publisher
|
||||
// generates a publish task per variant, so the aggregate `publishReleaseBundle`
|
||||
// would otherwise try BOTH flavors. Disabling sideload here means only
|
||||
// `publishGooglePlayReleaseBundle` can ever reach Play — see the `play { }`
|
||||
// block below and .github/workflows/release-android.yml.
|
||||
playConfigs {
|
||||
register("sideload") {
|
||||
enabled.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("boolean", "DEV_MODE", "true")
|
||||
@@ -243,10 +230,6 @@ dependencies {
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
|
||||
// Coil 3 — async image loading for generated images in chat
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// QR Code scanning (ML Kit + CameraX)
|
||||
implementation(libs.mlkit.barcode)
|
||||
implementation(libs.camera.core)
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PowerFeatureGateUiTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun requiresPairingCard_showsPairToUnlock() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredPairingCard_showsPairAgain() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardSignInCard_usesDashboardLanguage() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Manage",
|
||||
summary = "Open dashboard-backed management features.",
|
||||
status = PowerFeatureGateStatus.DashboardSignInRequired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
+162
-64
@@ -1,19 +1,22 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
* Instrumented tests for the onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@@ -21,175 +24,270 @@ class OnboardingFlowTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
onComplete = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.onNodeWithText("Hermes-Relay")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Standard")
|
||||
.onNodeWithText("Skip")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.onNodeWithText("Next")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat")
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
// Page 1: Hermes-Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").performClick()
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 5: Connect to Hermes
|
||||
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 6: Relay Server (last page)
|
||||
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Back button ---
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// On page 1, Back should not exist
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
fun backButton_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Go to page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Standard Hermes")
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_showsApiFields() {
|
||||
fun connectPage_hasApiKeyField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog is showing
|
||||
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
|
||||
|
||||
// Dismiss it
|
||||
composeTestRule.onNodeWithText("Got it").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should be gone
|
||||
composeTestRule
|
||||
.onNodeWithText("Connect")
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- Page 6: Relay page ---
|
||||
|
||||
@Test
|
||||
fun relayPage_showsOptionalMessaging() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5) // Last page
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("This is optional", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_keepsPairingOptional() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,157 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsCurrentTopBar() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val terminalViewModel = TerminalViewModel(app)
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsCurrentTopBar() {
|
||||
fun terminalScreen_showsPhase2Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 2")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Secure shell access", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Full ANSI terminal emulator", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("tmux session management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Bridge Screen ---
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Device Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPhase3Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Agent-controlled device interaction", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Permission management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,6 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- Turn-complete chat notification (TurnCompleteNotifier) — runtime-requested
|
||||
on API 33+ from the Chat Settings toggle. Lives in main (not just the
|
||||
sideload overlay) so the googlePlay flavor can notify too. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Opt-in "Keep connected in background" (GatewayKeepAliveService). In main
|
||||
(not the sideload overlay) so the googlePlay flavor ships it too — the
|
||||
Home-Assistant-class persistent-connection use case Play permits. The
|
||||
specialUse type requires a one-time Play Console foreground-service
|
||||
declaration at submission. (Also already present in the sideload overlay
|
||||
for the device-control bridge service; the merger dedups.) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
@@ -37,7 +24,6 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -67,20 +53,6 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- Opt-in "Keep connected in background" — holds the gateway chat
|
||||
socket open while backgrounded. In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".network.GatewayKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
v1.0.0 - The 1.0 release
|
||||
|
||||
Standard path
|
||||
* Chat, Manage, and voice now work on a plain Hermes agent — no relay
|
||||
plugin required. The plugin is optional and only adds power tools.
|
||||
|
||||
Chat
|
||||
* New gateway transport streams the agent's reasoning live, so the
|
||||
Thinking block fills in during generation instead of after.
|
||||
* Warm-start + opt-in "Keep connected in background" make returning to a
|
||||
conversation fast.
|
||||
* Attachments at desktop parity: images, PDFs, and files upload over the
|
||||
gateway. If a connection can't carry a file, you'll see a notice
|
||||
instead of a silent drop.
|
||||
* Steer a running turn, edit & resend your messages, watch subagent
|
||||
lanes, and a context-window meter — plus turn-complete notifications.
|
||||
* Tap an image to open it full-screen (pinch to zoom); save or share
|
||||
images and other attachments.
|
||||
* Redesigned input bar: pill field, one morphing Send/Voice/Stop button.
|
||||
|
||||
Profiles
|
||||
* Switch the whole agent — model, persona, and skills — per conversation.
|
||||
The drawer scopes to the active profile, and switching is ephemeral: it
|
||||
never changes your server's default agent.
|
||||
|
||||
Manage
|
||||
* Models, provider keys, profiles + SOUL.md, and a skills hub — parity
|
||||
with the desktop dashboard. Cached for instant cold-launch.
|
||||
v0.8.1 - Voice mode crash fix
|
||||
|
||||
Voice
|
||||
* Realtime Agent keeps one session across turns; long runs continue in
|
||||
the background and are spoken when ready.
|
||||
|
||||
Polish
|
||||
* Seamless LAN/Tailscale handoffs (no chat reload), slide-down status
|
||||
toasts, and a broad round of fixes.
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.Application
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.ComposeUiFlags
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
class HermesRelayApp : Application() {
|
||||
|
||||
/**
|
||||
* Coil's singleton image loader for the whole app. Registering the OkHttp
|
||||
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
|
||||
* generated-image link in a chat reply) load, rather than relying on
|
||||
* artifact auto-registration. Crossfade for a clean fade-in.
|
||||
*/
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components { add(OkHttpNetworkFetcherFactory()) }
|
||||
.crossfade(true)
|
||||
.build()
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun attachBaseContext(base: android.content.Context?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// Compose's adaptive refresh-rate hint path on API 35 can emit
|
||||
// `setRequestedFrameRate frameRate=NaN` from inside AndroidComposeView
|
||||
// on every draw pass. Disable ARR globally until the upstream fix lands.
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
instance = this
|
||||
AppAnalytics.initialize(this)
|
||||
// A8 — wire the bridge-gesture wake-lock wrapper so
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
@@ -38,7 +37,7 @@ class MainActivity : ComponentActivity() {
|
||||
// We do NOT call MediaProjectionHolder directly from here. On Android
|
||||
// 14+, getMediaProjection() must run from inside a foreground service
|
||||
// that has already called startForeground(type=mediaProjection), and
|
||||
// that startForeground call must happen AFT consent. So we hand the
|
||||
// that startForeground call must happen AFTER consent. So we hand the
|
||||
// result off to BridgeForegroundService, which:
|
||||
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
|
||||
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
@@ -143,9 +142,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* # Master enable / disable
|
||||
*
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
|
||||
* the hard switch — if it's off we never receive events. On top of that the
|
||||
* user can flip a soft master in Settings (`bridge_master_enabled`); when
|
||||
* that's false we still run (Android requires it to stay connected) but we
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -41,15 +40,6 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val pairedSessionMetaJson: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Orchestrates pairing + session token lifecycle for the relay channel.
|
||||
*
|
||||
@@ -100,7 +90,6 @@ class AuthManager(
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val HINT_API_KEY_PRESENT = "api_key_present"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
@@ -145,56 +134,6 @@ class AuthManager(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
ConnectionAuthSecrets(
|
||||
sessionToken = store.getString(KEY_SESSION_TOKEN),
|
||||
refreshToken = store.getString(KEY_REFRESH_TOKEN),
|
||||
deviceId = store.getString(KEY_DEVICE_ID),
|
||||
apiKey = store.getString(KEY_API_KEY),
|
||||
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun importStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
secrets: ConnectionAuthSecrets,
|
||||
) {
|
||||
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_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenStoreForBackup(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): SessionTokenStore {
|
||||
val appContext = context.applicationContext
|
||||
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
private fun writeOrRemove(
|
||||
store: SessionTokenStore,
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) {
|
||||
store.remove(key)
|
||||
} else {
|
||||
store.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `profiles` array from an `auth.ok` payload into a list of
|
||||
* [Profile] entries. Extracted out of [handleAuthOk] so it's
|
||||
@@ -273,48 +212,6 @@ class AuthManager(
|
||||
private var _store: SessionTokenStore? = null
|
||||
private val storeMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The encrypted-store filename for this connection — shared by [store]
|
||||
* and the plain hint file below so they always describe the same store.
|
||||
*/
|
||||
private val tokenPrefsName: String =
|
||||
tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain (non-encrypted) mirror of one boolean fact: "does this
|
||||
* connection have an API key stored?". Read at startup WITHOUT touching
|
||||
* the Keystore, so [ConnectionViewModel] can build the API client
|
||||
* immediately for key-less connections — the common local setup —
|
||||
* instead of queueing behind the encrypted store's first decrypt.
|
||||
*
|
||||
* Why this exists: on StrongBox devices every keystore operation runs
|
||||
* ~550ms and Tink serializes them process-globally; a measured S25
|
||||
* Ultra cold start spent 15 seconds in that marathon before
|
||||
* `getApiKey()` could return — only to answer "there is no key".
|
||||
*
|
||||
* The hint stores ONLY presence, never key material. It defaults to
|
||||
* `true` (unknown ⇒ assume a key exists ⇒ wait for the real decrypt),
|
||||
* so a missing or stale hint can never strip auth off a keyed
|
||||
* connection — the failure mode is "slow like before", never "401s".
|
||||
* It converges in [setApiKey]/[clearApiKey], in init's store
|
||||
* hydration, and after legacy migration.
|
||||
*/
|
||||
private val hintPrefs by lazy {
|
||||
context.getSharedPreferences("${tokenPrefsName}_plain_hints", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** True only when a previously-recorded hint says "no API key stored". */
|
||||
fun apiKeyKnownAbsent(): Boolean = !hintPrefs.getBoolean(HINT_API_KEY_PRESENT, true)
|
||||
|
||||
private fun recordApiKeyHint(present: Boolean) {
|
||||
_apiKeyPresent.value = present
|
||||
hintPrefs.edit().putBoolean(HINT_API_KEY_PRESENT, present).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily construct the best available token store. First tries
|
||||
* [KeystoreTokenStore] — if that fails on broken OEM keystores we fall
|
||||
@@ -330,26 +227,19 @@ class AuthManager(
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: [tokenPrefsName] picks the
|
||||
// EncryptedSharedPreferences filename for the bound
|
||||
// connection. The legacy sentinel keeps the pre-multi-
|
||||
// connection install on its original file so the existing
|
||||
// paired device keeps working with no migration.
|
||||
// Both encrypted backends decrypt their Tink keyset eagerly on
|
||||
// construction, so a corrupt file can throw AEADBadTagException
|
||||
// here. KeystoreTokenStore.tryCreate already degrades to null;
|
||||
// the legacy store self-heals its file in its constructor. If
|
||||
// even that rebuild fails (a fundamentally broken keystore),
|
||||
// fall back to a non-persistent store rather than force-close —
|
||||
// the user re-pairs, but the app stays up.
|
||||
// Multi-connection: pick the EncryptedSharedPreferences
|
||||
// filename based on the bound connection. The legacy sentinel
|
||||
// keeps the pre-multi-connection install on its original file
|
||||
// so the existing paired device keeps working with no
|
||||
// migration.
|
||||
val prefsName = tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
|
||||
?: runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Legacy token store unavailable (${e.message}) — using in-memory fallback; re-pair required")
|
||||
InMemoryTokenStore()
|
||||
}
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, prefsName)
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
@@ -539,9 +429,7 @@ class AuthManager(
|
||||
} 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())
|
||||
_apiKeyPresent.value = !s.getString(KEY_API_KEY).isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,16 +757,16 @@ class AuthManager(
|
||||
val s = store()
|
||||
if (trimmed.isBlank()) {
|
||||
s.remove(KEY_API_KEY)
|
||||
recordApiKeyHint(false)
|
||||
_apiKeyPresent.value = false
|
||||
} else {
|
||||
s.putString(KEY_API_KEY, trimmed)
|
||||
recordApiKeyHint(true)
|
||||
_apiKeyPresent.value = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearApiKey() {
|
||||
store().remove(KEY_API_KEY)
|
||||
recordApiKeyHint(false)
|
||||
_apiKeyPresent.value = false
|
||||
}
|
||||
|
||||
val isPaired: Boolean
|
||||
|
||||
@@ -72,12 +72,9 @@ class KeystoreTokenStore private constructor(
|
||||
) : SessionTokenStore {
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. This field initializer runs [buildPrefs] eagerly, so it
|
||||
// CAN throw (e.g. AEADBadTagException on a corrupt keyset) — but the
|
||||
// constructor is private and only reachable via [tryCreate], which wraps
|
||||
// construction in try/catch and degrades to the legacy store. The
|
||||
// directly-constructed legacy path self-heals instead; see
|
||||
// [LegacyEncryptedPrefsTokenStore.buildPrefsResilient].
|
||||
// file is deleted. Built lazily via [buildPrefs] so the constructor can't
|
||||
// throw — [tryCreate] still controls the "is this device usable at all"
|
||||
// decision via its init probe below.
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
@@ -260,38 +257,7 @@ class LegacyEncryptedPrefsTokenStore(
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. See [KeystoreTokenStore.resetPrefs] for the rationale.
|
||||
//
|
||||
// Built via [buildPrefsResilient] so a corrupt keyset can't crash the
|
||||
// constructor. Unlike [KeystoreTokenStore], this class is `new`-ed
|
||||
// directly (it's the fallback when KeystoreTokenStore.tryCreate returns
|
||||
// null, and the migration source), so there's no tryCreate-style guard
|
||||
// upstream — the healing has to live here.
|
||||
private var prefs: SharedPreferences = buildPrefsResilient()
|
||||
|
||||
/**
|
||||
* Build the encrypted prefs, healing a corrupted keyset on the way.
|
||||
*
|
||||
* [EncryptedSharedPreferences.create] decrypts the Tink keyset eagerly, so
|
||||
* a stale/corrupt legacy file throws [javax.crypto.AEADBadTagException]
|
||||
* (AES-GCM tag mismatch) right here in the constructor. This is the classic
|
||||
* post-upgrade / post-restore failure: the encrypted blob persists but the
|
||||
* hardware master key it was sealed against is gone or rotated. Delete the
|
||||
* file and rebuild a fresh keyset against the current master key rather
|
||||
* than letting the exception escape and force-close the app — the token in
|
||||
* the unreadable file was lost anyway, so the user simply re-pairs.
|
||||
*/
|
||||
private fun buildPrefsResilient(): SharedPreferences =
|
||||
try {
|
||||
buildPrefs()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial legacy prefs build failed — wiping corrupted file and rebuilding: ${e.message}")
|
||||
try {
|
||||
appContext.deleteSharedPreferences(prefsName)
|
||||
} catch (e2: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
|
||||
}
|
||||
buildPrefs()
|
||||
}
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(appContext)
|
||||
@@ -376,24 +342,3 @@ class LegacyEncryptedPrefsTokenStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-resort implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Non-persistent [SessionTokenStore]. Used only when BOTH the Keystore and the
|
||||
* (self-healing) legacy encrypted store fail to construct — i.e. the device's
|
||||
* AndroidKeystore is so broken it can't even build a fresh key. Tokens live for
|
||||
* the process lifetime only, so the user re-pairs on the next cold start, but
|
||||
* the app stays up instead of force-closing. See [AuthManager.store].
|
||||
*/
|
||||
class InMemoryTokenStore : SessionTokenStore {
|
||||
private val map = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
override val hasHardwareBackedStorage: Boolean = false
|
||||
override fun getString(key: String): String? = map[key]
|
||||
override fun putString(key: String, value: String) { map[key] = value }
|
||||
override fun remove(key: String) { map.remove(key) }
|
||||
override fun contains(key: String): Boolean = map.containsKey(key)
|
||||
override fun clearAll() { map.clear() }
|
||||
}
|
||||
|
||||
@@ -10,60 +10,28 @@ package com.hermesandroid.relay.data
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
private val GENERIC_MODEL_ALIASES = setOf(
|
||||
"hermes-agent",
|
||||
"hermes_agent",
|
||||
"hermes agent",
|
||||
)
|
||||
|
||||
// Only an EXPLICIT pick drives request/session identity. The advertised
|
||||
// "default" profile is an alias for server default, so falling back to it
|
||||
// here would split chat, voice, or session scope.
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun effectiveProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
|
||||
|
||||
// Display can use the synthetic default profile's metadata without making
|
||||
// it a request/session override. Verbose SOUL summaries are filtered by
|
||||
// profileDisplayName below, so this is safe for headers/cards.
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
// that description looks like a concise human agent name ("Victor"), not a
|
||||
// verbose SOUL summary.
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
if (isServerDefaultAlias(profile.name)) {
|
||||
return defaultProfileDisplayName(profile)
|
||||
}
|
||||
return when {
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultProfileDisplayName(profile: Profile?): String? =
|
||||
profile
|
||||
?.description
|
||||
?.trim()
|
||||
?.takeIf { it.looksLikeConciseAgentName() }
|
||||
?.let(::titleCase)
|
||||
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
connectionLabel: String?,
|
||||
localDisplayAlias: String? = null,
|
||||
): String {
|
||||
localDisplayAlias(localDisplayAlias)?.let { return it }
|
||||
profileDisplayName(profile)?.let { return it }
|
||||
|
||||
val personalityName = if (
|
||||
@@ -93,12 +61,6 @@ object AgentDisplay {
|
||||
else -> "Default"
|
||||
}
|
||||
|
||||
fun displayModelName(model: String?): String? =
|
||||
model
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
|
||||
|
||||
fun isServerDefaultAlias(profileName: String?): Boolean =
|
||||
profileName?.trim()?.equals("default", ignoreCase = true) == true
|
||||
|
||||
@@ -116,22 +78,6 @@ object AgentDisplay {
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
|
||||
fun localDisplayAlias(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun String.looksLikeConciseAgentName(): Boolean {
|
||||
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
|
||||
return false
|
||||
}
|
||||
if (any { it == '.' || it == ':' || it == ';' }) {
|
||||
return false
|
||||
}
|
||||
return trim().split(Regex("\\s+")).size <= 4
|
||||
}
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@@ -238,27 +238,7 @@ data class ToolCall(
|
||||
val provenance: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null,
|
||||
/**
|
||||
* Gateway `tool.generating` pre-start phase — the model is still
|
||||
* streaming this tool's arguments. Cleared (flipped false) when the
|
||||
* matching `tool.start` arrives and the call begins executing. Renders
|
||||
* as the quiet "preparing" state in ToolProgressCard / CompactToolCall
|
||||
* rather than the active running spinner.
|
||||
*/
|
||||
val isGenerating: Boolean = false,
|
||||
/**
|
||||
* Subagent lane index from gateway `subagent.*` events (`task_index`).
|
||||
* Null = top-level tool call, rendered exactly as before. Non-null
|
||||
* calls are grouped per index into a SubagentLane under the bubble.
|
||||
*/
|
||||
val taskIndex: Int? = null,
|
||||
/**
|
||||
* Human label for the owning subagent lane — the `subagent.start`
|
||||
* goal truncated to 60 chars. Carried on each child call so the lane
|
||||
* header can render without a separate lane registry.
|
||||
*/
|
||||
val taskLabel: String? = null
|
||||
val completedAt: Long? = null
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
@@ -272,16 +252,5 @@ data class ChatSession(
|
||||
val title: String?,
|
||||
val model: String?,
|
||||
val messageCount: Int = 0,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
val startTimestamp: Long
|
||||
get() = firstPositive(startedAt, updatedAt, lastActivityAt)
|
||||
|
||||
private fun firstPositive(vararg values: Long): Long =
|
||||
values.firstOrNull { it > 0L } ?: 0L
|
||||
}
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
@@ -3,18 +3,6 @@ package com.hermesandroid.relay.data
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
@Serializable
|
||||
data class DashboardConnectionStatus(
|
||||
val checkedAtMillis: Long? = null,
|
||||
val reachable: Boolean = false,
|
||||
val authRequired: Boolean? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authenticated: Boolean? = null,
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
@@ -42,9 +30,9 @@ data class DashboardConnectionStatus(
|
||||
*
|
||||
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
|
||||
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
|
||||
* free to mean upstream Hermes profiles: separate host-side Hermes homes
|
||||
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
|
||||
* sessions, skills, cron, and provider state.
|
||||
* free to mean what Hermes's server config means by it (agent profiles —
|
||||
* name + model + description defined under `agent.profiles` in config.yaml).
|
||||
* A follow-up pass will introduce the new `Profile` concept on top.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
@@ -53,24 +41,6 @@ data class Connection(
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/**
|
||||
* Hermes dashboard/admin URL. Dashboard management features use this
|
||||
* separately from the relay pairing channel; a blank/null value means
|
||||
* "derive from [apiServerUrl] using the conventional same-host :9119".
|
||||
*/
|
||||
val dashboardUrl: String? = null,
|
||||
val dashboardAuthRequired: Boolean? = null,
|
||||
val dashboardAuthProviders: List<String> = emptyList(),
|
||||
val dashboardLastStatus: DashboardConnectionStatus? = null,
|
||||
/**
|
||||
* Candidate host routes for this saved Hermes server. Standard setup
|
||||
* stores at least one candidate here so API, dashboard, voice, and Relay
|
||||
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
|
||||
* Older installs and legacy serialized records default to an empty list.
|
||||
*/
|
||||
val routeCandidates: List<EndpointCandidate> = emptyList(),
|
||||
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
@@ -78,12 +48,6 @@ data class Connection(
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -93,8 +57,6 @@ data class Connection(
|
||||
*/
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
* from a connection UUID. Trimmed to the first 8 characters of the
|
||||
@@ -118,213 +80,5 @@ data class Connection(
|
||||
apiServerUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
apiServerUrl: String,
|
||||
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun buildRouteCandidates(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
role = inferRouteRole(apiServerUrl),
|
||||
priority = 0,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
.map { it.first.trim() to it.second.trim() }
|
||||
.filter { (_, url) -> url.isNotBlank() }
|
||||
.forEachIndexed { index, (role, url) ->
|
||||
endpointCandidateFromApiUrl(
|
||||
role = role.ifBlank { inferRouteRole(url) },
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a freshly-rebuilt candidate list onto an existing stored
|
||||
* one, preserving the stored extras (priority > 0) that the rebuild
|
||||
* doesn't already cover. URL edits rebuild only the route(s) the
|
||||
* user actually touched — without this merge, saving an API or
|
||||
* Relay URL collapsed the stored list to a single candidate,
|
||||
* silently dropping the setup wizard's Tailscale route (or a
|
||||
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
|
||||
*
|
||||
* Stored extras are preserved **verbatim** (role, priority, relay
|
||||
* URL) rather than re-derived, so payload-specified relay URLs
|
||||
* survive. Host:port collisions defer to the rebuilt entry.
|
||||
*/
|
||||
fun mergeRouteCandidates(
|
||||
rebuilt: List<EndpointCandidate>,
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
|
||||
* default a missing scheme to `http://`, and default a missing port
|
||||
* to [defaultPort] — most Hermes API servers speak plain HTTP on
|
||||
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
|
||||
* the most common thing users type.
|
||||
*
|
||||
* URLs that already carry a scheme are preserved **verbatim**
|
||||
* (including a wrong one like `ws://`, so downstream validators can
|
||||
* complain precisely): an explicit `https://hermes.example.com` may
|
||||
* be a reverse proxy on 443, and force-appending :8642 would break
|
||||
* it. Port-defaulting applies only to scheme-less input, where the
|
||||
* user is visibly relying on our defaults.
|
||||
*/
|
||||
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
|
||||
fun endpointCandidateFromApiUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = if (uri.port > 0) uri.port else 8642
|
||||
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl)
|
||||
?: return null
|
||||
val transportHint = when {
|
||||
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
?.lowercase()
|
||||
?: return "custom"
|
||||
return when {
|
||||
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
|
||||
host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
isPrivateLanIpv4(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTailscaleIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return parts[0] == 100 && parts[1] in 64..127
|
||||
}
|
||||
|
||||
private fun isPrivateLanIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,17 +100,6 @@ class ConnectionStore private constructor(
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
* seed values, which are indistinguishable from a genuinely empty store.
|
||||
* Consumers that must not mistake "still loading" for "nothing configured"
|
||||
* — e.g. the chat empty-state, which would otherwise flash a "Connect to
|
||||
* Hermes" CTA on every cold start — gate on this instead of on emptiness.
|
||||
*/
|
||||
private val _isHydrated = MutableStateFlow(false)
|
||||
val isHydrated: StateFlow<Boolean> = _isHydrated.asStateFlow()
|
||||
|
||||
/**
|
||||
* Derived: the active connection, or null when the active ID is missing
|
||||
* or points to a deleted connection. Recomputes every time either
|
||||
@@ -155,11 +144,6 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
} finally {
|
||||
// Mark hydration done even on failure — a failed read still
|
||||
// means "we now know the store's state is empty", so the UI
|
||||
// should stop showing the neutral loading gate.
|
||||
_isHydrated.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,8 +158,7 @@ class ConnectionStore private constructor(
|
||||
// callers shouldn't rely on insertion order of a duplicate
|
||||
// add, and the alternative (throwing) makes migration code
|
||||
// more brittle than it needs to be.
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.filterNot { it.id == connection.id } + normalized
|
||||
val next = current.filterNot { it.id == connection.id } + connection
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -194,8 +177,7 @@ class ConnectionStore private constructor(
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.map { if (it.id == connection.id) normalized else it }
|
||||
val next = current.map { if (it.id == connection.id) connection else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -230,83 +212,27 @@ class ConnectionStore private constructor(
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-reset helper: clear the persisted connection list, active
|
||||
* pointer, legacy profile aliases, and every known per-connection auth
|
||||
* store. Unlike removing one connection, this intentionally does not pick
|
||||
* a successor; callers are resetting the app back to "no connection".
|
||||
*/
|
||||
suspend fun clearAllConnections() {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
val normalizedConnections = connections.map { it.withDashboardDefaults() }
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (normalizedConnections.isEmpty()) {
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
} else {
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
|
||||
}
|
||||
if (normalizedActiveId == null) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTokenStoresFor(connection: Connection) {
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
removed?.let { connection ->
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,8 +294,6 @@ class ConnectionStore private constructor(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
@@ -416,8 +340,6 @@ class ConnectionStore private constructor(
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
|
||||
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
@@ -433,33 +355,6 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDashboardStatus(
|
||||
connectionId: String,
|
||||
status: DashboardConnectionStatus,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
dashboardAuthRequired = status.authRequired,
|
||||
dashboardAuthProviders = status.authProviders,
|
||||
dashboardLastStatus = status,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---------------------------------------------------
|
||||
|
||||
private fun encodeConnections(list: List<Connection>): String =
|
||||
@@ -469,36 +364,12 @@ class ConnectionStore private constructor(
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(connectionListSerializer, raw)
|
||||
.map { it.withDashboardDefaults() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@ import android.util.Log
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
|
||||
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.StoredDashboardCookie
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -24,8 +20,8 @@ import java.io.File
|
||||
/**
|
||||
* Manages app data: backup, restore, and reset.
|
||||
*
|
||||
* Backup format is a JSON file containing full connection metadata and
|
||||
* credentials. Treat exported files as sensitive secrets.
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
*/
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
@@ -55,7 +51,8 @@ class DataManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup data model.
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
*
|
||||
* **Schema history:**
|
||||
* - v1: `serverUrl` only (single endpoint, pre-API-split).
|
||||
@@ -69,50 +66,22 @@ class DataManager(
|
||||
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
|
||||
* get `connections = emptyList()` since the old string list was not
|
||||
* structurally compatible.
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 5,
|
||||
val version: Int = 4,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val activeConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionSecretBackup(
|
||||
val connectionId: String,
|
||||
val tokenStoreKey: String,
|
||||
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
|
||||
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardCookieBackup(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Includes connection credentials. The export UI must warn the user that
|
||||
* the resulting JSON file is sensitive.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
*
|
||||
* The `sessionLabels` parameter is a legacy dead parameter — it was
|
||||
* previously sourced from `AuthManager.sessionLabels`, a field removed
|
||||
@@ -131,7 +100,7 @@ class DataManager(
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
|
||||
val connectionsSnapshot = connectionStore?.connections?.value
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
@@ -139,56 +108,19 @@ class DataManager(
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val connectionSecrets = connectionsSnapshot.map { connection ->
|
||||
ConnectionSecretBackup(
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
|
||||
dashboardCookies = EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).load().map { it.toBackup() },
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 5,
|
||||
version = 4,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
connections = connectionsSnapshot ?: emptyList(),
|
||||
exportedAt = System.currentTimeMillis()
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
}
|
||||
|
||||
suspend fun restoreConnectionBackup(backup: AppBackup) {
|
||||
val store = connectionStore ?: return
|
||||
deleteSensitivePreferenceFiles()
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
backup.connectionSecrets.forEach { secret ->
|
||||
val connection = connectionsById[secret.connectionId] ?: return@forEach
|
||||
AuthManager.importStoredSecrets(
|
||||
context = context,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
secrets = secret.auth,
|
||||
)
|
||||
EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
).save(secret.dashboardCookies.map { it.toStoredCookie() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from a JSON string.
|
||||
* Returns the parsed backup, or null if invalid.
|
||||
@@ -289,11 +221,6 @@ class DataManager(
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
|
||||
// Multi-connection reset: clear the hot ConnectionStore state and
|
||||
// delete every per-connection token store before the global
|
||||
// DataStore is wiped.
|
||||
connectionStore?.clearAllConnections()
|
||||
|
||||
// Clear all DataStore preferences
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
|
||||
@@ -304,7 +231,15 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
deleteSensitivePreferenceFiles()
|
||||
// Delete the EncryptedSharedPreferences file for auth tokens
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
|
||||
if (authFile.exists()) {
|
||||
authFile.delete()
|
||||
Log.d(TAG, "Deleted auth preferences file")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache directory
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -319,61 +254,6 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val stores = buildSet {
|
||||
add(AUTH_PREFS_NAME)
|
||||
add(Connection.LEGACY_TOKEN_STORE_KEY)
|
||||
prefsDir.listFiles()?.forEach { file ->
|
||||
if (file.extension == "xml") {
|
||||
val name = file.nameWithoutExtension
|
||||
if (
|
||||
name.startsWith("hermes_auth_") ||
|
||||
name.startsWith("hermes_dashboard_")
|
||||
) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stores.forEach { storeName ->
|
||||
try {
|
||||
context.deleteSharedPreferences(storeName)
|
||||
Log.d(TAG, "Deleted auth preferences file: $storeName")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
|
||||
DashboardCookieBackup(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
|
||||
/**
|
||||
* Single source of truth for the opt-in "keep the gateway chat connection
|
||||
* alive in the background" preference. Off by default.
|
||||
*
|
||||
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
|
||||
* StateFlow + setter that drive the foreground service and the client's
|
||||
* no-background-close flag) and
|
||||
* [com.hermesandroid.relay.network.GatewayKeepAliveService]'s Stop notification
|
||||
* action, so both read/write the same key.
|
||||
*/
|
||||
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
|
||||
|
||||
/** Persist the keep-alive preference. Used by the FGS Stop action. */
|
||||
suspend fun Context.setGatewayKeepAlive(enabled: Boolean) {
|
||||
relayDataStore.edit { it[KEY_GATEWAY_KEEP_ALIVE] = enabled }
|
||||
}
|
||||
@@ -53,22 +53,8 @@ data class HermesCard(
|
||||
* which action (if any) has been dispatched, so the same card reloaded
|
||||
* from session history doesn't re-prompt. Falls back to the card's
|
||||
* position in the message when null.
|
||||
*
|
||||
* For the gateway ask types this is the ask's `request_id` (or
|
||||
* `approval-<sid>-<ts>` for approval, which has no request id) — the
|
||||
* dispatch tracker keys answer-once semantics off it.
|
||||
*/
|
||||
val id: String? = null,
|
||||
/**
|
||||
* Interactive input slot rendered between [fields] and [actions] —
|
||||
* the answer surface for the gateway ask cards (`ask.clarify` choice
|
||||
* chips + free text, `ask.secret` masked field, `ask.sudo`
|
||||
* hold-to-confirm). Null for every plain card. Submissions flow
|
||||
* through the renderer's `onInputSubmit(cardKey, value)` callback and
|
||||
* collapse the card via the same [HermesCardDispatch] list as button
|
||||
* actions.
|
||||
*/
|
||||
val input: HermesCardInput? = null,
|
||||
) {
|
||||
object BuiltInTypes {
|
||||
const val SKILL_RESULT = "skill_result"
|
||||
@@ -76,14 +62,6 @@ data class HermesCard(
|
||||
const val LINK_PREVIEW = "link_preview"
|
||||
const val CALENDAR_EVENT = "calendar_event"
|
||||
const val WEATHER = "weather"
|
||||
|
||||
// Gateway interactive asks (desktop-parity wave). Locally built
|
||||
// from clarify/approval/sudo/secret request events — never parsed
|
||||
// out of the text stream.
|
||||
const val ASK_APPROVAL = "ask.approval"
|
||||
const val ASK_CLARIFY = "ask.clarify"
|
||||
const val ASK_SUDO = "ask.sudo"
|
||||
const val ASK_SECRET = "ask.secret"
|
||||
}
|
||||
|
||||
object Accents {
|
||||
@@ -94,67 +72,6 @@ data class HermesCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive input slot on a [HermesCard]. The flags compose rather than
|
||||
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
|
||||
* submit is the 650ms press-fill button), while clarify is
|
||||
* `choices + allowFreeText` and secret is `masked` alone.
|
||||
*
|
||||
* Security contract: when [masked] is true the submitted value is a secret.
|
||||
* It must never be echoed into chat content, logged, or synced via
|
||||
* CardDispatchSyncBuilder — record [SECRET_PROVIDED_STAMP] as the dispatch's
|
||||
* actionValue instead of the real value. The renderer masks the collapse
|
||||
* stamp for masked inputs regardless, but the dispatch record itself is
|
||||
* persisted and synced, so the caller must not put the secret there.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardInput(
|
||||
/**
|
||||
* Input kind — one of [Kinds]. Drives which composite the renderer
|
||||
* builds; unknown kinds degrade to a plain free-text field so newer
|
||||
* asks still get an answer surface.
|
||||
*/
|
||||
val kind: String,
|
||||
/** Quick-answer chips (clarify). Empty = no chip row. */
|
||||
val choices: List<String> = emptyList(),
|
||||
/** Render the inline free-text mini field under the chips. */
|
||||
val allowFreeText: Boolean = false,
|
||||
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
|
||||
val masked: Boolean = false,
|
||||
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
|
||||
val holdToConfirm: Boolean = false,
|
||||
/**
|
||||
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
|
||||
* The renderer shows a countdown footer (Amber under 30s) and
|
||||
* self-collapses to "Expired — not granted" past it. Null = no timeout
|
||||
* (approval is session-scoped).
|
||||
*/
|
||||
val expiresAtMillis: Long? = null,
|
||||
) {
|
||||
object Kinds {
|
||||
const val CHOICE = "choice"
|
||||
const val TEXT = "text"
|
||||
const val SECRET = "secret"
|
||||
const val CONFIRM = "confirm"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel recorded as [HermesCardDispatch.actionValue] when a
|
||||
* [masked] input is submitted. The real secret value goes only to
|
||||
* the ask-respond RPC — never into the dispatch record, chat
|
||||
* content, or session sync.
|
||||
*/
|
||||
const val SECRET_PROVIDED_STAMP = "secret-provided"
|
||||
|
||||
/**
|
||||
* Value submitted by a bare hold-to-confirm (no text field) — the
|
||||
* sudo/approval "yes" that carries no payload of its own.
|
||||
*/
|
||||
const val CONFIRM_VALUE = "confirm"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A label/value row inside a card. [value] is rendered as markdown so the
|
||||
* agent can embed emphasis, inline code, or links.
|
||||
@@ -200,16 +117,6 @@ data class HermesCardAction(
|
||||
const val SEND_TEXT = "send_text"
|
||||
const val SLASH_COMMAND = "slash_command"
|
||||
const val OPEN_URL = "open_url"
|
||||
|
||||
/**
|
||||
* Ask-card answer: dispatch [value] straight to the gateway
|
||||
* ask-respond RPC (clarify/sudo/secret/approval.respond), never
|
||||
* as chat text. Dispatches in this mode are EXCLUDED from
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] —
|
||||
* the server already absorbed the answer through the blocking
|
||||
* ask, and for secrets the value must not enter session memory.
|
||||
*/
|
||||
const val SUBMIT_ASK = "submit_ask"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,8 @@ import kotlinx.serialization.Serializable
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* A Profile is an upstream Hermes profile context within a Connection.
|
||||
* Upstream stores named profiles as separate Hermes homes under
|
||||
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
|
||||
* identity for the Android chat surface:
|
||||
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
|
||||
* changes the active agent identity for the Android chat surface:
|
||||
* - which profile API server the phone routes chat/session calls to when
|
||||
* the relay advertises [apiServerUrl];
|
||||
* - which profile name the phone sends to the server for new sessions and
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Local-only display aliases for agent profiles.
|
||||
*
|
||||
* These names are phone UI labels. They are never sent to Hermes and are keyed
|
||||
* by connection + profile context so the server-default agent can be called
|
||||
* something different on each configured Hermes host.
|
||||
*/
|
||||
class ProfileDisplayAliasStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileDisplayAliasesDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_alias__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setAlias(connectionId: String, profileName: String?, alias: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (alias.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = alias
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun aliasFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs -> prefs.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileDisplayAliasesDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_display_aliases")
|
||||
@@ -87,12 +87,6 @@ class ProfileSelectionStore(
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,62 +10,12 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Which chat transport created (and can resume) a stored session.
|
||||
*
|
||||
* The two chat transports do NOT share session storage, so their ids are not
|
||||
* interchangeable on a non-default profile:
|
||||
* - [GATEWAY] — the `/api/ws` tui_gateway path. `session.create`/`session.resume`
|
||||
* bind the profile's own HERMES_HOME, so sessions live in that profile's
|
||||
* `state.db`. Ids look like `YYYYMMDD_HHMMSS_<hex>`.
|
||||
* - [SSE] — the api_server chat path (`/api/sessions/.../chat/stream`,
|
||||
* `/v1/runs`). The api_server has no per-request profile scoping; it always
|
||||
* persists to its launch `state.db`. Ids look like `api_<unixsecs>_<hex>`.
|
||||
*
|
||||
* Resuming an [SSE] id over the [GATEWAY] (which opens the profile DB) — or vice
|
||||
* versa — fails with "session not found" and silently forks a new session. So
|
||||
* each transport gets its own persisted slot, and a stored id is only ever
|
||||
* restored for the transport that can actually resume it.
|
||||
*/
|
||||
enum class SessionTransport(val key: String) {
|
||||
GATEWAY("gw"),
|
||||
SSE("sse");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Bucket a stored session id by the subsystem that created it — the
|
||||
* id's namespace is the server's own ground truth about which transport
|
||||
* can resume it, more reliable than re-deriving the resolved endpoint
|
||||
* (a turn can fall back from gateway to SSE per-turn).
|
||||
*/
|
||||
fun forSessionId(sessionId: String): SessionTransport =
|
||||
if (sessionId.startsWith("api_")) SSE else GATEWAY
|
||||
|
||||
/**
|
||||
* Bucket a resolved streaming endpoint. Only `"gateway"` resumes from
|
||||
* the per-profile DB; every SSE-family member (`"sessions"` /
|
||||
* `"completions"` / `"runs"`) rides the api_server's launch DB.
|
||||
*/
|
||||
fun forEndpoint(resolvedEndpoint: String): SessionTransport =
|
||||
if (resolvedEndpoint == "gateway") GATEWAY else SSE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection, per-Hermes-profile, per-transport last active chat session.
|
||||
* Per-connection, per-Hermes-profile last active chat session.
|
||||
*
|
||||
* This is intentionally separate from [ProfileSelectionStore]. Selection says
|
||||
* which agent is active; this store says which chat session belongs to that
|
||||
* agent on that connection. Null profile name is the explicit Server default
|
||||
* context.
|
||||
*
|
||||
* **Transport dimension (v1.0.0).** The slot is keyed by [SessionTransport] too,
|
||||
* because a gateway session and an api_server (SSE) session are stored in
|
||||
* different databases and cannot be cross-resumed on a non-default profile.
|
||||
* Keying by transport keeps the two from clobbering one slot and guarantees a
|
||||
* restored id is always resumable by the transport asking for it. The key shape
|
||||
* changed in this release, so pre-existing (untransported) slots are not read —
|
||||
* a one-time drop of the "last session" pointer that also clears the exact stale
|
||||
* cross-transport ids that caused mid-conversation forks.
|
||||
*/
|
||||
class ProfileSessionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
@@ -75,18 +25,11 @@ class ProfileSessionStore(
|
||||
companion object {
|
||||
private const val PREFIX = "profile_session__"
|
||||
|
||||
private fun keyName(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}__${transport.key}"
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) = stringPreferencesKey(keyName(connectionId, profileName, transport))
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
@@ -95,11 +38,10 @@ class ProfileSessionStore(
|
||||
suspend fun setSessionId(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
sessionId: String?,
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName, transport)
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
@@ -108,12 +50,8 @@ class ProfileSessionStore(
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionIdFlow(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName, transport)
|
||||
fun sessionIdFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
@@ -125,12 +63,6 @@ class ProfileSessionStore(
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileSessionsDataStore: DataStore<Preferences>
|
||||
|
||||
@@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.map
|
||||
*/
|
||||
data class VoiceSettings(
|
||||
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 3000L,
|
||||
val autoTts: Boolean = false,
|
||||
@@ -49,24 +48,12 @@ enum class VoiceEngineMode(val storageValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoiceAudioRoute(val storageValue: String) {
|
||||
Auto("auto"),
|
||||
Standard("standard"),
|
||||
Relay("relay");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceAudioRoute =
|
||||
values().firstOrNull { it.storageValue == value } ?: Auto
|
||||
}
|
||||
}
|
||||
|
||||
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
|
||||
private val KEY_AUDIO_ROUTE = stringPreferencesKey("voice_audio_route")
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
|
||||
@@ -76,7 +63,6 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
|
||||
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
|
||||
const val DEFAULT_AUDIO_ROUTE = "auto"
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
|
||||
const val DEFAULT_AUTO_TTS = false
|
||||
@@ -91,9 +77,6 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
engineMode = VoiceEngineMode.fromStorage(
|
||||
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
|
||||
).storageValue,
|
||||
audioRoute = VoiceAudioRoute.fromStorage(
|
||||
prefs[KEY_AUDIO_ROUTE] ?: DEFAULT_AUDIO_ROUTE,
|
||||
).storageValue,
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
@@ -110,10 +93,6 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setAudioRoute(route: VoiceAudioRoute) {
|
||||
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setInteractionMode(mode: String) {
|
||||
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
}
|
||||
|
||||
@@ -81,21 +81,12 @@ class ConnectionManager(
|
||||
private val context: Context? = null,
|
||||
/**
|
||||
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
|
||||
* either [endpointCandidatesProvider] or a non-null [deviceIdProvider],
|
||||
* every call to [connect] first consults the resolver before opening the
|
||||
* WSS; on network changes the resolver is re-run and we hot-swap to the
|
||||
* new winner. When null the manager uses the caller-supplied URL verbatim
|
||||
* (pre-ADR-24 behavior).
|
||||
* a non-null [deviceIdProvider], every call to [connect] first consults
|
||||
* the resolver before opening the WSS; on network changes the resolver
|
||||
* is re-run and we hot-swap to the new winner. When null the manager
|
||||
* uses the caller-supplied URL verbatim (pre-ADR-24 behavior).
|
||||
*/
|
||||
private val endpointResolver: EndpointResolver? = null,
|
||||
/**
|
||||
* Candidate supplier for the active saved connection. This is the
|
||||
* standard-Hermes route source: it works before Relay pairing, so API,
|
||||
* dashboard, voice, and future Relay calls can hand off between LAN and
|
||||
* Tailscale using the same resolver. If it returns an empty list, we fall
|
||||
* back to the legacy per-device PairingPreferences source below.
|
||||
*/
|
||||
private val endpointCandidatesProvider: (suspend () -> List<EndpointCandidate>)? = null,
|
||||
/**
|
||||
* Suspending supplier for the active device id. Used to key into
|
||||
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
|
||||
@@ -173,68 +164,18 @@ class ConnectionManager(
|
||||
* user-preferred endpoint that doesn't respond to HEAD /health falls
|
||||
* back through the normal priority chain.
|
||||
*
|
||||
* Two writers feed this: a sticky [Connection.preferredRouteRole] is
|
||||
* restored into it on connection load, and the Routes card's transient
|
||||
* "Use now" writes it directly without persisting. Cleared on
|
||||
* [disconnect] per ADR 24's "clears on disconnect" semantics.
|
||||
*
|
||||
* Exposed as [manualRoleOverrideFlow] so the Routes card can label the
|
||||
* current route as automatic / preferred / manually switched.
|
||||
* Cleared on [disconnect] per ADR 24's "clears on disconnect" semantics
|
||||
* from the UI card.
|
||||
*/
|
||||
private val _manualRoleOverride = MutableStateFlow<String?>(null)
|
||||
val manualRoleOverrideFlow: StateFlow<String?> = _manualRoleOverride.asStateFlow()
|
||||
@Volatile
|
||||
private var manualRoleOverride: String? = null
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/**
|
||||
* Debounce job for network-change re-resolution. Android fires one
|
||||
* onAvailable per satisfying network (Wi-Fi + cell + VPN can land within
|
||||
* milliseconds of each other, and registration itself replays every
|
||||
* current network), so each event cancels the previous pending resolve
|
||||
* and the last one wins after a short settle window.
|
||||
*/
|
||||
@Volatile
|
||||
private var networkResolveJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/** Deferred reaction to a network loss — cancelled if a network returns within the grace. */
|
||||
private var networkLossJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Set when a network loss outlives [NETWORK_LOSS_GRACE_MS] — only then may
|
||||
* a re-resolve switch DOWN to a lower-priority endpoint. Prevents a
|
||||
* transient probe miss (Wi-Fi settling) from switching routes and
|
||||
* cancelling an in-flight turn. Cleared once a resolution is published.
|
||||
*/
|
||||
@Volatile
|
||||
private var sustainedLossDeclared = false
|
||||
|
||||
init {
|
||||
// Register at construction, not on first connect(). Standard
|
||||
// (no-Relay) connections never open the WSS socket, but their HTTP
|
||||
// surfaces (chat, dashboard, voice) still need [activeEndpoint] to
|
||||
// follow LAN/Tailscale handoffs — leaving registration inside
|
||||
// connect() left the whole ADR 24 network-aware path dormant for
|
||||
// exactly those users. No-op when [context] is null (tests).
|
||||
ensureNetworkCallbackRegistered()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// Settle window before re-resolving after a network event. Long
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
// enough that a route swap still feels immediate.
|
||||
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
|
||||
|
||||
/**
|
||||
* Grace before reacting to a network loss. A transient blip (Wi-Fi
|
||||
* power-save/roam, a brief drop, the OS swapping radios) recovers
|
||||
* within this window and must NOT mark the active endpoint unreachable
|
||||
* or switch routes — doing so rebuilds the chat client and cancels an
|
||||
* in-flight turn. Only a loss sustained past the grace switches.
|
||||
*/
|
||||
private const val NETWORK_LOSS_GRACE_MS = 6_000L
|
||||
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
|
||||
// on the WSS upgrade, we're IP-banned server-side — retrying at
|
||||
// our normal 1-30s cadence re-fills the ban bucket and keeps us
|
||||
@@ -390,37 +331,28 @@ class ConnectionManager(
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
|
||||
val endpoints = try {
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val endpoints: List<EndpointCandidate> = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
endpointCandidatesProvider?.invoke()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: run {
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
if (endpoints.isEmpty()) return null
|
||||
|
||||
// Manual override: if the user pinned a role in the Endpoints card,
|
||||
// try that one first; fall through to the strict-priority algorithm
|
||||
// if it isn't reachable.
|
||||
_manualRoleOverride.value?.let { preferredRole ->
|
||||
manualRoleOverride?.let { preferredRole ->
|
||||
val preferred = endpoints.firstOrNull {
|
||||
it.role.equals(preferredRole, ignoreCase = true)
|
||||
}
|
||||
@@ -439,58 +371,35 @@ class ConnectionManager(
|
||||
/**
|
||||
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
|
||||
* of cache state. Backs the "Probe now" row action in the Endpoints card.
|
||||
* Fire-and-forget wrapper around [probeAndReconnectNow] for callers that
|
||||
* don't need the outcome.
|
||||
*/
|
||||
fun probeAndReconnect() {
|
||||
scope.launch { probeAndReconnectNow() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaitable body of [probeAndReconnect]. Returns the resolved winner —
|
||||
* or null when no candidate answered — so callers (probe-status UI) can
|
||||
* report the outcome instead of guessing with a fixed delay.
|
||||
*
|
||||
* Unlike the pre-2026-06 version this ALWAYS publishes the resolve
|
||||
* outcome to [activeEndpoint]: a standard (no relay socket) connection
|
||||
* whose probes all failed used to early-return before publishing,
|
||||
* leaving the Routes card stuck on "Resolving" with no feedback. The
|
||||
* only exception is the live-socket transient-miss guard shared with
|
||||
* [refreshActiveEndpoint].
|
||||
*/
|
||||
suspend fun probeAndReconnectNow(): EndpointCandidate? {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// — keep the live route published rather than downgrading every
|
||||
// HTTP surface to the saved URL. Mirrors refreshActiveEndpoint.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,22 +407,9 @@ class ConnectionManager(
|
||||
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
|
||||
* so they can follow LAN/Tailscale/VPN route changes even when the relay
|
||||
* socket is currently disconnected or intentionally not paired.
|
||||
*
|
||||
* @param clearProbeCache wipe the resolver's probe cache first. Pass
|
||||
* `true` from "the world may have changed" triggers (app resume,
|
||||
* network change) — otherwise a route that died within the positive
|
||||
* cache TTL (60s) can still be returned as the winner.
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
|
||||
if (clearProbeCache) endpointResolver?.clearCache()
|
||||
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
|
||||
// Transient probe miss while the relay socket is demonstrably up
|
||||
// (slow resume, mid-handoff blip) — keep publishing the live
|
||||
// route instead of downgrading every HTTP surface to the saved
|
||||
// URL. Mirrors scheduleNetworkReResolve's guard.
|
||||
return _activeEndpoint.value
|
||||
}
|
||||
_activeEndpoint.value = resolved
|
||||
return resolved
|
||||
}
|
||||
@@ -524,11 +420,11 @@ class ConnectionManager(
|
||||
* cycle — call [probeAndReconnect] to apply immediately.
|
||||
*/
|
||||
fun setManualRoleOverride(role: String?) {
|
||||
_manualRoleOverride.value = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${_manualRoleOverride.value ?: "(cleared)"}")
|
||||
manualRoleOverride = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${manualRoleOverride ?: "(cleared)"}")
|
||||
}
|
||||
|
||||
fun getManualRoleOverride(): String? = _manualRoleOverride.value
|
||||
fun getManualRoleOverride(): String? = manualRoleOverride
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
@@ -536,65 +432,26 @@ class ConnectionManager(
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced network-change re-resolution, shared by both NetworkCallback
|
||||
* events. Re-runs the resolver and publishes the winner to
|
||||
* [activeEndpoint] so HTTP-only surfaces (chat, dashboard, standard
|
||||
* voice) follow the route change even when no relay socket exists. When
|
||||
* a socket IS up, additionally swaps it to a differing winner, or
|
||||
* reconnects a disconnected socket on the same winner — preserving the
|
||||
* pre-refactor relay-path behavior.
|
||||
*/
|
||||
private fun scheduleNetworkReResolve(closeReason: String) {
|
||||
private fun resolveAndSwitchIfNeeded(closeReason: String) {
|
||||
if (endpointResolver == null) return
|
||||
networkResolveJob?.cancel()
|
||||
networkResolveJob = scope.launch {
|
||||
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
|
||||
val current = serverUrl
|
||||
val current = serverUrl ?: return
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
// Don't clear a live socket's endpoint on a transient probe
|
||||
// miss — only drop the published route when nothing is
|
||||
// actually connected.
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
_activeEndpoint.value = null
|
||||
return@launch
|
||||
}
|
||||
// Endpoint hysteresis: a transient blip can make the active
|
||||
// (higher-priority) endpoint's health probe miss, so the resolver
|
||||
// falls through to a LOWER-priority fallback. Switching on that
|
||||
// transient miss rebuilds the chat client and CANCELS an in-flight
|
||||
// turn. Don't switch DOWN in priority unless a sustained loss was
|
||||
// actually declared (the onLost grace elapsed). Same/upgrade
|
||||
// winners always publish.
|
||||
val active = _activeEndpoint.value
|
||||
if (active != null && resolved.priority > active.priority && !sustainedLossDeclared) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"re-resolve picked lower-priority ${resolved.role}(p${resolved.priority}) over " +
|
||||
"active ${active.role}(p${active.priority}) not confirmed dead — keeping active",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
sustainedLossDeclared = false
|
||||
val newUrl = resolved.relay.url
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
if (current == null) return@launch
|
||||
// After an explicit disconnect() the route still publishes above
|
||||
// (HTTP surfaces keep roaming), but no socket action: without
|
||||
// this gate a network event whose winner differs from the last
|
||||
// URL would resurrect a socket the user deliberately closed.
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(newUrl, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
|
||||
Log.i(TAG, "endpoint fallback: same winner is disconnected — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
@@ -607,33 +464,36 @@ class ConnectionManager(
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
// A network returned — cancel any pending loss reaction: the
|
||||
// drop was transient, so don't switch routes / rebuild the chat
|
||||
// client / cancel an in-flight turn. Re-resolve to pick the best
|
||||
// route (usually the same one); the rebuild only fires if the
|
||||
// URL actually moved.
|
||||
networkLossJob?.cancel()
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
if (endpointResolver == null) return
|
||||
val url = serverUrl ?: return
|
||||
endpointResolver.clearCache()
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val newUrl = resolved?.relay?.url
|
||||
if (newUrl == null) {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Only swap if the winner actually differs from the
|
||||
// currently-connected URL. Avoids dropping a healthy
|
||||
// socket on a no-op network flap (Wi-Fi scan, cell
|
||||
// handover that ends up on the same route, etc.).
|
||||
if (normalizedNew != url) {
|
||||
Log.i(TAG, "network change: swapping $url → $normalizedNew")
|
||||
connectToUrlOnMainPath(newUrl, "Network change — switching endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
// Defer the reaction: a transient blip recovers within the grace
|
||||
// (onAvailable cancels this job). Reacting immediately — marking
|
||||
// the active endpoint unreachable + re-resolving to a fallback —
|
||||
// switches routes mid-blip, which rebuilds the chat client and
|
||||
// CANCELS the in-flight turn. The gateway client already handles
|
||||
// its own socket reconnect across the blip.
|
||||
Log.i(TAG, "network onLost — deferring fallback re-resolve by ${NETWORK_LOSS_GRACE_MS}ms")
|
||||
networkLossJob?.cancel()
|
||||
networkLossJob = scope.launch {
|
||||
delay(NETWORK_LOSS_GRACE_MS)
|
||||
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
|
||||
sustainedLossDeclared = true
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost (sustained)")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
}
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
resolveAndSwitchIfNeeded("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -650,8 +510,6 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
networkLossJob?.cancel()
|
||||
networkLossJob = null
|
||||
val ctx = context ?: return
|
||||
val cb = networkCallback ?: return
|
||||
try {
|
||||
@@ -692,11 +550,10 @@ class ConnectionManager(
|
||||
webSocket = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isInsecureConnection.value = false
|
||||
// ADR 24: clear manual override on explicit disconnect — a "Use
|
||||
// now" switch lasts until the user disconnects, then resets to
|
||||
// resolver-picked. A sticky preferredRouteRole is re-installed by
|
||||
// the ViewModel on the next connection load.
|
||||
_manualRoleOverride.value = null
|
||||
// ADR 24: clear manual override on explicit disconnect — the
|
||||
// Routes card's "Prefer this route" menu contract is that it lasts
|
||||
// until the user disconnects, then resets to resolver-picked.
|
||||
manualRoleOverride = null
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,38 +10,13 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Last observed probe result for a single [EndpointCandidate], keyed by
|
||||
* [EndpointResolver.cacheKey] in [EndpointResolver.probeOutcomes]. Unlike the
|
||||
* probe *cache* (a short-TTL "don't re-ask the network" optimization), this is
|
||||
* a UI-facing record of what actually happened — it survives [EndpointResolver
|
||||
* .clearCache] so the Routes card can keep showing the most recent
|
||||
* reachability verdict between probes.
|
||||
*/
|
||||
data class RouteProbeOutcome(
|
||||
val reachable: Boolean,
|
||||
/** Short human-readable failure reason; null when [reachable]. */
|
||||
val detail: String? = null,
|
||||
/** Resolver-clock timestamp of when the probe finished. */
|
||||
val atMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the highest-priority **reachable** [EndpointCandidate] from a
|
||||
@@ -95,26 +70,6 @@ class EndpointResolver(
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||
|
||||
/**
|
||||
* Last probe verdict per candidate, keyed by [cacheKey]. Drives the
|
||||
* per-row reachability line in the Routes card. Deliberately NOT wiped by
|
||||
* [clearCache] — the cache controls when we re-ask the network; this
|
||||
* records what the network last said.
|
||||
*/
|
||||
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
|
||||
|
||||
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
|
||||
_probeOutcomes.update { outcomes ->
|
||||
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
|
||||
reachable = reachable,
|
||||
detail = detail,
|
||||
atMillis = clock(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EndpointResolver"
|
||||
/**
|
||||
@@ -144,9 +99,6 @@ class EndpointResolver(
|
||||
*/
|
||||
const val NEGATIVE_CACHE_TTL_MS = 2_000L
|
||||
|
||||
/** Shared timeout wording so HEAD-timeout and socket-timeout read the same. */
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
@@ -293,7 +245,6 @@ class EndpointResolver(
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -321,11 +272,6 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
||||
)
|
||||
ok
|
||||
}
|
||||
} ?: run {
|
||||
@@ -338,7 +284,6 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
@@ -351,7 +296,6 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
@@ -365,27 +309,11 @@ class EndpointResolver(
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a probe exception to a short, actionable string for the Routes
|
||||
* card. The TLS case is the headline: a route saved with `https://`
|
||||
* against a plain-HTTP Hermes API server fails its handshake on every
|
||||
* probe and previously surfaced as a silent "never switches" mystery.
|
||||
*/
|
||||
private fun humanProbeFailure(e: Exception): String = when (e) {
|
||||
is SSLException -> "TLS failed — server may be http://, not https://"
|
||||
is ConnectException -> "Connection refused"
|
||||
is UnknownHostException -> "Host not found"
|
||||
is SocketTimeoutException -> PROBE_TIMEOUT_DETAIL
|
||||
is NoRouteToHostException -> "No route to host"
|
||||
else -> e.javaClass.simpleName
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
@@ -401,16 +329,9 @@ class EndpointResolver(
|
||||
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the probe cache so the next resolve runs fresh probes. Called on
|
||||
* "the world changed" triggers — NetworkCallback events, manual "Probe
|
||||
* now", and [refreshActiveEndpoint][ConnectionManager.refreshActiveEndpoint]
|
||||
* with `clearProbeCache = true` — where a positive entry for a
|
||||
* just-died route must not outlive the handoff.
|
||||
*/
|
||||
/** Test-only: wipe the probe cache so a fresh run starts clean. */
|
||||
internal fun clearCache() {
|
||||
probeCache.clear()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,282 +0,0 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
/**
|
||||
* Maps tui_gateway events for ONE chat turn onto [GatewayTurnCallbacks].
|
||||
*
|
||||
* Pure JVM (no Android deps) so the whole mapping table is unit-testable.
|
||||
* The caller (GatewayChatClient) filters events by live session id and
|
||||
* invokes [onEvent] in arrival order; this class owns per-turn state
|
||||
* (backfill guards, synthetic tool ids, turn-end detection).
|
||||
*
|
||||
* Forward-compat contract: unknown event types MUST be ignored — upstream
|
||||
* adds event types freely and old clients are expected to skip them. That is
|
||||
* why dispatch is a manual `when (type)` over [JsonObject] rather than a
|
||||
* sealed polymorphic hierarchy (which throws on unknown discriminators).
|
||||
*/
|
||||
class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
|
||||
|
||||
/** True once `message.complete` or `error` has been seen — the turn is over. */
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var syntheticToolCounter = 0
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
* server omits the id we synthesize one per start and match completes by
|
||||
* tool name, FIFO.
|
||||
*/
|
||||
private val openSyntheticIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
/**
|
||||
* `tool.generating` pre-registrations awaiting their `tool.start`, per
|
||||
* name FIFO — the start adopts the pre-minted id (unless the server
|
||||
* supplied a real `tool_id`) so the "preparing" placeholder and the
|
||||
* running card stay one ToolCall.
|
||||
*/
|
||||
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
when (type) {
|
||||
"reasoning.delta", "thinking.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
sawThinkingDelta = true
|
||||
callbacks.onThinkingDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Post-hoc reasoning (providers that don't stream it) — only
|
||||
// useful when nothing streamed live.
|
||||
"reasoning.available" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty() && !sawThinkingDelta) {
|
||||
sawThinkingDelta = true
|
||||
callbacks.onThinkingDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
sawTextDelta = true
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// Gateway has no server-side message id (placeholder UUID
|
||||
// stays). A second start inside one turn means a new
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
}
|
||||
|
||||
"tool.generating" -> {
|
||||
// `{name?}` with NO tool_id — the model is still streaming
|
||||
// this tool's arguments.
|
||||
val name = payload.string("name")
|
||||
if (name != null) {
|
||||
generatingIdsByName.getOrPut(name) { ArrayDeque() }
|
||||
.addLast("gateway-tool-$name-${syntheticToolCounter++}")
|
||||
}
|
||||
callbacks.onToolGenerating(name)
|
||||
}
|
||||
|
||||
"tool.start" -> {
|
||||
val name = payload.string("name") ?: "unknown"
|
||||
// A pending generating placeholder for this name is adopted
|
||||
// (consumed FIFO) whether or not the server sent a real id.
|
||||
val adopted = generatingIdsByName[name]?.removeFirstOrNull()
|
||||
val serverId = payload.string("tool_id")
|
||||
val toolId = when {
|
||||
serverId != null -> serverId
|
||||
adopted != null -> adopted.also {
|
||||
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(it)
|
||||
}
|
||||
else -> syntheticToolId(name)
|
||||
}
|
||||
callbacks.onToolCallStart(toolId, name)
|
||||
}
|
||||
|
||||
"tool.complete" -> {
|
||||
val name = payload.string("name") ?: "unknown"
|
||||
val toolId = payload.string("tool_id")
|
||||
?: openSyntheticIdsByName[name]?.removeFirstOrNull()
|
||||
?: return
|
||||
val error = payload.string("error")
|
||||
if (!error.isNullOrEmpty()) {
|
||||
callbacks.onToolCallFailed(toolId, error)
|
||||
} else {
|
||||
callbacks.onToolCallDone(toolId, payload.string("summary"))
|
||||
}
|
||||
}
|
||||
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
if (!sawThinkingDelta && !reasoning.isNullOrEmpty()) {
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
turnEnded = true
|
||||
callbacks.onError(payload.string("message") ?: "Gateway error")
|
||||
}
|
||||
|
||||
"subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
val phase = when (type) {
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
|
||||
else -> GatewaySubagentEvent.Phase.COMPLETE
|
||||
}
|
||||
callbacks.onSubagentEvent(
|
||||
GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = payload.int("task_index") ?: 0,
|
||||
taskCount = payload.int("task_count") ?: 1,
|
||||
goal = payload.string("goal") ?: "",
|
||||
status = payload.string("status"),
|
||||
summary = payload.string("summary"),
|
||||
toolName = payload.string("tool_name"),
|
||||
// subagent.tool sets tool_preview AND mirrors it into
|
||||
// text; thinking/progress carry text only.
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"clarify.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"approval.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
timeoutSeconds = 0,
|
||||
),
|
||||
)
|
||||
|
||||
"sudo.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
// Payload carries request_id ONLY — no command to show.
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"secret.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
// Known-but-unrendered (notification.show, status.update, …) and
|
||||
// unknown types alike: ignore.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun syntheticToolId(name: String): String {
|
||||
val id = "gateway-tool-$name-${syntheticToolCounter++}"
|
||||
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(id)
|
||||
return id
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* `message.complete.usage` uses tui_gateway's own key names
|
||||
* (`input`/`output`/`total`, with `prompt`/`completion` as the raw
|
||||
* counterparts — see upstream `_get_usage()`), NOT the
|
||||
* `input_tokens`/`prompt_tokens` schemes [UsageInfo] decodes from the
|
||||
* SSE paths. Translate explicitly. Values are session-cumulative.
|
||||
*
|
||||
* The context-window block (`context_used`/`context_max`/
|
||||
* `context_percent`) exists only when the server's context compressor
|
||||
* is active — absent fields stay null and the meter stays hidden.
|
||||
*/
|
||||
fun parseGatewayUsage(usage: JsonObject?): UsageInfo? {
|
||||
if (usage == null) return null
|
||||
val input = usage.int("input") ?: usage.int("prompt")
|
||||
val output = usage.int("output") ?: usage.int("completion")
|
||||
val total = usage.int("total")
|
||||
val contextUsed = usage.int("context_used")
|
||||
val contextMax = usage.int("context_max")
|
||||
val contextPercent = usage.int("context_percent")
|
||||
if (input == null && output == null && total == null &&
|
||||
contextUsed == null && contextMax == null && contextPercent == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return UsageInfo(
|
||||
inputTokens = input,
|
||||
outputTokens = output,
|
||||
totalTokens = total,
|
||||
contextUsed = contextUsed,
|
||||
contextMax = contextMax,
|
||||
contextPercent = contextPercent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream `_block()` timeouts per ask kind (server.py) — the blocked thread
|
||||
// resolves to "" when these elapse. Approval has none (session-scoped).
|
||||
private const val CLARIFY_TIMEOUT_SECONDS = 300
|
||||
private const val SUDO_TIMEOUT_SECONDS = 120
|
||||
private const val SECRET_TIMEOUT_SECONDS = 300
|
||||
|
||||
private fun JsonObject?.string(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun JsonObject?.int(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.intOrNull
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Opt-in foreground service that keeps the app process alive so the gateway
|
||||
* chat WebSocket (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) survives Android's background-freeze / Doze — i.e.
|
||||
* "keep connected in the background".
|
||||
*
|
||||
* # Both flavors (Play declaration required)
|
||||
*
|
||||
* Declared in the MAIN manifest (unlike the device-control
|
||||
* [com.hermesandroid.relay.bridge.BridgeForegroundService], which is sideload
|
||||
* only), so googlePlay ships it too — the Home-Assistant-class persistent-
|
||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||
* at submission. Off by default; only runs while the user enables the toggle.
|
||||
*
|
||||
* # It does NOT own the socket
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
*
|
||||
* # Android 15 watchdog
|
||||
*
|
||||
* On target SDK 35 any intent to a service that declares a foregroundServiceType
|
||||
* must call `startForeground` within 5s — so [onStartCommand] always does that
|
||||
* first, before branching on the action. Shutdown goes through [stop]
|
||||
* (`stopService`) to bypass [onStartCommand] entirely.
|
||||
*/
|
||||
class GatewayKeepAliveService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "GatewayKeepAliveSvc"
|
||||
const val CHANNEL_ID = "gateway_keepalive"
|
||||
private const val CHANNEL_NAME = "Background connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
// stopService() bypasses onStartCommand, so a "please shut down"
|
||||
// never trips the Android 15 foreground-start watchdog.
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
||||
// Flip the pref off so ConnectionViewModel's collector won't
|
||||
// restart us on the next foreground.
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||
// are all declared in the main manifest (both flavors), so the type is
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
// check is finicky about correlating the runtime type arg with the manifest.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): android.app.Notification {
|
||||
val tapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
|
||||
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes stays connected")
|
||||
.setContentText("Keeping your chat connection warm in the background.")
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description =
|
||||
"Persistent indicator while Hermes keeps your chat connection open in the background."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
|
||||
/**
|
||||
* Shared types for the Gateway chat transport — upstream hermes-agent's
|
||||
* `tui_gateway` JSON-RPC-over-WebSocket surface at the dashboard's `/api/ws`.
|
||||
*
|
||||
* This is the same wire protocol the official hermes-desktop client and the
|
||||
* Ink TUI speak (reference shapes vendored in `desktop/src/gatewayTypes.ts`).
|
||||
* It is the only upstream surface that streams reasoning live
|
||||
* (`reasoning.delta` / `thinking.delta`) — the api_server SSE paths only
|
||||
* deliver reasoning after generation completes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Why the Gateway chat transport is or isn't usable right now. Mirrors
|
||||
* [com.hermesandroid.relay.viewmodel.StandardVoiceAvailability] — both ride
|
||||
* the dashboard surface and share the same probe — minus the audio-route
|
||||
* requirement (`/api/ws` ships with every embedded-chat dashboard build).
|
||||
*/
|
||||
enum class GatewayAvailability {
|
||||
/** No probe has completed yet (startup, connection switch). */
|
||||
Unknown,
|
||||
|
||||
/** Dashboard reachable and authenticated (or auth not required). */
|
||||
Ready,
|
||||
|
||||
/** Dashboard reachable and gated, but no signed-in session — Manage sign-in unlocks it. */
|
||||
SignInRequired,
|
||||
|
||||
/** Dashboard URL configured but `/api/status` did not answer. */
|
||||
Unreachable,
|
||||
|
||||
/**
|
||||
* Runtime sticky downgrade: the WS upgrade or ticket mint was rejected in
|
||||
* a way that says this server build has no usable `/api/ws` (404 on the
|
||||
* route, dashboard build predating the embedded chat). Cleared on
|
||||
* connection switch / fresh probe cycle.
|
||||
*/
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/** Lifecycle of the gateway WebSocket, exposed for diagnostics. */
|
||||
enum class GatewayConnectionState {
|
||||
Idle,
|
||||
MintingTicket,
|
||||
Connecting,
|
||||
AwaitingReady,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
* delegates here with its live state.
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
gateway: GatewayAvailability,
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable handle for one in-flight chat turn, regardless of transport.
|
||||
* SSE turns wrap their [okhttp3.sse.EventSource]; gateway turns wrap a
|
||||
* `session.interrupt` dispatch. Replaces the raw `EventSource?` field in
|
||||
* ChatViewModel so both transports share the cancel/teardown sites.
|
||||
*/
|
||||
fun interface ActiveTurnHandle {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
* server-side), or the turn is cancelled (`session.interrupt` force-releases
|
||||
* pending asks and force-denies approvals). Built by [GatewayEventMapper]
|
||||
* from the four `*.request` events; answered via the
|
||||
* [GatewayChatClient] `respond*` helpers.
|
||||
*/
|
||||
data class GatewayAsk(
|
||||
val kind: Kind,
|
||||
/**
|
||||
* Correlates the answer with the blocked server thread. Null ONLY for
|
||||
* [Kind.APPROVAL] — upstream approvals correlate per-session, not
|
||||
* per-request (`approval.respond` carries `session_id` instead).
|
||||
*/
|
||||
val requestId: String?,
|
||||
/** Question / command / prompt — whatever the ask wants the user to read. */
|
||||
val text: String,
|
||||
/** Clarify-only: server-suggested answers. */
|
||||
val choices: List<String>? = null,
|
||||
/** Secret-only: the env var the value will be stored under. */
|
||||
val envVar: String? = null,
|
||||
/**
|
||||
* Upstream blocking timeout (clarify/secret 300s, sudo 120s). 0 means no
|
||||
* countdown — approvals are session-scoped and never expire on their own.
|
||||
*/
|
||||
val timeoutSeconds: Int,
|
||||
) {
|
||||
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
|
||||
}
|
||||
|
||||
/**
|
||||
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
|
||||
* per task: START → (THINKING | TOOL | PROGRESS)* → COMPLETE. Field
|
||||
* availability varies by phase — [toolName]/[preview] ride TOOL,
|
||||
* [status]/[summary]/[durationSeconds] ride COMPLETE — and older emitters
|
||||
* omit everything beyond the three defaults-bearing fields.
|
||||
*/
|
||||
data class GatewaySubagentEvent(
|
||||
val phase: Phase,
|
||||
val taskIndex: Int,
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val status: String? = null,
|
||||
val summary: String? = null,
|
||||
val toolName: String? = null,
|
||||
val preview: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
) {
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider from the gateway `model.options` RPC — the curated, authenticated
|
||||
* provider/model list the upstream desktop + TUI model picker uses (NOT the
|
||||
* api_server `/v1/models`, which collapses to a single generic agent alias).
|
||||
*/
|
||||
data class GatewayModelProvider(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val models: List<String>,
|
||||
val isCurrent: Boolean,
|
||||
val warning: String?,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Result of the gateway `config.get {key:"reasoning"}` RPC. */
|
||||
data class GatewayReasoningSettings(
|
||||
val effort: String,
|
||||
val display: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Callback set for one gateway turn. Shapes intentionally mirror the SSE
|
||||
* callback lambdas in ChatViewModel.startStream() so the gateway branch can
|
||||
* forward to the exact same ChatHandler mutations.
|
||||
*
|
||||
* Every member is a REQUIRED constructor param on purpose: GatewayChatClient
|
||||
* `dispatchOn` must wrap each one onto the main thread, and a defaulted
|
||||
* member would compile unwrapped — running on the OkHttp reader thread.
|
||||
*/
|
||||
class GatewayTurnCallbacks(
|
||||
/** Stored (DB) session id — fired on session create/rotate so the drawer + persistence stay correct. */
|
||||
val onSessionId: (String) -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
val onToolCallFailed: (toolCallId: String, errorMsg: String?) -> Unit,
|
||||
val onTurnComplete: () -> Unit,
|
||||
val onComplete: () -> Unit,
|
||||
val onUsage: (UsageInfo?) -> Unit,
|
||||
val onError: (String) -> Unit,
|
||||
/**
|
||||
* `tool.generating` — the model is still writing this tool's arguments.
|
||||
* Carries the tool name when upstream sent one. The next `tool.start`
|
||||
* for the same name adopts the "preparing" placeholder (per name, FIFO).
|
||||
*/
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
* cancelled.
|
||||
*/
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
)
|
||||
@@ -16,7 +16,6 @@ import com.hermesandroid.relay.network.models.SessionResponse
|
||||
import com.hermesandroid.relay.network.models.SkillInfo
|
||||
import com.hermesandroid.relay.network.models.SkillListResponse
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -24,7 +23,6 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -64,9 +62,9 @@ enum class ChatMode {
|
||||
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
|
||||
*/
|
||||
data class ServerCapabilities(
|
||||
/** `/api/sessions` (CRUD) — true on native upstream, fork, OR bootstrap-injected older builds. */
|
||||
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
|
||||
val sessionsApi: Boolean,
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true on native upstream or legacy fork builds. */
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
|
||||
val sessionsChatStream: Boolean,
|
||||
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
|
||||
val runs: Boolean,
|
||||
@@ -101,44 +99,6 @@ data class ServerCapabilities(
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.childObject(key: String): JsonObject? = this[key] as? JsonObject
|
||||
|
||||
private fun JsonObject.booleanFlag(key: String): Boolean =
|
||||
(this[key] as? JsonPrimitive)?.booleanOrNull == true
|
||||
|
||||
private fun JsonObject.hasEndpoint(key: String): Boolean {
|
||||
val path = ((this[key] as? JsonObject)?.get("path") as? JsonPrimitive)?.contentOrNull
|
||||
return !path.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities? {
|
||||
val root = try {
|
||||
json.decodeFromString<JsonObject>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val features = root.childObject("features")
|
||||
val endpoints = root.childObject("endpoints")
|
||||
if (features == null && endpoints == null) return null
|
||||
|
||||
fun feature(name: String): Boolean = features?.booleanFlag(name) == true
|
||||
fun endpoint(name: String): Boolean = endpoints?.hasEndpoint(name) == true
|
||||
|
||||
return ServerCapabilities(
|
||||
sessionsApi = feature("session_resources") ||
|
||||
endpoint("sessions") ||
|
||||
endpoint("session_create"),
|
||||
sessionsChatStream = feature("session_chat_streaming") ||
|
||||
endpoint("session_chat_stream"),
|
||||
runs = feature("run_events_sse") || endpoint("run_events"),
|
||||
portable = feature("chat_completions_streaming") ||
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
|
||||
|
||||
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
@@ -288,7 +248,7 @@ class HermesApiClient(
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 200): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
suspend fun listSessionsResult(limit: Int = 50): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
@@ -300,7 +260,7 @@ class HermesApiClient(
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
|
||||
Result.success(parsed.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
@@ -308,7 +268,7 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 200): List<SessionItem> =
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
@@ -389,7 +349,7 @@ class HermesApiClient(
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<MessageListResponse>(body)
|
||||
parsed.data ?: parsed.items ?: parsed.messages ?: emptyList()
|
||||
parsed.items ?: parsed.messages ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get messages: ${e.message}")
|
||||
@@ -417,32 +377,6 @@ class HermesApiClient(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
* Available model ids from `GET /v1/models` (OpenAI-compatible:
|
||||
* `{"object":"list","data":[{"id":"…"}]}`). Backs the in-chat model
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -583,8 +517,6 @@ class HermesApiClient(
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
// Notify caller of the session ID being used
|
||||
mainHandler.post { onSessionId(sessionId) }
|
||||
@@ -596,7 +528,6 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -607,14 +538,6 @@ class HermesApiClient(
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// First visible streamed token (reasoning OR text) — the
|
||||
// metric that exposes SSE's reasoning dead-air vs gateway.
|
||||
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
|
||||
!event.thinking.isNullOrEmpty()
|
||||
) {
|
||||
tracer.mark("ttft")
|
||||
}
|
||||
|
||||
// Check for usage data on ANY event before type resolution
|
||||
// (OpenAI-format chunks have no type/event field but may carry usage)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
@@ -759,7 +682,6 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -773,7 +695,6 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -836,8 +757,6 @@ class HermesApiClient(
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
@@ -846,7 +765,6 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -875,14 +793,12 @@ class HermesApiClient(
|
||||
|
||||
openAiReasoningDelta(event)?.let { reasoning ->
|
||||
if (reasoning.isNotEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onThinkingDelta(reasoning) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiTextDelta(event)?.let { delta ->
|
||||
if (delta.isNotEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
@@ -901,7 +817,6 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -915,7 +830,6 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -1024,8 +938,6 @@ class HermesApiClient(
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
@@ -1034,7 +946,6 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -1045,14 +956,6 @@ class HermesApiClient(
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// First visible streamed token (reasoning OR text) — the
|
||||
// metric that exposes SSE's reasoning dead-air vs gateway.
|
||||
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
|
||||
!event.thinking.isNullOrEmpty()
|
||||
) {
|
||||
tracer.mark("ttft")
|
||||
}
|
||||
|
||||
// Check for usage data before type resolution (catches OpenAI-format chunks)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
mainHandler.post { onUsage(event.usage) }
|
||||
@@ -1113,7 +1016,6 @@ class HermesApiClient(
|
||||
"reasoning.available" -> {
|
||||
val reasoningText = event.text
|
||||
if (!reasoningText.isNullOrEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onThinkingDelta(reasoningText) }
|
||||
}
|
||||
}
|
||||
@@ -1200,7 +1102,6 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -1214,7 +1115,6 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -1241,15 +1141,14 @@ class HermesApiClient(
|
||||
*
|
||||
* Probe order:
|
||||
* 1. `/health` — if this fails, everything else is moot.
|
||||
* 2. `GET /v1/capabilities` — native upstream feature + endpoint map.
|
||||
* 3. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork,
|
||||
* native upstream, OR bootstrap-injected older upstream).
|
||||
* 4. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* 2. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork OR
|
||||
* bootstrap-injected upstream).
|
||||
* 3. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
|
||||
* presence. The handler only accepts POST, so HEAD returns 405
|
||||
* (Method Not Allowed) when the route is registered. 404 means
|
||||
* the route doesn't exist at all.
|
||||
* 5. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 6. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* 4. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 5. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* when the response explicitly advertises event-stream compatibility.
|
||||
*
|
||||
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
|
||||
@@ -1281,20 +1180,6 @@ class HermesApiClient(
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
null
|
||||
} else {
|
||||
parseCapabilitiesBody(json, response.body.string())
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
// Dispatchers.IO context from the outer withContext, so the
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkAddress
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Inet4Address
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesLanDiscoveryResult(
|
||||
val host: String,
|
||||
val apiUrl: String,
|
||||
val dashboardUrl: String?,
|
||||
val apiReachable: Boolean,
|
||||
val dashboardReachable: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-triggered local-network discovery for standard Hermes setup.
|
||||
*
|
||||
* This deliberately scans only the active RFC1918/link-local LAN around the
|
||||
* phone, never broad public or Tailscale ranges. Tailscale/public routes still
|
||||
* belong in the explicit advanced fields where the user controls the URL.
|
||||
*/
|
||||
object HermesLanDiscovery {
|
||||
private const val TAG = "HermesLanDiscovery"
|
||||
private const val MAX_HOSTS = 254
|
||||
private const val MAX_CONCURRENT_PROBES = 32
|
||||
private const val PROBE_TIMEOUT_MS = 650L
|
||||
private const val IPV4_MASK = 0xFFFF_FFFFL
|
||||
|
||||
suspend fun scan(
|
||||
context: Context,
|
||||
apiPort: Int = 8642,
|
||||
dashboardPort: Int = 9119,
|
||||
): List<HermesLanDiscoveryResult> = withContext(Dispatchers.IO) {
|
||||
val hosts = localLanHosts(context.applicationContext)
|
||||
if (hosts.isEmpty()) return@withContext emptyList()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
coroutineScope {
|
||||
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
|
||||
hosts.map { host ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
probeHost(client, host, apiPort, dashboardPort)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
.filterNotNull()
|
||||
.sortedWith(
|
||||
compareByDescending<HermesLanDiscoveryResult> { it.dashboardReachable }
|
||||
.thenByDescending { it.apiReachable }
|
||||
.thenBy { it.host },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeHost(
|
||||
client: OkHttpClient,
|
||||
host: String,
|
||||
apiPort: Int,
|
||||
dashboardPort: Int,
|
||||
): HermesLanDiscoveryResult? {
|
||||
val apiUrl = "http://$host:$apiPort"
|
||||
val dashboardUrl = "http://$host:$dashboardPort"
|
||||
val dashboardReachable = probe(
|
||||
client = client,
|
||||
url = "$dashboardUrl/api/status",
|
||||
expectedBody = ::looksLikeDashboardStatus,
|
||||
)
|
||||
val apiReachable = probe(
|
||||
client = client,
|
||||
url = "$apiUrl/health",
|
||||
expectedBody = ::looksLikeApiHealth,
|
||||
)
|
||||
if (!dashboardReachable && !apiReachable) return null
|
||||
return HermesLanDiscoveryResult(
|
||||
host = host,
|
||||
apiUrl = apiUrl,
|
||||
dashboardUrl = dashboardUrl.takeIf { dashboardReachable },
|
||||
apiReachable = apiReachable,
|
||||
dashboardReachable = dashboardReachable,
|
||||
)
|
||||
}
|
||||
|
||||
private fun probe(
|
||||
client: OkHttpClient,
|
||||
url: String,
|
||||
expectedBody: (String, String) -> Boolean,
|
||||
): Boolean {
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: return false
|
||||
val request = Request.Builder()
|
||||
.url(httpUrl)
|
||||
.get()
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return true
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
}
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
val body = response.body.string().take(2_048)
|
||||
expectedBody(body, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return contentType.contains("json", ignoreCase = true) && (
|
||||
lower.contains("auth_required") ||
|
||||
lower.contains("auth_providers") ||
|
||||
lower.contains("authenticated") ||
|
||||
lower.contains("hermes")
|
||||
)
|
||||
}
|
||||
|
||||
private fun looksLikeApiHealth(body: String, contentType: String): Boolean {
|
||||
if (contentType.contains("json", ignoreCase = true)) return true
|
||||
if (contentType.contains("text/plain", ignoreCase = true)) return true
|
||||
return body.isBlank() || body.trimStart().startsWith("{")
|
||||
}
|
||||
|
||||
private fun localLanHosts(context: Context): List<String> {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
?: return emptyList()
|
||||
val networks = buildList {
|
||||
connectivityManager.activeNetwork?.let(::add)
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
if (!contains(network)) add(network)
|
||||
}
|
||||
}
|
||||
|
||||
val hosts = linkedSetOf<String>()
|
||||
for (network in networks) {
|
||||
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
|
||||
for (linkAddress in linkProperties.linkAddresses) {
|
||||
addHostsForLink(linkAddress, hosts)
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
if (hosts.size >= MAX_HOSTS) break
|
||||
}
|
||||
return hosts.take(MAX_HOSTS)
|
||||
}
|
||||
|
||||
private fun addHostsForLink(linkAddress: LinkAddress, hosts: MutableSet<String>) {
|
||||
val address = linkAddress.address as? Inet4Address ?: return
|
||||
if (address.isLoopbackAddress || address.isMulticastAddress) return
|
||||
|
||||
val local = ipv4ToLong(address)
|
||||
if (!isScannableLanAddress(local)) return
|
||||
|
||||
val scanPrefix = when (linkAddress.prefixLength) {
|
||||
in 24..30 -> linkAddress.prefixLength
|
||||
else -> 24
|
||||
}
|
||||
val mask = subnetMask(scanPrefix)
|
||||
val network = local and mask
|
||||
val broadcast = network or (mask.inv() and IPV4_MASK)
|
||||
val first = network + 1
|
||||
val last = broadcast - 1
|
||||
if (first > last) return
|
||||
|
||||
for (candidate in first..last) {
|
||||
if (candidate == local) continue
|
||||
hosts.add(longToIpv4(candidate))
|
||||
if (hosts.size >= MAX_HOSTS) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun subnetMask(prefixLength: Int): Long {
|
||||
return (IPV4_MASK shl (32 - prefixLength)) and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun ipv4ToLong(address: Inet4Address): Long {
|
||||
return address.address.fold(0L) { acc, byte ->
|
||||
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
|
||||
} and IPV4_MASK
|
||||
}
|
||||
|
||||
private fun longToIpv4(value: Long): String {
|
||||
return listOf(
|
||||
(value shr 24) and 0xFF,
|
||||
(value shr 16) and 0xFF,
|
||||
(value shr 8) and 0xFF,
|
||||
value and 0xFF,
|
||||
).joinToString(".") { it.toString() }
|
||||
}
|
||||
|
||||
private fun isScannableLanAddress(value: Long): Boolean {
|
||||
val first = ((value shr 24) and 0xFF).toInt()
|
||||
val second = ((value shr 16) and 0xFF).toInt()
|
||||
return when {
|
||||
first == 10 -> true
|
||||
first == 172 && second in 16..31 -> true
|
||||
first == 192 && second == 168 -> true
|
||||
first == 169 && second == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface VoiceAudioClient {
|
||||
val route: VoiceAudioRoute
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
}
|
||||
|
||||
class RelayVoiceAudioClientAdapter(
|
||||
private val relayVoiceClient: RelayVoiceClient,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
relayVoiceClient.transcribe(audioFile)
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
relayVoiceClient.synthesize(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
|
||||
*
|
||||
* Auto preference order is **Relay first, then Standard**: a paired Relay is
|
||||
* the purpose-built mobile facade — profile-aware voice config, no dashboard
|
||||
* sign-in dependency — so users who installed the plugin keep the richer
|
||||
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
|
||||
* used whenever Relay isn't configured/paired (or fails mid-call). Power
|
||||
* users can force either route in Voice Settings.
|
||||
*/
|
||||
class AutoVoiceAudioClient(
|
||||
private val standardClient: VoiceAudioClient,
|
||||
private val relayClient: VoiceAudioClient,
|
||||
private val routeProvider: () -> VoiceAudioRoute,
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> =
|
||||
runWithSelectedRoute { it.transcribe(audioFile) }
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
IllegalStateException(
|
||||
"Standard Hermes voice is not available — check dashboard sign-in in Manage",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
block(standardClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Relay -> {
|
||||
if (!relayReadyProvider()) {
|
||||
Result.failure(IllegalStateException("Relay voice is not available"))
|
||||
} else {
|
||||
block(relayClient)
|
||||
}
|
||||
}
|
||||
VoiceAudioRoute.Auto -> runAuto(block)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> runAuto(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
var relayFailure: Result<T>? = null
|
||||
if (relayReadyProvider()) {
|
||||
val result = block(relayClient)
|
||||
if (result.isSuccess || !standardReadyProvider()) return result
|
||||
relayFailure = result
|
||||
}
|
||||
if (standardReadyProvider()) {
|
||||
val result = block(standardClient)
|
||||
if (result.isSuccess) return result
|
||||
return relayFailure ?: result
|
||||
}
|
||||
return relayFailure ?: Result.failure(
|
||||
IllegalStateException("Voice needs a reachable Hermes dashboard or Relay voice route"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
|
||||
* server** contract that hermes-desktop's voice mode uses:
|
||||
*
|
||||
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} → {ok, transcript}
|
||||
* POST {dashboard}/api/audio/speak {text} → {ok, data_url, mime_type}
|
||||
*
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md §6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
},
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
|
||||
val dataUrl = buildAudioDataUrl(audioFile)
|
||||
val payload = buildJsonObject {
|
||||
put("data_url", dataUrl)
|
||||
put("mime_type", mediaTypeForAudioFile(audioFile))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/transcribe")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
if (transcript.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio transcribe returned an empty transcript")
|
||||
}
|
||||
transcript
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
}
|
||||
|
||||
val payload = buildJsonObject { put("text", cleanText) }
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/api/audio/speak")
|
||||
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
}
|
||||
val mimeType = root.stringField("mime_type")
|
||||
?: root.stringField("mimeType")
|
||||
?: mimeTypeFromDataUrl(dataUrl)
|
||||
?: "audio/mpeg"
|
||||
val bytes = decodeDataUrl(dataUrl)
|
||||
if (bytes.isEmpty()) throw IOException("Hermes audio speak returned empty audio")
|
||||
|
||||
val extension = extensionForMimeType(mimeType)
|
||||
File(context.cacheDir, "hermes_voice_${System.currentTimeMillis()}.$extension")
|
||||
.also { it.writeBytes(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(apiFailure(response, operation))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return Result.failure(IOException("$operation returned an empty response"))
|
||||
}
|
||||
val root = json.decodeFromString<JsonObject>(body)
|
||||
val ok = (root["ok"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toBooleanStrictOrNull()
|
||||
if (ok == false) {
|
||||
val message = root.stringField("message")
|
||||
?: root.stringField("error")
|
||||
?: "$operation failed"
|
||||
return Result.failure(IOException(message))
|
||||
}
|
||||
Result.success(root)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "network error"}", e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IOException("$operation failed: ${e.message ?: "parse error"}", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val body = runCatching { response.body.string() }.getOrDefault("")
|
||||
val detail = body.takeIf { it.isNotBlank() } ?: response.message
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation needs dashboard sign-in - open Manage to sign in"
|
||||
404 -> "$operation unavailable on this Hermes build - update hermes-agent or use Relay"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}: $detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun buildAudioDataUrl(audioFile: File): String {
|
||||
val mimeType = mediaTypeForAudioFile(audioFile)
|
||||
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
|
||||
return "data:$mimeType;base64,$encoded"
|
||||
}
|
||||
|
||||
private fun mediaTypeForAudioFile(file: File): String =
|
||||
when (file.extension.lowercase()) {
|
||||
"wav" -> "audio/wav"
|
||||
"m4a", "mp4" -> "audio/mp4"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"ogg" -> "audio/ogg"
|
||||
"webm" -> "audio/webm"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
private fun decodeDataUrl(dataUrl: String): ByteArray {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
val payload = if (comma >= 0) dataUrl.substring(comma + 1) else dataUrl
|
||||
return Base64.getDecoder().decode(payload)
|
||||
}
|
||||
|
||||
private fun mimeTypeFromDataUrl(dataUrl: String): String? {
|
||||
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
|
||||
val semi = dataUrl.indexOf(';')
|
||||
if (semi <= "data:".length) return null
|
||||
return dataUrl.substring("data:".length, semi).takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun extensionForMimeType(mimeType: String): String =
|
||||
when (mimeType.lowercase().substringBefore(';')) {
|
||||
"audio/wav", "audio/wave", "audio/x-wav" -> "wav"
|
||||
"audio/mp4", "audio/aac", "audio/m4a" -> "m4a"
|
||||
"audio/ogg" -> "ogg"
|
||||
"audio/webm" -> "webm"
|
||||
else -> "mp3"
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -538,7 +538,7 @@ class BridgeCommandHandler(
|
||||
put(
|
||||
"error",
|
||||
"Device Control is not included in the Google Play build " +
|
||||
"of Hermes-Relay. This build keeps Hermes Bridge Core " +
|
||||
"of Hermes Relay. This build keeps Hermes Bridge Core " +
|
||||
"features such as chat, voice, terminal, media, " +
|
||||
"notifications, and relay status, but it does not " +
|
||||
"ship AccessibilityService, screen reading, taps, " +
|
||||
@@ -562,7 +562,7 @@ class BridgeCommandHandler(
|
||||
"Hermes accessibility service is not enabled. " +
|
||||
"The phone IS paired and connected — this is " +
|
||||
"NOT a pairing problem. The user must enable " +
|
||||
"the Hermes-Relay accessibility service in " +
|
||||
"the Hermes Relay accessibility service in " +
|
||||
"Android Settings > Accessibility > " +
|
||||
"Installed services before the bridge can " +
|
||||
"dispatch phone-control commands.",
|
||||
@@ -570,7 +570,7 @@ class BridgeCommandHandler(
|
||||
put("error_code", "service_unavailable")
|
||||
put(
|
||||
"required_action",
|
||||
"User enables Hermes-Relay in Android Accessibility Settings",
|
||||
"User enables Hermes Relay in Android Accessibility Settings",
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -813,7 +813,7 @@ class BridgeCommandHandler(
|
||||
}
|
||||
|
||||
// === PHASE3-return-to-hermes ===
|
||||
// Bring the Hermes-Relay app back to foreground. Used by the
|
||||
// Bring the Hermes Relay app back to foreground. Used by the
|
||||
// server-side agent as the final step of any multi-app task
|
||||
// (e.g. after driving Messages to send an SMS) so the user
|
||||
// sees the agent's reply in-context without manually switching
|
||||
@@ -1219,7 +1219,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_location is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error", "android_location is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1234,7 +1234,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_search_contacts is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error", "android_search_contacts is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1258,7 +1258,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1312,7 +1312,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_send_sms is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error", "android_send_sms is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1370,7 +1370,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "$path is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error", "$path is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1411,9 +1411,9 @@ class BridgeCommandHandler(
|
||||
val target = if (to.isBlank()) "the selected recipient" else to
|
||||
"Send MMS compose to $target with $attachmentCount attachment(s)?"
|
||||
} else if (attachmentCount > 0) {
|
||||
"Share $attachmentCount attachment(s) from Hermes-Relay?"
|
||||
"Share $attachmentCount attachment(s) from Hermes Relay?"
|
||||
} else {
|
||||
"Share text from Hermes-Relay?"
|
||||
"Share text from Hermes Relay?"
|
||||
}
|
||||
val allowed = safetyManager.awaitConfirmation(path, confirmText)
|
||||
if (!allowed) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -34,15 +33,7 @@ class ChatHandler {
|
||||
private const val TAG = "ChatHandler"
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
internal const val MAX_MESSAGES = 500
|
||||
|
||||
private fun timestampToMillis(timestamp: Double?): Long {
|
||||
val value = timestamp ?: return 0L
|
||||
return if (value > 1e12) value.toLong() else (value * 1000).toLong()
|
||||
}
|
||||
|
||||
private fun firstPositive(vararg values: Long): Long =
|
||||
values.firstOrNull { it > 0L } ?: 0L
|
||||
private const val MAX_MESSAGES = 500
|
||||
|
||||
// Tool annotation patterns embedded as text markers by Hermes.
|
||||
//
|
||||
@@ -209,58 +200,6 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a SYSTEM-role notice bubble (e.g. a gateway interactive ask the
|
||||
* phone can't answer). SYSTEM role keeps it out of the voice TTS observer
|
||||
* and renders with the muted system styling in MessageBubble.
|
||||
*/
|
||||
fun addSystemNotice(text: String) {
|
||||
_messages.update { list ->
|
||||
val notice = ChatMessage(
|
||||
id = "system-notice-${java.util.UUID.randomUUID()}",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = text,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
(list + notice).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an assistant message that carries ONLY a gateway ask card
|
||||
* (clarify / approval / sudo / secret). Local-only — the server never
|
||||
* stores the ask as a message, so [loadMessageHistory] preserves the
|
||||
* `ask-` id prefix the same way it preserves voice-intent traces.
|
||||
* Idempotent on [messageId] so a re-emitted ask never duplicates.
|
||||
*/
|
||||
fun appendAskCardMessage(messageId: String, card: HermesCard) {
|
||||
_messages.update { list ->
|
||||
if (list.any { it.id == messageId }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
cards = listOf(card),
|
||||
agentName = activeAgentName,
|
||||
)
|
||||
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit-and-regenerate local truncation: drop [messageId] and everything
|
||||
* after it. The gateway performs the authoritative truncation via
|
||||
* `truncate_before_user_ordinal`; this keeps the visible list consistent
|
||||
* until the post-turn history reload reconciles any divergence.
|
||||
*/
|
||||
fun truncateMessagesFrom(messageId: String) {
|
||||
_messages.update { list ->
|
||||
val idx = list.indexOfFirst { it.id == messageId }
|
||||
if (idx < 0) list else list.take(idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
@@ -655,7 +594,6 @@ class ChatHandler {
|
||||
activeAnnotationTools.clear()
|
||||
cardLineBuffer.clear()
|
||||
dispatchedCardMarkers.clear()
|
||||
subagentLabels.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -797,14 +735,6 @@ class ChatHandler {
|
||||
toolCalls = toolCalls,
|
||||
cards = extractedCards,
|
||||
agentName = if (role == MessageRole.ASSISTANT) activeAgentName else null,
|
||||
// Server persists per-message reasoning — restore it so the
|
||||
// Thought-process block survives returning to the chat
|
||||
// instead of existing only for the live turn.
|
||||
thinkingContent = if (role == MessageRole.ASSISTANT) {
|
||||
item.resolvedReasoning?.trim() ?: ""
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -825,15 +755,8 @@ class ChatHandler {
|
||||
// sync (so these traces reach the LLM's session memory too) is
|
||||
// still a v0.4.1 follow-up, but preserving them client-side is
|
||||
// enough to fix the disappearing-scrollback bug today.
|
||||
// Gateway-local bubbles ride the same preservation: steered text
|
||||
// (id "steer-…") lives inside a server-side tool result, never as a
|
||||
// user message, and ask cards (id "ask-…") are built from gateway
|
||||
// events that have no server-side message at all — a wholesale
|
||||
// reload would silently erase both.
|
||||
val preservedVoiceTraces = _messages.value.filter {
|
||||
it.id.startsWith("voice-intent-") ||
|
||||
it.id.startsWith("steer-") ||
|
||||
it.id.startsWith("ask-")
|
||||
it.id.startsWith("voice-intent-")
|
||||
}
|
||||
val merged = if (preservedVoiceTraces.isEmpty()) {
|
||||
loaded
|
||||
@@ -1010,34 +933,18 @@ class ChatHandler {
|
||||
* Update sessions list from API response.
|
||||
*/
|
||||
fun updateSessions(items: List<SessionItem>) {
|
||||
val mapped = items.map { item ->
|
||||
val startedAtMs = timestampToMillis(item.startedAt)
|
||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||
_sessions.value = items.map { item ->
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.startedAt ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
updatedAt = activityAtMs,
|
||||
startedAt = startedAtMs,
|
||||
lastActivityAt = lastActivityAtMs,
|
||||
updatedAt = timestampMs
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
// doesn't include it yet: a freshly created chat has 0 messages and the
|
||||
// drawer's `min_messages=1` query filters it out until its first turn
|
||||
// persists. Keeping the local row (its title/preview is already set)
|
||||
// stops a new chat from vanishing from the drawer between creation and
|
||||
// the first message. Once it has messages the server returns it and the
|
||||
// id-match below replaces the optimistic copy.
|
||||
val activeId = _currentSessionId.value
|
||||
val pending = if (activeId != null && mapped.none { it.sessionId == activeId }) {
|
||||
_sessions.value.firstOrNull { it.sessionId == activeId }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
_sessions.value = if (pending != null) listOf(pending) + mapped else mapped
|
||||
}
|
||||
|
||||
fun clearSessions() {
|
||||
@@ -1709,29 +1616,26 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
|
||||
private var syntheticToolSeq = 0
|
||||
|
||||
/**
|
||||
* Gateway `tool.generating` — the model is still streaming this tool's
|
||||
* arguments. Appends a quiet "preparing" placeholder ToolCall; the
|
||||
* matching `tool.start` (same name, FIFO — see [onToolCallStart])
|
||||
* adopts it so the preparing card and the running card stay one entry.
|
||||
* Nameless events get a blank-name placeholder that the next start
|
||||
* adopts as a fallback; any never-adopted placeholders are swept in
|
||||
* [onStreamComplete].
|
||||
*/
|
||||
fun onToolGenerating(messageId: String, toolName: String?) {
|
||||
fun onToolCallStart(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
toolName: String,
|
||||
runId: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
_isStreaming.value = true
|
||||
val placeholder = ToolCall(
|
||||
id = "generating-${toolName ?: "tool"}-${syntheticToolSeq++}",
|
||||
name = toolName ?: "",
|
||||
|
||||
val toolCall = ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
isGenerating = true,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
)
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
@@ -1739,7 +1643,7 @@ class ChatHandler {
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(toolCalls = msg.toolCalls + placeholder)
|
||||
msg.copy(toolCalls = msg.toolCalls + toolCall)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
@@ -1751,214 +1655,13 @@ class ChatHandler {
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(placeholder),
|
||||
toolCalls = listOf(toolCall),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallStart(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
toolName: String,
|
||||
runId: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
_isStreaming.value = true
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
// Adopt a pending "preparing" placeholder for this name
|
||||
// (or, failing that, the oldest nameless one) so the
|
||||
// generating card flips to running in place instead of a
|
||||
// second card appearing.
|
||||
val genIdx = target.toolCalls
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name == toolName }
|
||||
.takeIf { it >= 0 }
|
||||
?: target.toolCalls
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name.isEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (genIdx != null) {
|
||||
val calls = msg.toolCalls.toMutableList()
|
||||
calls[genIdx] = calls[genIdx].copy(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
isGenerating = false,
|
||||
// Execution starts now — preparing time isn't runtime.
|
||||
startedAt = System.currentTimeMillis(),
|
||||
runId = runId ?: calls[genIdx].runId,
|
||||
provenance = provenance ?: calls[genIdx].provenance,
|
||||
)
|
||||
msg.copy(toolCalls = calls)
|
||||
} else {
|
||||
msg.copy(
|
||||
toolCalls = msg.toolCalls + ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(
|
||||
ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
),
|
||||
),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
* [com.hermesandroid.relay.ui.components.SubagentLane] can render its
|
||||
* header without a separate registry. Per-run state — cleared on
|
||||
* [onStreamComplete] / [clearMessages].
|
||||
*/
|
||||
private val subagentLabels = mutableMapOf<Int, String>()
|
||||
|
||||
/**
|
||||
* Apply one gateway `subagent.*` lifecycle event to the streaming
|
||||
* message's tool calls. Mutation model mirrors the upstream child
|
||||
* mirror: a TOOL event closes the lane's open tool then starts the new
|
||||
* one; COMPLETE closes whatever is still open and (for a lane that
|
||||
* never surfaced a tool) appends a single completed summary entry so
|
||||
* the lane is visible in history. THINKING/PROGRESS carry preview text
|
||||
* the lanes don't render — ignored.
|
||||
*/
|
||||
fun onSubagentEvent(messageId: String, event: GatewaySubagentEvent) {
|
||||
val label = event.goal.trim().take(60).ifBlank { null }
|
||||
when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.START -> {
|
||||
if (label != null) subagentLabels[event.taskIndex] = label
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.TOOL -> {
|
||||
_isStreaming.value = true
|
||||
val laneLabel = subagentLabels[event.taskIndex] ?: label
|
||||
val newCall = ToolCall(
|
||||
id = "subagent-${event.taskIndex}-${syntheticToolSeq++}",
|
||||
name = event.toolName?.takeIf { it.isNotBlank() } ?: "tool",
|
||||
args = event.preview,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
taskIndex = event.taskIndex,
|
||||
taskLabel = laneLabel,
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
success = true,
|
||||
isComplete = true,
|
||||
completedAt = System.currentTimeMillis(),
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
msg.copy(toolCalls = closed + newCall)
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(newCall),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> {
|
||||
// "interrupted" lanes never finished — not a success either.
|
||||
val failed = event.status == "failed" || event.status == "interrupted"
|
||||
val laneLabel = subagentLabels.remove(event.taskIndex) ?: label
|
||||
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val hasLaneCalls = msg.toolCalls.any { it.taskIndex == event.taskIndex }
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
success = !failed,
|
||||
isComplete = true,
|
||||
result = event.summary ?: call.result,
|
||||
error = if (failed) (event.summary ?: event.status) else call.error,
|
||||
completedAt = System.currentTimeMillis(),
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
val withSummary = if (hasLaneCalls) {
|
||||
closed
|
||||
} else {
|
||||
closed + ToolCall(
|
||||
id = summaryId,
|
||||
name = laneLabel ?: "subagent",
|
||||
args = null,
|
||||
result = event.summary,
|
||||
success = !failed,
|
||||
isComplete = true,
|
||||
error = if (failed) (event.summary ?: event.status) else null,
|
||||
completedAt = System.currentTimeMillis(),
|
||||
taskIndex = event.taskIndex,
|
||||
taskLabel = laneLabel,
|
||||
)
|
||||
}
|
||||
msg.copy(toolCalls = withSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.PROGRESS,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallComplete(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
@@ -2115,19 +1818,6 @@ class ChatHandler {
|
||||
// Finalize media markers unconditionally
|
||||
finalizeMediaMarkers(messageId)
|
||||
finalizeCardMarkers(messageId)
|
||||
|
||||
// Sweep "preparing" placeholders whose tool.start never arrived —
|
||||
// they never executed and would otherwise breathe forever.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.toolCalls.none { it.isGenerating && !it.isComplete }) {
|
||||
msg
|
||||
} else {
|
||||
msg.copy(toolCalls = msg.toolCalls.filterNot { it.isGenerating && !it.isComplete })
|
||||
}
|
||||
}
|
||||
}
|
||||
subagentLabels.clear()
|
||||
}
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
@@ -2141,19 +1831,6 @@ class ChatHandler {
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
|
||||
// Same sweep as onStreamComplete — error/watchdog/transport-failure
|
||||
// turns must not leave "preparing" placeholders breathing forever.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.toolCalls.none { it.isGenerating && !it.isComplete }) {
|
||||
msg
|
||||
} else {
|
||||
msg.copy(toolCalls = msg.toolCalls.filterNot { it.isGenerating && !it.isComplete })
|
||||
}
|
||||
}
|
||||
}
|
||||
subagentLabels.clear()
|
||||
}
|
||||
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Models for the Hermes /api/sessions REST API.
|
||||
@@ -76,50 +75,12 @@ object FlexibleIdNonNullSerializer : KSerializer<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Timestamp serializer for Hermes session metadata.
|
||||
*
|
||||
* Upstream currently returns epoch seconds for `started_at` / `last_active`;
|
||||
* some documented surfaces use ISO strings for update-style fields. Decode
|
||||
* both into epoch seconds so callers can convert once at the UI boundary.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
object FlexibleTimestampSerializer : KSerializer<Double?> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("FlexibleTimestamp", PrimitiveKind.DOUBLE)
|
||||
|
||||
override fun deserialize(decoder: Decoder): Double? {
|
||||
return try {
|
||||
val jsonDecoder = decoder as? JsonDecoder
|
||||
?: return decoder.decodeDouble()
|
||||
val element = jsonDecoder.decodeJsonElement()
|
||||
when (element) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> parseTimestamp(element.content)
|
||||
else -> null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Double?) {
|
||||
if (value != null) encoder.encodeDouble(value) else encoder.encodeNull()
|
||||
}
|
||||
|
||||
private fun parseTimestamp(raw: String): Double? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
trimmed.toDoubleOrNull()?.let { return it }
|
||||
return runCatching { Instant.parse(trimmed).toEpochMilli() / 1000.0 }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD responses ---
|
||||
|
||||
@Serializable
|
||||
data class SessionListResponse(
|
||||
val items: List<SessionItem>? = null,
|
||||
val sessions: List<SessionItem>? = null, // alternate key
|
||||
val data: List<SessionItem>? = null, // upstream /api/sessions list envelope
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@@ -140,32 +101,13 @@ data class SessionItem(
|
||||
val title: String? = null,
|
||||
val model: String? = null,
|
||||
val source: String? = null,
|
||||
@SerialName("started_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val startedAt: Double? = null,
|
||||
@SerialName("ended_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val endedAt: Double? = null,
|
||||
@SerialName("last_active")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val lastActive: Double? = null,
|
||||
@SerialName("last_activity")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val lastActivity: Double? = null,
|
||||
@SerialName("last_activity_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val lastActivityAt: Double? = null,
|
||||
@SerialName("updated_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val updatedAt: Double? = null,
|
||||
@SerialName("started_at") val startedAt: Double? = null,
|
||||
@SerialName("ended_at") val endedAt: Double? = null,
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
||||
) {
|
||||
val resolvedLastActivity: Double?
|
||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||
}
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
@@ -185,7 +127,6 @@ data class RenameSessionRequest(
|
||||
data class MessageListResponse(
|
||||
val items: List<MessageItem>? = null,
|
||||
val messages: List<MessageItem>? = null, // alternate key
|
||||
val data: List<MessageItem>? = null, // upstream /api/sessions/{id}/messages list envelope
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@@ -204,19 +145,8 @@ data class MessageItem(
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = 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
|
||||
// return to the chat instead of existing only for the live turn.
|
||||
val reasoning: String? = null,
|
||||
@SerialName("reasoning_content") val reasoningContent: String? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null
|
||||
) {
|
||||
/** Reasoning text under whichever field name the server used. */
|
||||
val resolvedReasoning: String?
|
||||
get() = reasoning?.takeIf { it.isNotBlank() }
|
||||
?: reasoningContent?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
|
||||
val contentText: String?
|
||||
get() = when (content) {
|
||||
@@ -344,13 +274,7 @@ data class UsageInfo(
|
||||
@SerialName("completion_tokens") val completionTokens: Int? = null,
|
||||
// Cache tokens
|
||||
@SerialName("cache_creation_input_tokens") val cacheCreationInputTokens: Int? = null,
|
||||
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null,
|
||||
// Gateway context-window block (session-cumulative; present only when the
|
||||
// server's context compressor is active — upstream _get_usage()). Render
|
||||
// context UI only when contextMax is non-null.
|
||||
@SerialName("context_used") val contextUsed: Int? = null,
|
||||
@SerialName("context_max") val contextMax: Int? = null,
|
||||
@SerialName("context_percent") val contextPercent: Int? = null
|
||||
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null
|
||||
) {
|
||||
/** Resolved input tokens — prefers Hermes naming, falls back to OpenAI. */
|
||||
val resolvedInputTokens: Int? get() = inputTokens ?: promptTokens
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* One-shot "Hermes finished responding" notification — posted from
|
||||
* ChatViewModel's turn-complete path when the app is backgrounded, never
|
||||
* for cancelled streams or errors. Structural twin of
|
||||
* [com.hermesandroid.relay.bridge.AutoDisableWorker]'s notification half:
|
||||
* same channel-ensure, permission-gate, and tap-intent anatomy.
|
||||
*
|
||||
* One stable slot ([NOTIFICATION_ID]) — chat is one stream, so the latest
|
||||
* turn replaces any prior notification rather than stacking noise. The
|
||||
* caller cancels it via [cancel] when the user returns to the app
|
||||
* (MainActivity onResume).
|
||||
*
|
||||
* Tap routes through the existing cross-layer deep-link path: the intent
|
||||
* carries [MainActivity.EXTRA_NAV_ROUTE] → MainActivity pumps it onto
|
||||
* NavRouteRequest → RelayApp's collector navigates.
|
||||
*/
|
||||
object TurnCompleteNotifier {
|
||||
|
||||
private const val TAG = "TurnCompleteNotifier"
|
||||
private const val CHANNEL_ID = "chat_turn_complete"
|
||||
private const val CHANNEL_NAME = "Hermes replies"
|
||||
const val NOTIFICATION_ID = 3822
|
||||
|
||||
/**
|
||||
* Compose nav route for the Chat tab. Hardcoded on purpose to avoid
|
||||
* pulling the ui.RelayApp graph into the notifications classpath — if
|
||||
* Screen.Chat.route() changes in RelayApp.kt, change it here too.
|
||||
* (Same convention as BridgeForegroundService's settings deep-link.)
|
||||
*/
|
||||
private const val CHAT_ROUTE = "chat"
|
||||
|
||||
/**
|
||||
* Post (or replace) the turn-complete notification.
|
||||
*
|
||||
* @param agentName Display name for the title; blank falls back to
|
||||
* "Hermes".
|
||||
* @param responseText Final assistant text — collapsed line is the
|
||||
* first 120 chars, BigTextStyle expands to 400.
|
||||
* @param toolCount Number of tool calls the turn ran; 0 hides the
|
||||
* subText line.
|
||||
* @param durationSeconds Wall-clock turn duration for the subText
|
||||
* ("3 tools · 42s"); null renders the count alone.
|
||||
*/
|
||||
// Lint can't trace through [hasPostNotificationsPermission] to see that
|
||||
// we early-return when the runtime grant isn't held, and the notify()
|
||||
// call is also wrapped in runCatching to swallow SecurityException as
|
||||
// a belt-and-braces. Suppress here rather than inlining the check —
|
||||
// the helper exists so the same gate can grow more conditions later
|
||||
// without each call site re-implementing it. Both IDs are needed:
|
||||
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
|
||||
// on API 33+); `MissingPermission` is the generic fallback.
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notifyTurnComplete(
|
||||
context: Context,
|
||||
agentName: String?,
|
||||
responseText: String,
|
||||
toolCount: Int = 0,
|
||||
durationSeconds: Long? = null,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
if (!hasPostNotificationsPermission(context)) {
|
||||
Log.i(TAG, "POST_NOTIFICATIONS not granted — skipping turn-complete notification")
|
||||
return
|
||||
}
|
||||
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, CHAT_ROUTE)
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(context, 0, tapIntent, pendingFlags)
|
||||
|
||||
val title = agentName?.takeIf { it.isNotBlank() } ?: "Hermes"
|
||||
val collapsed = responseText.take(120)
|
||||
val expanded = responseText.take(400)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(collapsed)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expanded))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
if (toolCount > 0) {
|
||||
val tools = "$toolCount tool${if (toolCount == 1) "" else "s"}"
|
||||
builder.setSubText(
|
||||
durationSeconds?.let { "$tools · ${it}s" } ?: tools
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
|
||||
}.onFailure { Log.w(TAG, "notifyTurnComplete: notify failed", it) }
|
||||
}
|
||||
|
||||
/** Clear the slot — call from MainActivity.onResume so returning to the app dismisses it. */
|
||||
fun cancel(context: Context) {
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}.onFailure { Log.w(TAG, "cancel: failed", it) }
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val existing = nm.getNotificationChannel(CHANNEL_ID)
|
||||
if (existing != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Notifies when Hermes finishes responding while the app is in the background."
|
||||
// Unlike bridge_auto_disable, a reply badge is desirable.
|
||||
setShowBadge(true)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+67
-114
@@ -94,21 +94,30 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
|
||||
* here so users do not have to open Manage just to discover sign-in is needed.
|
||||
* Three tappable status rows (API / Relay / Session), always visible on
|
||||
* the active card. Replaces the old "Active Connection" quick-look card
|
||||
* that used to live at the top of `SettingsScreen` — same information
|
||||
* density, same tap-for-info-sheet behavior.
|
||||
*
|
||||
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
|
||||
* reconnect + toast; every other row falls through to the info sheet
|
||||
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardStandardStatusSection(
|
||||
fun ActiveCardStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
@@ -123,94 +132,46 @@ fun ActiveCardStandardStatusSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Dashboard",
|
||||
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
|
||||
statusText = when {
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
|
||||
dashboardStatus == null -> "Not checked"
|
||||
!dashboardStatus.reachable -> "Unreachable"
|
||||
dashboardSignInRequired -> "Sign-in required"
|
||||
dashboardStatus.authenticated == true -> "Signed in"
|
||||
dashboardStatus.authRequired == false -> "Available"
|
||||
else -> "Available"
|
||||
},
|
||||
onClick = onOpenDashboard,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (dashboardSignInRequired) {
|
||||
Row(
|
||||
if (relayEnabled) {
|
||||
// ADR 24: relayRowState carries both the phase and the active
|
||||
// endpoint role. statusText appends " · <Role>" when the
|
||||
// resolver has picked one, so the chip reads "Connected · LAN"
|
||||
// etc. without any extra wiring here.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Dashboard controls need a sign-in for this route.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onOpenDashboard) {
|
||||
Text("Sign in")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional Relay status rows (transport / paired session). Kept separate from
|
||||
* [ActiveCardStandardStatusSection] so API/dashboard setup does not visually
|
||||
* read as incomplete when Relay is not paired.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardRelayStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
// ADR 24: relayRowState carries both the phase and the active endpoint
|
||||
// role. statusText appends " · <Role>" when the resolver has picked one.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced expandable section — three subsections:
|
||||
* - Manual URL configuration (API URL + key + Save & Test,
|
||||
@@ -365,7 +326,7 @@ private fun ManualUrlSubsection(
|
||||
) { result ->
|
||||
isTestingApi = false
|
||||
apiVoiceSetupResult = result
|
||||
if (!result.voiceConfigReachable && result.voiceRoute == "relay" && result.relayAutoDerived) {
|
||||
if (!result.voiceConfigReachable && result.relayAutoDerived) {
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
@@ -373,13 +334,9 @@ private fun ManualUrlSubsection(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
"API and standard voice reachable"
|
||||
} else {
|
||||
"API and relay voice reachable"
|
||||
}
|
||||
"API and voice relay reachable"
|
||||
result.apiReachable ->
|
||||
"API reachable; voice route needs review"
|
||||
"API reachable; relay URL needs review"
|
||||
else -> "Cannot reach API server"
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
@@ -416,7 +373,7 @@ private fun ManualUrlSubsection(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Relay is optional for voice. Standard voice uses the Hermes API; Relay voice uses this route when selected or needed.",
|
||||
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -446,7 +403,7 @@ private fun ManualUrlSubsection(
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Only needed when the optional Relay route cannot be auto-derived")
|
||||
Text("Only needed when Auto cannot reach /voice/config")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -460,13 +417,9 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
if (result.voiceRoute == "standard") {
|
||||
"Voice ready via standard Hermes API"
|
||||
} else {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
}
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
} else {
|
||||
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
@@ -621,7 +574,7 @@ private fun InsecureToggleSubsection(
|
||||
* the QR scanner isn't usable (no camera, headless host, bad lighting).
|
||||
*
|
||||
* 1. Copy the phone-generated code (with Refresh to regenerate)
|
||||
* 2. Run `hermes pair --register-code <code>` on the host
|
||||
* 2. Run `hermes-pair --register-code <code>` on the host
|
||||
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
|
||||
* failure through the global snackbar host
|
||||
*
|
||||
@@ -743,7 +696,7 @@ private fun ManualPairingCodeSubsection(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "hermes pair --register-code $pairingCode",
|
||||
text = "hermes-pair --register-code $pairingCode",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
@@ -752,10 +705,10 @@ private fun ManualPairingCodeSubsection(
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val cmd = "hermes pair --register-code $pairingCode"
|
||||
val cmd = "hermes-pair --register-code $pairingCode"
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
|
||||
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Command copied")
|
||||
}
|
||||
@@ -764,7 +717,7 @@ private fun ManualPairingCodeSubsection(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy hermes pair command",
|
||||
contentDescription = "Copy hermes-pair command",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -830,9 +783,9 @@ private fun ManualPairingCodeSubsection(
|
||||
text = "This is a fallback for when you can't scan the pairing QR " +
|
||||
"— for example, no camera, the host can't render a QR, or you " +
|
||||
"only have SSH access from a single device. The canonical flow " +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
|
||||
"How it works: the phone generates a 6-character code locally. " +
|
||||
"You paste that code into the host's `hermes pair --register-code` " +
|
||||
"You paste that code into the host's `hermes-pair --register-code` " +
|
||||
"command, which pre-registers it with the relay. When you tap " +
|
||||
"Connect here, the phone presents the same code to the relay " +
|
||||
"and gets a long-lived session token in return.\n\n" +
|
||||
|
||||
@@ -44,7 +44,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Visual style mirrors the status
|
||||
* cards in `PairedDevicesScreen`: surfaceVariant background, 16dp padding,
|
||||
* 10dp row spacing. Uses [ConnectionStatusBadge] for the pulsing status dot
|
||||
* so the Bridge tab looks visually consistent with the Settings → Connections
|
||||
* so the Bridge tab looks visually consistent with the Settings → Connection
|
||||
* section.
|
||||
*
|
||||
* The headline switch is `enabled = allowEnable` so users can't flip it on
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
|
||||
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Kept distinct from
|
||||
* [BridgeMasterToggle] so that Agent safety-rails in Wave 2 can relocate the master
|
||||
* toggle without losing the status surface (and so we can reuse this card
|
||||
* in the Settings → Connections section later if desired).
|
||||
* in the Settings → Connection section later if desired).
|
||||
*/
|
||||
@Composable
|
||||
fun BridgeStatusCard(
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.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.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
|
||||
/**
|
||||
* One markdown image reference (``) pulled out of an assistant
|
||||
* message so it can be rendered as a real image (or a graceful inline notice)
|
||||
* instead of the empty/blank element the markdown renderer produces for it.
|
||||
*/
|
||||
data class ChatInlineImage(val alt: String, val src: String)
|
||||
|
||||
// `` and ``. src = first non-space, non-`)` run.
|
||||
private val MARKDOWN_IMAGE_REGEX = Regex("""!\[([^\]]*)]\(([^)\s]+)[^)]*\)""")
|
||||
|
||||
/**
|
||||
* Split assistant [content] into (markdown body without image links, parsed
|
||||
* images). The `![...]()` token is removed from the body so it doesn't render
|
||||
* as a broken/empty element; surrounding prose is preserved.
|
||||
*/
|
||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||
if (!content.contains("![")) return content to emptyList()
|
||||
val images = mutableListOf<ChatInlineImage>()
|
||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||
images += ChatInlineImage(alt = m.groupValues[1].trim(), src = m.groupValues[2].trim())
|
||||
""
|
||||
}
|
||||
if (images.isEmpty()) return content to emptyList()
|
||||
// Collapse the blank lines the removal can leave behind.
|
||||
return stripped.replace(Regex("\n{3,}"), "\n\n").trim() to images
|
||||
}
|
||||
|
||||
private fun ChatInlineImage.isRemote(): Boolean {
|
||||
val s = src.lowercase()
|
||||
return s.startsWith("http://") || s.startsWith("https://")
|
||||
}
|
||||
|
||||
/**
|
||||
* Render generated/inline images for an assistant bubble. Remote `http(s)`
|
||||
* URLs load via Coil with loading/error states; anything else (a server-local
|
||||
* file path, `file://`, a relative path) degrades to an inline notice that
|
||||
* explains WHY it can't be shown rather than rendering blank.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatInlineImages(
|
||||
images: List<ChatInlineImage>,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
if (images.isEmpty()) return
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
images.forEach { image ->
|
||||
if (image.isRemote()) {
|
||||
RemoteChatImage(image, maxWidth)
|
||||
} else {
|
||||
UnrenderableImageNotice(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Coil(
|
||||
model = image.src,
|
||||
displayName = image.alt.ifBlank { "image" },
|
||||
mime = "image/*",
|
||||
bytesProvider = { MediaSaver.fetchRemoteBytes(image.src).first },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
)
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = image.src,
|
||||
contentDescription = image.alt.ifBlank { "Generated image" },
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
) {
|
||||
val state by painter.state.collectAsState()
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||
is AsyncImagePainter.State.Loading -> Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
// Error / Empty — couldn't load. Offer to open it externally.
|
||||
else -> UnrenderableImageNotice(image, reason = "Couldn't load this image.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnrenderableImageNotice(
|
||||
image: ChatInlineImage,
|
||||
reason: String = "This image is on the server and can't be shown here.",
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val remote = image.isRemote()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.then(
|
||||
if (remote) {
|
||||
Modifier.clickable {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, image.src.toUri()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (remote) Icons.Filled.BrokenImage else Icons.Outlined.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = image.alt.ifBlank { "Image" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = if (remote) "Tap to open · ${image.src}" else "$reason\n${image.src}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
|
||||
*
|
||||
* Display and byte-acquisition are decoupled on purpose: a remote image
|
||||
* displays straight from its URL via Coil but fetches bytes over HTTP, while an
|
||||
* inbound attachment displays from an already-decoded [ImageBitmap] but reads
|
||||
* its original bytes back from the cached `content://` URI — so Save preserves
|
||||
* the real file, not a re-encode.
|
||||
*/
|
||||
sealed interface ChatImageViewerSource {
|
||||
val displayName: String
|
||||
val mime: String
|
||||
|
||||
/** Returns the bytes to save/share, or null if they can't be obtained. */
|
||||
val bytesProvider: suspend () -> ByteArray?
|
||||
|
||||
/** Display via Coil from any Coil-native model (URL string, content:// Uri). */
|
||||
data class Coil(
|
||||
val model: Any,
|
||||
override val displayName: String,
|
||||
override val mime: String,
|
||||
override val bytesProvider: suspend () -> ByteArray?,
|
||||
) : ChatImageViewerSource
|
||||
|
||||
/** Display from an already-decoded bitmap (outbound / cached attachments). */
|
||||
data class Bitmap(
|
||||
val bitmap: ImageBitmap,
|
||||
override val displayName: String,
|
||||
override val mime: String,
|
||||
override val bytesProvider: suspend () -> ByteArray?,
|
||||
) : ChatImageViewerSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen image viewer dialog: pinch-to-zoom + pan (double-tap to toggle
|
||||
* 1×/2.5×), with overlaid Share / Save / Close controls. Save lands in
|
||||
* `Pictures/Hermes-Relay` on Android 10+; on older versions (or any failure
|
||||
* path) it falls back to the share sheet via [MediaSaver].
|
||||
*/
|
||||
@Composable
|
||||
fun ChatImageViewer(
|
||||
source: ChatImageViewerSource,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val gestureModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(1f, 6f)
|
||||
offset = if (scale > 1f) offset + pan else Offset.Zero
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
} else {
|
||||
scale = 2.5f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (source) {
|
||||
is ChatImageViewerSource.Coil -> AsyncImage(
|
||||
model = source.model,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
|
||||
is ChatImageViewerSource.Bitmap -> Image(
|
||||
bitmap = source.bitmap,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
|
||||
// Control bar — top-right, inset past the status bar / notch.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, "Couldn't load this image")
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Share")
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, "Couldn't load this image")
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, "Saved to ${result.location}")
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, "Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = "Save")
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: android.content.Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.graphics.SolidColor
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* What the single trailing slot of the input bar renders. The caller
|
||||
* derives the state — the bar never widens, it morphs:
|
||||
*
|
||||
* ```
|
||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||
* !isStreaming -> VOICE // GraphicEq, primary
|
||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
||||
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||
* ```
|
||||
*/
|
||||
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
|
||||
|
||||
private val ChatComposerShape = RoundedCornerShape(18.dp)
|
||||
private val ChatInputChipShape = RoundedCornerShape(12.dp)
|
||||
|
||||
data class ChatInputPickerOption(
|
||||
val label: String,
|
||||
val value: String?,
|
||||
val provider: String? = null,
|
||||
val secondary: String? = null,
|
||||
val group: String? = null,
|
||||
val selected: Boolean = false,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
data class ChatInputPickerControl(
|
||||
val value: String,
|
||||
val contentDescription: String,
|
||||
val options: List<ChatInputPickerOption>,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* The minimal Telegram-style chat input bar — 3 elements, one trailing
|
||||
* button. Replaces ChatScreen's Row of attach / slash / OutlinedTextField /
|
||||
* Stop / smart-swap.
|
||||
*
|
||||
* - "+" tap = file picker ([onAttach]); long-press = CommandPalette
|
||||
* ([onLongPressAttach]) — the app's quiet-gesture idiom. The dedicated
|
||||
* slash button is gone; typing "/" still surfaces InlineAutocomplete.
|
||||
* - Pill [BasicTextField] (surfaceContainerHigh, hairline border, grows
|
||||
* to 5 lines) instead of OutlinedTextField chrome.
|
||||
* - ONE trailing slot morphing through [ChatInputTrailing] with
|
||||
* [AnimatedContent] — Stop stops being a separate slot so the bar never
|
||||
* widens during streaming.
|
||||
* - Char counter as a tiny mono overline above the bar (Amber, Danger at
|
||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||
* reflows the bar, the overline doesn't.
|
||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
||||
* (steer/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* is STEER, muted otherwise. Null collapses the row.
|
||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||
* ("needs setup" reads intentional, not broken) and the tap still goes
|
||||
* to [onVoice] for the route-specific toast. [showVoiceHint] one-shot
|
||||
* floats the "Live voice conversation" pill above the button for ~3s
|
||||
* (DataStore flag owned by the caller, consumed via [onVoiceHintShown]).
|
||||
* - [purpleGlow] on the trailing button (dark theme only) when it is an
|
||||
* enabled SEND — the bar's one flourish, exactly as before.
|
||||
*
|
||||
* [onSend] fires for SEND, STEER, and QUEUE — the caller already encoded
|
||||
* the meaning in the state it passed; [onVoice]/[onStop] for theirs.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatInputBar(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
trailing: ChatInputTrailing,
|
||||
onSend: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onLongPressAttach: () -> Unit,
|
||||
charLimit: Int,
|
||||
caption: String?,
|
||||
voiceReady: Boolean,
|
||||
showVoiceHint: Boolean,
|
||||
onVoiceHintShown: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
modelControl: ChatInputPickerControl? = null,
|
||||
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
effortControl: ChatInputPickerControl? = null,
|
||||
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Keep the last caption around so the AnimatedVisibility exit doesn't
|
||||
// flash an empty line while collapsing.
|
||||
var lastCaption by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(caption) {
|
||||
if (caption != null) lastCaption = caption
|
||||
}
|
||||
|
||||
// One-shot voice hint. Consumed-flag locally so flipping the DataStore
|
||||
// flag (via onVoiceHintShown) can't restart-cancel the visible window;
|
||||
// the hide timer is keyed on visibility alone so trailing-state morphs
|
||||
// mid-delay don't strand the pill.
|
||||
var hintVisible by remember { mutableStateOf(false) }
|
||||
var hintConsumed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showVoiceHint, trailing) {
|
||||
if (showVoiceHint && !hintConsumed && trailing == ChatInputTrailing.VOICE) {
|
||||
hintConsumed = true
|
||||
hintVisible = true
|
||||
onVoiceHintShown()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(hintVisible) {
|
||||
if (hintVisible) {
|
||||
delay(3_000)
|
||||
hintVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Caption row — steer/queue hinting, single line, no buttons.
|
||||
AnimatedVisibility(visible = caption != null) {
|
||||
Text(
|
||||
text = caption ?: lastCaption.orEmpty(),
|
||||
style = relayMetadataStyle(),
|
||||
color = if (trailing == ChatInputTrailing.STEER) {
|
||||
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Voice hint pill — floats above the trailing button.
|
||||
AnimatedVisibility(
|
||||
visible = hintVisible,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 12.dp, bottom = 2.dp),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "Live voice conversation",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Char counter overline — same near-limit threshold as before, but
|
||||
// it no longer reflows the bar.
|
||||
if (value.length > charLimit - 200) {
|
||||
Text(
|
||||
text = "${value.length}/$charLimit",
|
||||
style = relayMetadataStyle(),
|
||||
color = if (value.length >= charLimit) RelayRefresh.Danger else RelayRefresh.Amber,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 16.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = ChatComposerShape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
maxLines = 5,
|
||||
enabled = enabled,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { inner ->
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = RelayRefresh.Dim,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// "+" tap attaches, long-press opens the command palette.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = onAttach,
|
||||
onLongClick = onLongPressAttach,
|
||||
onLongClickLabel = "Browse commands",
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "Attach file; hold for commands",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (modelControl != null) {
|
||||
ChatInputPickerChip(
|
||||
control = modelControl,
|
||||
onSelect = onModelOptionSelected,
|
||||
modifier = Modifier.widthIn(max = 126.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (effortControl != null) {
|
||||
ChatInputPickerChip(
|
||||
control = effortControl,
|
||||
onSelect = onEffortOptionSelected,
|
||||
modifier = Modifier.widthIn(max = 104.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Trailing slot
|
||||
val glow = trailing == ChatInputTrailing.SEND && enabled && isDarkTheme
|
||||
Box(
|
||||
modifier = if (glow) {
|
||||
Modifier.purpleGlow(radius = 24.dp, alpha = 0.35f, isDarkTheme = true)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = trailing,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(150)) + scaleIn(initialScale = 0.8f))
|
||||
.togetherWith(fadeOut(tween(100)))
|
||||
},
|
||||
label = "chatInputTrailing",
|
||||
) { state ->
|
||||
when (state) {
|
||||
ChatInputTrailing.SEND -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send message",
|
||||
tint = if (enabled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputTrailing.VOICE -> Box {
|
||||
IconButton(onClick = onVoice) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = if (voiceReady) "Start voice conversation"
|
||||
else "Voice conversation — setup needed",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
// "Needs setup" badge — full-alpha button + Amber
|
||||
// dot instead of a half-dimmed broken-looking mic.
|
||||
if (!voiceReady) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(RelayRefresh.Amber),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatInputTrailing.STOP -> IconButton(onClick = onStop) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = "Stop streaming",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatInputTrailing.STEER -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Steer the response",
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputTrailing.QUEUE -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Queue message",
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 5.dp, y = (-3).dp)
|
||||
.size(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputPickerChip(
|
||||
control: ChatInputPickerControl,
|
||||
onSelect: (ChatInputPickerOption) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val enabled = control.enabled && control.options.isNotEmpty()
|
||||
val contentColor = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Surface(
|
||||
shape = ChatInputChipShape,
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.32f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)),
|
||||
modifier = Modifier
|
||||
.heightIn(min = 32.dp)
|
||||
.clip(ChatInputChipShape)
|
||||
.clickable(enabled = enabled) { expanded = true },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = control.value,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = control.contentDescription,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
var lastGroup: String? = null
|
||||
control.options.forEachIndexed { index, option ->
|
||||
val group = option.group?.takeIf { it.isNotBlank() }
|
||||
if (group != null && group != lastGroup) {
|
||||
if (index > 0) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
Text(
|
||||
text = group,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
lastGroup = group
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column(modifier = Modifier.widthIn(max = 280.dp)) {
|
||||
Text(
|
||||
text = option.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!option.secondary.isNullOrBlank()) {
|
||||
Text(
|
||||
text = option.secondary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(option)
|
||||
},
|
||||
enabled = option.enabled,
|
||||
leadingIcon = if (option.selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,30 +47,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A slash command entry — built-in, personality, server skill, or (on the
|
||||
* gateway transport) a server-catalog command from `commands.catalog`.
|
||||
* A slash command entry — built-in, personality, or server skill.
|
||||
*/
|
||||
data class SlashCommand(
|
||||
val command: String,
|
||||
val description: String,
|
||||
val category: String = "built-in",
|
||||
/**
|
||||
* Where the entry came from — [SOURCE_SERVER] for gateway
|
||||
* `commands.catalog` entries, null for client-defined sources.
|
||||
* Used by the merge in ChatScreen's `allCommands` to dedupe by name
|
||||
* with the server description winning, and by the send path to route
|
||||
* server-only commands through `slash.exec` / `command.dispatch`
|
||||
* instead of plain text.
|
||||
*/
|
||||
val source: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val SOURCE_SERVER = "server"
|
||||
|
||||
/** Palette category for server-catalog commands without one. */
|
||||
const val CATEGORY_SERVER = "server"
|
||||
}
|
||||
}
|
||||
val category: String = "built-in"
|
||||
)
|
||||
|
||||
/**
|
||||
* Full-screen command palette as a bottom sheet.
|
||||
@@ -90,10 +73,7 @@ fun CommandPalette(
|
||||
|
||||
// Get unique categories in a logical order
|
||||
val categories = remember(commands) {
|
||||
val priorityOrder = listOf(
|
||||
"session", "configuration", "info", "personality",
|
||||
SlashCommand.CATEGORY_SERVER,
|
||||
)
|
||||
val priorityOrder = listOf("session", "configuration", "info", "personality")
|
||||
commands.map { it.category }.distinct().sortedWith(
|
||||
compareBy<String> {
|
||||
val idx = priorityOrder.indexOf(it)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -15,7 +10,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -34,11 +28,9 @@ fun CompactToolCall(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
isPreparing -> "preparing"
|
||||
else -> "running"
|
||||
}
|
||||
|
||||
@@ -78,24 +70,6 @@ fun CompactToolCall(
|
||||
|
||||
// Status indicator
|
||||
when {
|
||||
// tool.generating parity with ToolProgressCard's "preparing"
|
||||
// state — MoreHoriz in Muted with the same alpha breathe; the
|
||||
// Cyan spinner stays reserved for actually-executing tools.
|
||||
isPreparing -> {
|
||||
val breathe = rememberInfiniteTransition(label = "compactToolGenerating")
|
||||
val alpha = breathe.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0.9f,
|
||||
animationSpec = infiniteRepeatable(tween(900), repeatMode = RepeatMode.Reverse),
|
||||
label = "compactToolGeneratingAlpha",
|
||||
).value
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreHoriz,
|
||||
contentDescription = "Preparing",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)
|
||||
)
|
||||
}
|
||||
!toolCall.isComplete -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(10.dp),
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -37,11 +36,6 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusSnapshot
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
|
||||
@@ -65,7 +59,6 @@ fun ConnectionStatusBanner(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
@@ -102,7 +95,6 @@ fun ConnectionStatusBanner(
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -162,15 +154,6 @@ fun ConnectionStatusBanner(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.86f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val outputLines = current.entries
|
||||
.takeLast(2)
|
||||
@@ -211,185 +194,6 @@ fun ConnectionStatusBanner(
|
||||
}
|
||||
}
|
||||
|
||||
private const val SWIPE_DISMISS_THRESHOLD_PX = 80f
|
||||
|
||||
/**
|
||||
* Floating, in-theme connection status **toast** for connection switches,
|
||||
* network handoffs, and disconnects.
|
||||
*
|
||||
* Unlike [ConnectionStatusBanner] (edge-to-edge, takes layout space above the
|
||||
* Scaffold and so resizes the content), this is meant to be rendered as a
|
||||
* top-aligned overlay inside a `Box` — it slides down OVER the UI without
|
||||
* shifting it. Pair it with `AnimatedVisibility(enter = slideInVertically{-it})`
|
||||
* at the call site.
|
||||
*
|
||||
* - Spinner while [ConnectionStatusSnapshot.active] (handoff / loading).
|
||||
* - [onClick] acts on it (reconnect / open the relevant screen).
|
||||
* - [onDismiss] is wired to a swipe-up; the host suppresses re-show until the
|
||||
* status content changes.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionStatusToast(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.82f)
|
||||
current.success -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor = when {
|
||||
current.tone == ConnectionStatusTone.Error ||
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
// Reset the swipe accumulator whenever a new status arrives.
|
||||
val dragAccum = remember(current.updatedAtMs) { mutableFloatStateOf(0f) }
|
||||
val swipeModifier = if (onDismiss != null) {
|
||||
Modifier.pointerInput(onDismiss) {
|
||||
detectVerticalDragGestures(
|
||||
onDragEnd = {
|
||||
if (dragAccum.floatValue < -SWIPE_DISMISS_THRESHOLD_PX) onDismiss()
|
||||
dragAccum.floatValue = 0f
|
||||
},
|
||||
onVerticalDrag = { change, dy ->
|
||||
if (dy < 0f) {
|
||||
dragAccum.floatValue += dy
|
||||
change.consume()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.then(swipeModifier)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 40.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
current.active -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor,
|
||||
)
|
||||
current.success -> Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
current.tone == ConnectionStatusTone.Warning ||
|
||||
current.tone == ConnectionStatusTone.Error -> Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = current.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
current.route?.takeIf { it.isNotBlank() }?.let { route ->
|
||||
Text(
|
||||
text = route,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val outputLines = current.entries
|
||||
.takeLast(2)
|
||||
.mapNotNull { entry ->
|
||||
val label = entry.label.trim().takeIf { it.isNotBlank() }
|
||||
val detail = entry.detail?.trim()?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
label != null && detail != null -> "$label: $detail"
|
||||
label != null -> label
|
||||
detail != null -> detail
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
outputLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.86f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
|
||||
val infinite = rememberInfiniteTransition(label = "connection-handoff-pulse")
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -34,14 +33,12 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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
|
||||
@@ -61,7 +58,6 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
@@ -631,27 +627,15 @@ fun AgentInfoSheet(
|
||||
chatViewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// Profile + personality state — same flows the old pickers consumed.
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
|
||||
// Pull the gateway's curated provider/model list (model.options) when the
|
||||
// sheet opens — the real switchable models, grouped by provider.
|
||||
LaunchedEffect(Unit) { chatViewModel.refreshModelOptions() }
|
||||
// Pull the host's agent profiles from the dashboard so they appear in the
|
||||
// Profile picker even on a dashboard-only (non-relay) connection.
|
||||
LaunchedEffect(Unit) { connectionViewModel.refreshDashboardProfiles() }
|
||||
|
||||
// Connection summary state.
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
@@ -691,16 +675,6 @@ fun AgentInfoSheet(
|
||||
val profileOverridesPersonality =
|
||||
selectedProfile?.systemMessage?.isNotBlank() == true
|
||||
var endpointsExpanded by remember { mutableStateOf(false) }
|
||||
val effectiveDisplayProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
val serverResolvedAgentName = AgentDisplay.agentName(
|
||||
profile = effectiveDisplayProfile,
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
connectionLabel = null,
|
||||
)
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -732,22 +706,16 @@ fun AgentInfoSheet(
|
||||
) {
|
||||
// ---- Header: avatar + agent name + live status ----
|
||||
AgentSheetHeader(
|
||||
profile = effectiveDisplayProfile,
|
||||
profile = AgentDisplay.effectiveProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
),
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
localDisplayAlias = profileDisplayAlias,
|
||||
serverModelName = serverModelName,
|
||||
apiServerReachable = apiServerReachable,
|
||||
chatMode = chatMode,
|
||||
isCustomized = selectedProfile != null ||
|
||||
selectedPersonality != "default" ||
|
||||
profileDisplayAlias != null,
|
||||
)
|
||||
|
||||
DisplayAliasSection(
|
||||
currentAlias = profileDisplayAlias,
|
||||
fallbackName = serverResolvedAgentName,
|
||||
onSave = connectionViewModel::setProfileDisplayAlias,
|
||||
isCustomized = selectedProfile != null || selectedPersonality != "default",
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
@@ -770,13 +738,11 @@ fun AgentInfoSheet(
|
||||
val apparentActiveProfile = agentProfiles
|
||||
.firstOrNull { it.gatewayRunning }
|
||||
|
||||
CollapsiblePickerSection(
|
||||
title = "Profile",
|
||||
hint = "Host-side Hermes contexts",
|
||||
currentValue = profileDisplayAlias
|
||||
?: AgentDisplay.profileDisplayName(selectedProfile)
|
||||
?: "Server default",
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SectionLabel(
|
||||
title = "Profile",
|
||||
hint = "Overlay an agent's model + SOUL",
|
||||
)
|
||||
|
||||
val defaultDotColor = serverDefaultProfile?.let { profile ->
|
||||
if (profile.gatewayRunning) {
|
||||
@@ -791,13 +757,9 @@ fun AgentInfoSheet(
|
||||
val defaultRunning = serverDefaultProfile?.let { profile ->
|
||||
if (profile.gatewayRunning) " \u2022 Running" else " \u2022 Idle"
|
||||
}.orEmpty()
|
||||
val defaultDisplay = if (selectedProfile == null && profileDisplayAlias != null) {
|
||||
profileDisplayAlias
|
||||
} else {
|
||||
serverDefaultProfile?.let { profile ->
|
||||
AgentDisplay.profileDisplayName(profile)
|
||||
val defaultDisplay = serverDefaultProfile?.let { profile ->
|
||||
AgentDisplay.profileDisplayName(profile)
|
||||
?: profile.name.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
val defaultSecondary = serverDefaultProfile?.let { profile ->
|
||||
listOfNotNull(
|
||||
@@ -848,9 +810,6 @@ fun AgentInfoSheet(
|
||||
onSelect = {
|
||||
if (selectedProfile != null) {
|
||||
connectionViewModel.selectProfile(null)
|
||||
// Gateway turns carry no per-request profile —
|
||||
// hot-swap the live session server-side too.
|
||||
chatViewModel.activateGatewayProfile(null)
|
||||
toast("Using Server default")
|
||||
}
|
||||
},
|
||||
@@ -889,13 +848,8 @@ fun AgentInfoSheet(
|
||||
// to the capitalised profile name when description
|
||||
// is blank.
|
||||
val hasDescription = profile.description.isNotBlank()
|
||||
// Headline is the profile NAME (easy to scan); the
|
||||
// description + model ride one subtitle line.
|
||||
val primaryLabel = if (
|
||||
selectedProfile?.name == profile.name &&
|
||||
profileDisplayAlias != null
|
||||
) {
|
||||
profileDisplayAlias.orEmpty()
|
||||
val primaryLabel = if (hasDescription) {
|
||||
profile.description
|
||||
} else {
|
||||
profile.name.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
@@ -904,42 +858,32 @@ fun AgentInfoSheet(
|
||||
// colour; the text label is the a11y-visible
|
||||
// complement so a screen reader user also gets
|
||||
// the status without relying on colour.
|
||||
val secondaryLine = listOfNotNull(
|
||||
profile.description.takeIf { hasDescription },
|
||||
profile.model.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · ").takeIf { it.isNotBlank() }
|
||||
val secondaryLine = profile.model + runningLabel
|
||||
// Tertiary caption: the profile identifier (when
|
||||
// we promoted the description to primary) plus an
|
||||
// "active on server" hint when this row matches
|
||||
// the apparent default.
|
||||
val tertiaryLine: String? = null
|
||||
val tertiaryLine = buildString {
|
||||
if (hasDescription) {
|
||||
append("profile: ")
|
||||
append(profile.name)
|
||||
}
|
||||
if (isApparentActive && selectedProfile == null) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("This is the server's active profile")
|
||||
}
|
||||
}.takeIf { it.isNotBlank() }
|
||||
ProfileRadioRow(
|
||||
primary = primaryLabel,
|
||||
secondary = secondaryLine,
|
||||
// Cleaner card: drop the verbose "profile: … ·
|
||||
// compatibility overlay · active" caption now that
|
||||
// the name is the headline.
|
||||
tertiary = null,
|
||||
tertiary = tertiaryLine,
|
||||
selected = selectedProfile?.name == profile.name,
|
||||
enabled = !isStreaming,
|
||||
contentAlpha = 1f,
|
||||
leadingDotColor = dotColor,
|
||||
leadingDotContentDescription = dotA11y,
|
||||
secondaryTrailing = if (
|
||||
profile.gatewayRunning || profile.hasSoul || profile.skillCount > 0
|
||||
) {
|
||||
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
|
||||
{
|
||||
// Prominent status chip — the running/active
|
||||
// profile, so the dropped "· Running" text
|
||||
// doesn't cost status visibility (the green
|
||||
// leading dot still reinforces it).
|
||||
if (profile.gatewayRunning) {
|
||||
ProfileMetadataBadge(
|
||||
text = "Active",
|
||||
background = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
if (profile.skillCount > 0) {
|
||||
ProfileMetadataBadge(
|
||||
text = "${profile.skillCount} skills",
|
||||
@@ -959,14 +903,8 @@ fun AgentInfoSheet(
|
||||
onSelect = {
|
||||
if (selectedProfile?.name != profile.name) {
|
||||
connectionViewModel.selectProfile(profile)
|
||||
// Gateway turns carry no per-request profile;
|
||||
// hot-swap the live session server-side so the
|
||||
// agent (SOUL+model+skills) changes in place.
|
||||
chatViewModel.activateGatewayProfile(profile)
|
||||
val display = primaryLabel
|
||||
val suffix = if (profile.hasIsolatedApi) {
|
||||
" — profile API active"
|
||||
} else if (profile.systemMessage?.isNotBlank() == true) {
|
||||
val suffix = if (profile.systemMessage?.isNotBlank() == true) {
|
||||
" — model + SOUL applied"
|
||||
} else {
|
||||
" — model applied"
|
||||
@@ -977,21 +915,6 @@ fun AgentInfoSheet(
|
||||
)
|
||||
}
|
||||
|
||||
val inspectorTarget = selectedProfile
|
||||
?: serverDefaultProfile
|
||||
?: selectableProfiles.firstOrNull()
|
||||
inspectorTarget?.let { profile ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNavigateToProfileInspector(profile.name)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Inspect ${AgentDisplay.profileDisplayName(profile) ?: profile.name}")
|
||||
}
|
||||
}
|
||||
|
||||
if (profileOverridesPersonality) {
|
||||
Text(
|
||||
text = "This profile's system message overrides the personality below.",
|
||||
@@ -1010,12 +933,14 @@ fun AgentInfoSheet(
|
||||
// row is still tappable because the user may want to queue the
|
||||
// choice for after they clear the profile. No alpha on the entire
|
||||
// Column because the section header would look broken.
|
||||
CollapsiblePickerSection(
|
||||
title = "Personality",
|
||||
hint = "System-prompt preset on this agent",
|
||||
currentValue = AgentDisplay.personalityLabel(selectedPersonality, defaultPersonality),
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.alpha(if (profileOverridesPersonality) 0.55f else 1f),
|
||||
) {
|
||||
SectionLabel(
|
||||
title = "Personality",
|
||||
hint = "System-prompt preset on this agent",
|
||||
)
|
||||
|
||||
// Default row — maps to selectedPersonality == "default" which
|
||||
// the VM resolves to whatever server-side personality is
|
||||
@@ -1075,88 +1000,6 @@ fun AgentInfoSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Model section (host-side provider model) ----
|
||||
// Switches the model for THIS session. On the gateway this fires a
|
||||
// `/model` dispatch (the rich model-info card lands in chat); on SSE
|
||||
// the pick rides the next request body. Hidden when the server
|
||||
// advertises no models. Locked mid-turn — the gateway rejects a
|
||||
// switch while a turn runs, and SSE would race the in-flight request.
|
||||
// SSE fallback model list — /v1/models plus the configured profiles'
|
||||
// models (used only when the gateway model.options groups aren't
|
||||
// available, e.g. on an SSE transport).
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
||||
.distinct()
|
||||
}
|
||||
if (modelProviders.isNotEmpty() || sseModelOptions.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
CollapsiblePickerSection(
|
||||
title = "Model",
|
||||
hint = "Provider model for this session",
|
||||
currentValue = selectedModelOverride ?: "Server default",
|
||||
) {
|
||||
ProfileRadioRow(
|
||||
primary = "Server default",
|
||||
secondary = AgentDisplay.displayModelName(serverModelName),
|
||||
selected = selectedModelOverride == null,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != null) {
|
||||
chatViewModel.selectModel(null)
|
||||
toast("Using server default model")
|
||||
}
|
||||
},
|
||||
)
|
||||
if (modelProviders.isNotEmpty()) {
|
||||
// Gateway: the curated provider→model groups the desktop
|
||||
// picker uses (grok / kimi / gpt-5.5 …). Each provider's
|
||||
// models are grouped under its name; the switch carries
|
||||
// `--provider <slug>`.
|
||||
modelProviders.forEach { provider ->
|
||||
if (provider.models.isNotEmpty()) {
|
||||
Text(
|
||||
text = provider.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
|
||||
)
|
||||
provider.models.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model, provider.slug)
|
||||
toast("Model: $model")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model)
|
||||
toast("Model: $model")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// ---- Session + stats section ----
|
||||
@@ -1222,7 +1065,7 @@ fun AgentInfoSheet(
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> "$hostname • Standard"
|
||||
connection.pairedAt == null -> "$hostname • Not paired"
|
||||
else -> "$hostname • Paired"
|
||||
}
|
||||
ProfileRadioRow(
|
||||
@@ -1378,112 +1221,6 @@ private fun SectionLabel(title: String, hint: String?) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A space-saving picker section: a tappable header (the [SectionLabel] plus the
|
||||
* current value and a chevron) that collapses its option rows by default and
|
||||
* expands them on tap — a dropdown for the agent sheet's Profile / Personality
|
||||
* / Model lists so the sheet doesn't render every option at once. Selecting an
|
||||
* option (inside [content]) updates [currentValue] in the header; callers may
|
||||
* collapse on select by toggling their own state if desired, but leaving it
|
||||
* open lets the user see the new selection land.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapsiblePickerSection(
|
||||
title: String,
|
||||
hint: String?,
|
||||
currentValue: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
SectionLabel(title = title, hint = hint)
|
||||
}
|
||||
if (!expanded && currentValue.isNotBlank()) {
|
||||
Text(
|
||||
text = currentValue,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 150.dp)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "Collapse $title" else "Expand $title",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayAliasSection(
|
||||
currentAlias: String?,
|
||||
fallbackName: String,
|
||||
onSave: (String?) -> Unit,
|
||||
) {
|
||||
var draft by remember { mutableStateOf(currentAlias.orEmpty()) }
|
||||
LaunchedEffect(currentAlias) {
|
||||
draft = currentAlias.orEmpty()
|
||||
}
|
||||
val normalizedDraft = AgentDisplay.localDisplayAlias(draft)
|
||||
val hasChange = normalizedDraft != currentAlias
|
||||
|
||||
CollapsiblePickerSection(
|
||||
title = "Local display name",
|
||||
hint = "Phone label",
|
||||
currentValue = currentAlias ?: "Not set",
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
label = { Text("Name") },
|
||||
placeholder = { Text(fallbackName) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
draft = ""
|
||||
onSave(null)
|
||||
},
|
||||
enabled = currentAlias != null,
|
||||
) {
|
||||
Text("Clear")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSave(normalizedDraft) },
|
||||
enabled = hasChange,
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Radio-style row used by both Profile and Personality sections. Whole row
|
||||
* is a tap target (and selectable() for a11y). Disabled when [enabled] is
|
||||
@@ -1630,7 +1367,6 @@ private fun AgentSheetHeader(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
localDisplayAlias: String?,
|
||||
serverModelName: String,
|
||||
apiServerReachable: Boolean,
|
||||
chatMode: ChatMode,
|
||||
@@ -1641,10 +1377,8 @@ private fun AgentSheetHeader(
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
connectionLabel = null,
|
||||
localDisplayAlias = localDisplayAlias,
|
||||
)
|
||||
val modelLabel = AgentDisplay.displayModelName(profile?.model)
|
||||
?: AgentDisplay.displayModelName(serverModelName)
|
||||
val modelLabel = profile?.model ?: serverModelName
|
||||
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
|
||||
val statusText = when {
|
||||
apiServerReachable -> "Connected"
|
||||
@@ -1695,9 +1429,9 @@ private fun AgentSheetHeader(
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
modelLabel?.takeIf { it.isNotBlank() }?.let { label ->
|
||||
if (modelLabel.isNotBlank()) {
|
||||
Text(
|
||||
text = label,
|
||||
text = modelLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ private fun ConnectionRow(
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Standard"
|
||||
"$hostname • Not paired"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Ambient context-window meter — a 2dp hairline strip seated at the seam
|
||||
* between the TopAppBar and RelayModeStrip. The Telegram answer: zero-tap,
|
||||
* invisible until it matters.
|
||||
*
|
||||
* Silent below 50% usage (composes to nothing — no reserved height, the
|
||||
* seam simply stays a seam). From 50% the fill tracks
|
||||
* [usedFraction] through Relay → Amber (≥75%) → Danger (≥90%), the same
|
||||
* caution ladder the sudo countdown and voice badge use. Amber/Danger are
|
||||
* deliberate direct RelayRefresh reads — identical in both schemes.
|
||||
*
|
||||
* Render only when the gateway usage carries a context_max (the compressor
|
||||
* is present); pass null otherwise and the strip vanishes.
|
||||
*/
|
||||
@Composable
|
||||
fun ContextMeterBar(usedFraction: Float?, modifier: Modifier = Modifier) {
|
||||
if (usedFraction == null || usedFraction < 0.5f) return
|
||||
|
||||
val fill by animateFloatAsState(
|
||||
targetValue = usedFraction.coerceIn(0f, 1f),
|
||||
animationSpec = tween(600),
|
||||
label = "ctxFill",
|
||||
)
|
||||
val color = when {
|
||||
fill >= 0.9f -> RelayRefresh.Danger
|
||||
fill >= 0.75f -> RelayRefresh.Amber
|
||||
else -> RelayRefresh.Relay.copy(alpha = 0.8f)
|
||||
}
|
||||
val percent = (usedFraction.coerceIn(0f, 1f) * 100).roundToInt()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
.semantics { contentDescription = "Context $percent% used" },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fill)
|
||||
.fillMaxHeight()
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,15 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -46,11 +42,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.network.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -58,14 +52,8 @@ import kotlinx.coroutines.launch
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
* settings screen. Renders one row per [EndpointCandidate] stored for the
|
||||
* active device, with a health chip, a 3-dot menu (Prefer / Probe now /
|
||||
* View pin), and bottom clear actions when a switch/preference is set.
|
||||
*
|
||||
* Two distinct route actions, deliberately separated:
|
||||
* - "Use now" (row button) — one-time switch. Holds until the next
|
||||
* disconnect, never persisted, cancelled by [onCancelUseNow].
|
||||
* - "Prefer this route" (3-dot menu) — sticky policy. Persisted, restored
|
||||
* on app start, tried first on every resolve; cleared by
|
||||
* [onClearPreferred].
|
||||
* View pin), and a bottom "Clear manual override" action when a preferred
|
||||
* role is set.
|
||||
*
|
||||
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
|
||||
* do NOT collapse into a master row. The card itself is wrapped in a
|
||||
@@ -79,70 +67,26 @@ import kotlinx.coroutines.launch
|
||||
fun EndpointsCard(
|
||||
endpoints: List<EndpointCandidate>,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
/** Persisted sticky preference ([Connection.preferredRouteRole]). */
|
||||
preferredRole: String?,
|
||||
/**
|
||||
* Live transient override installed by "Use now" (or by preference
|
||||
* restoration — equal to [preferredRole] in that case). Drives the
|
||||
* automatic / preferred / manual annotation on the Current line.
|
||||
*/
|
||||
manualOverrideRole: String?,
|
||||
onUseNow: (EndpointCandidate) -> Unit,
|
||||
onCancelUseNow: () -> Unit,
|
||||
onPreferEndpoint: (EndpointCandidate) -> Unit,
|
||||
onClearPreferred: () -> Unit,
|
||||
onClearOverride: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
/** True while a user-triggered route probe is in flight. */
|
||||
isProbing: Boolean = false,
|
||||
/**
|
||||
* Last probe verdict for a route, or null when it has never been
|
||||
* probed. Lambda (not a map) so the card stays decoupled from the
|
||||
* resolver's cache-key scheme.
|
||||
*/
|
||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
||||
/**
|
||||
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||
* Edit/Remove only appear on fallback rows (priority > 0); the primary
|
||||
* row mirrors the connection's API URL and is edited there.
|
||||
*/
|
||||
onAddRoute: (() -> Unit)? = null,
|
||||
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
|
||||
) {
|
||||
if (endpoints.isEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "No route candidates stored for this connection yet. " +
|
||||
"Add a remote route (Tailscale, public URL) for automatic " +
|
||||
"switching when the phone leaves this network — or scan a " +
|
||||
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (onAddRoute != null) {
|
||||
TextButton(onClick = onAddRoute) {
|
||||
Text("Add route")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "No route candidates stored for this device yet. " +
|
||||
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
|
||||
"switching — LAN + Tailscale + public URLs.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// A manual "Use now" switch is in effect when the live override differs
|
||||
// from the sticky preference (preference restoration writes the same
|
||||
// role into both, so equality means "preferred", not "manual").
|
||||
val manualSwitchActive = manualOverrideRole != null &&
|
||||
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}" + when {
|
||||
manualSwitchActive -> " · manual (until disconnect)"
|
||||
manualOverrideRole != null -> " · preferred"
|
||||
else -> " · automatic"
|
||||
},
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -155,39 +99,16 @@ fun EndpointsCard(
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
isProbing = isProbing,
|
||||
outcome = outcomeFor(candidate),
|
||||
onUseNow = { onUseNow(candidate) },
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onClearPrefer = onClearPreferred,
|
||||
onProbeNow = onProbeNow,
|
||||
onViewPin = onViewPin,
|
||||
onEdit = onEditRoute?.takeIf { candidate.priority > 0 }
|
||||
?.let { edit -> { edit(candidate) } },
|
||||
onRemove = onRemoveRoute?.takeIf { candidate.priority > 0 }
|
||||
?.let { remove -> { remove(candidate) } },
|
||||
)
|
||||
}
|
||||
|
||||
if (onAddRoute != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Add route")
|
||||
}
|
||||
}
|
||||
|
||||
if (manualSwitchActive) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onCancelUseNow, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Cancel manual switch (back to ${preferredRole ?: "automatic"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (preferredRole != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onClearPreferred, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Stop preferring $preferredRole (back to automatic)")
|
||||
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Clear manual override (preferring $preferredRole)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,19 +122,12 @@ private fun EndpointRow(
|
||||
candidate: EndpointCandidate,
|
||||
isActive: Boolean,
|
||||
isPreferred: Boolean,
|
||||
isProbing: Boolean = false,
|
||||
outcome: RouteProbeOutcome? = null,
|
||||
onUseNow: () -> Unit,
|
||||
onPrefer: () -> Unit,
|
||||
onClearPrefer: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -260,46 +174,21 @@ private fun EndpointRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Full URL, scheme included: http vs https decides whether
|
||||
// the health probe TLS-handshakes, so two rows that both
|
||||
// read "host:8642" can behave completely differently. The
|
||||
// scheme must be visible to be debuggable.
|
||||
Text(
|
||||
text = candidate.api.url +
|
||||
text = "${candidate.api.host}:${candidate.api.port}" +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = "Checking…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome == null -> Unit // never probed — say nothing
|
||||
outcome.reachable -> Text(
|
||||
text = "Reachable",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> Text(
|
||||
text = "Unreachable — ${outcome.detail ?: "no detail"}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
// without needing to expand into a detail sheet. "View pin"
|
||||
// suspends to read CertPinStore, so we resolve it into a dialog
|
||||
// when the user taps it.
|
||||
//
|
||||
// "Use now" is the TRANSIENT switch (until disconnect); the
|
||||
// sticky "Prefer this route" lives in the menu below.
|
||||
if (!isActive) {
|
||||
TextButton(onClick = onUseNow) {
|
||||
TextButton(onClick = onPrefer) {
|
||||
Text("Use now")
|
||||
}
|
||||
}
|
||||
@@ -315,25 +204,10 @@ private fun EndpointRow(
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (isPreferred) "Stop preferring" else "Prefer this route",
|
||||
)
|
||||
Text(
|
||||
text = if (isPreferred) {
|
||||
"Back to automatic choice"
|
||||
} else {
|
||||
"Always try this route first"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
text = { Text("Prefer this route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
if (isPreferred) onClearPrefer() else onPrefer()
|
||||
onPrefer()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
@@ -354,54 +228,11 @@ private fun EndpointRow(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (onEdit != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onEdit()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onRemove != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
confirmRemove = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove && onRemove != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = false },
|
||||
title = { Text("Remove ${candidate.displayLabel()} route?") },
|
||||
text = {
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
|
||||
"probed as a fallback. You can add it back any time.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmRemove = false
|
||||
onRemove()
|
||||
},
|
||||
) { Text("Remove") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
@@ -497,154 +328,3 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
"public" -> Icons.Filled.Public
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit dialog for an extra fallback route — the manual counterpart of a
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||
*
|
||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
||||
*
|
||||
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||
* URL, keeps the stored priority).
|
||||
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
|
||||
* receives a user-facing error string to render inline, or null on
|
||||
* success (the dialog then closes itself).
|
||||
*/
|
||||
@Composable
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val knownRoles = listOf("tailscale", "public")
|
||||
var selectedRole by remember {
|
||||
mutableStateOf(
|
||||
when (original?.role?.lowercase()) {
|
||||
null -> "tailscale"
|
||||
in knownRoles -> original.role.lowercase()
|
||||
else -> CUSTOM_ROLE
|
||||
},
|
||||
)
|
||||
}
|
||||
var customRole by remember {
|
||||
mutableStateOf(
|
||||
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
|
||||
)
|
||||
}
|
||||
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
|
||||
val saveEnabled = !saving &&
|
||||
url.isNotBlank() &&
|
||||
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(if (original == null) "Add route" else "Edit route") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "A fallback route the phone switches to when the " +
|
||||
"primary stops answering — e.g. your server's " +
|
||||
"Tailscale or public URL.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = selectedRole == "tailscale",
|
||||
onClick = { selectedRole = "tailscale" },
|
||||
label = { Text("Tailscale") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == "public",
|
||||
onClick = { selectedRole = "public" },
|
||||
label = { Text("Public") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedRole == CUSTOM_ROLE,
|
||||
onClick = { selectedRole = CUSTOM_ROLE },
|
||||
label = { Text("Custom") },
|
||||
)
|
||||
}
|
||||
if (selectedRole == CUSTOM_ROLE) {
|
||||
OutlinedTextField(
|
||||
value = customRole,
|
||||
onValueChange = { customRole = it },
|
||||
label = { Text("Route name") },
|
||||
placeholder = { Text("wireguard-home") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
// Live preview of what will actually be saved — scheme and
|
||||
// port defaults applied — so "what, which port, http or
|
||||
// https?" is answered before Save, not after a failed probe.
|
||||
val previewCandidate = remember(url, effectiveRole) {
|
||||
url.takeIf { it.isNotBlank() }?.let {
|
||||
Connection.endpointCandidateFromApiUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
apiServerUrl = Connection.normalizeApiUrlInput(it),
|
||||
relayUrl = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
errorText = null
|
||||
},
|
||||
label = { Text("API server URL or host") },
|
||||
placeholder = { Text("100.71.8.56 or http://host:8642") },
|
||||
singleLine = true,
|
||||
isError = errorText != null,
|
||||
supportingText = {
|
||||
Text(
|
||||
text = errorText ?: when {
|
||||
url.isBlank() ->
|
||||
"Use the API server port (8642 by default) — " +
|
||||
"not the dashboard's 9119. http:// is " +
|
||||
"assumed unless you type https://."
|
||||
previewCandidate != null ->
|
||||
"Will save: ${previewCandidate.api.url} — relay " +
|
||||
"and dashboard URLs are derived from the host"
|
||||
else ->
|
||||
"Enter a host/IP or an http(s):// URL " +
|
||||
"(API port 8642, not dashboard 9119)"
|
||||
},
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = saveEnabled,
|
||||
onClick = {
|
||||
saving = true
|
||||
onSave(effectiveRole, url) { error ->
|
||||
saving = false
|
||||
if (error == null) {
|
||||
onDismiss()
|
||||
} else {
|
||||
errorText = error
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val CUSTOM_ROLE = "__custom__"
|
||||
|
||||
@@ -2,13 +2,8 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,65 +20,37 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.HourglassBottom
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.HermesCardField
|
||||
import com.hermesandroid.relay.data.HermesCardInput
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Inline rich-card render for a [HermesCard] extracted from an assistant
|
||||
@@ -102,23 +69,11 @@ import kotlinx.coroutines.launch
|
||||
* fields + actions — so a newer agent emitting a type the phone build
|
||||
* doesn't recognize still gets a coherent card, not an empty bubble.
|
||||
*
|
||||
* Action dispatch is fully delegated to [onActionTap]; input-slot
|
||||
* submissions (ask cards — clarify answer, secret value, sudo confirm)
|
||||
* are delegated to [onInputSubmit] the same way. The bubble is stateless
|
||||
* w.r.t. dispatch tracking — it reads [dispatches] (from the owning
|
||||
* [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and renders
|
||||
* a confirmation row instead of the answer surfaces once the user has
|
||||
* chosen.
|
||||
*
|
||||
* Timed asks ([HermesCardInput.expiresAtMillis]) tick a countdown footer
|
||||
* (Amber under 30s) and self-collapse to a muted "Expired — not granted"
|
||||
* stamp past expiry — history reload past the deadline lands directly in
|
||||
* the collapsed state, so a dead ask never re-prompts.
|
||||
*
|
||||
* Secrets: when [HermesCardInput.masked] is set, the submitted value goes
|
||||
* only through [onInputSubmit]; the collapse stamp renders masked dots and
|
||||
* the caller must record [HermesCardInput.SECRET_PROVIDED_STAMP] as the
|
||||
* dispatch value, never the secret itself.
|
||||
* Action dispatch is fully delegated to [onActionTap]. The bubble is
|
||||
* stateless w.r.t. dispatch tracking — it reads [dispatches] (from the
|
||||
* owning [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and
|
||||
* renders a confirmation row instead of the action buttons once the user
|
||||
* has chosen.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -127,7 +82,6 @@ fun HermesCardBubble(
|
||||
cardKey: String,
|
||||
dispatches: List<HermesCardDispatch>,
|
||||
onActionTap: (cardKey: String, action: HermesCardAction) -> Unit,
|
||||
onInputSubmit: (cardKey: String, value: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
@@ -135,21 +89,6 @@ fun HermesCardBubble(
|
||||
val typeIcon = iconForType(card.type)
|
||||
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
|
||||
|
||||
// Expiry clock for timed asks. Ticks once a second while the deadline
|
||||
// is ahead; freezes after. Keyed on the deadline so a re-used card id
|
||||
// with a fresh expiry restarts the loop.
|
||||
val expiresAt = card.input?.expiresAtMillis
|
||||
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(expiresAt) {
|
||||
if (expiresAt == null) return@LaunchedEffect
|
||||
while (System.currentTimeMillis() < expiresAt) {
|
||||
nowMillis = System.currentTimeMillis()
|
||||
delay(1_000)
|
||||
}
|
||||
nowMillis = System.currentTimeMillis()
|
||||
}
|
||||
val expired = expiresAt != null && nowMillis >= expiresAt && alreadyChosen == null
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
@@ -239,66 +178,26 @@ fun HermesCardBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Input slot + actions OR a single dispatch/expiry stamp.
|
||||
// A card with an input slot owns ONE stamp for the whole
|
||||
// card — answering via chip, field, hold-confirm, or an
|
||||
// action button all collapse the same way.
|
||||
val input = card.input
|
||||
when {
|
||||
alreadyChosen != null -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val chosenAction = card.actions.firstOrNull {
|
||||
// Actions OR dispatch confirmation
|
||||
if (card.actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (alreadyChosen != null) {
|
||||
val chosen = card.actions.firstOrNull {
|
||||
it.value == alreadyChosen.actionValue
|
||||
}
|
||||
when {
|
||||
chosenAction != null -> ChoseRow("Chose: ${chosenAction.label}")
|
||||
input?.masked == true -> ChoseRow("Secret provided · ••••")
|
||||
input != null -> ChoseRow("Answered: ${alreadyChosen.actionValue}")
|
||||
else -> ChoseRow("Chose: ${alreadyChosen.actionValue}")
|
||||
}
|
||||
}
|
||||
expired -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ChoseRow(
|
||||
text = "Expired — not granted",
|
||||
icon = Icons.Filled.HourglassBottom,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
if (input != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
CardInputSlot(
|
||||
input = input,
|
||||
onSubmit = { value -> onInputSubmit(cardKey, value) },
|
||||
)
|
||||
}
|
||||
if (card.actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
card.actions.forEach { action ->
|
||||
ActionButton(
|
||||
action = action,
|
||||
onClick = { onActionTap(cardKey, action) },
|
||||
)
|
||||
}
|
||||
ChoseRow(chosen?.label ?: alreadyChosen.actionValue)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
card.actions.forEach { action ->
|
||||
ActionButton(
|
||||
action = action,
|
||||
onClick = { onActionTap(cardKey, action) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Countdown footer for timed asks — Amber when the
|
||||
// deadline is inside 30s.
|
||||
if (expiresAt != null) {
|
||||
val remainingSec = ((expiresAt - nowMillis) / 1000).coerceAtLeast(0)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "expires in %d:%02d".format(remainingSec / 60, remainingSec % 60),
|
||||
style = relayMetadataStyle(),
|
||||
color = if (remainingSec < 30) RelayRefresh.Amber
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,11 +250,7 @@ private fun looksMonospaceWorthy(value: String): Boolean {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoseRow(
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Filled.Check,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
private fun ChoseRow(label: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -364,264 +259,20 @@ private fun ChoseRow(
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = text,
|
||||
text = "Chose: $label",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive answer surface for ask cards, composed from the
|
||||
* [HermesCardInput] flags rather than the card type:
|
||||
* - [HermesCardInput.choices] → AssistChip row, one tap dispatches.
|
||||
* - [HermesCardInput.allowFreeText] → [InlineAnswerField] mini pill +
|
||||
* 18dp send affordance.
|
||||
* - [HermesCardInput.masked] → password-style OutlinedTextField with a
|
||||
* reveal toggle and the "Not stored in chat history" assurance line.
|
||||
* - [HermesCardInput.holdToConfirm] → [HoldToConfirmButton] replaces the
|
||||
* plain submit (sudo). With [HermesCardInput.masked] it submits the
|
||||
* typed value; bare, it submits [HermesCardInput.CONFIRM_VALUE].
|
||||
*
|
||||
* Unknown kinds degrade to the free-text field so a newer ask still gets
|
||||
* an answer surface.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CardInputSlot(
|
||||
input: HermesCardInput,
|
||||
onSubmit: (String) -> Unit,
|
||||
) {
|
||||
// Deliberately remember, not rememberSaveable — a typed secret must
|
||||
// never be written into the saved-instance-state Bundle.
|
||||
var answerText by remember { mutableStateOf("") }
|
||||
var reveal by remember { mutableStateOf(false) }
|
||||
|
||||
val showFreeText = !input.masked && (
|
||||
input.allowFreeText ||
|
||||
input.kind == HermesCardInput.Kinds.TEXT ||
|
||||
// Unknown-kind fallback: with no other surface, still offer text.
|
||||
(input.choices.isEmpty() && !input.holdToConfirm &&
|
||||
input.kind != HermesCardInput.Kinds.CONFIRM)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Choice chips
|
||||
if (input.choices.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
input.choices.forEach { choice ->
|
||||
AssistChip(
|
||||
onClick = { onSubmit(choice) },
|
||||
label = {
|
||||
Text(choice, style = MaterialTheme.typography.labelMedium)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Masked secret field
|
||||
if (input.masked) {
|
||||
if (input.choices.isNotEmpty()) Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = answerText,
|
||||
onValueChange = { answerText = it },
|
||||
singleLine = true,
|
||||
visualTransformation = if (reveal) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { reveal = !reveal }) {
|
||||
Icon(
|
||||
imageVector = if (reveal) Icons.Filled.VisibilityOff
|
||||
else Icons.Filled.Visibility,
|
||||
contentDescription = if (reveal) "Hide value" else "Reveal value",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Not stored in chat history",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
// Free-text mini field (clarify)
|
||||
if (showFreeText) {
|
||||
if (input.choices.isNotEmpty()) Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
InlineAnswerField(
|
||||
value = answerText,
|
||||
onValueChange = { answerText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { onSubmit(answerText.trim()) },
|
||||
enabled = answerText.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send answer",
|
||||
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit affordance for masked / hold-to-confirm inputs
|
||||
when {
|
||||
input.holdToConfirm -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HoldToConfirmButton(
|
||||
label = "Hold to confirm",
|
||||
enabled = !input.masked || answerText.isNotEmpty(),
|
||||
onConfirmed = {
|
||||
onSubmit(
|
||||
if (input.masked) answerText
|
||||
else HermesCardInput.CONFIRM_VALUE,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
input.masked -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Button(
|
||||
onClick = { onSubmit(answerText) },
|
||||
enabled = answerText.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) { Text("Submit", style = MaterialTheme.typography.labelMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini pill answer field — Navy3 surface, hairline border, bodyMedium,
|
||||
* single line growing to 3. The in-card sibling of the chat input pill.
|
||||
*/
|
||||
@Composable
|
||||
private fun InlineAnswerField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = "Type an answer…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sudo confirm hold duration — long enough to defeat a drive-by tap. */
|
||||
private const val HOLD_TO_CONFIRM_MS = 650
|
||||
|
||||
/**
|
||||
* Error-container button whose fill completes over [HOLD_TO_CONFIRM_MS] of
|
||||
* sustained press (pointerInput onPress racing tryAwaitRelease). Releasing
|
||||
* early snaps the fill back; holding to completion fires [onConfirmed]
|
||||
* exactly once at the moment the fill lands.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldToConfirmButton(
|
||||
label: String,
|
||||
enabled: Boolean,
|
||||
onConfirmed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val fill = remember { Animatable(0f) }
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(shape)
|
||||
.background(errorColor.copy(alpha = if (enabled) 0.18f else 0.08f))
|
||||
.border(1.dp, errorColor.copy(alpha = if (enabled) 0.6f else 0.25f), shape)
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
coroutineScope {
|
||||
val ramp = launch {
|
||||
fill.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(HOLD_TO_CONFIRM_MS, easing = LinearEasing),
|
||||
)
|
||||
onConfirmed()
|
||||
}
|
||||
tryAwaitRelease()
|
||||
ramp.cancel()
|
||||
}
|
||||
fill.snapTo(0f)
|
||||
},
|
||||
)
|
||||
}
|
||||
.semantics { contentDescription = "$label — press and hold" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Press-fill layer grows left → right under the label.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(fill.value.coerceIn(0f, 1f))
|
||||
.background(errorColor.copy(alpha = 0.45f)),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (enabled) errorColor else errorColor.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
action: HermesCardAction,
|
||||
@@ -669,10 +320,6 @@ private fun iconForType(type: String): ImageVector? = when (type) {
|
||||
HermesCard.BuiltInTypes.CALENDAR_EVENT -> Icons.Filled.CalendarToday
|
||||
HermesCard.BuiltInTypes.WEATHER -> Icons.Filled.WbSunny
|
||||
HermesCard.BuiltInTypes.SKILL_RESULT -> Icons.Filled.AutoAwesome
|
||||
HermesCard.BuiltInTypes.ASK_APPROVAL -> Icons.Filled.Shield
|
||||
HermesCard.BuiltInTypes.ASK_SUDO -> Icons.Filled.Shield
|
||||
HermesCard.BuiltInTypes.ASK_CLARIFY -> Icons.Filled.AutoAwesome
|
||||
HermesCard.BuiltInTypes.ASK_SECRET -> Icons.Filled.Lock
|
||||
else -> Icons.Filled.AutoAwesome
|
||||
}
|
||||
|
||||
|
||||
+19
-116
@@ -1,13 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,17 +13,11 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -41,11 +31,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Discord-style inline render for any attachment — outbound (user-authored)
|
||||
@@ -227,25 +213,12 @@ private fun ImageRender(
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Bitmap(
|
||||
bitmap = bitmap,
|
||||
displayName = attachment.fileName ?: "image",
|
||||
mime = attachment.contentType.ifBlank { "image/*" },
|
||||
bytesProvider = { attachmentBytes(context, attachment) },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
)
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = attachment.fileName,
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
} else {
|
||||
@@ -259,7 +232,6 @@ private fun ImageRender(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FileCardRender(
|
||||
attachment: Attachment,
|
||||
@@ -267,30 +239,32 @@ private fun FileCardRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val openExternal = {
|
||||
val uriStr = attachment.cachedUri
|
||||
if (!uriStr.isNullOrBlank()) {
|
||||
MediaSaver.open(context, Uri.parse(uriStr), attachment.contentType)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
// Tap opens externally (unchanged); long-press surfaces the
|
||||
// Open / Share / Save menu — only when there are bytes to act on.
|
||||
.combinedClickable(
|
||||
onClick = openExternal,
|
||||
onLongClick = { if (!attachment.cachedUri.isNullOrBlank()) menuExpanded = true },
|
||||
)
|
||||
.clickable {
|
||||
val uriStr = attachment.cachedUri
|
||||
if (!uriStr.isNullOrBlank()) {
|
||||
try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, attachment.contentType)
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// No viewer installed or malformed URI — silently ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -324,80 +298,9 @@ private fun FileCardRender(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
openExternal()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
scope.launch {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save to device") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
scope.launch {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
attachmentToast(context, "Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
attachmentToast(context, "Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original bytes behind an attachment — read from the cached `content://` URI
|
||||
* when present (inbound), else base64-decoded from the inline content
|
||||
* (outbound). Off the main thread; null when neither source is available.
|
||||
*/
|
||||
private suspend fun attachmentBytes(context: Context, attachment: Attachment): ByteArray? {
|
||||
val uriStr = attachment.cachedUri
|
||||
return when {
|
||||
!uriStr.isNullOrBlank() -> MediaSaver.readUriBytes(context, Uri.parse(uriStr))
|
||||
attachment.content.isNotBlank() -> withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
android.util.Base64.decode(attachment.content, android.util.Base64.DEFAULT)
|
||||
}.getOrNull()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachmentToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun emojiAndLabelFor(
|
||||
mode: AttachmentRenderMode,
|
||||
contentType: String
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
||||
import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans
|
||||
import com.mikepenz.markdown.compose.extendedspans.RoundedCornerSpanPainter
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
@@ -29,6 +37,7 @@ import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun MarkdownContent(
|
||||
@@ -46,11 +55,12 @@ fun MarkdownContent(
|
||||
modifier = modifier,
|
||||
colors = markdownColor(
|
||||
text = textColor,
|
||||
codeText = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
|
||||
inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant
|
||||
linkText = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
typography = markdownTypography(
|
||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -58,20 +68,14 @@ fun MarkdownContent(
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true
|
||||
)
|
||||
MarkdownCodeBlock(it.content, it.node) { code, language ->
|
||||
CodeBlockWithCopyButton(code, language, highlightsBuilder)
|
||||
}
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true
|
||||
)
|
||||
MarkdownCodeFence(it.content, it.node) { code, language ->
|
||||
CodeBlockWithCopyButton(code, language, highlightsBuilder)
|
||||
}
|
||||
}
|
||||
),
|
||||
extendedSpans = markdownExtendedSpans {
|
||||
@@ -81,147 +85,46 @@ fun MarkdownContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamingMarkdownContent(
|
||||
content: String,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
private fun CodeBlockWithCopyButton(
|
||||
code: String,
|
||||
language: String?,
|
||||
highlightsBuilder: Highlights.Builder
|
||||
) {
|
||||
val blocks = remember(content) { parseStreamingMarkdownBlocks(content) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is StreamingMarkdownBlock.Text -> Text(
|
||||
text = block.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
|
||||
}
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
Box {
|
||||
MarkdownHighlightedCode(code, language, highlightsBuilder)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(code))
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
if (block.language.isNotBlank()) {
|
||||
Text(
|
||||
text = block.language,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = block.code.ifEmpty { " " },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
softWrap = false,
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = if (copied) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface StreamingMarkdownBlock {
|
||||
data class Text(val text: String) : StreamingMarkdownBlock
|
||||
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
|
||||
}
|
||||
|
||||
private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
|
||||
if (content.isBlank()) return emptyList()
|
||||
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val paragraph = StringBuilder()
|
||||
val code = StringBuilder()
|
||||
var inFence = false
|
||||
var activeFence = ""
|
||||
var language = ""
|
||||
|
||||
fun flushParagraph() {
|
||||
val text = paragraph.toString().trimEnd()
|
||||
if (text.isNotBlank()) {
|
||||
blocks += StreamingMarkdownBlock.Text(text)
|
||||
}
|
||||
paragraph.clear()
|
||||
}
|
||||
|
||||
fun flushCode() {
|
||||
blocks += StreamingMarkdownBlock.Code(
|
||||
language = language,
|
||||
code = code.toString().trimEnd('\n'),
|
||||
)
|
||||
code.clear()
|
||||
}
|
||||
|
||||
val lines = content
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split('\n')
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
|
||||
|
||||
if (!inFence) {
|
||||
val fence = streamingFenceMarker(line)
|
||||
if (fence != null) {
|
||||
flushParagraph()
|
||||
inFence = true
|
||||
activeFence = fence
|
||||
language = streamingFenceLanguage(line, fence)
|
||||
} else {
|
||||
paragraph.append(lineWithBreak)
|
||||
}
|
||||
} else if (streamingFenceMarker(line) == activeFence) {
|
||||
flushCode()
|
||||
inFence = false
|
||||
activeFence = ""
|
||||
language = ""
|
||||
} else {
|
||||
code.append(lineWithBreak)
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
flushCode()
|
||||
} else {
|
||||
flushParagraph()
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private fun streamingFenceMarker(line: String): String? {
|
||||
val trimmed = line.trimStart()
|
||||
return when {
|
||||
trimmed.startsWith("```") -> "```"
|
||||
trimmed.startsWith("~~~") -> "~~~"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamingFenceLanguage(line: String, marker: String): String {
|
||||
val tail = line.trimStart().removePrefix(marker).trim()
|
||||
return tail
|
||||
.takeWhile { !it.isWhitespace() && it != '`' && it != '~' }
|
||||
.take(32)
|
||||
}
|
||||
|
||||
@@ -27,17 +27,11 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -50,6 +44,7 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -61,11 +56,6 @@ fun MessageBubble(
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Quote this message into the input field. Null hides the Quote entry in
|
||||
* the long-press menu, so legacy call sites keep the copy-only behavior.
|
||||
*/
|
||||
onQuoteMessage: ((String) -> Unit)? = null,
|
||||
/**
|
||||
* Invoked when the user taps a FAILED inbound attachment card.
|
||||
* `attachmentIndex` is the position in [ChatMessage.attachments] so the
|
||||
@@ -85,20 +75,7 @@ fun MessageBubble(
|
||||
* card collapses) and forwards the action value per its mode.
|
||||
* Defaults to no-op so legacy callers / tests don't have to wire it.
|
||||
*/
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> },
|
||||
/**
|
||||
* Invoked when the user submits a card's interactive input slot (the
|
||||
* gateway ask cards — clarify answer, secret value, sudo confirm).
|
||||
* Routed to [com.hermesandroid.relay.viewmodel.ChatViewModel.answerAsk]
|
||||
* by ChatScreen; defaults to no-op for legacy callers.
|
||||
*/
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit = { _, _, _ -> },
|
||||
/**
|
||||
* "Edit & resend" entry in the USER-bubble long-press menu — gateway
|
||||
* transport only (the only path that supports rewinding the server
|
||||
* conversation). Null hides the entry.
|
||||
*/
|
||||
onEditMessage: ((ChatMessage) -> Unit)? = null,
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> }
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -150,23 +127,10 @@ fun MessageBubble(
|
||||
}
|
||||
|
||||
val alignment = if (isUser) Alignment.End else Alignment.Start
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val timeFormat = remember(locale) { SimpleDateFormat("h:mm a", locale) }
|
||||
val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
val a11yDescription = "${message.role.name.lowercase()} message: ${message.content.take(100)}"
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
// Pull generated/inline image links (``) out of assistant
|
||||
// content so they render as real images (remote URLs via Coil) or a
|
||||
// graceful inline notice — not the blank element the markdown renderer
|
||||
// emits for an image link. User/system bubbles keep their raw content.
|
||||
val (markdownBody, inlineImages) = remember(message.content, isUser, isSystem) {
|
||||
if (isUser || isSystem) {
|
||||
message.content to emptyList()
|
||||
} else {
|
||||
extractChatInlineImages(message.content)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment
|
||||
@@ -200,7 +164,6 @@ fun MessageBubble(
|
||||
ThinkingBlock(
|
||||
thinkingContent = message.thinkingContent,
|
||||
isStreaming = message.isThinkingStreaming,
|
||||
timestamp = message.timestamp,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp)
|
||||
@@ -214,21 +177,6 @@ fun MessageBubble(
|
||||
// is rendered as a separate Box so it hugs the bubble's left edge
|
||||
// regardless of content height (tall bubbles with multi-line
|
||||
// markdown stretch the bar via fillMaxHeight + IntrinsicSize).
|
||||
//
|
||||
// Suppress an otherwise-empty assistant bubble: a message that
|
||||
// carries only thinking and/or tool calls (both rendered OUTSIDE
|
||||
// this Surface — the ThinkingBlock above, the tool pills as separate
|
||||
// rows) would otherwise paint a bare timestamp-only chip between the
|
||||
// Thought-process block and the tool pill. Keep the bubble while
|
||||
// streaming (StreamingDots is the live "working" indicator) and
|
||||
// whenever there are cards/attachments to render inside it.
|
||||
val showBubble = isUser || isSystem ||
|
||||
message.content.isNotBlank() ||
|
||||
message.isStreaming ||
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
if (showBubble) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = maxBubbleWidth),
|
||||
verticalAlignment = Alignment.Top,
|
||||
@@ -243,43 +191,6 @@ fun MessageBubble(
|
||||
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f))
|
||||
)
|
||||
}
|
||||
// Long-press opens a compact action menu when a quote handler is
|
||||
// wired; with copy as the only action it stays a direct copy so the
|
||||
// one-action case doesn't pay a menu tap.
|
||||
var showMessageActions by remember { mutableStateOf(false) }
|
||||
val showEditAction = onEditMessage != null && isUser
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(message.content)
|
||||
},
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Quote in reply") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.content)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showEditAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit & resend") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onEditMessage(message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
@@ -295,13 +206,7 @@ fun MessageBubble(
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(message.content)
|
||||
}
|
||||
}
|
||||
onLongClick = { onCopyMessage(message.content) }
|
||||
)
|
||||
.semantics { contentDescription = a11yDescription }
|
||||
) {
|
||||
@@ -315,38 +220,16 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Use a stable lightweight renderer while streaming.
|
||||
// The full parser/highlighter can rebuild block shapes
|
||||
// on every partial fence/token and visibly flicker.
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
if (message.isStreaming) {
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor
|
||||
)
|
||||
} else {
|
||||
MarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
// Markdown for assistant messages
|
||||
if (message.content.isNotEmpty()) {
|
||||
MarkdownContent(
|
||||
content = message.content,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline generated images (assistant only) — rendered OUTSIDE
|
||||
// the SelectionContainer (they're not selectable text). Remote
|
||||
// http(s) URLs load via Coil; server-local paths and load
|
||||
// failures degrade to a notice that says why, instead of a
|
||||
// blank space.
|
||||
if (!isUser && !isSystem && inlineImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
ChatInlineImages(
|
||||
images = inlineImages,
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
)
|
||||
}
|
||||
|
||||
// Rich cards — rendered between the markdown body and
|
||||
// attachments so the reading order stays: narration → card
|
||||
// → attached file. Each card gets a stable key built from
|
||||
@@ -364,9 +247,6 @@ fun MessageBubble(
|
||||
onActionTap = { key, action ->
|
||||
onCardAction(message.id, key, action)
|
||||
},
|
||||
onInputSubmit = { key, value ->
|
||||
onCardInput(message.id, key, value)
|
||||
},
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
@@ -419,7 +299,6 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
} // end Row (bubble + optional leading accent bar)
|
||||
} // end if (showBubble)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
enum class PowerFeatureGateStatus(
|
||||
val label: String,
|
||||
val actionLabel: String,
|
||||
val explanation: String,
|
||||
) {
|
||||
RequiresPairing(
|
||||
label = "Requires pairing",
|
||||
actionLabel = "Pair to unlock",
|
||||
explanation = "This feature uses relay grants and requires a paired device session.",
|
||||
),
|
||||
PairingExpired(
|
||||
label = "Pairing expired",
|
||||
actionLabel = "Pair again",
|
||||
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
|
||||
),
|
||||
Unavailable(
|
||||
label = "Unavailable on this server",
|
||||
actionLabel = "View connection",
|
||||
explanation = "This server does not currently expose the relay capability this feature needs.",
|
||||
),
|
||||
DashboardSignInRequired(
|
||||
label = "Dashboard sign-in required",
|
||||
actionLabel = "Open sign-in",
|
||||
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromRelayAuth(authState: AuthState): PowerFeatureGateStatus {
|
||||
return when (authState) {
|
||||
is AuthState.Failed -> {
|
||||
val reason = authState.reason.lowercase()
|
||||
if ("expired" in reason || "token" in reason || "session" in reason) {
|
||||
PairingExpired
|
||||
} else {
|
||||
RequiresPairing
|
||||
}
|
||||
}
|
||||
else -> RequiresPairing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PowerFeatureGateScreen(
|
||||
title: String,
|
||||
summary: String,
|
||||
status: PowerFeatureGateStatus,
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PowerFeatureGateCard(
|
||||
title = title,
|
||||
summary = summary,
|
||||
status = status,
|
||||
onPrimaryAction = onPrimaryAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PowerFeatureGateCard(
|
||||
title: String,
|
||||
summary: String,
|
||||
status: PowerFeatureGateStatus,
|
||||
onPrimaryAction: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = status.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = status.explanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Button(
|
||||
onClick = onPrimaryAction,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(status.actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PowerFeatureGatePreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PowerFeatureGateExpiredPreview() {
|
||||
HermesRelayTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,8 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -134,7 +132,7 @@ import kotlin.math.max
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The top-level fields configure the direct Hermes API server. The
|
||||
* The top-level fields configure the direct-chat Hermes API server. The
|
||||
* optional [relay] block configures the Hermes-Relay WSS connection used by
|
||||
* the terminal and bridge channels. The [endpoints] list (v3+) carries an
|
||||
* ordered array of candidate endpoints; the phone picks the highest-priority
|
||||
@@ -163,8 +161,6 @@ data class HermesPairingPayload(
|
||||
val port: Int = 8642,
|
||||
val key: String = "",
|
||||
val tls: Boolean = false,
|
||||
@SerialName("dashboard_url")
|
||||
val dashboardUrl: String? = null,
|
||||
val relay: RelayPairing? = null,
|
||||
val sig: String? = null,
|
||||
/**
|
||||
@@ -221,18 +217,12 @@ private val json = Json {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a scanned string as a Hermes connection QR payload.
|
||||
* Try to parse a scanned string as a Hermes pairing QR payload.
|
||||
*
|
||||
* Accepts v1, v2, and v3 (or anything without a `hermes` field — we default
|
||||
* to `1`). Returns null when the payload is not valid JSON, has no `host`
|
||||
* field, or fails strict decoding.
|
||||
*
|
||||
* For standard Hermes setup, also accepts generic API-only QRs:
|
||||
* - a plain `http://host:8642` or `https://host:8642` URL
|
||||
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
|
||||
* optional `api_key`, `apiKey`, or `key`, and optional `dashboard_url` or
|
||||
* `dashboardUrl`
|
||||
*
|
||||
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
|
||||
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
|
||||
* from the top-level fields so downstream code can always iterate
|
||||
@@ -243,13 +233,6 @@ private val json = Json {
|
||||
* role case, priority order, and unknown roles are all preserved.
|
||||
*/
|
||||
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
val trimmed = raw.trim()
|
||||
return parseHermesRelayQr(trimmed)
|
||||
?: parseGenericApiJsonQr(trimmed)
|
||||
?: parseGenericApiUrlQr(trimmed)
|
||||
}
|
||||
|
||||
private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
// Quick check: must contain a `host` field and be valid JSON. We no
|
||||
// longer reject based on the `hermes` version int — future v4+ QRs
|
||||
@@ -260,13 +243,6 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
if (version < 1) return null
|
||||
val decoded = json.decodeFromString<HermesPairingPayload>(raw)
|
||||
if (decoded.host.isBlank()) return null
|
||||
val dashboardAlias = firstString(obj, "dashboardUrl")
|
||||
val decodedWithAliases =
|
||||
if (decoded.dashboardUrl.isNullOrBlank() && dashboardAlias != null) {
|
||||
decoded.copy(dashboardUrl = dashboardAlias)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
// TODO(security): verify `decoded.sig` against the server's HMAC
|
||||
// secret once the pairing protocol exposes a public verification
|
||||
@@ -277,103 +253,16 @@ 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 (decoded.endpoints.isNullOrEmpty()) {
|
||||
decoded.copy(endpoints = listOf(synthesizeLegacyEndpoint(decoded)))
|
||||
} else {
|
||||
decodedWithAliases
|
||||
decoded
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGenericApiJsonQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
val apiUrl = firstString(
|
||||
obj,
|
||||
"api_url",
|
||||
"apiUrl",
|
||||
"server_url",
|
||||
"serverUrl",
|
||||
"url",
|
||||
) ?: return null
|
||||
val apiKey = firstString(obj, "api_key", "apiKey", "key").orEmpty()
|
||||
val dashboardUrl = firstString(obj, "dashboard_url", "dashboardUrl")
|
||||
payloadFromApiUrl(apiUrl, apiKey, dashboardUrl)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGenericApiUrlQr(raw: String): HermesPairingPayload? {
|
||||
return payloadFromApiUrl(raw, apiKey = "")
|
||||
}
|
||||
|
||||
private fun firstString(obj: JsonObject, vararg names: String): String? {
|
||||
return names.firstNotNullOfOrNull { name ->
|
||||
obj[name]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun payloadFromApiUrl(
|
||||
apiUrl: String,
|
||||
apiKey: String,
|
||||
dashboardUrl: String? = null,
|
||||
): HermesPairingPayload? {
|
||||
val uri = runCatching { URI(apiUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val payload = HermesPairingPayload(
|
||||
host = host,
|
||||
port = if (uri.port > 0) uri.port else 8642,
|
||||
key = apiKey.trim(),
|
||||
tls = tls,
|
||||
dashboardUrl = dashboardUrl?.trim()?.takeIf { it.isNotBlank() },
|
||||
relay = null,
|
||||
)
|
||||
return payload.copy(endpoints = listOf(synthesizeGenericEndpoint(payload)))
|
||||
}
|
||||
|
||||
private fun synthesizeGenericEndpoint(payload: HermesPairingPayload): EndpointCandidate {
|
||||
val host = payload.host.lowercase()
|
||||
val role = when {
|
||||
host.endsWith(".ts.net") || host.startsWith("100.") -> "tailscale"
|
||||
isPrivateLanHost(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role,
|
||||
priority = 0,
|
||||
api = ApiEndpoint(
|
||||
host = payload.host,
|
||||
port = payload.port,
|
||||
tls = payload.tls,
|
||||
),
|
||||
relay = RelayEndpoint(url = "", transportHint = null),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isPrivateLanHost(host: String): Boolean {
|
||||
if (host == "localhost" || host == "127.0.0.1" || host == "::1") return true
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single priority-0 [EndpointCandidate] from a v1/v2 pairing payload
|
||||
* that lacked an `endpoints` array. Preserves the top-level API coordinates
|
||||
@@ -746,7 +635,7 @@ fun QrPairingScanner(
|
||||
// Instructions
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Icon(
|
||||
@@ -756,14 +645,14 @@ fun QrPairingScanner(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Hermes setup QR",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
text = "Point at a Hermes pairing QR code",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Hermes-Relay plugin.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = "Generate one on your server with: hermes-pair",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayDottedOverlay
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import com.hermesandroid.relay.ui.theme.relayPanel
|
||||
import com.hermesandroid.relay.ui.theme.relaySelectedPanel
|
||||
|
||||
enum class RelayPrimaryMode(val label: String) {
|
||||
Chat("Chat"),
|
||||
Manage("Manage"),
|
||||
Bridge("Bridge"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayModeStrip(
|
||||
selected: RelayPrimaryMode,
|
||||
onModeSelected: (RelayPrimaryMode) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RelayPrimaryMode.entries.forEach { mode ->
|
||||
val active = mode == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp)
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.then(
|
||||
if (active) {
|
||||
Modifier.relaySelectedPanel()
|
||||
} else {
|
||||
Modifier.relayPanel(
|
||||
background = RelayRefresh.Background.copy(alpha = 0.45f),
|
||||
)
|
||||
},
|
||||
)
|
||||
.clickable { onModeSelected(mode) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = mode.label,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (active) RelayRefresh.Paper else RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayStatusStrip(
|
||||
leading: String,
|
||||
trailing: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingColor: Color = RelayRefresh.Green,
|
||||
) {
|
||||
// Floating capsule, not an edge-to-edge bar: a full-width bordered
|
||||
// rectangle clashes with rounded display corners and reads as a hard
|
||||
// shelf. Insets are applied BEFORE the margins so the pill floats above
|
||||
// the gesture area with the app background showing around it.
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
|
||||
),
|
||||
)
|
||||
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.relayPanel(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
|
||||
borderColor = RelayRefresh.Line,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = leading,
|
||||
style = relayMetadataStyle(),
|
||||
color = leadingColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = trailing,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayReturnStrip(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "Back",
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.relaySelectedPanel()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.58f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Paper,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.size(32.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Navy3.copy(alpha = 0.72f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Relay,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Relay,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayHeroPanel(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
accent: Color = RelayRefresh.Relay,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.relaySelectedPanel(),
|
||||
) {
|
||||
RelayDottedOverlay(alpha = 0.18f)
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = RelayRefresh.Ink.copy(alpha = 0.86f),
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayMetricCard(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
valueColor: Color = RelayRefresh.Paper,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.relayPanel()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayNavTile(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val base = if (selected) {
|
||||
Modifier.relaySelectedPanel()
|
||||
} else {
|
||||
Modifier.relayPanel(background = RelayRefresh.Navy2.copy(alpha = 0.72f))
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
|
||||
.then(base)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = RelayRefresh.Navy3.copy(alpha = if (enabled) 0.86f else 0.38f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) RelayRefresh.Relay else RelayRefresh.Dim,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = if (enabled) RelayRefresh.Paper else RelayRefresh.Dim,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (trailing != null) {
|
||||
trailing()
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelaySectionCaption(
|
||||
title: String,
|
||||
meta: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = RelayRefresh.Paper,
|
||||
)
|
||||
meta?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayStatusPill(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.relayPanel(
|
||||
background = if (active) RelayRefresh.Green.copy(alpha = 0.12f) else RelayRefresh.Navy3.copy(alpha = 0.7f),
|
||||
borderColor = if (active) RelayRefresh.Green.copy(alpha = 0.36f) else RelayRefresh.Line,
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (active) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = null,
|
||||
tint = if (active) RelayRefresh.Green else RelayRefresh.Muted,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = relayMetadataStyle(),
|
||||
color = if (active) RelayRefresh.Green else RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayChromeIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(38.dp),
|
||||
shape = RoundedCornerShape(RelayRefresh.CardRadius),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.52f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = RelayRefresh.Paper,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,21 @@ package com.hermesandroid.relay.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -38,37 +27,25 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.platform.LocalLocale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private enum class SessionDrawerFilter(val label: String) {
|
||||
All("All"),
|
||||
Pinned("Pinned"),
|
||||
Archive("Archive"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
currentSessionId: String?,
|
||||
scopeTitle: String = "Sessions",
|
||||
scopeSubtitle: String? = null,
|
||||
isLoading: Boolean = false,
|
||||
isOpen: Boolean = true,
|
||||
onNewChat: () -> Unit,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
@@ -76,69 +53,8 @@ fun SessionDrawerContent(
|
||||
) {
|
||||
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val listState = rememberLazyListState()
|
||||
var scrollToTopPending by remember { mutableStateOf(false) }
|
||||
val trimmedQuery = query.trim()
|
||||
val visibleSessions = sessions
|
||||
.asSequence()
|
||||
.filter { session ->
|
||||
when (filter) {
|
||||
SessionDrawerFilter.All -> session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Pinned ->
|
||||
session.sessionId in pinnedSessionIds &&
|
||||
session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Archive -> session.sessionId in archivedSessionIds
|
||||
}
|
||||
}
|
||||
.filter { session ->
|
||||
val needle = trimmedQuery
|
||||
needle.isBlank() ||
|
||||
session.sessionId.contains(needle, ignoreCase = true) ||
|
||||
session.title.orEmpty().contains(needle, ignoreCase = true) ||
|
||||
session.model.orEmpty().contains(needle, ignoreCase = true)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<ChatSession> { it.sessionId in pinnedSessionIds }
|
||||
.thenByDescending { it.activityTimestamp }
|
||||
.thenByDescending { it.startTimestamp }
|
||||
.thenBy { it.title.orEmpty().lowercase(locale = Locale.ROOT) }
|
||||
)
|
||||
.toList()
|
||||
val topVisibleSessionId = visibleSessions.firstOrNull()?.sessionId
|
||||
|
||||
LaunchedEffect(isOpen) {
|
||||
scrollToTopPending = isOpen
|
||||
if (isOpen && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filter, trimmedQuery) {
|
||||
if (isOpen && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
} else if (isOpen) {
|
||||
scrollToTopPending = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isOpen, topVisibleSessionId, visibleSessions.size) {
|
||||
if (isOpen && scrollToTopPending && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
}
|
||||
}
|
||||
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.width(320.dp),
|
||||
drawerContainerColor = RelayRefresh.Background,
|
||||
drawerContentColor = RelayRefresh.Ink,
|
||||
) {
|
||||
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header
|
||||
Text(
|
||||
@@ -168,61 +84,12 @@ fun SessionDrawerContent(
|
||||
Text("New Chat")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text("Search sessions or id...") },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SessionDrawerFilter.entries.forEach { item ->
|
||||
FilterChip(
|
||||
selected = filter == item,
|
||||
onClick = { filter = item },
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = relayMetadataStyle(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (isLoading && sessions.isEmpty()) {
|
||||
// First load (or a profile switch) — show a quiet spinner instead of
|
||||
// flashing "No sessions yet" before the list arrives.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Loading sessions…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (visibleSessions.isEmpty()) {
|
||||
if (sessions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -230,7 +97,7 @@ fun SessionDrawerContent(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
|
||||
text = "No sessions yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -241,28 +108,12 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(state = listState) {
|
||||
items(visibleSessions, key = { it.sessionId }) { session ->
|
||||
LazyColumn {
|
||||
items(sessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
isActive = session.sessionId == currentSessionId,
|
||||
pinned = session.sessionId in pinnedSessionIds,
|
||||
archived = session.sessionId in archivedSessionIds,
|
||||
onClick = { onSelectSession(session.sessionId) },
|
||||
onTogglePinned = {
|
||||
pinnedSessionIds = if (session.sessionId in pinnedSessionIds) {
|
||||
pinnedSessionIds - session.sessionId
|
||||
} else {
|
||||
pinnedSessionIds + session.sessionId
|
||||
}
|
||||
},
|
||||
onToggleArchived = {
|
||||
archivedSessionIds = if (session.sessionId in archivedSessionIds) {
|
||||
archivedSessionIds - session.sessionId
|
||||
} else {
|
||||
archivedSessionIds + session.sessionId
|
||||
}
|
||||
},
|
||||
onRename = { renameDialogSession = session },
|
||||
onDelete = { deleteDialogSession = session }
|
||||
)
|
||||
@@ -333,16 +184,10 @@ fun SessionDrawerContent(
|
||||
private fun SessionItem(
|
||||
session: ChatSession,
|
||||
isActive: Boolean,
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
@@ -357,11 +202,7 @@ private fun SessionItem(
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = session.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -374,129 +215,49 @@ private fun SessionItem(
|
||||
}
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
sessionTimestampText(session, locale)?.let { timestamp ->
|
||||
if (session.updatedAt > 0) {
|
||||
Text(
|
||||
text = timestamp,
|
||||
text = formatTimestamp(session.updatedAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (session.messageCount > 0) {
|
||||
Text(
|
||||
text = "${session.messageCount} msgs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onTogglePinned,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = if (pinned) "Unpin session" else "Pin session",
|
||||
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(19.dp),
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { menuOpen = true },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = "Session actions",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (archived) "Restore" else "Archive") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Archive,
|
||||
contentDescription = null,
|
||||
tint = if (archived) {
|
||||
RelayRefresh.Relay
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onToggleArchived()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = "Delete",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.75f),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
val hasDistinctActivity =
|
||||
session.lastActivityAt > 0L &&
|
||||
session.startTimestamp > 0L &&
|
||||
session.lastActivityAt != session.startTimestamp
|
||||
val prefix = if (hasDistinctActivity) "Active" else "Started"
|
||||
return "$prefix ${formatTimestamp(timestamp, locale)}"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long, locale: Locale): String {
|
||||
private fun formatTimestamp(millis: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
return when {
|
||||
diff < 60_000 -> "Just now"
|
||||
diff < 3_600_000 -> "${diff / 60_000}m ago"
|
||||
diff < 86_400_000 -> SimpleDateFormat("h:mm a", locale).format(Date(millis))
|
||||
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
|
||||
diff < 86_400_000 -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis))
|
||||
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(millis))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ private fun defaultOptionIndex(options: List<TtlOption>): Int =
|
||||
|
||||
/**
|
||||
* Compute the default TTL for a new pair based on:
|
||||
* - QR payload's `ttlSeconds` (operator intent via `hermes pair --ttl`)
|
||||
* - QR payload's `ttlSeconds` (operator intent via `hermes-pair --ttl`)
|
||||
* - Transport hint (`"wss"` → 30d, `"ws"` → 7d)
|
||||
* - Tailscale detected → 30d
|
||||
* - Fallback → 30d
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/**
|
||||
* One subagent lane — the grouped render for every [ToolCall] sharing a
|
||||
* non-null [ToolCall.taskIndex], shown after top-level tool cards under the
|
||||
* streaming bubble. Grouping is a pure derivation in the caller
|
||||
* (`message.toolCalls.groupBy { it.taskIndex }`); the null group renders
|
||||
* exactly as before (flat ToolProgressCard / CompactToolCall).
|
||||
*
|
||||
* Anatomy mirrors the ToolProgressCard header (icon, 6dp gap, labelMedium
|
||||
* title, weight spacer, mono meta, expand chevron) with a 2dp guide rail
|
||||
* down the leading edge — tertiary while any child runs, hairline once done.
|
||||
* Children are ALWAYS [CompactToolCall] regardless of the toolDisplay pref:
|
||||
* full cards nested in a lane are visual noise on a phone width.
|
||||
*
|
||||
* Collapse behavior copies ToolProgressCard's auto-collapse: expanded while
|
||||
* any child is running, folds to the one-line summary header when the last
|
||||
* child completes ([LaunchedEffect] keyed on allComplete). A failed child
|
||||
* stamps the folded header with Close in the error tint instead of Check.
|
||||
*
|
||||
* Max depth is 1 — nested subagent-of-subagent calls arrive flattened into
|
||||
* their parent lane by the mapper (taskLabel prefixed "parent / child"), so
|
||||
* this component never indents twice.
|
||||
*/
|
||||
@Composable
|
||||
fun SubagentLane(
|
||||
taskIndex: Int,
|
||||
calls: List<ToolCall>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val anyRunning = calls.any { !it.isComplete }
|
||||
val allComplete = calls.isNotEmpty() && calls.all { it.isComplete }
|
||||
val anyFailed = calls.any { it.isComplete && it.success == false }
|
||||
val runningCount = calls.count { !it.isComplete }
|
||||
|
||||
val laneLabel = calls.firstNotNullOfOrNull { call ->
|
||||
call.taskLabel?.takeIf { it.isNotBlank() }
|
||||
} ?: "Agent ${taskIndex + 1}"
|
||||
|
||||
// Lane duration: first child start → last child completion.
|
||||
val duration = run {
|
||||
val startedAt = calls.minOfOrNull { it.startedAt }
|
||||
val completedAt = calls.mapNotNull { it.completedAt }.maxOrNull()
|
||||
if (allComplete && startedAt != null && completedAt != null && completedAt >= startedAt) {
|
||||
String.format("%.1fs", (completedAt - startedAt) / 1000.0)
|
||||
} else null
|
||||
}
|
||||
|
||||
val toolCountLabel = "${calls.size} tool${if (calls.size == 1) "" else "s"}"
|
||||
val statusMeta = when {
|
||||
anyRunning -> "$runningCount running"
|
||||
duration != null -> "$toolCountLabel · $duration"
|
||||
else -> toolCountLabel
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(!allComplete) }
|
||||
|
||||
// Auto-collapse when the last child completes — same pattern as
|
||||
// ToolProgressCard's LaunchedEffect(toolCall.isComplete).
|
||||
LaunchedEffect(allComplete) {
|
||||
if (allComplete) expanded = false
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.semantics {
|
||||
contentDescription = "Subagent $laneLabel, $statusMeta" +
|
||||
if (anyFailed) ", failed" else ""
|
||||
},
|
||||
) {
|
||||
// Guide rail
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (anyRunning) MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f)
|
||||
else RelayRefresh.Line
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Lane header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
Text(
|
||||
text = laneLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = statusMeta,
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (allComplete) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = if (anyFailed) Icons.Filled.Close else Icons.Filled.Check,
|
||||
contentDescription = if (anyFailed) "Failed" else "Completed",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = if (anyFailed) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
calls.forEach { call ->
|
||||
CompactToolCall(toolCall = call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -36,18 +35,9 @@ import androidx.compose.ui.unit.dp
|
||||
fun ThinkingBlock(
|
||||
thinkingContent: String,
|
||||
isStreaming: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val timeLabel = timestamp?.let {
|
||||
remember(it, locale) {
|
||||
java.text.SimpleDateFormat("h:mm a", locale)
|
||||
.format(java.util.Date(it))
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -76,14 +66,6 @@ fun ThinkingBlock(
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (!isStreaming && timeLabel != null) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -28,7 +22,6 @@ import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TouchApp
|
||||
@@ -47,37 +40,18 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
|
||||
@Composable
|
||||
fun ToolProgressCard(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Fallback wall-clock for the right-aligned header time when the call
|
||||
* has no completion stamp (history-restored calls) — usually the parent
|
||||
* message timestamp. Null hides the time.
|
||||
*/
|
||||
messageTimestamp: Long? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Preparing (tool.generating) starts collapsed — the args preview line
|
||||
// under the header is the whole story until tool.start lands.
|
||||
var expanded by remember { mutableStateOf(!toolCall.isComplete && !toolCall.isGenerating) }
|
||||
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
|
||||
val timeMillis = toolCall.completedAt ?: messageTimestamp
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val timeLabel = timeMillis?.takeIf { toolCall.isComplete }?.let {
|
||||
remember(it, locale) {
|
||||
java.text.SimpleDateFormat("h:mm a", locale)
|
||||
.format(java.util.Date(it))
|
||||
}
|
||||
}
|
||||
var expanded by remember { mutableStateOf(!toolCall.isComplete) }
|
||||
|
||||
// Auto-collapse when tool completes
|
||||
LaunchedEffect(toolCall.isComplete) {
|
||||
@@ -94,13 +68,6 @@ fun ToolProgressCard(
|
||||
statusIcon = Icons.Filled.Close
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
// Args still streaming — "preparing" must read as LESS active than
|
||||
// running: Muted instead of tertiary (Cyan stays reserved for
|
||||
// actually-executing tools).
|
||||
isPreparing -> {
|
||||
statusIcon = Icons.Filled.MoreHoriz
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
statusIcon = Icons.Filled.HourglassTop
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
@@ -111,22 +78,9 @@ fun ToolProgressCard(
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
isPreparing -> "preparing"
|
||||
else -> "running"
|
||||
}
|
||||
|
||||
// Slow alpha breathe on the tool icon while preparing — an indeterminate
|
||||
// bar promises imminent work; a breathe says "being written".
|
||||
val toolIconAlpha = if (isPreparing) {
|
||||
val breathe = rememberInfiniteTransition(label = "toolGenerating")
|
||||
breathe.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0.9f,
|
||||
animationSpec = infiniteRepeatable(tween(900), repeatMode = RepeatMode.Reverse),
|
||||
label = "toolGeneratingAlpha",
|
||||
).value
|
||||
} else 1f
|
||||
|
||||
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
|
||||
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
|
||||
String.format("%.1fs", seconds)
|
||||
@@ -155,41 +109,35 @@ fun ToolProgressCard(
|
||||
imageVector = toolIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = toolIconAlpha)
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
// Tool name — tool.generating may arrive nameless
|
||||
// Tool name
|
||||
Text(
|
||||
text = if (isPreparing) toolCall.name.ifBlank { "Preparing tool…" } else toolCall.name,
|
||||
text = toolCall.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isPreparing) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else androidx.compose.ui.graphics.Color.Unspecified,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Duration + completion time ("3.1s · 5:32 PM")
|
||||
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
|
||||
if (metaLabel.isNotEmpty()) {
|
||||
// Duration
|
||||
if (duration != null) {
|
||||
Text(
|
||||
text = metaLabel,
|
||||
text = duration,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
// Status icon — crossfade so MoreHoriz→HourglassTop on
|
||||
// tool.start reads as a state change, not a card swap.
|
||||
Crossfade(targetState = statusIcon, label = "toolStatusIcon") { icon ->
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = statusText,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
// Status icon
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = statusText,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
@@ -202,21 +150,8 @@ fun ToolProgressCard(
|
||||
)
|
||||
}
|
||||
|
||||
// One-line faded mono args preview while preparing — partial
|
||||
// JSON grows per delta; no expand needed, the card stays folded.
|
||||
if (isPreparing && !toolCall.args.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = compactToolDetail(toolCall.args, 80),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar while running (not while args are still streaming)
|
||||
if (!toolCall.isComplete && !isPreparing) {
|
||||
// Progress bar while running
|
||||
if (!toolCall.isComplete) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
@@ -24,7 +21,7 @@ import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -51,22 +48,14 @@ fun UpdateBanner(
|
||||
update: AvailableUpdate,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.shadow(8.dp, RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp))
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
|
||||
@@ -1134,7 +1134,6 @@ private fun CompactTranscriptRow(
|
||||
ThinkingBlock(
|
||||
thinkingContent = message.thinkingContent,
|
||||
isStreaming = message.isThinkingStreaming,
|
||||
timestamp = message.timestamp,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -17,80 +13,30 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
|
||||
* the About screen. Parses [whats_new.txt]'s tiny markup — a version line,
|
||||
* blank-separated sections with a plain-text header, `*` bullets with indented
|
||||
* continuation lines — into styled Compose instead of pasting the raw text
|
||||
* (which showed literal `*` and gave headers no emphasis).
|
||||
*/
|
||||
@Composable
|
||||
fun WhatsNewDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val notes = remember { parseWhatsNew(loadWhatsNew(context)) }
|
||||
val changelogText = remember { loadWhatsNew(context) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Column {
|
||||
Text("What's New")
|
||||
notes.version?.let { version ->
|
||||
Text(
|
||||
text = version,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("What's New") },
|
||||
text = {
|
||||
Column(
|
||||
Text(
|
||||
text = changelogText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Default
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 420.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (notes.groups.isEmpty()) {
|
||||
Text(
|
||||
text = notes.fallback ?: "No release notes available.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
notes.groups.forEachIndexed { index, group ->
|
||||
group.header?.let { header ->
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
|
||||
)
|
||||
}
|
||||
group.bullets.forEach { bullet ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = bullet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
@@ -100,74 +46,6 @@ fun WhatsNewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** One section: an optional header plus its bullets. */
|
||||
private data class WhatsNewGroup(val header: String?, val bullets: List<String>)
|
||||
|
||||
/** Parsed release notes: the leading version line plus styled sections. */
|
||||
private data class WhatsNewNotes(
|
||||
val version: String?,
|
||||
val groups: List<WhatsNewGroup>,
|
||||
val fallback: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Classify each line of the [whats_new.txt] format:
|
||||
* - line 0 (non-bullet) → version subtitle (`-` upgraded to an em dash),
|
||||
* - blank line → ends the current bullet,
|
||||
* - `* ` prefix → a new bullet,
|
||||
* - leading whitespace → continuation appended to the current bullet,
|
||||
* - anything else → a section header.
|
||||
*/
|
||||
private fun parseWhatsNew(raw: String): WhatsNewNotes {
|
||||
if (raw.isBlank()) return WhatsNewNotes(null, emptyList(), raw.ifBlank { null })
|
||||
|
||||
val lines = raw.lines()
|
||||
var version: String? = null
|
||||
val groups = mutableListOf<WhatsNewGroup>()
|
||||
var currentHeader: String? = null
|
||||
val currentBullets = mutableListOf<String>()
|
||||
val pending = StringBuilder()
|
||||
|
||||
fun flushBullet() {
|
||||
if (pending.isNotEmpty()) {
|
||||
currentBullets += pending.toString().trim()
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun flushGroup() {
|
||||
flushBullet()
|
||||
if (currentHeader != null || currentBullets.isNotEmpty()) {
|
||||
groups += WhatsNewGroup(currentHeader, currentBullets.toList())
|
||||
}
|
||||
currentHeader = null
|
||||
currentBullets.clear()
|
||||
}
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val trimmed = line.trim()
|
||||
when {
|
||||
index == 0 && trimmed.isNotEmpty() && !trimmed.startsWith("*") ->
|
||||
version = trimmed.replace(" - ", " — ")
|
||||
trimmed.isEmpty() -> flushBullet()
|
||||
trimmed.startsWith("* ") -> {
|
||||
flushBullet()
|
||||
pending.append(trimmed.removePrefix("* "))
|
||||
}
|
||||
line.startsWith(" ") || line.startsWith("\t") -> {
|
||||
if (pending.isNotEmpty()) pending.append(' ')
|
||||
pending.append(trimmed)
|
||||
}
|
||||
else -> {
|
||||
flushGroup()
|
||||
currentHeader = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
flushGroup()
|
||||
return WhatsNewNotes(version, groups, fallback = null)
|
||||
}
|
||||
|
||||
private fun loadWhatsNew(context: Context): String {
|
||||
return try {
|
||||
context.assets.open("whats_new.txt").bufferedReader().readText()
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -41,7 +40,6 @@ fun OnboardingPage(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
transparentHero: Boolean = false,
|
||||
heroContent: @Composable BoxScope.() -> Unit = {
|
||||
FeatureHero(
|
||||
icon = icon,
|
||||
@@ -84,14 +82,14 @@ fun OnboardingPage(
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
|
||||
@@ -24,8 +24,8 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.PhonelinkSetup
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -48,12 +48,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
@@ -62,16 +63,18 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Page identifiers for dynamic onboarding flow. */
|
||||
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
|
||||
private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect }
|
||||
|
||||
/**
|
||||
* Standard-first onboarding:
|
||||
* Three-stage onboarding:
|
||||
*
|
||||
* 1. **Feature pages** (Welcome / Chat / Manage / Power tools) — standard
|
||||
* Hermes API/dashboard features first, Relay-only power tools second.
|
||||
* 1. **Feature pages** (Welcome / Chat / Terminal / Bridge) — informational
|
||||
* swipe-able introduction. Bridge / Terminal pages only show when the
|
||||
* relay feature is enabled (Developer Options).
|
||||
* 2. **Connect page** — embeds the shared [ConnectionWizard] so onboarding
|
||||
* uses the exact same Standard API/dashboard and optional Relay pairing
|
||||
* flow as Settings → Connections.
|
||||
* uses the exact same scan → confirm → verify flow as Settings → Connection.
|
||||
* The wizard owns credential application; on success / skip it calls back
|
||||
* into [onComplete] to finish onboarding and navigate to chat.
|
||||
*
|
||||
* The previous separate "ConnectPage" + "RelayPage" pair has been removed —
|
||||
* it discarded the QR's relay block, never applied per-channel grants, never
|
||||
@@ -99,14 +102,19 @@ fun OnboardingScreen(
|
||||
// lands on the right VM and survives the Onboarding→Chat transition.
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit = onComplete,
|
||||
) {
|
||||
val pages = remember {
|
||||
val context = LocalContext.current
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Build page list dynamically based on feature flags
|
||||
val pages = remember(relayEnabled) {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
add(OnboardingPage.Manage)
|
||||
add(OnboardingPage.Power)
|
||||
if (relayEnabled) {
|
||||
add(OnboardingPage.Terminal)
|
||||
add(OnboardingPage.Bridge)
|
||||
}
|
||||
add(OnboardingPage.Connect)
|
||||
}
|
||||
}
|
||||
@@ -126,11 +134,7 @@ fun OnboardingScreen(
|
||||
onDismissRequest = { showSkipConfirm = false },
|
||||
title = { Text("Skip setup?") },
|
||||
text = {
|
||||
Text(
|
||||
"You can configure your Hermes connection later in Settings → Connections. " +
|
||||
"Without a connection, Chat and Manage won't load. Relay pairing can " +
|
||||
"be added later for power tools."
|
||||
)
|
||||
Text("You can configure your server connection later in Settings → Connection. Without pairing, chat and voice features won't work yet.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
@@ -181,12 +185,11 @@ fun OnboardingScreen(
|
||||
when (pages[pageIndex]) {
|
||||
OnboardingPage.Welcome -> WelcomePage()
|
||||
OnboardingPage.Chat -> ChatPage()
|
||||
OnboardingPage.Manage -> ManagePage()
|
||||
OnboardingPage.Power -> PowerToolsPage()
|
||||
OnboardingPage.Terminal -> TerminalPage()
|
||||
OnboardingPage.Bridge -> BridgePage()
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onManageSignIn = onManageSignIn,
|
||||
onSkip = { showSkipConfirm = true },
|
||||
)
|
||||
}
|
||||
@@ -257,15 +260,14 @@ private fun WelcomePage() {
|
||||
val context = LocalContext.current
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
title = "Hermes-Relay for Android",
|
||||
description = "Chat with Hermes and manage your dashboard from your phone.",
|
||||
transparentHero = true,
|
||||
title = "Hermes-Relay",
|
||||
description = "Your Hermes agent, in your pocket.",
|
||||
heroContent = {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
MorphingSphere(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
state = SphereState.Idle,
|
||||
intensity = 0.12f,
|
||||
)
|
||||
@@ -285,48 +287,27 @@ private fun WelcomePage() {
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
|
||||
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(bottom = 18.dp)
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "Hermes logo",
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = "Hermes-Relay logo",
|
||||
modifier = Modifier.size(42.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Standard",
|
||||
description = "Connect to your running Hermes dashboard and API. No Relay install or pairing required.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Advanced",
|
||||
description = "Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.",
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "The setup guide has copy/paste commands when you need to start Hermes on a computer or server.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = "Read the app guide, browse the repo, or jump to Hermes Agent docs while you finish server setup.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -337,7 +318,7 @@ private fun WelcomePage() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -348,158 +329,71 @@ private fun WelcomePage() {
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Setup Guide")
|
||||
Text("User Guide")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
painter = painterResource(R.drawable.ic_github),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Hermes Docs")
|
||||
Text("GitHub")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Hermes API server docs",
|
||||
text = "hermes-agent.nousresearch.com",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com")))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPathSummary(
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(78.dp),
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Forum,
|
||||
title = "Chat",
|
||||
description = "Your Hermes agent, streaming in real time.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Streaming",
|
||||
description = "Live responses with tool progress, markdown, and rich cards as the agent works.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Profiles",
|
||||
description = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Voice",
|
||||
description = "Tap the mic to talk. Speech runs through your Hermes server — no extra install.",
|
||||
)
|
||||
}
|
||||
}
|
||||
description = "Talk to any Hermes agent profile with real-time streaming responses, tool progress, and full markdown."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManagePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = "Manage",
|
||||
description = "Your Hermes dashboard, pocket-sized.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Control",
|
||||
description = "Profiles, skills, automations, MCP servers, models, and provider keys.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Skills hub",
|
||||
description = "Search the hub, read a skill before installing, and install from your phone.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "One sign-in",
|
||||
description = "Signing into the dashboard once also unlocks voice for this connection.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerToolsPage() {
|
||||
private fun TerminalPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Terminal,
|
||||
title = "Power tools",
|
||||
description = "Pair the optional Relay when you want more than standard.",
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = "Terminal",
|
||||
description = "A real tmux session on your server, in your pocket.",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Bridge",
|
||||
description = "Let the agent operate this phone — with safety rails (sideload builds).",
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = "Realtime",
|
||||
description = "Provider-native realtime voice agent and profile-aware voice providers.",
|
||||
)
|
||||
}
|
||||
}
|
||||
title = "Terminal",
|
||||
description = "Secure remote shell access to your server via tmux. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.PhonelinkSetup,
|
||||
title = "Bridge",
|
||||
description = "Let your agent control your device — taps, typing, screenshots, and automation. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onComplete: () -> Unit,
|
||||
onManageSignIn: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -512,7 +406,6 @@ private fun ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onCancel = onSkip,
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,18 +251,6 @@ fun AppearanceSettingsScreen(
|
||||
enabled = animEnabled
|
||||
)
|
||||
}
|
||||
|
||||
// The ambient-mode entry is a gesture with no visible
|
||||
// control — this line is its discoverable documentation
|
||||
// (including for screen-reader users browsing settings).
|
||||
if (animEnabled) {
|
||||
Text(
|
||||
text = "Tip: long-press the chat background for a fullscreen " +
|
||||
"ambient sphere; tap anywhere to return.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
@@ -24,7 +22,6 @@ import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -43,18 +40,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayNavTile
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelaySectionCaption
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
|
||||
/**
|
||||
@@ -69,42 +55,20 @@ import com.hermesandroid.relay.viewmodel.statusText
|
||||
fun BridgeCoreScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToVoiceSettings: () -> Unit,
|
||||
onNavigateToNotificationCompanion: () -> Unit,
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToRelaySessions: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
returnTitle: String? = null,
|
||||
returnSubtitle: String = "",
|
||||
returnLabel: String = "Back",
|
||||
onReturn: (() -> Unit)? = null,
|
||||
) {
|
||||
val relayState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayConnected = relayState == RelayUiState.Connected
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = "Terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -113,52 +77,10 @@ fun BridgeCoreScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
title = returnTitle,
|
||||
subtitle = returnSubtitle,
|
||||
label = returnLabel,
|
||||
onClick = onReturn,
|
||||
)
|
||||
}
|
||||
RelayHeroPanel(
|
||||
title = if (relayConnected) "Phone bridge is paired" else "Bridge Core is waiting",
|
||||
subtitle = if (relayConnected) {
|
||||
"Terminal, voice, notification, media, and relay-session controls share this grant."
|
||||
} else {
|
||||
"Pair Relay to use Terminal and phone bridge tools. Chat and Manage continue over standard Hermes API."
|
||||
},
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayStatusPill(
|
||||
text = relayState.statusText("connected").lowercase(),
|
||||
active = relayConnected,
|
||||
)
|
||||
RelayStatusPill(
|
||||
text = "safety on",
|
||||
active = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -239,47 +161,59 @@ fun BridgeCoreScreen(
|
||||
}
|
||||
}
|
||||
|
||||
RelaySectionCaption(
|
||||
title = "Bridge Surface",
|
||||
meta = "not hidden in settings",
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Connections",
|
||||
subtitle = "Pair, switch, and verify relay routes",
|
||||
onClick = onNavigateToConnections,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Attach to your Hermes relay terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
selected = relayConnected,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
title = "Voice",
|
||||
subtitle = "Provider, model, output voice",
|
||||
onClick = onNavigateToVoiceSettings,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notifications",
|
||||
subtitle = "Shared app notifications",
|
||||
onClick = onNavigateToNotificationCompanion,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
subtitle = "Inbound attachments and cache behavior",
|
||||
onClick = onNavigateToMediaSettings,
|
||||
)
|
||||
RelayNavTile(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
subtitle = "Review active grants for this server",
|
||||
onClick = onNavigateToRelaySessions,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Bridge Features",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Connections",
|
||||
subtitle = "Pair, switch, and verify relay routes",
|
||||
onClick = onNavigateToConnections,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Code,
|
||||
title = "Terminal",
|
||||
subtitle = "Attach to your Hermes relay terminal",
|
||||
onClick = onNavigateToTerminal,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
title = "Voice",
|
||||
subtitle = "Configure STT, TTS, provider, and voice mode",
|
||||
onClick = onNavigateToVoiceSettings,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Notifications,
|
||||
title = "Notification companion",
|
||||
subtitle = "Forward notifications you grant Android access to share",
|
||||
onClick = onNavigateToNotificationCompanion,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Image,
|
||||
title = "Media",
|
||||
subtitle = "Manage inbound attachments and cache behavior",
|
||||
onClick = onNavigateToMediaSettings,
|
||||
)
|
||||
BridgeCoreRow(
|
||||
icon = Icons.Filled.Devices,
|
||||
title = "Relay sessions",
|
||||
subtitle = "Review active grants for this server",
|
||||
onClick = onNavigateToRelaySessions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,8 +24,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -65,17 +62,9 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.BridgeActivityLog
|
||||
import com.hermesandroid.relay.ui.components.BridgeMasterToggle
|
||||
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
// === v0.4.1 unattended-access ===
|
||||
import com.hermesandroid.relay.ui.components.UnattendedAccessRow
|
||||
// === END v0.4.1 unattended-access ===
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.viewmodel.BridgeViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -116,13 +105,6 @@ fun BridgeScreen(
|
||||
// === PHASE3-safety-rails: safety summary card ===
|
||||
onNavigateToBridgeSafety: () -> Unit = {},
|
||||
// === END PHASE3-safety-rails ===
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onNavigateToManage: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
returnTitle: String? = null,
|
||||
returnSubtitle: String = "",
|
||||
returnLabel: String = "Back",
|
||||
onReturn: (() -> Unit)? = null,
|
||||
) {
|
||||
val masterToggle by viewModel.masterToggle.collectAsState()
|
||||
val permissionStatus by viewModel.permissionStatus.collectAsState()
|
||||
@@ -215,16 +197,8 @@ fun BridgeScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = "Settings",
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -233,45 +207,10 @@ fun BridgeScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
title = returnTitle,
|
||||
subtitle = returnSubtitle,
|
||||
label = returnLabel,
|
||||
onClick = onReturn,
|
||||
)
|
||||
}
|
||||
RelayHeroPanel(
|
||||
title = if (relayReady) "Phone bridge is paired" else "Bridge controls are staged",
|
||||
subtitle = if (relayReady) {
|
||||
"Terminal, voice, notification, media, and advanced phone controls share this grant."
|
||||
} else {
|
||||
"Pair Relay to receive bridge commands. You can still configure permissions and safety before pairing."
|
||||
},
|
||||
action = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RelayStatusPill("relay", relayReady)
|
||||
RelayStatusPill("safety", true)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Relay-not-connected banner. Bridge commands arrive over the
|
||||
// relay's WSS — when relay is Unpaired / Disconnected / URL
|
||||
// blank, the AccessibilityService + foreground service will
|
||||
@@ -312,7 +251,7 @@ fun BridgeScreen(
|
||||
)
|
||||
Text(
|
||||
text = "Bridge commands travel over the relay. " +
|
||||
"Pair a relay in Settings → Connections for " +
|
||||
"Pair a relay in Settings → Connection for " +
|
||||
"the bridge to actually do anything.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -45,17 +42,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.GatewayAvailability
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
/**
|
||||
* Dedicated Chat settings screen. Hosts message length, attachment size,
|
||||
* chat endpoint preference, smooth auto-scroll, and related chat behavior knobs.
|
||||
* Reasoning and tool-progress visibility are server display settings managed
|
||||
* through the Manage tab so Android matches desktop.
|
||||
* tool display mode, chat endpoint preference, show-reasoning toggle,
|
||||
* smooth auto-scroll, and related chat behavior knobs.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -63,6 +57,8 @@ fun ChatSettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val showThinkingSetting by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val appContextEnabled by connectionViewModel.appContextEnabled.collectAsState()
|
||||
// === PHASE3-status: granular phone-status sub-toggles ===
|
||||
val appContextBridgeState by connectionViewModel.appContextBridgeState.collectAsState()
|
||||
@@ -118,6 +114,31 @@ fun ChatSettingsScreen(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Show reasoning toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Show reasoning",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Display the AI's thinking process above responses",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = showThinkingSetting,
|
||||
onCheckedChange = { connectionViewModel.setShowThinking(it) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Smooth auto-scroll toggle
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
Row(
|
||||
@@ -144,101 +165,36 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Close sessions on send",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Return to the conversation after sending from the session drawer. Off keeps the drawer open for session triage.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = closeDrawerOnSend,
|
||||
onCheckedChange = { connectionViewModel.setCloseDrawerOnSend(it) }
|
||||
// Tool call display mode
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Tool call display",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val keepComposerFocusedOnSend by
|
||||
connectionViewModel.keepComposerFocusedOnSend.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Keep keyboard open on send",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = keepComposerFocusedOnSend,
|
||||
onCheckedChange = {
|
||||
connectionViewModel.setKeepComposerFocusedOnSend(it)
|
||||
}
|
||||
Text(
|
||||
text = "How tool calls (file reads, searches, etc.) appear in chat",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
val toolDisplayOptions = listOf("off", "compact", "detailed")
|
||||
val toolDisplayLabels = listOf("Off", "Compact", "Detailed")
|
||||
val selectedToolIndex = toolDisplayOptions.indexOf(toolDisplay).coerceAtLeast(0)
|
||||
|
||||
// Turn-complete notification toggle. First enable on
|
||||
// API 33+ runs the POST_NOTIFICATIONS request (the
|
||||
// BridgeScreen master-toggle precedent); if the user
|
||||
// denies, the notifier silently no-ops at post time.
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
val settingsContext = LocalContext.current
|
||||
val notifyPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { /* Notifier re-checks the grant at post time. */ }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Notify when Hermes finishes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Post a notification when a reply completes while the app is in the background",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = notifyTurnComplete,
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setNotifyTurnComplete(enabled)
|
||||
if (enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
settingsContext,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
toolDisplayOptions.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = toolDisplayOptions.size
|
||||
),
|
||||
onClick = { connectionViewModel.setToolDisplay(option) },
|
||||
selected = index == selectedToolIndex
|
||||
) {
|
||||
notifyPermissionLauncher.launch(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
Text(toolDisplayLabels[index])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
@@ -379,18 +335,10 @@ fun ChatSettingsScreen(
|
||||
battery = appContextBattery,
|
||||
safetyStatus = appContextSafetyStatus,
|
||||
),
|
||||
// Preview uses representative placeholder values (NOT the
|
||||
// user's real phone state) so each enabled toggle visibly
|
||||
// contributes its line — an empty snapshot left the
|
||||
// Foreground app / Battery / Safety rails toggles looking
|
||||
// inert because their lines guard on snapshot data.
|
||||
snapshot = com.hermesandroid.relay.util.PhoneSnapshot(
|
||||
currentApp = "com.android.chrome",
|
||||
batteryPercent = 82,
|
||||
blocklistCount = 3,
|
||||
destructiveVerbCount = 5,
|
||||
autoDisableMinutes = 15,
|
||||
),
|
||||
// Preview uses a neutral "nothing bound" snapshot so the
|
||||
// user sees the shape without leaking their current
|
||||
// phone state into the settings screen.
|
||||
snapshot = com.hermesandroid.relay.util.PhoneSnapshot(),
|
||||
)
|
||||
}
|
||||
Card(
|
||||
@@ -425,9 +373,11 @@ fun ChatSettingsScreen(
|
||||
HorizontalDivider()
|
||||
|
||||
val serverCaps by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val resolvedStreamingEndpoint =
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
val resolvedStreamingEndpoint = if (streamingEndpoint == "auto") {
|
||||
serverCaps.preferredChatEndpoint()
|
||||
} else {
|
||||
streamingEndpoint
|
||||
}
|
||||
|
||||
// Parse tool annotations toggle (text-stream endpoints only)
|
||||
val isTextAnnotationMode = resolvedStreamingEndpoint == "sessions" ||
|
||||
@@ -490,8 +440,6 @@ fun ChatSettingsScreen(
|
||||
"Auto: picks the best path based on what your server exposes. " +
|
||||
"Currently using: $resolvedStreamingEndpoint" +
|
||||
when {
|
||||
resolvedStreamingEndpoint == "gateway" ->
|
||||
" (live thinking via the dashboard WebSocket)"
|
||||
!serverCaps.sessionsChatStream && serverCaps.portable ->
|
||||
" (chat via /v1/chat/completions)"
|
||||
!serverCaps.sessionsChatStream && serverCaps.runs ->
|
||||
@@ -499,9 +447,6 @@ fun ChatSettingsScreen(
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
"gateway" -> "Gateway: live thinking + rich tool events over the " +
|
||||
"dashboard WebSocket (/api/ws) — what the desktop app uses. " +
|
||||
"Requires Manage sign-in; falls back to SSE per turn when unavailable."
|
||||
"sessions" -> "Sessions: Hermes-native /api/sessions/{id}/chat/stream."
|
||||
"completions" -> "Chat: OpenAI-compatible SSE via /v1/chat/completions."
|
||||
"runs" -> "Runs: use only when your server streams /v1/runs directly."
|
||||
@@ -512,19 +457,9 @@ fun ChatSettingsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (gatewayAvailability == GatewayAvailability.SignInRequired &&
|
||||
(streamingEndpoint == "gateway" || streamingEndpoint == "auto")
|
||||
) {
|
||||
Text(
|
||||
text = "Sign in via the Manage tab to enable Gateway streaming " +
|
||||
"(live thinking).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
|
||||
val endpointOptions = listOf("auto", "gateway", "sessions", "completions", "runs")
|
||||
val endpointLabels = listOf("Auto", "Gateway", "Sessions", "Chat", "Runs")
|
||||
val endpointOptions = listOf("auto", "sessions", "completions", "runs")
|
||||
val endpointLabels = listOf("Auto", "Sessions", "Chat", "Runs")
|
||||
val selectedEndpointIndex = endpointOptions.indexOf(streamingEndpoint).coerceAtLeast(0)
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -535,18 +470,9 @@ fun ChatSettingsScreen(
|
||||
count = endpointOptions.size
|
||||
),
|
||||
onClick = { connectionViewModel.setStreamingEndpoint(option) },
|
||||
selected = index == selectedEndpointIndex,
|
||||
// Drop the default check icon — with 5 segments its
|
||||
// reserved width pushed "Gateway"/"Sessions" onto a
|
||||
// second line. Selection still reads via the fill.
|
||||
icon = {},
|
||||
selected = index == selectedEndpointIndex
|
||||
) {
|
||||
Text(
|
||||
text = endpointLabels[index],
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Text(endpointLabels[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,36 +480,6 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Keep connected in background — opt-in, both flavors.
|
||||
run {
|
||||
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Keep connected in background",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Hold the chat connection open while the app is in the " +
|
||||
"background via a persistent notification, so replies stay " +
|
||||
"instant. Uses more battery; off by default.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = gatewayKeepAlive,
|
||||
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Limits — expandable
|
||||
var limitsExpanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user