Compare commits
155
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c14b5db1 | ||
|
|
e3d6dd9509 | ||
|
|
91d05c982e | ||
|
|
85bbbd004d | ||
|
|
a8f61f2aeb | ||
|
|
d554c1819a | ||
|
|
920e7d58f0 | ||
|
|
0ce7c6c6b3 | ||
|
|
46e8f90c39 | ||
|
|
2d28f171e0 | ||
|
|
fdd8301796 | ||
|
|
c9ddc2ef8d | ||
|
|
b0d662b802 | ||
|
|
63ca0a1428 | ||
|
|
8196856d76 | ||
|
|
605ff00cc0 | ||
|
|
c9b1e1b04e | ||
|
|
a4466e4ca0 | ||
|
|
e13e381e3a | ||
|
|
19d33910d0 | ||
|
|
41480a3254 | ||
|
|
50f151edba | ||
|
|
0f108fe971 | ||
|
|
c8d6119e1a | ||
|
|
b2b7a2572b | ||
|
|
1ccf87401a | ||
|
|
50b1a17895 | ||
|
|
d536923c55 | ||
|
|
14a2e01ac5 | ||
|
|
a0d03f6947 | ||
|
|
478eeac3f7 | ||
|
|
799159457a | ||
|
|
f90650bdc9 | ||
|
|
6a41ec154c | ||
|
|
53f4c8187b | ||
|
|
675252090c | ||
|
|
e94db467e8 | ||
|
|
65dae79c8c | ||
|
|
e5d0334c8b | ||
|
|
9cc7b25fd1 | ||
|
|
2ce352a320 | ||
|
|
a6957268b9 | ||
|
|
8f42b96be1 | ||
|
|
8a9c058ddb | ||
|
|
5ef2c40f81 | ||
|
|
6b32fe7ddd | ||
|
|
02f38322fa | ||
|
|
f40b7abaf0 | ||
|
|
5fcbe5ff63 | ||
|
|
6ce504b1c9 | ||
|
|
31ebef825f | ||
|
|
68abbf156d | ||
|
|
73a8af7c8f | ||
|
|
2db00d4c59 | ||
|
|
847444d115 | ||
|
|
798e8eef55 | ||
|
|
35f791be50 | ||
|
|
e727979897 | ||
|
|
91025b733f | ||
|
|
34da86a96a | ||
|
|
30f9f51e2a | ||
|
|
713529f79f | ||
|
|
16d1728306 | ||
|
|
7a813995ba | ||
|
|
c86b2224ce | ||
|
|
11a782bc44 | ||
|
|
884a17ded7 | ||
|
|
745904d468 | ||
|
|
4258322acc | ||
|
|
2c544b8ab0 | ||
|
|
5122ee69f6 | ||
|
|
512199448e | ||
|
|
7a7b10f08a | ||
|
|
f7845291e7 | ||
|
|
689219405b | ||
|
|
f4e9de425a | ||
|
|
d4cdb96bab | ||
|
|
481ad48c57 | ||
|
|
c5f35145b4 | ||
|
|
3283c9b601 | ||
|
|
74be630e05 | ||
|
|
44f030f93b | ||
|
|
abce00f49e | ||
|
|
a61d27a92d | ||
|
|
123f1d1263 | ||
|
|
61c4337807 | ||
|
|
6e1338004c | ||
|
|
348152a7cb | ||
|
|
3ec34502ec | ||
|
|
d30de8656d | ||
|
|
9b9b8c7c06 | ||
|
|
93b82aa538 | ||
|
|
7f2049fa0a | ||
|
|
92444a7039 | ||
|
|
a02d7e10df | ||
|
|
8a3935ffa1 | ||
|
|
e48935929a | ||
|
|
1e82347e7d | ||
|
|
75ed3c4226 | ||
|
|
bade61ac34 | ||
|
|
f88559f856 | ||
|
|
22e4d24817 | ||
|
|
7edaa2df14 | ||
|
|
165feaa0d6 | ||
|
|
8c516c3c8d | ||
|
|
40bb0a4ef8 | ||
|
|
d96898a6aa | ||
|
|
b4e595e320 | ||
|
|
31c41fb2ff | ||
|
|
ab0f7b726a | ||
|
|
f72904ab53 | ||
|
|
4fc5f668de | ||
|
|
cedc340091 | ||
|
|
97cb30c927 | ||
|
|
ed41be3390 | ||
|
|
08816cfe63 | ||
|
|
01a0cde589 | ||
|
|
ed6742afe4 | ||
|
|
a6264df910 | ||
|
|
64e2e2eca6 | ||
|
|
1ccaf2c4f1 | ||
|
|
7686bb41e7 | ||
|
|
a940b4b8ea | ||
|
|
46afdeab59 | ||
|
|
d4a8aad050 | ||
|
|
0a6e95ae74 | ||
|
|
a6fc53e5cf | ||
|
|
c013daacda | ||
|
|
f5b1d377a4 | ||
|
|
bb1e406f3f | ||
|
|
10213ca8ed | ||
|
|
cbfccd8ccf | ||
|
|
c3c98caa31 | ||
|
|
ed60abd57c | ||
|
|
cab0d90530 | ||
|
|
d977600f9d | ||
|
|
c902c00101 | ||
|
|
aa6b48a068 | ||
|
|
50297d1496 | ||
|
|
d80f36a087 | ||
|
|
b6117c2d41 | ||
|
|
b0ee6935fe | ||
|
|
33538fde0c | ||
|
|
603919c8ff | ||
|
|
52df3adbf6 | ||
|
|
3eab11c639 | ||
|
|
2673f228bb | ||
|
|
53b8f6a418 | ||
|
|
87cd9e7b9d | ||
|
|
51a020bd22 | ||
|
|
34ff4d0629 | ||
|
|
c452c25148 | ||
|
|
0db5c02722 | ||
|
|
4630695c17 | ||
|
|
f4ee440015 |
@@ -4,7 +4,7 @@ contact_links:
|
||||
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
|
||||
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
|
||||
- name: User documentation
|
||||
url: https://codename-11.github.io/hermes-relay/
|
||||
url: https://hermes-relay.dev/docs/
|
||||
about: Read setup, pairing, remote access, and troubleshooting docs.
|
||||
- name: Contributing guide
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
|
||||
|
||||
@@ -25,7 +25,7 @@ otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Target branch is `dev` unless this is a release PR
|
||||
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
|
||||
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
|
||||
- [ ] Translation changes: locale status/review references are accurate, `python scripts/check-android-locales.py` ran, and device/emulator review is documented, or N/A
|
||||
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
|
||||
#
|
||||
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
|
||||
# intentionally small: check out a clean upstream hermes-agent beside Relay and
|
||||
# run the desktop typed-stream/renderer tests that protect the gateway event
|
||||
# contract. A later expansion can boot the upstream gateway with a mock provider
|
||||
# once that harness is stable enough for CI.
|
||||
|
||||
name: CI - Desktop Upstream Baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check"
|
||||
required: false
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "30 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-upstream-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop-baseline:
|
||||
name: Desktop typed gateway baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}"
|
||||
else
|
||||
REF="main"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout vanilla upstream
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Assert upstream checkout is vanilla
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
|
||||
fi
|
||||
git -C _upstream status --short --untracked-files=no
|
||||
|
||||
- name: Run desktop gateway baseline contract
|
||||
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Run desktop gateway baseline tests
|
||||
working-directory: desktop
|
||||
env:
|
||||
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
|
||||
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
|
||||
@@ -50,6 +50,7 @@ jobs:
|
||||
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/config.py
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
@@ -103,4 +104,6 @@ jobs:
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
@@ -42,13 +44,11 @@ jobs:
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const paths = files.map((file) => file.filename);
|
||||
const { stdout } = await exec.getExecOutput(
|
||||
'git',
|
||||
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
|
||||
);
|
||||
const paths = stdout.split(/\r?\n/).filter(Boolean);
|
||||
const { classifyCiPaths } = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Hermes-Relay — Docs Deployment
|
||||
#
|
||||
# Builds VitePress docs and deploys to GitHub Pages.
|
||||
# Triggers on pushes to main that change user-docs/ content,
|
||||
# or manually via workflow_dispatch.
|
||||
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'user-docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
# Node 24 ships npm 11, matching the npm that generates
|
||||
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
|
||||
# the lock over the optional `search-insights` peer dep of bundled
|
||||
# docsearch. Keep this aligned with the npm used to write the lock.
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
run: npm run build
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Deploy legacy docs redirects
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: legacy-docs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build redirect artifact
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build redirect-only site
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_file="legacy-pages-redirect/redirect.html"
|
||||
privacy_file="website/public/privacy.html"
|
||||
output_dir="legacy-pages-redirect/_site"
|
||||
rm -rf "$output_dir"
|
||||
mkdir -p \
|
||||
"$output_dir/guide/getting-started" \
|
||||
"$output_dir/privacy" \
|
||||
"$output_dir/reference/relay-server" \
|
||||
"$output_dir/architecture"
|
||||
for target in \
|
||||
index.html \
|
||||
404.html \
|
||||
guide/getting-started.html \
|
||||
guide/getting-started/index.html \
|
||||
reference/relay-server.html \
|
||||
reference/relay-server/index.html \
|
||||
architecture/connection-security.html; do
|
||||
cp "$source_file" "$output_dir/$target"
|
||||
done
|
||||
cp "$privacy_file" "$output_dir/privacy.html"
|
||||
cp "$privacy_file" "$output_dir/privacy/index.html"
|
||||
touch "$output_dir/.nojekyll"
|
||||
test "$(find "$output_dir" -type f | wc -l)" -eq 10
|
||||
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
|
||||
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
|
||||
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
|
||||
echo "Full documentation content must not be deployed by this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload redirect artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: legacy-pages-redirect/_site
|
||||
|
||||
deploy:
|
||||
name: Deploy redirect shim
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
- name: Validate release metadata and source compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py --live
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
@@ -65,9 +67,26 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
- name: Verify public privacy policy URLs
|
||||
run: python3 scripts/check-privacy-policy.py --live
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require successful Play preflight for this exact release tree
|
||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||
env:
|
||||
@@ -107,6 +126,7 @@ jobs:
|
||||
- name: Validate release metadata and Android API compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Release CLI
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['cli-v*']
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -36,13 +36,17 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#cli-v}"
|
||||
version="${GITHUB_REF_NAME#desktop-v}"
|
||||
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
|
||||
echo "Expected a cli-v* tag, got $GITHUB_REF_NAME" >&2
|
||||
echo "Expected a desktop-v* tag, got $GITHUB_REF_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
npm run check:version-sync -- --expect "$version"
|
||||
if ! grep -Fq "## [$version]" ../CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has no release heading for $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
shell: bash
|
||||
@@ -52,7 +56,7 @@ jobs:
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "CLI releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
echo "Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -234,9 +238,9 @@ jobs:
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- 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@v8
|
||||
with:
|
||||
@@ -253,7 +257,7 @@ jobs:
|
||||
|
||||
# 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.
|
||||
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
@@ -266,7 +270,7 @@ jobs:
|
||||
- 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') }}
|
||||
|
||||
@@ -1,32 +1,49 @@
|
||||
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:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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 and changelog
|
||||
run: |
|
||||
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test Plugin package
|
||||
needs: validate
|
||||
@@ -100,8 +117,8 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -93,3 +93,5 @@ keystore.properties
|
||||
|
||||
# Legacy generated desktop tray assets may remain after upgrading a worktree.
|
||||
desktop/tray/ui/vendor/
|
||||
# Generated from assets/screenshots/02_chat.png before docs dev/build.
|
||||
/user-docs/public/chat-demo.png
|
||||
|
||||
@@ -5,26 +5,49 @@ 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`.
|
||||
This file is the provider-neutral canonical agent context. Read it before
|
||||
touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
|
||||
such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
|
||||
not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
|
||||
|
||||
## Branch contract
|
||||
|
||||
| Contract item | Canonical source or target |
|
||||
|---|---|
|
||||
| Integration branch | `dev`; normal feature, fix, docs, and chore PRs target `dev` |
|
||||
| Release branch | `main`; release history and hotfix integration only |
|
||||
| Tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
|
||||
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
|
||||
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
|
||||
| Hotfix base | The immutable production tag for the affected surface |
|
||||
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
|
||||
|
||||
Feature completion means merged and verified on `dev`; it does not mean
|
||||
released. A release train is separate work owned by a Forge release
|
||||
issue/session: reconcile only the affected surface version and notes on `dev`,
|
||||
open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
|
||||
surface artifacts, deploy or roll out, and verify the live result. Never create
|
||||
a staging branch.
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
|
||||
chat via the API server, Vanilla Hermes 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.
|
||||
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
|
||||
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
|
||||
and Vanilla Hermes voice. The API server is an optional automatic fallback and
|
||||
advanced headless-compatibility surface; Relay adds optional extensions. This
|
||||
path 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`.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
|
||||
`dev` and PR back to `dev`; merge commits/no-ff are the repository policy.
|
||||
Version bumps happen only during release preparation on `dev`, and production
|
||||
tags are cut only from `main`.
|
||||
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
|
||||
|
||||
@@ -6,6 +6,77 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
|
||||
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
|
||||
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
|
||||
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
|
||||
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
|
||||
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
|
||||
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
|
||||
|
||||
## [1.4.8] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
|
||||
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
|
||||
|
||||
## [1.4.7] - 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
|
||||
|
||||
## [Android 1.4.6] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Hermes-Relay — Claude Code Context
|
||||
# Hermes-Relay — Claude Code Adapter
|
||||
|
||||
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
|
||||
> Read [AGENTS.md](AGENTS.md) first. It is the provider-neutral canonical agent
|
||||
> context. Branch, release, staging, and hotfix rules live in `AGENTS.md` and
|
||||
> [RELEASE.md](RELEASE.md); this file only adds Claude-specific project and tool
|
||||
> guidance. Then read `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
## What This Is
|
||||
|
||||
@@ -183,18 +186,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
### Git
|
||||
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **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`.
|
||||
- **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.
|
||||
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
|
||||
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
|
||||
a Claude-specific parallel policy here.
|
||||
|
||||
### 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 and release gates:** follow the repository-wide requirements in
|
||||
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -406,7 +407,7 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
|
||||
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
5. **Commit + push** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
|
||||
6. **Pull + restart on server** — see Server Deployment below.
|
||||
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
|
||||
|
||||
@@ -455,15 +456,10 @@ must not depend on this hook.
|
||||
|
||||
### Release Process
|
||||
|
||||
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
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
See [AGENTS.md](AGENTS.md) for the canonical branch contract and
|
||||
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
|
||||
hotfixes, secrets, publishing, and verification. Claude-specific automation
|
||||
must not infer release authority from feature completion.
|
||||
|
||||
## Integration Points
|
||||
|
||||
|
||||
@@ -60,4 +60,4 @@ hermes-relay daemon status
|
||||
|
||||
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
|
||||
|
||||
See the [CLI and systray guide](https://codename-11.github.io/hermes-relay/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
|
||||
+12
-2
@@ -92,9 +92,19 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
|
||||
|
||||
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
|
||||
**Branching model: `main` + `dev`.** Feature branches — `feature/<name>`,
|
||||
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back
|
||||
into `dev` via merge-commit/no-ff PRs. This includes small documentation fixes.
|
||||
`main` is release history, not the normal contribution target; it receives
|
||||
approved release PRs from `dev` and focused hotfix PRs based on production tags.
|
||||
|
||||
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`, `plugin-vX.Y.Z`, or `cli-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
Feature completion means merged and verified on `dev`; it does not mean the
|
||||
change has been released. A separate Forge release issue/session owns release
|
||||
preparation, the `dev` → `main` release PR, tagging, artifacts, rollout or
|
||||
deployment, and live verification. Release-prep commits land on `dev`; tags are
|
||||
cut from the resulting `main` tip as `android-vX.Y.Z`, `server-vX.Y.Z`, or
|
||||
`desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release and hotfix
|
||||
procedures.
|
||||
|
||||
## Stale PR salvage and contributor credit
|
||||
|
||||
|
||||
@@ -1,5 +1,300 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-24 — Post-connect permission setup
|
||||
|
||||
Android onboarding now finishes with a layered permission step after a
|
||||
successful Hermes connection. Standard Chat and Manage are explicitly ready
|
||||
without a phone grant; Android notifications are recommended through one
|
||||
user-triggered runtime prompt; camera, microphone, notification companion, and
|
||||
flavor-supported device tools remain individually optional on the centralized
|
||||
Permissions screen. Existing just-in-time permission prompts remain available
|
||||
when users skip setup.
|
||||
|
||||
Coverage separates the Android-version notification policy from the Compose
|
||||
presentation and checks the recommended, granted, optional-review, and skip
|
||||
states. English and all shipped Android locale catalogs were updated together.
|
||||
|
||||
## 2026-07-24 — Realtime background-task delivery continuity
|
||||
|
||||
Chat stream completion now preserves an otherwise empty assistant row when it
|
||||
owns a promoted background task. This keeps the task identity available after
|
||||
the provider's initial spoken handoff, so later progress, completion, and
|
||||
forced-summary events update and settle the initiating row even when a newer
|
||||
persistent Voice command has started. Existing empty-response cleanup remains
|
||||
unchanged for assistant rows without background work.
|
||||
|
||||
## 2026-07-23 — Background interaction notifications
|
||||
|
||||
Android Gateway chat now treats approval, clarification, elevated-permission,
|
||||
and secret requests as actionable background events. Privacy-safe notifications
|
||||
use stable per-session identities, reopen the exact conversation, replace
|
||||
replayed requests, and clear on answer, expiry, or resumed turn activity.
|
||||
Detached active turns retain and replay their pending interaction when the
|
||||
conversation is reopened.
|
||||
|
||||
The audit classified `terminal.read.request` as renderer plumbing rather than a
|
||||
user decision. Android now answers it with the upstream no-terminal empty
|
||||
response instead of showing an interaction or waiting for the server timeout.
|
||||
The shared main manifest continues to provide notification and persistent
|
||||
connection support to both Google Play and sideload builds.
|
||||
|
||||
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
|
||||
|
||||
The shared Android network security configuration now accepts CA certificates
|
||||
that the device owner deliberately installed in Android's user credential store,
|
||||
in addition to system roots. Because the policy is attached to the common
|
||||
application manifest, it covers both product flavors and every platform-backed
|
||||
Hermes transport: endpoint probes, Dashboard requests and authentication
|
||||
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
|
||||
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
|
||||
checks remain active, and Relay's independent certificate pinner is not
|
||||
overridden.
|
||||
|
||||
An app-wide policy is required for arbitrary operator-supplied server names and
|
||||
for consistent WebView behavior. A runtime opt-in would require parallel custom
|
||||
trust implementations for each client and could not safely reconfigure WebView;
|
||||
per-connection CA import would add private trust-material lifecycle without
|
||||
covering all transports. The tradeoff is that Android disables public
|
||||
Certificate Transparency verification when user trust anchors are enabled.
|
||||
User documentation records the deliberate-installation boundary and a device or
|
||||
emulator validation procedure with positive chain and negative hostname checks.
|
||||
JVM coverage locks the accepted anchor sources and rejects pin overrides or
|
||||
debug-only trust additions.
|
||||
|
||||
## 2026-07-23 — Bounded Android code-highlighting ranges
|
||||
|
||||
Android Markdown code rendering now validates every syntax-highlighting span
|
||||
before applying it to Compose text. The bundled highlighting dependency can
|
||||
emit a reversed multiline-comment range when malformed or incomplete code
|
||||
contains a closing delimiter before its opening delimiter; the Markdown
|
||||
renderer previously passed that range directly to `AnnotatedString` and
|
||||
crashed. Both fenced and indented code use the guarded renderer, valid spans
|
||||
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
|
||||
coverage reproduces the dependency output and verifies the safe conversion.
|
||||
|
||||
## 2026-07-20 — Image generation placeholder during turns
|
||||
|
||||
Android chat now specializes the generic tool lifecycle for active
|
||||
`image_generate` calls. While the tool is pending, the message shows a
|
||||
theme-aware procedural diffusion canvas with a polite live-region announcement
|
||||
instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
connection and onboarding work merged to `dev`. The public changelog, GitHub
|
||||
release notes, in-app What's New surfaces, English and Simplified Chinese Play
|
||||
notes, and store-listing copy now describe the Android-only patch. Unreleased
|
||||
Relay security hardening and the Windows gateway PID fix remain assigned to the
|
||||
independent server release track.
|
||||
|
||||
## 2026-07-19 — Connection management detail pass
|
||||
|
||||
The Connections list now uses compact capability chips and exposes a cold-start
|
||||
preference when more than one server is saved. Last used remains the default;
|
||||
pinning a startup connection does not change the active connection during the
|
||||
current run. Dashboard-only connections now render their configured Dashboard
|
||||
and authentication status in Routes instead of an empty endpoint list, while
|
||||
optional API fallback routes remain explicitly separate.
|
||||
|
||||
Advanced settings render directly in their dedicated tab without a redundant
|
||||
expander. Security adds Dashboard authentication, credential-storage, Relay
|
||||
session, sign-out, and transport posture facts sourced from current runtime
|
||||
state. Android catalogs and localization source hashes were refreshed alongside
|
||||
focused startup-persistence coverage.
|
||||
|
||||
## 2026-07-18 — Dashboard-primary connection contract
|
||||
|
||||
The canonical Android connection model now treats one Hermes installation as a
|
||||
stable identity with independently optional endpoints. Dashboard/Gateway owns
|
||||
the standard upstream chat, authentication, sessions, Manage, and voice path;
|
||||
the API server is an automatic fallback and advanced headless compatibility
|
||||
surface; Relay remains an additive extension for terminal, bridge, media, and
|
||||
enhanced voice capabilities. Existing API-first records and pairing payloads
|
||||
remain compatible without defining normal onboarding.
|
||||
|
||||
The audit reconciled `AGENTS.md`, `docs/spec.md`, `docs/decisions.md`,
|
||||
`docs/upstream-surface-matrix.md`, `README.md`,
|
||||
`user-docs/features/connections.md`, `user-docs/features/dashboard.md`,
|
||||
`CHANGELOG.md`, and `DEVLOG.md`. `RELEASE.md` was reviewed and required no
|
||||
change because it contains no connection-routing contract.
|
||||
|
||||
## 2026-07-18 — Non-signalling Windows gateway PID probes
|
||||
|
||||
Relay profile discovery now prefers Hermes' supported psutil liveness check and
|
||||
falls back to native Windows process handles when psutil is unavailable. The
|
||||
POSIX signal-zero probe is restricted to POSIX hosts, preventing relay startup
|
||||
and periodic profile rescans from signalling or terminating the gateway named
|
||||
by `gateway.pid`. Regression coverage uses disposable child processes instead
|
||||
of pointing PID fixtures at the test runner.
|
||||
|
||||
## 2026-07-18 — Android privacy-policy URL hotfix
|
||||
|
||||
The canonical Android privacy policy moved to a stable public page on
|
||||
hermes-relay.dev. The historical GitHub Pages path now serves the complete
|
||||
policy for compatibility with existing store metadata instead of depending on
|
||||
a client-side redirect. Android's About screen, public privacy references, and
|
||||
Play submission documentation use the canonical site URL.
|
||||
|
||||
The legacy Pages workflow publishes both file and directory policy paths from
|
||||
the same canonical HTML source. Repository validation checks the policy's
|
||||
required disclosures and URL wiring, while Play preflight and stable tag release
|
||||
jobs additionally require both deployed policy URLs to return complete policy
|
||||
content before an artifact can be uploaded or promoted. Android advanced to
|
||||
1.4.8 with versionCode 31 for the replacement Play submission.
|
||||
|
||||
## 2026-07-18 — Android 1.4.7 release preparation
|
||||
|
||||
Android advanced to 1.4.7 with versionCode 30 after the localization, streaming,
|
||||
release-history, and branch-contract reconciliation landed on `dev`. The public
|
||||
changelog, GitHub release body, in-app What's New surfaces, Play metadata, and
|
||||
store-listing copy now describe the Android-only patch while the unreleased Relay
|
||||
security work remains assigned to its independent server release track.
|
||||
|
||||
## 2026-07-17 — Smooth streamed-reply rendering and finalization
|
||||
|
||||
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
|
||||
tool events as authoritative instead of immediately republishing the transcript
|
||||
through a full history read. Rejoined sockets, Sessions SSE, detached turns,
|
||||
profile-aware resume, errors, and missing-data recovery retain their required
|
||||
authoritative reconciliation paths.
|
||||
|
||||
Bursty provider deltas now enter a main-thread frame pacer that publishes adaptive
|
||||
UTF-16-safe slices at a display-sized cadence. The visible live tail uses one stable
|
||||
plain-text node, ignores leading blank transport lines, and expands inside a short
|
||||
clipped size animation. Tool, thinking, completion, cancellation, and error
|
||||
boundaries still flush buffered content immediately and preserve event order.
|
||||
|
||||
Bottom-following is driven by stable row identity, real drag interactions, measured
|
||||
positive tail growth, and structural anchors. The active response retains its live
|
||||
renderer through completion, settles the exact footer for two frames, and releases
|
||||
to full Markdown after another row becomes the tail or the session is revisited.
|
||||
This prevents both the completion-time top snap and the transient scroll-to-bottom
|
||||
button without interrupting readers who intentionally move into history.
|
||||
|
||||
Focused stream-pacing, Unicode-boundary, leading-whitespace, Gateway reconnect,
|
||||
completion-policy, and scroll regressions passed. Repeated sideload builds and live
|
||||
phone tests verified smooth following, stable completion, exact-bottom settling,
|
||||
frame-paced text insertion, and clipped bubble growth.
|
||||
|
||||
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
|
||||
|
||||
Android chat now separates the stable Compose identity of a visible message row
|
||||
from its authoritative server message ID. The post-turn history reconcile can
|
||||
adopt persisted IDs and rebuild message boundaries without making LazyColumn
|
||||
remove and reinsert the long answer currently anchoring the viewport.
|
||||
|
||||
Regression coverage exercises both the same-count user/assistant ID adoption
|
||||
and a list-expansion reconcile that inserts persisted rows around a matched live
|
||||
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
|
||||
|
||||
## 2026-07-16 — Stable chat position after stream completion
|
||||
|
||||
Android chat now observes the assistant message identity and the streaming-to-final
|
||||
transition as conversation-tail changes. When a reader is already following the
|
||||
response, completion performs an instant multi-frame bottom settle after the
|
||||
streaming renderer is replaced by the final Markdown layout. The existing
|
||||
user-scroll gate remains authoritative, so reading older messages is not
|
||||
interrupted.
|
||||
|
||||
Focused snapshot regression coverage verifies completion detection, ordinary
|
||||
stream growth, stream startup, and server message-ID reconciliation.
|
||||
|
||||
## 2026-07-16 — Critical Relay authorization hardening
|
||||
|
||||
Relay privileged interfaces now enforce host-authorized policy at every shared
|
||||
dispatch boundary. Anonymous pairing-code minting was removed; pairing clients
|
||||
can no longer choose session lifetime or grants; Android bridge HTTP routes and
|
||||
terminal messages require live sessions with active route grants; ordinary
|
||||
session bearers can only reduce their own lifetime and existing grants; remote
|
||||
profile config reads expose an explicit public schema without host paths; and
|
||||
voice requests cannot override credential-bearing provider origins.
|
||||
|
||||
Six bounded exploit harnesses stopped at the restored boundaries. The combined
|
||||
security regression set passed 96 tests, Python compilation passed, Ruff passed
|
||||
for changed modules and tests apart from the pre-existing unused `signal`
|
||||
import in `server.py`, and `git diff --check` passed. The broad plugin
|
||||
discovery run progressed through unrelated suites but was interrupted by the
|
||||
existing Windows async-suite `KeyboardInterrupt` behavior, so the focused
|
||||
security and neighboring route suites remain the authoritative local result.
|
||||
The required-check path classifier now reads changed paths from the checked-out
|
||||
PR merge commit instead of GitHub's PR-files API, so an API outage cannot skip
|
||||
every surface check.
|
||||
|
||||
## 2026-07-16 — Fix production docs asset context
|
||||
|
||||
Updated the production docs Docker stage to build from the same full repository
|
||||
checkout used by CI rather than a hand-maintained file allowlist. This supplies
|
||||
the canonical screenshot manifest, localization registry and validators, route
|
||||
contract source, version metadata, and preview renderer without creating
|
||||
Docker-only `ENOENT` failures when those build inputs grow. Both Node build
|
||||
stages now install Python 3 so the localized website and docs validators run
|
||||
inside the production image build as they do in CI.
|
||||
|
||||
## 2026-07-16 — Localized marketing site
|
||||
|
||||
The Astro product site now publishes German, Spanish, Japanese, Brazilian
|
||||
Portuguese, and Simplified Chinese routes from one typed copy contract. Each
|
||||
route localizes marketing copy, navigation, accessibility labels, metadata, and
|
||||
links into the matching first-run documentation while retaining canonical
|
||||
screenshots, command examples, and UI recreations as shipped-product evidence.
|
||||
|
||||
Locale-aware canonical URLs, alternate-language links, Open Graph locale data,
|
||||
structured-data language, sitemap entries, and a responsive language selector
|
||||
were added. The localization registry records English-source freshness, and the
|
||||
website development and build commands reject missing or stale translations.
|
||||
Astro diagnostics, deterministic asset checks, the six-page production build,
|
||||
built-site validation, desktop/mobile browser checks, and `git diff --check`
|
||||
passed.
|
||||
|
||||
## 2026-07-16 — Temporary redirect shim for pre-migration Android builds
|
||||
|
||||
Restored GitHub Pages only as a redirect-only compatibility endpoint for app
|
||||
versions that still open `https://codename-11.github.io/hermes-relay/`. The
|
||||
shim preserves known paths, query strings, and fragments while forwarding to
|
||||
`https://hermes-relay.dev/docs/`; it does not publish the VitePress site.
|
||||
The production Nginx configuration resolves VitePress clean URLs to their
|
||||
`.html` artifacts so both legacy and corrected in-app links reach real pages.
|
||||
Removal criteria and the operator review date are tracked in `TODO.md`.
|
||||
|
||||
## 2026-07-15 — German, Brazilian Portuguese, and Japanese localization
|
||||
|
||||
Android now includes complete German, Brazilian Portuguese, and Japanese
|
||||
catalogs across the Google Play and sideload flavors. German and Brazilian
|
||||
Portuguese were recovered from unfinished translation drafts and refreshed
|
||||
against the current English resource contract; Japanese was generated through
|
||||
the same deterministic translation harness. The in-app picker, Android locale
|
||||
configuration, localization registry, contributor references, and user-facing
|
||||
language lists now describe the expanded set consistently.
|
||||
|
||||
The catalogs remain marked as AI-translated until fluent review is recorded.
|
||||
Structural validation covers resource parity, placeholders, plurals, arrays,
|
||||
formatting flags, XML parsing, and canonical source hashes. The 10-catalog
|
||||
validator, five focused `AppLanguageTest` cases, both flavor Kotlin/resource
|
||||
compilations, sideload debug lint, and `git diff --check` passed.
|
||||
|
||||
## 2026-07-15 — Retire GitHub Pages and move docs to hermes-relay.dev
|
||||
|
||||
Disabled and removed the GitHub Pages deployment path, changed the repository
|
||||
homepage to `https://hermes-relay.dev`, and moved the existing VitePress guide
|
||||
to `https://hermes-relay.dev/docs/` inside the production Coolify image. Active
|
||||
README, website, Android, release-note, and pet-schema links now target the new
|
||||
docs origin while historical DEVLOG entries remain unchanged.
|
||||
|
||||
## 2026-07-15 — Coolify root-context website deployment hotfix
|
||||
|
||||
Added a repository-owned multi-stage Dockerfile for the Astro marketing site
|
||||
and corrected its Coolify instructions. Production builds now keep the
|
||||
repository root as Docker context, run the existing `build:production` gate
|
||||
from `website/`, and serve the generated static output with Nginx. This keeps
|
||||
the site's canonical screenshot comparison against `docs/media/` intact while
|
||||
avoiding Nixpacks' incorrect Android/Gradle provider selection at monorepo root.
|
||||
|
||||
Verification: local website checks, production build, link validation, Docker
|
||||
image build, and Nginx-served smoke checks passed before deployment.
|
||||
|
||||
## 2026-07-15 — Android 1.4.6 and Plugin 1.4.2 release preparation
|
||||
|
||||
The profile-continuity and profile-image work was prepared as a two-surface
|
||||
@@ -52,6 +347,60 @@ Verification: seven profile-avatar endpoint tests and the focused Android host
|
||||
avatar client plus profile-controller suites passed. Android lint and final diff
|
||||
checks are recorded with the completed work.
|
||||
|
||||
## 2026-07-15 — Repository branch, release, and hotfix contract reconciliation
|
||||
|
||||
Repository guidance now has one provider-neutral branch contract in `AGENTS.md`:
|
||||
normal work, including documentation, branches from and returns to `dev`; release
|
||||
preparation happens on `dev`; approved release PRs merge `dev` to `main`; and
|
||||
immutable surface tags are cut from the new `main` tip. Staging is an environment
|
||||
sourced from an exact tested SHA or release-candidate tag. Production uses
|
||||
`android-v*`, `server-v*`, or `desktop-v*`. Hotfixes branch from the affected
|
||||
production tag, change and patch-bump only that surface, merge to `main`, tag,
|
||||
verify, and immediately merge `main` back into `dev`.
|
||||
|
||||
Stable release workflows now require the tagged commit to be contained in
|
||||
`main`, require the tag to match the authoritative surface version source, and
|
||||
require a matching `CHANGELOG.md` release heading before any build or publish
|
||||
job. Server and Desktop use the canonical `server-v*` and `desktop-v*` prefixes;
|
||||
runtime update discovery retains fallback support for immutable historical
|
||||
`plugin-v*` and `cli-v*` releases. No historical tag was moved or rewritten.
|
||||
|
||||
Audit inventory:
|
||||
|
||||
- Canonical/root guidance: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`,
|
||||
`RELEASE.md`, `README.md`, `DEVLOG.md`, `PLUGIN_RELEASE_NOTES.md`, and
|
||||
`CLI_RELEASE_NOTES.md`.
|
||||
- Contributor metadata: `.github/PULL_REQUEST_TEMPLATE.md`; every file in
|
||||
`.github/ISSUE_TEMPLATE/` (`bug_report.yml`, `config.yml`, `docs.yml`,
|
||||
`feature_request.yml`, and `translation.yml`); `.github/copilot-instructions.md`;
|
||||
and `.github/dependabot.yml`.
|
||||
- GitHub workflows: `approve-release-android.yml`, `ci-android.yml`,
|
||||
`ci-contract.yml`, `ci-dashboard.yml`, `ci-desktop.yml`, `ci-plugin.yml`,
|
||||
`ci-required.yml`, `dependabot-auto-merge.yml`, `docs.yml`, `issue-triage.yml`,
|
||||
`play-listing.yml`, `play-preflight-android.yml`, `release-android.yml`,
|
||||
`release-cli.yml`, and `release-plugin.yml`.
|
||||
- Developer documentation: every Markdown file directly under `docs/`, plus
|
||||
`docs/audits/`, `docs/diagrams/`, and `docs/mockups/`. Historical files under
|
||||
`docs/plans/` were inspected for classification but not rewritten as current
|
||||
instructions. Current contradictions were corrected in `docs/decisions.md`
|
||||
and `docs/worktree-workflow.md`.
|
||||
- Public documentation: every Markdown file under `user-docs/`, including the
|
||||
architecture, desktop, features, guide, reference, and `zh-CN` trees. Current
|
||||
tag guidance was corrected in `user-docs/desktop/index.md` and
|
||||
`user-docs/desktop/installation.md`.
|
||||
- Release/runtime seams: all three release workflows; `scripts/bump-version.sh`,
|
||||
`scripts/bump-plugin-version.sh`, `scripts/check-version-tracks.py`, and
|
||||
`scripts/check-plugin-version-sync.py`; Desktop install/update sources under
|
||||
`desktop/scripts/` and `desktop/src/`; and Server update-discovery sources and
|
||||
focused tests under `plugin/`.
|
||||
|
||||
The repository audit also confirmed that GitHub-owned settings cannot be
|
||||
reconciled through repository files. The default branch correctly remained
|
||||
`main`, the release-history branch. At audit time, `dev` was unprotected, squash
|
||||
and rebase merges were enabled, and `main` protection did not apply to
|
||||
administrators. An operator must align those remaining settings with the
|
||||
documented contract.
|
||||
|
||||
## 2026-07-15 — Android 1.4.5 release and automated Play gate
|
||||
|
||||
Android 1.4.5 shipped as versionCode 28 after the signed release build, final
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** July 15, 2026
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
|
||||
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
|
||||
|
||||
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
|
||||
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
|
||||
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
|
||||
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
|
||||
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
|
||||
|
||||
## Install / update
|
||||
|
||||
@@ -33,4 +35,4 @@ Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat a
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use android-v*, plugin releases use plugin-v*, and CLI releases use cli-v*.
|
||||
Tag prefixes: Android releases use android-v*, Server releases use server-v*, and Desktop releases use desktop-v*.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
|
||||
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
@@ -50,56 +50,52 @@ Install → connect → talk, in about two minutes.
|
||||
### 1 · Install the app
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **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).
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2 · Have Hermes running
|
||||
### 2 · Have the Hermes Dashboard running
|
||||
|
||||
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 vanilla 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.
|
||||
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
|
||||
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
|
||||
provider is vanilla Hermes setup:
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
|
||||
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
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
hermes dashboard # start the standard Dashboard/Gateway surface
|
||||
```
|
||||
|
||||
`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**.
|
||||
|
||||
> **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).
|
||||
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
|
||||
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
|
||||
covers Windows, remote access, and dashboard authentication. You do not need to
|
||||
enable the separate API server or invent an API key for the standard path.
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
|
||||
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
|
||||
configured provider when prompted. The app probes the available upstream
|
||||
capabilities and finishes with a connection summary.
|
||||
|
||||
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Vanilla 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 separate API server can be discovered automatically or added later under
|
||||
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
|
||||
key is requested only when that optional endpoint is configured. Existing
|
||||
API-first setup QRs remain importable.
|
||||
|
||||
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 |
|
||||
| **Chat** | Dashboard/Gateway ready — you can talk |
|
||||
| **Manage** | Models, keys, skills, and profiles are available 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 |
|
||||
| **API fallback** | Optional API route available/unavailable |
|
||||
| **Relay** | Optional extensions — 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 Vanilla Hermes setup.
|
||||
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
|
||||
the whole Vanilla Hermes 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).
|
||||
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
@@ -133,7 +129,7 @@ the QR from the phone's Connections screen — or use
|
||||
|
||||
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.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -162,12 +158,12 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The Android app also ships a complete AI-assisted Spanish catalog. Choose
|
||||
**Español** from **Settings → Appearance → Language**; translation status and
|
||||
fluent review are tracked independently so community corrections remain easy
|
||||
to contribute.
|
||||
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
|
||||
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
|
||||
**Settings → Appearance → Language**; translation status and fluent review are
|
||||
tracked independently so community corrections remain easy to contribute.
|
||||
|
||||
<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>
|
||||
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -183,7 +179,7 @@ to contribute.
|
||||
- **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).
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
@@ -201,11 +197,11 @@ hermes-relay daemon start # background tool router — agen
|
||||
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*`.
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
|
||||
|
||||
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## How It Works
|
||||
@@ -229,14 +225,14 @@ configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[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 |
|
||||
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://hermes-relay.dev/docs/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*`) |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `server-v*`, `desktop-v*`; historical prefixes remain immutable) |
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>简体中文</strong> · <a href="README.md">English</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
|
||||
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
|
||||
<a href="CHANGELOG.md">更新日志</a>
|
||||
</p>
|
||||
@@ -76,7 +76,7 @@ hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
|
||||
+124
-65
@@ -13,23 +13,24 @@ 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 ships three independently versioned production surfaces. Public
|
||||
GitHub Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
|
||||
the corresponding build and deployment lane.
|
||||
|
||||
| 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` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
|
||||
| Hermes-Relay-Server | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-Desktop | `desktop-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.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.
|
||||
plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
|
||||
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
|
||||
new releases use the canonical prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
@@ -87,7 +88,7 @@ lockstep:
|
||||
| `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
|
||||
@@ -105,23 +106,23 @@ Check all release tracks at once with:
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, plugin, and CLI versions
|
||||
This aggregate check reports Android, Server, and Desktop 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,
|
||||
The `server-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
|
||||
publishes a `Hermes-Relay-Server vX.Y.Z` GitHub Release with the package
|
||||
artifacts.
|
||||
|
||||
### CLI / tray versioning
|
||||
|
||||
`desktop/package.json` is the CLI release track's source of truth. Its version
|
||||
`desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
|
||||
must match the generated CLI and native Windows systray metadata. The systray is
|
||||
a menu-only controller for the installed CLI; it has no application window,
|
||||
WebView, embedded terminal, or separate desktop product surface. The public
|
||||
release remains one `Hermes-Relay-CLI` track containing CLI binaries plus the
|
||||
release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
|
||||
optional Windows installer.
|
||||
|
||||
| File | Purpose |
|
||||
@@ -166,12 +167,28 @@ the accumulator: every merged PR appends bullets there. A release is a
|
||||
separate act, taken when the accumulated state on `dev` is worth shipping
|
||||
(see "When to cut a release" below). Cutting a release means opening a
|
||||
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
|
||||
then tagging `main`.
|
||||
then tagging `main`. Feature completion means merged and verified on `dev`; it
|
||||
does not mean released.
|
||||
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
||||
see state that lives on `main` and on release tags.
|
||||
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
|
||||
an immutable release-candidate tag to staging. Record that source in the Forge
|
||||
release issue/session. Never deploy a moving branch name as the source of record
|
||||
and never create a staging branch. Production deploys only immutable
|
||||
`android-v*`, `server-v*`, or `desktop-v*` tags cut from `main`.
|
||||
|
||||
### Normal contribution and release flow
|
||||
|
||||
1. Branch `feature/*`, `fix/*`, `docs/*`, or `chore/*` from `dev`.
|
||||
2. Open the PR into `dev` and require CI to pass.
|
||||
3. Merge with a merge commit/no-ff according to repository policy.
|
||||
4. Accumulate user-facing work under `CHANGELOG.md` `[Unreleased]`.
|
||||
5. Treat the feature as complete when it is merged and verified on `dev`.
|
||||
6. Start a separate Forge release issue/session when a release train is approved.
|
||||
7. Prepare the affected surface release on `dev`, including its version and notes.
|
||||
8. Open and approve the release PR from `dev` into `main`.
|
||||
9. Tag the new `main` tip with the affected surface prefix.
|
||||
10. Build and publish that surface's artifacts, roll out or deploy from the
|
||||
immutable tag, and verify the release and live environment.
|
||||
|
||||
### Branch names
|
||||
|
||||
@@ -211,23 +228,35 @@ 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
|
||||
`release(desktop): desktop-vX.Y.Z`. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled:
|
||||
Repository files define the contract and CI, but GitHub owns the default branch,
|
||||
branch protection, rulesets, allowed merge methods, and required-check settings.
|
||||
Those settings require an operator or infrastructure automation.
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Plugin) 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
|
||||
blocked.
|
||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
||||
The intended settings are:
|
||||
|
||||
- **`main`** — PRs required; `Required checks` required and current; force push
|
||||
and deletion blocked. Normal work does not target this branch.
|
||||
- **`dev`** — PRs and `Required checks` required; force push and deletion
|
||||
blocked. This is the normal contribution target.
|
||||
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
|
||||
the no-ff contract cannot be bypassed in the GitHub UI.
|
||||
- **Default branch** — `main`, which remains the release-history branch and the
|
||||
repository's canonical landing page. Normal contribution PRs must explicitly
|
||||
target `dev`.
|
||||
|
||||
As of the 2026-07-15 repository audit, the default branch was correctly `main`.
|
||||
The remaining GitHub-owned gaps were that `dev` had no protection, squash and
|
||||
rebase merges were enabled, and `main` protection did not apply to
|
||||
administrators. Those settings must be reconciled separately; this documentation
|
||||
PR does not mutate them.
|
||||
|
||||
## One-time Setup
|
||||
|
||||
@@ -388,6 +417,15 @@ tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
## Release train ownership
|
||||
|
||||
Every release train gets its own Forge release issue/session. That owner records
|
||||
the exact tested staging source, reconciles the affected surface version and
|
||||
notes on `dev`, owns the `dev` → `main` PR, tags the new `main` tip, observes the
|
||||
artifact workflow, performs the rollout or deployment, and captures live
|
||||
verification. Feature implementation sessions stop at merged and verified on
|
||||
`dev`; they do not inherit release authority.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the Android app version
|
||||
@@ -430,7 +468,7 @@ the new app version and a higher `appVersionCode`.
|
||||
three* surfaces (Android + CLI + plugin), but releases are
|
||||
per-surface. Move only the entries for the surface you're cutting into
|
||||
the new versioned block, and leave the other surfaces' entries under
|
||||
the fresh `[Unreleased]` for their own `cli-v*` / `plugin-v*` cut.
|
||||
the fresh `[Unreleased]` for their own `desktop-v*` / `server-v*` cut.
|
||||
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
|
||||
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
|
||||
historical record.)
|
||||
@@ -585,14 +623,14 @@ publication.
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin 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
|
||||
`server-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.
|
||||
@@ -603,31 +641,31 @@ 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"
|
||||
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
|
||||
Pushing `server-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.
|
||||
Release named `Hermes-Relay-Server v<version>` for the server/plugin package.
|
||||
|
||||
### CLI / Windows systray release
|
||||
|
||||
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
|
||||
Android and plugin versions do not need to move with it.
|
||||
|
||||
First rewrite `CLI_RELEASE_NOTES.md` for the new CLI release and promote only
|
||||
First rewrite `CLI_RELEASE_NOTES.md` for the new Desktop release and promote only
|
||||
CLI/tray-relevant changelog bullets into the release block. Then:
|
||||
|
||||
```powershell
|
||||
@@ -641,7 +679,7 @@ cd ..
|
||||
|
||||
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
|
||||
desktop/tray/Cargo.toml desktop/tray/Cargo.lock CHANGELOG.md CLI_RELEASE_NOTES.md
|
||||
git commit -m "release(cli): cli-v0.4.0-alpha.2"
|
||||
git commit -m "release(desktop): desktop-v0.4.0-alpha.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
@@ -651,8 +689,8 @@ git pull --ff-only origin main
|
||||
cd desktop
|
||||
npm run check:version-sync -- --expect 0.4.0-alpha.2
|
||||
cd ..
|
||||
git tag cli-v0.4.0-alpha.2
|
||||
git push origin cli-v0.4.0-alpha.2
|
||||
git tag desktop-v0.4.0-alpha.2
|
||||
git push origin desktop-v0.4.0-alpha.2
|
||||
```
|
||||
|
||||
The tag workflow rejects version drift and tags whose commit is not in
|
||||
@@ -768,7 +806,8 @@ plugin changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Validates the tag matches `appVersionName` in
|
||||
1. Verifies the stable tag resolves to a commit contained in `main` and that the
|
||||
tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
@@ -778,30 +817,35 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
|
||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||
googlePlay AAB are attached (see §Release assets).
|
||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
6. Promotes the exact preflighted Production draft to `completed`; a missing
|
||||
credential or rejected Play edit fails before public GitHub publication.
|
||||
7. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
|
||||
|
||||
On every push of a tag matching `plugin-v*`,
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
|
||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`.
|
||||
1. Verifies the tag commit is contained in `main`, validates the tag against
|
||||
all server/plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`, and requires the matching release
|
||||
heading in `CHANGELOG.md`.
|
||||
2. Runs plugin 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*`,
|
||||
On every push of a tag matching `desktop-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
|
||||
`desktop-v0.3.0`) so the install/pin commands stay accurate. It rejects tags
|
||||
whose commit is not contained in `main`, whose version differs from
|
||||
`desktop/package.json`, or whose version has no `CHANGELOG.md` release heading.
|
||||
Fill its Summary and
|
||||
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||
Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
@@ -816,19 +860,28 @@ 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) |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | Stable Play submission | 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.
|
||||
Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
|
||||
the Production draft and the tag workflow promotes that exact version code to
|
||||
`completed`. The workflow does not fall back to manual upload or publish GitHub
|
||||
first. With Play Managed Publishing off, an approved release publishes
|
||||
automatically; with it on, Play holds the approved change for an operator action
|
||||
that the Developer API does not expose.
|
||||
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unreleased work from `dev`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
When production has a bug, use the same invariant for every surface:
|
||||
|
||||
1. Branch from the affected immutable `android-v*`, `server-v*`, or `desktop-v*`
|
||||
production tag, never from the moving `main` or `dev` branch.
|
||||
2. Make the smallest safe fix and add focused verification.
|
||||
3. Bump only the affected surface's patch version and release notes.
|
||||
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
|
||||
5. Tag the new `main` tip with the affected surface's patch tag.
|
||||
6. Verify the artifacts and production rollout or deployment.
|
||||
7. Merge `main` back into `dev` immediately so integration inherits the fix and
|
||||
version history.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
@@ -842,17 +895,23 @@ For an Android app hotfix:
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Upload to Play Console as normal.
|
||||
7. Verify the automated Play submission, GitHub artifacts, and rollout.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`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
|
||||
For a Server hotfix, branch from the affected `server-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
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
|
||||
`main` back to `dev`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
For a Desktop hotfix, branch from the affected `desktop-v*` tag, update only
|
||||
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
|
||||
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
|
||||
then merge `main` back to `dev`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
|
||||
+15
-10
@@ -1,30 +1,35 @@
|
||||
# Hermes-Relay-Android v1.4.6
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
|
||||
**Release Date:** July 15, 2026
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.4.6-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/sideload) for installation help.
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
|
||||
|
||||
## Summary
|
||||
|
||||
This patch keeps Server default aligned with Hermes' active profile, adds per-profile icon import and organization controls, and prevents the session drawer from mixing profile databases.
|
||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
||||
|
||||
## Added
|
||||
|
||||
- Reorder or hide profiles per connection without changing server configuration.
|
||||
- Choose a profile icon from the Android file picker or import `avatar.png`/`profile.jpg` from a paired Relay host.
|
||||
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
|
||||
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
|
||||
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Server default resolves Hermes' sticky active profile before Gateway session create/resume and dashboard session operations, keeping the agent, drawer, transcript, and writes in one profile database.
|
||||
- Host image import distinguishes an outdated Relay from a genuinely missing avatar and presents **Choose file** as a reliable fallback.
|
||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
||||
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
|
||||
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
|
||||
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
|
||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.4.6** (versionCode **29**).
|
||||
- App version: **1.5.0** (versionCode **33**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -6,6 +6,85 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
`https://hermes-relay.dev/docs/`, but Android 1.4.0 and earlier releases still
|
||||
contain hardcoded `https://codename-11.github.io/hermes-relay/` links. GitHub
|
||||
Pages therefore serves a redirect-only compatibility shim from
|
||||
`legacy-pages-redirect/`; it must never regain full documentation content.
|
||||
|
||||
Retire the shim only after the first Android release containing merge commit
|
||||
`52df3adbf6d61d0ddbfb69671546f7c4953f956a` has been available for at least
|
||||
90 days **and** at least two Android releases containing the corrected links
|
||||
have shipped. If either condition is unmet at review time, retain it and set a
|
||||
new review date.
|
||||
|
||||
Removal checklist:
|
||||
|
||||
- Remove `.github/workflows/legacy-docs-redirect.yml` and
|
||||
`legacy-pages-redirect/` through a reviewed PR.
|
||||
- Delete/disable the repository Pages site after that PR merges.
|
||||
- Verify `https://codename-11.github.io/hermes-relay/` no longer serves the
|
||||
shim and `https://hermes-relay.dev/docs/` plus representative deep links
|
||||
still return HTTP 200.
|
||||
- Update `DEVLOG.md` and the canonical Obsidian Hermes-Relay project note.
|
||||
|
||||
A one-shot operator reminder is scheduled for **2026-10-15 at 09:00 ET** to
|
||||
review these gates; it is a review trigger, not authorization for automatic
|
||||
removal.
|
||||
|
||||
---
|
||||
|
||||
## Upstream impact certification follow-ups (2026-07-19)
|
||||
|
||||
The client/plugin implementation batch for queued recovery,
|
||||
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
|
||||
trust, and retained bootstrap async safety is implemented. The following gates
|
||||
intentionally remain outside that code batch:
|
||||
|
||||
- **Image-generation lifecycle while tool progress is hidden.** The upstream
|
||||
TUI gateway suppresses every `tool.start` / `tool.complete` event when
|
||||
`display.tool_progress` is off, so a client cannot distinguish an active
|
||||
`image_generate` turn from generic model work. Propose a narrow upstream
|
||||
exception that always emits the lifecycle for `image_generate` while leaving
|
||||
unrelated tool diagnostics hidden. Android already treats that lifecycle as
|
||||
presentation state rather than a generic tool card and keeps the diffusion
|
||||
canvas visible when its local tool display is off.
|
||||
- Run `docs/upstream-compatibility-certification.md` against an approved test
|
||||
gateway with real provider calls and an Android device. Include concurrent
|
||||
model/image routing, turn isolation off/on, queued reconnect, same-profile
|
||||
background-completion ownership, compression lineage, and the explicitly
|
||||
approved restart case. Static upstream fixtures are necessary but do not
|
||||
prove device or restart behavior.
|
||||
- Upstream the atomic one-turn model arm/submit contract proposed in
|
||||
`docs/upstream-contributions.md`. Until then, document the narrow race where a
|
||||
disconnect or Stop after `/model --once` succeeds but before prompt submission
|
||||
can leave the override armed for a later prompt.
|
||||
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
|
||||
exposes a reset on the active gateway session or an authoritative reset event.
|
||||
`slash.exec` runs the command in a separate worker today, and the mirrored
|
||||
slash side effects do not reset the active TUI session's agent. Relay must not
|
||||
clear local model, reasoning, or Fast pins from a successful command response
|
||||
that did not mutate the agent those controls describe.
|
||||
- Keep profile-scoped cron execution attempts blocked on the public upstream API
|
||||
proposed in `docs/upstream-contributions.md`. The first-class interim
|
||||
assistant event is no longer blocked: Relay Android and desktop consume
|
||||
upstream `message.interim` / `response_previewed`.
|
||||
- Keep Standard voice labeled host-global until upstream exposes a stable
|
||||
profile/per-request audio contract; do not emulate it through Relay on the
|
||||
vanilla path.
|
||||
- Keep provider exclusion/disable filtering out of Android Manage until the
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
checkout and the desktop typed gateway renderer/tests.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||
|
||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||
@@ -615,13 +694,12 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
gated to pre-first-token. Deferred:
|
||||
|
||||
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
|
||||
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
|
||||
the final Markdown renderer during generation while the active tail stays raw.
|
||||
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
|
||||
final parse because partial CommonMark containers can re-parent earlier blocks.
|
||||
Verify that the chosen boundary removes the common heading/prose pop without
|
||||
introducing partial-fence or list flicker.
|
||||
- **Streaming↔final render parity — live reflow check remains.** Blank-terminated,
|
||||
unambiguous top-level prose/headings use the final Markdown renderer during
|
||||
generation while the active tail stays lightweight. Completion intentionally
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
@@ -643,13 +721,12 @@ gated to pre-first-token. Deferred:
|
||||
kebab). Needs on-device confirmation of the current conflict first.
|
||||
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
|
||||
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
|
||||
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
|
||||
new ids → every visible bubble replays its enter animation (gateway transport,
|
||||
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
|
||||
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
|
||||
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
|
||||
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
|
||||
isn't scrolled away. Largely neutralized once render parity removes the height delta.
|
||||
- **Sessions per-turn reconciliation.** Current upstream includes assistant/tool
|
||||
rows in `run.completed.messages`, but Android still uses a full profile-aware
|
||||
history read for successful Sessions turns so older servers and persisted message
|
||||
boundaries remain safe. Replace it only with a bounded partial-turn merge that
|
||||
preserves the prior transcript and client-only fields, with a full-history fallback
|
||||
when the completion payload is absent or incomplete.
|
||||
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
|
||||
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
|
||||
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
|
||||
@@ -1042,7 +1119,7 @@ Things to look into:
|
||||
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
|
||||
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
@@ -326,8 +326,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Local device-review helper. Never runs in or ships with the application APK. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConnectionReviewSeedTest {
|
||||
|
||||
@Test
|
||||
fun seedOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.none { it.id == REVIEW_ID }) {
|
||||
store.addConnection(
|
||||
Connection(
|
||||
id = REVIEW_ID,
|
||||
label = "Lab NAS",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
|
||||
dashboardUrl = "http://192.0.2.10:9119",
|
||||
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.any { it.id == REVIEW_ID }) {
|
||||
store.removeConnection(REVIEW_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -121,42 +121,39 @@ class OnboardingFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
fun connectPage_showsNearbyFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Vanilla Hermes")
|
||||
.onNodeWithText("Enter address instead")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_showsApiFields() {
|
||||
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.onNodeWithText("Hermes address")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
fun manualSetup_findButton_isShown() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
.onNodeWithText("Find Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Other connection methods").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
|
||||
|
||||
/** Debug-build-only deterministic host for design QA screenshots. */
|
||||
class VoiceSettingsDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSettingsDesignQaScene() {
|
||||
val provider = remember {
|
||||
RealtimeProviderInfo(
|
||||
id = "xai_tts",
|
||||
name = "xAI Grok TTS",
|
||||
status = "ready",
|
||||
models = listOf("grok-tts", "grok-tts-fast"),
|
||||
voices = listOf("eve", "ara", "sal", "rex", "leo"),
|
||||
model_labels = mapOf("grok-tts" to "Grok TTS"),
|
||||
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
|
||||
recommended_voices = listOf("eve", "ara"),
|
||||
supports_tts = true,
|
||||
)
|
||||
}
|
||||
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
|
||||
var selectedVoice by remember { mutableStateOf("eve") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val allVoices = remember {
|
||||
listOf(
|
||||
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
|
||||
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
|
||||
VoiceChoice("sal", "Sal", "Calm · grounded"),
|
||||
VoiceChoice("rex", "Rex", "Direct · confident"),
|
||||
VoiceChoice("leo", "Leo", "Bright · conversational"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
VoiceSettingsTabs(selectedSection) { selectedSection = it }
|
||||
VoiceProviderGroupCard(
|
||||
provider = provider,
|
||||
providerValue = provider.id,
|
||||
enabled = true,
|
||||
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
|
||||
onEnabledChange = {},
|
||||
onProviderChange = {},
|
||||
controlsEnabled = true,
|
||||
)
|
||||
ModelAndVoiceGroupCard(
|
||||
modelValue = "grok-tts",
|
||||
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
|
||||
voices = previewVoiceChoices(allVoices, selectedVoice),
|
||||
allVoices = allVoices,
|
||||
selectedVoice = selectedVoice,
|
||||
previewState = VoicePreviewUiState(
|
||||
selectionKey = "voice:eve",
|
||||
isPlaying = true,
|
||||
amplitude = 0.42f,
|
||||
),
|
||||
onModelChange = {},
|
||||
onVoiceChange = { selectedVoice = it },
|
||||
onPreviewVoice = {},
|
||||
enabled = true,
|
||||
)
|
||||
LanguageQualityCard(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
language = "English",
|
||||
languages = listOf(VoiceChoice("en", "English")),
|
||||
onLanguageChange = {},
|
||||
sampleRate = "24000",
|
||||
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
|
||||
onSampleRateChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile. Reorder or hide profiles per connection, choose an icon from your phone, or import avatar.png/profile.jpg from an updated paired Relay. Image import now clearly distinguishes an outdated Relay from a missing avatar.
|
||||
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
|
||||
@@ -1 +1 @@
|
||||
“服务器默认”现在会让代理、聊天、会话抽屉和记录保持在 Hermes 当前活动配置文件中。可按连接重新排序或隐藏配置文件,从手机选择图标,或从已更新且配对的 Relay 导入 avatar.png/profile.jpg。图像导入也会明确区分 Relay 版本过旧与头像文件缺失。
|
||||
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Voice that keeps pace",
|
||||
"date": "2026-07-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A clearer voice studio",
|
||||
"bullets": [
|
||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Natural streaming speech",
|
||||
"bullets": [
|
||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More upstream-aware controls",
|
||||
"bullets": [
|
||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.9",
|
||||
"title": "Clearer Hermes connections",
|
||||
"date": "2026-07-19",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Dashboard-first setup",
|
||||
"bullets": [
|
||||
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
|
||||
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Consistent identity",
|
||||
"bullets": [
|
||||
"Server default now displays Hermes' pinned active profile consistently across the app.",
|
||||
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.8",
|
||||
"title": "Privacy policy restored",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Google Play compliance",
|
||||
"bullets": [
|
||||
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
|
||||
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.7",
|
||||
"title": "Smoother replies, more languages",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Smooth streaming",
|
||||
"bullets": [
|
||||
"Long replies grow at a display-paced cadence and stay anchored at the newest text through completion.",
|
||||
"Scrolling into history preserves your reading position instead of forcing the conversation back to the bottom."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More languages",
|
||||
"bullets": [
|
||||
"Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.",
|
||||
"Catalog freshness validation keeps every shipped translation aligned with the canonical English resources."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.6",
|
||||
"title": "Profiles stay together",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
v1.4.6 - Profiles stay together
|
||||
v1.5.0 - Voice that keeps pace
|
||||
|
||||
Profile continuity
|
||||
* Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile.
|
||||
* Reorder or hide profiles per connection without changing the server.
|
||||
|
||||
Profile icons
|
||||
* Choose an image file on your phone or import avatar.png/profile.jpg from an updated paired Relay.
|
||||
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
|
||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.notifications.InteractionRequestNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// Action-required notifications are durable across process death.
|
||||
// Once the authenticated chat surface is visible it owns presentation;
|
||||
// unresolved asks are re-posted if the app returns to the background.
|
||||
InteractionRequestNotifier.cancelAll(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-
|
||||
|
||||
@@ -31,7 +31,16 @@ object AgentDisplay {
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
serverDefaultProfileName: String? = null,
|
||||
): Profile? {
|
||||
selectedProfile?.let { return it }
|
||||
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
|
||||
return resolvedServerDefault
|
||||
?.let { activeName ->
|
||||
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
|
||||
}
|
||||
?: 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
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.util.Locale
|
||||
enum class AppLanguage(val languageTag: String) {
|
||||
SYSTEM_DEFAULT(""),
|
||||
ENGLISH("en"),
|
||||
GERMAN("de"),
|
||||
BRAZILIAN_PORTUGUESE("pt-BR"),
|
||||
JAPANESE("ja"),
|
||||
SIMPLIFIED_CHINESE("zh-Hans"),
|
||||
SPANISH("es"),
|
||||
;
|
||||
@@ -27,8 +30,11 @@ enum class AppLanguage(val languageTag: String) {
|
||||
val locale = Locale.forLanguageTag(primaryTag)
|
||||
|
||||
return when (locale.language.lowercase(Locale.ROOT)) {
|
||||
"de" -> GERMAN
|
||||
"en" -> ENGLISH
|
||||
"es" -> SPANISH
|
||||
"ja" -> JAPANESE
|
||||
"pt" -> BRAZILIAN_PORTUGUESE
|
||||
"zh" -> {
|
||||
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
|
||||
locale.script.isEmpty() ||
|
||||
|
||||
@@ -129,6 +129,19 @@ data class ChatMessage(
|
||||
* the live message can be matched to its server row.
|
||||
*/
|
||||
val backgroundTask: BackgroundTaskState? = null,
|
||||
/**
|
||||
* Stable identity for Compose list rendering.
|
||||
*
|
||||
* Gateway/user rows start with client UUIDs, then post-turn history
|
||||
* reconciliation adopts the server message id into [id]. That server-id
|
||||
* adoption must not make a visible bubble look removed and reinserted to
|
||||
* LazyColumn: doing so discards its scroll anchor, which is especially
|
||||
* disruptive when the row is a long answer occupying the viewport.
|
||||
*
|
||||
* New rows default to their current [id]. Reconciled rows retain this key
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Pure, persisted-state-derived availability for a Hermes connection.
|
||||
*
|
||||
* This deliberately describes configured surfaces, not live reachability or
|
||||
* authentication. Runtime layers can combine it with their probe/auth state
|
||||
* without treating a missing optional API server or Relay as a broken Hermes
|
||||
* connection.
|
||||
*/
|
||||
data class ConnectionCapabilities(
|
||||
val dashboardGatewayConfigured: Boolean,
|
||||
val apiServerConfigured: Boolean,
|
||||
val relayConfigured: Boolean,
|
||||
) {
|
||||
val gatewayChatAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val manageAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val standardVoiceAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val apiChatFallbackAvailable: Boolean get() = apiServerConfigured
|
||||
val relayFeaturesAvailable: Boolean get() = relayConfigured
|
||||
val chatConfigured: Boolean get() = gatewayChatAvailable || apiChatFallbackAvailable
|
||||
val anySurfaceConfigured: Boolean
|
||||
get() = dashboardGatewayConfigured || apiServerConfigured || relayConfigured
|
||||
}
|
||||
|
||||
val Connection.capabilities: ConnectionCapabilities
|
||||
get() = ConnectionCapabilities(
|
||||
dashboardGatewayConfigured = resolvedDashboardUrl.isNotBlank(),
|
||||
apiServerConfigured = apiServerUrl.isNotBlank(),
|
||||
relayConfigured = relayUrl.isNotBlank(),
|
||||
)
|
||||
@@ -13,13 +13,16 @@ data class DashboardConnectionStatus(
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val gatewayMode: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
* Each connection has its own:
|
||||
* - API server URL + relay URL
|
||||
* - One or more independently-configured Hermes surfaces. Dashboard/Gateway
|
||||
* is the standard primary path; API server and Relay are optional.
|
||||
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||
* session token, device ID, API key, and paired-session metadata.
|
||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||
@@ -73,17 +76,34 @@ data class Connection(
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
/** Last time the user explicitly selected this connection. */
|
||||
val lastUsedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
val transportHint: String? = null,
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
/**
|
||||
* Effective Dashboard/Gateway endpoint. Legacy records did not persist a
|
||||
* dashboard URL, so they retain the conventional same-host `:9119`
|
||||
* derivation from the API server. Dashboard-only records persist an
|
||||
* explicit URL and may leave [apiServerUrl] and [relayUrl] blank.
|
||||
*/
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
/** Stable display/host identity that does not depend on the API surface. */
|
||||
val primaryEndpointUrl: String
|
||||
get() = resolvedDashboardUrl.takeIf { it.isNotBlank() }
|
||||
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: relayUrl.trim()
|
||||
|
||||
val primaryHost: String
|
||||
get() = extractHost(primaryEndpointUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -94,6 +114,8 @@ data class Connection(
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
const val DEFAULT_API_PORT: Int = 8642
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
@@ -111,12 +133,40 @@ data class Connection(
|
||||
* user typed a malformed value — better to show something recognizable
|
||||
* than to crash).
|
||||
*/
|
||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
||||
return try {
|
||||
URI(apiServerUrl).host ?: apiServerUrl
|
||||
} catch (_: Exception) {
|
||||
apiServerUrl
|
||||
}
|
||||
fun extractDefaultLabel(apiServerUrl: String): String =
|
||||
extractHost(apiServerUrl) ?: apiServerUrl
|
||||
|
||||
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
|
||||
fun chooseDiscoveredLabel(
|
||||
currentLabel: String,
|
||||
primaryHost: String,
|
||||
discoveredHostname: String?,
|
||||
): String {
|
||||
val current = currentLabel.trim()
|
||||
val discovered = discoveredHostname?.trim()?.takeIf { it.isNotBlank() }
|
||||
val isAutomatic = current.isBlank() || current.equals(primaryHost.trim(), ignoreCase = true)
|
||||
return if (isAutomatic && discovered != null) discovered else currentLabel
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-first label for a connection whose surfaces are optional.
|
||||
* The one-argument overload above remains for source compatibility.
|
||||
*/
|
||||
fun extractDefaultLabel(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): String {
|
||||
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: relayUrl.trim()
|
||||
return extractHost(primary) ?: primary
|
||||
}
|
||||
|
||||
private fun extractHost(url: String): String? = try {
|
||||
URI(url).host
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
@@ -141,6 +191,29 @@ data class Connection(
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
|
||||
fun deriveDefaultApiUrl(
|
||||
dashboardUrl: String,
|
||||
apiPort: Int = DEFAULT_API_PORT,
|
||||
): String? {
|
||||
val trimmed = dashboardUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$apiPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
@@ -150,7 +223,7 @@ data class Connection(
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
relayPort: Int = DEFAULT_RELAY_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
@@ -199,7 +272,7 @@ data class Connection(
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
"${it.role.lowercase()}|${it.routeAuthority()}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
@@ -222,13 +295,13 @@ data class Connection(
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.mapNotNull { it.mergeAuthority() }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
@@ -296,6 +369,85 @@ data class Connection(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
* its dashboard-enriched replacement still collide. Dashboard-only
|
||||
* candidates fall back to their primary route authority.
|
||||
*/
|
||||
private fun EndpointCandidate.mergeAuthority(): String? =
|
||||
api?.let { endpoint -> "api|${endpoint.host.lowercase()}:${endpoint.port}" }
|
||||
?: routeAuthority()?.let { authority -> "route|$authority" }
|
||||
|
||||
/**
|
||||
* Build a Dashboard/Gateway-primary route from a remote host or URL.
|
||||
* API and Relay are retained only when explicitly configured; callers
|
||||
* no longer need to invent an API key or legacy surface URL.
|
||||
*/
|
||||
fun endpointCandidateFromDashboardUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
dashboardUrl: String,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val normalizedDashboard = normalizeDashboardUrlInput(dashboardUrl)
|
||||
val dashboardUri = runCatching { URI(normalizedDashboard) }.getOrNull() ?: return null
|
||||
if (dashboardUri.scheme?.lowercase() !in setOf("http", "https") ||
|
||||
dashboardUri.host.isNullOrBlank()
|
||||
) return null
|
||||
|
||||
val api = apiServerUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { apiUrl ->
|
||||
val apiUri = runCatching { URI(apiUrl.trimEnd('/')) }.getOrNull()
|
||||
?: return@let null
|
||||
val tls = when (apiUri.scheme?.lowercase()) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return@let null
|
||||
}
|
||||
val host = apiUri.host?.takeIf { it.isNotBlank() } ?: return@let null
|
||||
ApiEndpoint(host, if (apiUri.port > 0) apiUri.port else 8642, tls)
|
||||
}
|
||||
val relay = relayUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { url ->
|
||||
val hint = when {
|
||||
url.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
url.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
RelayEndpoint(url, hint)
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(normalizedDashboard) },
|
||||
priority = priority,
|
||||
dashboard = DashboardEndpoint(normalizedDashboard),
|
||||
api = api,
|
||||
relay = relay,
|
||||
)
|
||||
}
|
||||
|
||||
fun normalizeDashboardUrlInput(
|
||||
raw: String,
|
||||
defaultPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): 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
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
|
||||
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
|
||||
class ConnectionStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val context: Context?,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
|
||||
constructor(context: Context) : this(
|
||||
dataStore = context.relayDataStore,
|
||||
context = context.applicationContext,
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
|
||||
internal constructor(dataStore: DataStore<Preferences>) : this(
|
||||
dataStore = dataStore,
|
||||
context = null,
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
internal constructor(
|
||||
dataStore: DataStore<Preferences>,
|
||||
scope: CoroutineScope,
|
||||
) : this(dataStore = dataStore, context = null, scope = scope)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Optional cold-start pin. `null` means restore the last connection the
|
||||
* user actively selected, which remains the recommended default.
|
||||
*/
|
||||
private val _startupConnectionId = MutableStateFlow<String?>(null)
|
||||
val startupConnectionId: StateFlow<String?> = _startupConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
|
||||
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
||||
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
||||
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
|
||||
val startupId = prefs[KEY_STARTUP_CONNECTION_ID]
|
||||
|
||||
// Prefer the new key. If absent and the old key has data,
|
||||
// migrate it once: write to the new key and clear the old ones
|
||||
@@ -143,15 +157,21 @@ class ConnectionStore private constructor(
|
||||
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
}
|
||||
}
|
||||
_connections.value = decodeConnections(oldJson)
|
||||
_activeConnectionId.value = activeOld
|
||||
val restored = decodeConnections(oldJson)
|
||||
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||
_connections.value = restored
|
||||
_startupConnectionId.value = validStartupId
|
||||
_activeConnectionId.value = validStartupId ?: activeOld
|
||||
Log.i(
|
||||
TAG,
|
||||
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
||||
)
|
||||
} else {
|
||||
_connections.value = decodeConnections(newJson)
|
||||
_activeConnectionId.value = activeNew
|
||||
val restored = decodeConnections(newJson)
|
||||
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||
_connections.value = restored
|
||||
_startupConnectionId.value = validStartupId
|
||||
_activeConnectionId.value = validStartupId ?: activeNew
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
_startupConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
_startupConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
startupConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
val normalizedStartupId = startupConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
if (normalizedStartupId == null) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_STARTUP_CONNECTION_ID] = normalizedStartupId
|
||||
}
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
|
||||
_startupConnectionId.value = normalizedStartupId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
|
||||
suspend fun setActiveConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (current.any { it.id == id }) {
|
||||
val next = current.map { connection ->
|
||||
if (connection.id == id) {
|
||||
connection.copy(lastUsedAt = System.currentTimeMillis())
|
||||
} else {
|
||||
connection
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_activeConnectionId.value = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a specific cold-start connection, or `null` to restore last used. */
|
||||
suspend fun setStartupConnection(id: String?) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val validId = id?.takeIf { candidate ->
|
||||
decodeConnections(prefs[KEY_CONNECTIONS]).any { it.id == candidate }
|
||||
}
|
||||
if (validId == null) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
_activeConnectionId.value?.let { activeId ->
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = activeId
|
||||
}
|
||||
} else {
|
||||
prefs[KEY_STARTUP_CONNECTION_ID] = validId
|
||||
}
|
||||
_startupConnectionId.value = validId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update just the `lastActiveSessionId` on the identified connection.
|
||||
* Called whenever the user picks a chat session so connection-switch can
|
||||
@@ -504,6 +571,7 @@ class ConnectionStore private constructor(
|
||||
|
||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
||||
private val KEY_STARTUP_CONNECTION_ID = stringPreferencesKey("startup_connection_id")
|
||||
|
||||
// Pre-rename DataStore keys — read once in init on first launch after
|
||||
// the rename, then wiped. See the init block above.
|
||||
|
||||
@@ -52,6 +52,45 @@ object ConnectionValidation {
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
|
||||
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "Dashboard URL",
|
||||
)
|
||||
|
||||
/** A blank API server means the optional SSE fallback is not configured. */
|
||||
fun validateOptionalApiServerUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "API server URL",
|
||||
)
|
||||
|
||||
/** A blank Relay URL means Relay-only power features are not configured. */
|
||||
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("ws", "wss"),
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate the independently optional connection surfaces. A connection
|
||||
* needs at least one endpoint, but Dashboard-only, API-only, and
|
||||
* Relay-only records are all structurally valid.
|
||||
*/
|
||||
fun validateConnectionEndpoints(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): String? {
|
||||
if (dashboardUrl.isNullOrBlank() && apiServerUrl.isBlank() && relayUrl.isBlank()) {
|
||||
return "Configure at least one Hermes endpoint"
|
||||
}
|
||||
return validateDashboardUrl(dashboardUrl.orEmpty())
|
||||
?: validateOptionalApiServerUrl(apiServerUrl)
|
||||
?: validateOptionalRelayUrl(relayUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches the "added the same server twice" mistake. Matches when the
|
||||
* candidate's api + relay URLs exactly match an existing connection
|
||||
@@ -67,12 +106,33 @@ object ConnectionValidation {
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
excludeId: String? = null,
|
||||
dashboardUrl: String? = null,
|
||||
): Connection? = connections.firstOrNull { c ->
|
||||
c.id != excludeId &&
|
||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
||||
if (c.id == excludeId) {
|
||||
false
|
||||
} else {
|
||||
val legacyExactMatch =
|
||||
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
|
||||
urlsEqual(c.apiServerUrl, apiServerUrl) &&
|
||||
urlsEqual(c.relayUrl, relayUrl)
|
||||
val candidateDashboard = dashboardUrl
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val dashboardMatch = !candidateDashboard.isNullOrBlank() &&
|
||||
urlsEqual(c.resolvedDashboardUrl, candidateDashboard)
|
||||
legacyExactMatch || dashboardMatch
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateOptionalUrl(
|
||||
raw: String,
|
||||
allowedSchemes: Set<String>,
|
||||
kind: String,
|
||||
): String? = if (raw.isBlank()) null else validateUrl(raw, allowedSchemes, kind)
|
||||
|
||||
private fun urlsEqual(first: String, second: String): Boolean =
|
||||
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
|
||||
|
||||
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
||||
|
||||
@@ -72,10 +72,11 @@ class DataManager(
|
||||
* - 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.
|
||||
* - v6 (2026-07-19): preserves the optional pinned startup connection.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 5,
|
||||
val version: Int = 6,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
@@ -83,6 +84,7 @@ class DataManager(
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val activeConnectionId: String? = null,
|
||||
val startupConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
@@ -160,6 +162,7 @@ class DataManager(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
startupConnectionId = connectionStore?.startupConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
@@ -173,6 +176,7 @@ class DataManager(
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
startupConnectionId = backup.startupConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||
@@ -38,8 +39,10 @@ import kotlinx.serialization.Serializable
|
||||
data class EndpointCandidate(
|
||||
val role: String,
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
|
||||
val api: ApiEndpoint? = null,
|
||||
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
|
||||
val relay: RelayEndpoint? = null,
|
||||
val dashboard: DashboardEndpoint? = null,
|
||||
val proxy: ProxyEndpoint? = null,
|
||||
val security: String? = null,
|
||||
@@ -137,13 +140,42 @@ fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> if (api.tls) "HTTPS" else "Public"
|
||||
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
|
||||
"HTTPS"
|
||||
} else {
|
||||
"Public"
|
||||
}
|
||||
"https" -> "HTTPS"
|
||||
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
|
||||
fun EndpointCandidate.primaryRouteUrl(): String? =
|
||||
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: api?.url
|
||||
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Stable host/port identity without assuming that an API surface exists. */
|
||||
fun EndpointCandidate.routeAuthority(): String? {
|
||||
val rawUrl = primaryRouteUrl() ?: return null
|
||||
val httpUrl = when {
|
||||
rawUrl.startsWith("ws://", ignoreCase = true) -> "http://${rawUrl.substringAfter("://")}"
|
||||
rawUrl.startsWith("wss://", ignoreCase = true) -> "https://${rawUrl.substringAfter("://")}"
|
||||
else -> rawUrl
|
||||
}
|
||||
val uri = runCatching { URI(httpUrl) }.getOrNull() ?: return null
|
||||
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = when {
|
||||
uri.port > 0 -> uri.port
|
||||
uri.scheme.equals("https", ignoreCase = true) -> 443
|
||||
else -> 80
|
||||
}
|
||||
return "$host:$port"
|
||||
}
|
||||
|
||||
fun EndpointCandidate.hasSecureProxy(): Boolean =
|
||||
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
|
||||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
@@ -325,17 +326,18 @@ class ConnectionManager(
|
||||
// the synthesized list just collapses to the same URL anyway.
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: url
|
||||
val resolvedRelayUrl = resolved?.relay?.url?.takeIf { it.isNotBlank() }
|
||||
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
||||
"relay=${resolved.relay.url} (fallback would have been $url)")
|
||||
"route=${resolved.primaryRouteUrl()} (relay fallback would have been $url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.relay.url,
|
||||
url = resolved.primaryRouteUrl(),
|
||||
)
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
@@ -348,7 +350,11 @@ class ConnectionManager(
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
if (targetUrl != null) {
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else {
|
||||
Log.d(TAG, "connect: selected route has no Relay surface; route published for HTTP/Gateway clients")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,10 +662,11 @@ class ConnectionManager(
|
||||
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||
// the swap path never re-checked it.)
|
||||
if (!shouldReconnect) return@launch
|
||||
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
|
||||
val relayUrl = resolved.relay?.url?.takeIf { it.isNotBlank() } ?: return@launch
|
||||
val normalizedNew = normalizeRelayUrl(relayUrl)
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
||||
connectToUrlOnMainPath(relayUrl, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
reconnectGate()
|
||||
) {
|
||||
|
||||
@@ -135,6 +135,91 @@ class RelayHttpClient(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivitySnapshot(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val profile: String,
|
||||
val activities: List<ImageActivity> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageActivity(
|
||||
@SerialName("call_id") val callId: String,
|
||||
@SerialName("tool_name") val toolName: String,
|
||||
val state: String,
|
||||
@SerialName("started_at") val startedAt: Double,
|
||||
@SerialName("completed_at") val completedAt: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Poll the optional Relay image lifecycle bridge. A null success means the
|
||||
* connected Relay predates the endpoint; callers should stop polling and
|
||||
* continue using native Gateway events without surfacing an error.
|
||||
*/
|
||||
suspend fun fetchImageActivity(
|
||||
profile: String,
|
||||
sessionId: String,
|
||||
sinceEpochSeconds: Double,
|
||||
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = try {
|
||||
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("profile", profile)
|
||||
.addQueryParameter("session_id", sessionId)
|
||||
.addQueryParameter("since", sinceEpochSeconds.toString())
|
||||
.build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val activityClient = okHttpClient.newBuilder()
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
try {
|
||||
activityClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Image activity request failed (HTTP ${response.code})")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Empty response body"))
|
||||
}
|
||||
Result.success(
|
||||
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
|
||||
* [Result] — success carries a [FetchedMedia], failure wraps the
|
||||
@@ -615,6 +700,14 @@ class RelayHttpClient(
|
||||
val capabilities: List<String> = emptyList(),
|
||||
val profiles: List<RelayProfileInfo> = emptyList(),
|
||||
val health: String = "unknown",
|
||||
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayHeartbeat(
|
||||
val status: String = "missing",
|
||||
val supported: Boolean = false,
|
||||
@SerialName("age_seconds") val ageSeconds: Int? = null,
|
||||
)
|
||||
|
||||
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||
|
||||
@@ -834,6 +834,11 @@ class RelayVoiceClient(
|
||||
suspend fun runVoiceOutput(
|
||||
text: String,
|
||||
renderMode: String? = "verbatim",
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
onEvent: (RealtimeVoiceEvent) -> Unit,
|
||||
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
|
||||
@@ -844,7 +849,15 @@ class RelayVoiceClient(
|
||||
return@withContext Result.failure(missingAuthError())
|
||||
}
|
||||
|
||||
val sessionResult = createVoiceOutputSession(httpBase, token)
|
||||
val sessionResult = createVoiceOutputSession(
|
||||
httpBase = httpBase,
|
||||
token = token,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
|
||||
}
|
||||
@@ -1261,8 +1274,10 @@ class RelayVoiceClient(
|
||||
inputSampleRate: Int = 16_000,
|
||||
chatSessionId: String? = null,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
@@ -1290,8 +1305,10 @@ class RelayVoiceClient(
|
||||
token = token,
|
||||
chatSessionId = chatSessionId,
|
||||
conversationContext = conversationContext,
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
if (sessionResult.isFailure) {
|
||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||
@@ -2658,17 +2675,25 @@ class RelayVoiceClient(
|
||||
token: String,
|
||||
chatSessionId: String?,
|
||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
): Result<RealtimeSessionResponse> {
|
||||
val body = buildJsonObject {
|
||||
putProfile()
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("provider", JsonPrimitive(it))
|
||||
}
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("model", JsonPrimitive(it))
|
||||
}
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("voice", JsonPrimitive(it))
|
||||
}
|
||||
sampleRate?.takeIf { it > 0 }?.let {
|
||||
put("sample_rate", JsonPrimitive(it))
|
||||
}
|
||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||
put("chat_session_id", JsonPrimitive(it))
|
||||
}
|
||||
@@ -2726,8 +2751,23 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildJsonObject { putProfile() }.toString()
|
||||
private fun createVoiceOutputSession(
|
||||
httpBase: String,
|
||||
token: String,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): Result<VoiceOutputSessionResponse> {
|
||||
val body = buildVoiceOutputSessionPayload(
|
||||
profile = currentProfileName(),
|
||||
provider = provider,
|
||||
model = model,
|
||||
voice = voice,
|
||||
sampleRate = sampleRate,
|
||||
language = language,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("$httpBase/voice/output/session")
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
@@ -2970,6 +3010,22 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildVoiceOutputSessionPayload(
|
||||
profile: String?,
|
||||
provider: String? = null,
|
||||
model: String? = null,
|
||||
voice: String? = null,
|
||||
sampleRate: Int? = null,
|
||||
language: String? = null,
|
||||
): String = buildJsonObject {
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
|
||||
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
|
||||
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
|
||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
|
||||
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
|
||||
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Wire shape of `GET /voice/config`. Providers are returned as nested
|
||||
* objects describing the currently-active STT and TTS backend. Extra
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
@@ -56,7 +58,10 @@ data class RouteProbeOutcome(
|
||||
* candidate is reachable we use it; reachability never promotes a lower
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
|
||||
* * **Reachability probe.** Dashboard-first routes use `GET
|
||||
* ${dashboard.url}/api/status`; legacy API routes use `GET
|
||||
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
|
||||
* Each request has a 4-second
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
* transient handoff misses do not pin a good fallback offline.
|
||||
@@ -101,6 +106,12 @@ class EndpointResolver(
|
||||
*/
|
||||
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
|
||||
|
||||
private data class ProbeTarget(
|
||||
val baseUrl: String,
|
||||
val requestUrl: String,
|
||||
val path: String,
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||
@@ -156,13 +167,13 @@ class EndpointResolver(
|
||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Stable cache key for a candidate: `"<role>|<primary host>:<port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
* but hostnames are lowercased — two roles pointing at the same
|
||||
* host:port share reachability state.
|
||||
*/
|
||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
||||
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
|
||||
"${candidate.role}|${candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()}"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,14 +205,14 @@ class EndpointResolver(
|
||||
val winner = raceGroup(group)
|
||||
if (winner != null) {
|
||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||
"api=${winner.api.host}:${winner.api.port} priority=$priority")
|
||||
"route=${winner.primaryRouteUrl()} priority=$priority")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.relay.url,
|
||||
url = winner.primaryRouteUrl(),
|
||||
)
|
||||
return winner
|
||||
}
|
||||
@@ -281,7 +292,7 @@ class EndpointResolver(
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot HEAD /health probe against a candidate. 2-second timeout,
|
||||
* One-shot probe against a candidate's primary configured surface.
|
||||
* no retries — callers that need retry semantics can re-invoke after
|
||||
* the cache expires.
|
||||
*
|
||||
@@ -290,18 +301,19 @@ class EndpointResolver(
|
||||
*/
|
||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
||||
val target = probeTarget(candidate)
|
||||
val url = target?.requestUrl?.toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
|
||||
detail = "Invalid API URL",
|
||||
detail = "No valid Dashboard, API, or Relay URL",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = candidate.primaryRouteUrl(),
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
|
||||
recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
@@ -310,11 +322,14 @@ class EndpointResolver(
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.head()
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
// Hermes API's aiohttp health route accepts GET but returns 405 to
|
||||
// HEAD. That response proves connectivity while the old probe marked
|
||||
// the route unreachable. Health payloads are tiny, so follow the
|
||||
// endpoint's actual public contract on every surface.
|
||||
val request = requestBuilder.get().build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
@@ -331,13 +346,13 @@ class EndpointResolver(
|
||||
title = probeTitle,
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
reachable = ok,
|
||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
||||
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
|
||||
)
|
||||
ok
|
||||
}
|
||||
@@ -346,9 +361,9 @@ class EndpointResolver(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
@@ -359,23 +374,23 @@ class EndpointResolver(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
||||
"route=${target.baseUrl}: ${e.javaClass.simpleName}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
url = target.baseUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
||||
@@ -384,6 +399,50 @@ class EndpointResolver(
|
||||
}
|
||||
}
|
||||
|
||||
/** Choose the standard Dashboard/Gateway surface first when advertised. */
|
||||
private fun probeTarget(candidate: EndpointCandidate): ProbeTarget? {
|
||||
candidate.dashboard?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { base ->
|
||||
return ProbeTarget(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/api/status",
|
||||
path = "/api/status",
|
||||
)
|
||||
}
|
||||
|
||||
candidate.api?.url?.let { base ->
|
||||
return ProbeTarget(
|
||||
baseUrl = base,
|
||||
requestUrl = "$base/health",
|
||||
path = "/health",
|
||||
)
|
||||
}
|
||||
|
||||
candidate.relay?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { relayUrl ->
|
||||
val httpBase = when {
|
||||
relayUrl.startsWith("ws://", ignoreCase = true) ->
|
||||
"http://${relayUrl.substringAfter("://")}"
|
||||
relayUrl.startsWith("wss://", ignoreCase = true) ->
|
||||
"https://${relayUrl.substringAfter("://")}"
|
||||
else -> return null
|
||||
}
|
||||
return ProbeTarget(
|
||||
baseUrl = relayUrl,
|
||||
requestUrl = "$httpBase/health",
|
||||
path = "/health",
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a probe exception to a short, actionable string for the Routes
|
||||
* card. The TLS case is the headline: a route saved with `https://`
|
||||
|
||||
@@ -8,22 +8,29 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class HermesLanDiscoveryResult(
|
||||
val host: String,
|
||||
val hostname: String? = null,
|
||||
val apiUrl: String,
|
||||
val dashboardUrl: String?,
|
||||
val apiReachable: Boolean,
|
||||
val dashboardReachable: Boolean,
|
||||
)
|
||||
) {
|
||||
val displayHost: String
|
||||
get() = hostname ?: host
|
||||
}
|
||||
|
||||
/**
|
||||
* User-triggered local-network discovery for standard Hermes setup.
|
||||
@@ -59,7 +66,9 @@ object HermesLanDiscovery {
|
||||
hosts.map { host ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
probeHost(client, host, apiPort, dashboardPort)
|
||||
probeHost(client, host, apiPort, dashboardPort)?.let { result ->
|
||||
result.copy(hostname = resolveHostname(host))
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
@@ -130,6 +139,24 @@ object HermesLanDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveHostname(address: String): String? =
|
||||
withTimeoutOrNull(350L) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
normalizeResolvedHostname(
|
||||
address = address,
|
||||
resolved = InetAddress.getByName(address).canonicalHostName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun normalizeResolvedHostname(address: String, resolved: String?): String? {
|
||||
val normalized = resolved?.trim()?.trimEnd('.')?.takeIf { it.isNotBlank() } ?: return null
|
||||
if (normalized.equals(address.trim(), ignoreCase = true)) return null
|
||||
if (normalized.equals("localhost", ignoreCase = true)) return null
|
||||
if (normalized.matches(Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$"))) return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||
val lower = body.lowercase()
|
||||
return contentType.contains("json", ignoreCase = true) && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import java.net.URI
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical API base for a selected Hermes profile.
|
||||
*
|
||||
* A dedicated profile API URL remains authoritative when advertised. When
|
||||
* the dashboard has positively identified a shared multiplex gateway and
|
||||
* the selected non-default profile is in its served-profile list, route
|
||||
* through the upstream `/p/<profile>` mirror on the connection's root API
|
||||
* origin. Older/single-profile servers and incomplete topology snapshots
|
||||
* deliberately keep the root URL.
|
||||
*/
|
||||
fun resolveChatBase(
|
||||
profileApiUrl: String?,
|
||||
baseApiUrl: String?,
|
||||
selectedProfileName: String?,
|
||||
gatewayMode: String?,
|
||||
servedProfiles: Collection<String>,
|
||||
): String? {
|
||||
val base = normalize(baseApiUrl)
|
||||
val dedicated = resolveForConnection(profileApiUrl, base)
|
||||
if (dedicated != null) return dedicated
|
||||
|
||||
val profile = selectedProfileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
|
||||
?: return base
|
||||
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
|
||||
servedProfiles.any { it.equals(profile, ignoreCase = false) }
|
||||
if (!isKnownMultiplexProfile) return base
|
||||
|
||||
val root = base?.toHttpUrlOrNull() ?: return base
|
||||
return root.newBuilder()
|
||||
.addPathSegment("p")
|
||||
.addPathSegment(profile)
|
||||
.build()
|
||||
.toString()
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
||||
|
||||
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
|
||||
enum class VoiceSpeechStreamStatus {
|
||||
Completed,
|
||||
Fallback,
|
||||
Stopped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
data class VoiceSpeechStreamOutcome(
|
||||
val status: VoiceSpeechStreamStatus,
|
||||
val audioStarted: Boolean,
|
||||
val error: Throwable? = null,
|
||||
)
|
||||
|
||||
data class VoiceSpeechStreamCallbacks(
|
||||
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
|
||||
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
|
||||
)
|
||||
|
||||
interface VoiceSpeechStream {
|
||||
fun append(text: String)
|
||||
fun finish()
|
||||
fun stop()
|
||||
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral STT/TTS contract. The routing seam between the Standard
|
||||
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
|
||||
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
/**
|
||||
* Open one provider-backed PCM stream for an assistant reply. A null
|
||||
* success means this route has no streaming surface and the caller should
|
||||
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
|
||||
* calls made before the socket opens and report whether any PCM was emitted
|
||||
* so callers never replay already-heard audio during compatibility fallback.
|
||||
*/
|
||||
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
|
||||
Result.success(null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
|
||||
override suspend fun synthesize(text: String): Result<File> =
|
||||
runWithSelectedRoute { it.synthesize(text) }
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
|
||||
client.openSpeechStream(callbacks)
|
||||
}
|
||||
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
|
||||
@@ -1208,10 +1208,14 @@ class ChatHandler {
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
// Upstream injects role:system STEERING markers into the
|
||||
// session history on model/personality change — e.g.
|
||||
// "[System: The active model for this chat has changed to …]"
|
||||
@@ -1227,9 +1231,11 @@ class ChatHandler {
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
val displayContent = displayEventContent(displayKind, item.displayMetadata)
|
||||
val rawServerContent = displayContent ?: item.contentText ?: ""
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.timestamp ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
@@ -1241,8 +1247,8 @@ class ChatHandler {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = item.contentText ?: ""
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
@@ -1315,7 +1321,9 @@ class ChatHandler {
|
||||
// `id = messageId` adopts the server id: for an id-matched (SSE)
|
||||
// row it's a no-op, but for a positionally reconciled (gateway /
|
||||
// user) row whose `prior` still carries a client UUID it swaps in
|
||||
// the server id so EVERY future reload matches by id.
|
||||
// the server id so EVERY future reload matches by id. `uiKey` is
|
||||
// deliberately not overwritten: Compose must continue treating
|
||||
// this as the same visible row across the post-turn reload.
|
||||
prior.copy(
|
||||
id = messageId,
|
||||
role = role,
|
||||
@@ -1494,19 +1502,53 @@ class ChatHandler {
|
||||
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
|
||||
* actually render.
|
||||
*/
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" ->
|
||||
if (!showSystemMarkers &&
|
||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MessageRole.SYSTEM
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
|
||||
when (displayKind) {
|
||||
"model_switch" -> {
|
||||
val model = metadata.stringField("model")
|
||||
?: metadata.stringField("to_model")
|
||||
?: metadata.stringField("target_model")
|
||||
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
|
||||
}
|
||||
"async_delegation_complete" -> {
|
||||
val count = metadata.intField("task_count")
|
||||
?: metadata.intField("tasks")
|
||||
?: metadata.intField("count")
|
||||
when (count) {
|
||||
null -> "Background work completed"
|
||||
1 -> "1 background task completed"
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.intField(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
|
||||
primitive.contentOrNull?.toIntOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
|
||||
@@ -1680,7 +1722,10 @@ class ChatHandler {
|
||||
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
|
||||
// local title whenever the server hasn't supplied a non-blank one.
|
||||
val existingById = _sessions.value.associateBy { it.sessionId }
|
||||
val mapped = items.map { item ->
|
||||
// The drawer is always composed, even while closed, and keys rows by
|
||||
// session id. A refresh race or duplicated upstream row must not put
|
||||
// the same key into Compose's LazyColumn.
|
||||
val mapped = items.distinctBy { it.id }.map { item ->
|
||||
val startedAtMs = timestampToMillis(item.startedAt)
|
||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||
@@ -1758,7 +1803,15 @@ class ChatHandler {
|
||||
* Add a newly created session to the list.
|
||||
*/
|
||||
fun addSession(session: ChatSession) {
|
||||
_sessions.update { listOf(session) + it }
|
||||
// Gateway onSessionId and an overlapping REST refresh can both publish
|
||||
// the same freshly-created session. Treat this as an idempotent upsert,
|
||||
// preferring an existing server-enriched row while collapsing any
|
||||
// duplicates that were already present.
|
||||
_sessions.update { current ->
|
||||
val existing = current.firstOrNull { it.sessionId == session.sessionId }
|
||||
listOf(existing ?: session) +
|
||||
current.filterNot { it.sessionId == session.sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE streaming event entry points ---
|
||||
@@ -2865,13 +2918,22 @@ class ChatHandler {
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
msg.thinkingContent.isBlank() &&
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-stream reconciliation: re-scan final content for any annotation
|
||||
@@ -3071,3 +3133,15 @@ internal fun formatPhoneActionResult(
|
||||
append("Status ${result.status}.")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isIntentionalSilenceMarker(content: String): Boolean =
|
||||
when (content.trim().trim('"', '\'', '`').uppercase()) {
|
||||
"NO_REPLY",
|
||||
"[NO_REPLY]",
|
||||
"SILENT",
|
||||
"[SILENT]",
|
||||
"<SILENT>",
|
||||
"(SILENT)",
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
+333
-7
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -51,6 +52,37 @@ data class DashboardStatus(
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
@SerialName("gateway_mode") val gatewayMode: String? = null,
|
||||
val gateways: List<DashboardGatewayTopology> = emptyList(),
|
||||
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardGatewayTopology(
|
||||
val profile: String,
|
||||
val ports: Map<String, Int> = emptyMap(),
|
||||
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealthRollup(
|
||||
val supported: Boolean = false,
|
||||
val overall: String? = null,
|
||||
val components: List<DashboardComponentHealth> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardComponentHealth(
|
||||
val name: String,
|
||||
val status: String,
|
||||
val message: String? = null,
|
||||
val configured: Int? = null,
|
||||
val connected: Int? = null,
|
||||
val healthy: Boolean? = null,
|
||||
val ok: Boolean? = null,
|
||||
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -92,6 +124,53 @@ data class DashboardChatDisplaySettings(
|
||||
val toolDisplay: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardMcpOAuthFlow(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val status: String,
|
||||
val authorizationUrl: String? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val isTerminal: Boolean get() = status == "approved" || status == "error"
|
||||
}
|
||||
|
||||
data class DashboardCustomEndpoint(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val hasApiKey: Boolean = false,
|
||||
val apiKeyPreview: String? = null,
|
||||
val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpoints(
|
||||
val endpoints: List<DashboardCustomEndpoint>,
|
||||
val currentProvider: String? = null,
|
||||
val currentModel: String? = null,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointDraft(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
val makeDefault: Boolean = false,
|
||||
)
|
||||
|
||||
data class DashboardCustomEndpointValidation(
|
||||
val ok: Boolean,
|
||||
val reachable: Boolean,
|
||||
val message: String,
|
||||
val models: List<String>,
|
||||
)
|
||||
|
||||
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
|
||||
data class ElevenLabsVoice(
|
||||
val voiceId: String,
|
||||
@@ -255,6 +334,14 @@ class DashboardApiClient(
|
||||
*/
|
||||
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
|
||||
|
||||
/**
|
||||
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
|
||||
* This adds plugin provider names and readiness metadata. Command-provider
|
||||
* IDs remain sourced from the dynamic config-schema enum.
|
||||
*/
|
||||
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
|
||||
getJsonObject("/api/tools/toolsets/tts/config")
|
||||
|
||||
/**
|
||||
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
|
||||
* writes the WHOLE document, so [config] MUST be the full values tree
|
||||
@@ -466,25 +553,100 @@ class DashboardApiClient(
|
||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
|
||||
suspend fun setMcpServerEnabled(
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
|
||||
payload = buildJsonObject { put("enabled", enabled) },
|
||||
)
|
||||
|
||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
||||
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
|
||||
|
||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
||||
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
|
||||
|
||||
suspend fun startMcpOAuth(
|
||||
name: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> =
|
||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
|
||||
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
|
||||
.mapCatching(::parseMcpOAuthFlow)
|
||||
|
||||
/**
|
||||
* Read-only hosted-OAuth capability probe. New dashboards recognize the
|
||||
* flow-status route and return its canonical expired-flow 404; older
|
||||
* FastAPI routers return the generic route-level 404. No OAuth worker is
|
||||
* started and no provider/browser interaction occurs.
|
||||
*/
|
||||
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
|
||||
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
|
||||
return result.fold(
|
||||
onSuccess = { Result.success(true) },
|
||||
onFailure = { error ->
|
||||
val message = error.message.orEmpty()
|
||||
when {
|
||||
message.contains("OAuth flow not found or expired") -> Result.success(true)
|
||||
message.contains("HTTP 404") -> Result.success(false)
|
||||
else -> Result.failure(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
|
||||
getJsonObject("/api/providers/custom-endpoints")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun saveCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun validateCustomEndpoint(
|
||||
draft: DashboardCustomEndpointDraft,
|
||||
): Result<DashboardCustomEndpointValidation> =
|
||||
postJsonObject(
|
||||
"/api/providers/custom-endpoints/validate",
|
||||
customEndpointPayload(draft),
|
||||
).mapCatching { root ->
|
||||
DashboardCustomEndpointValidation(
|
||||
ok = root.booleanField("ok") == true,
|
||||
reachable = root.booleanField("reachable") == true,
|
||||
message = root.stringField("message").orEmpty(),
|
||||
models = root.stringList("models"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun activateCustomEndpoint(
|
||||
id: String,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
|
||||
|
||||
suspend fun deleteCustomEndpoint(
|
||||
id: String,
|
||||
): Result<DashboardCustomEndpoints> =
|
||||
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
|
||||
.mapCatching(::parseCustomEndpoints)
|
||||
|
||||
suspend fun installMcpCatalogEntry(
|
||||
name: String,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
enable: Boolean = true,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
postJsonObject(
|
||||
path = "/api/mcp/catalog/install",
|
||||
path = "/api/mcp/catalog/install${profileQuery(profile)}",
|
||||
payload = buildJsonObject {
|
||||
put("name", name)
|
||||
put(
|
||||
@@ -929,6 +1091,57 @@ class DashboardApiClient(
|
||||
return params.joinToString(prefix = "?", separator = "&")
|
||||
}
|
||||
|
||||
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
|
||||
val flowId = root.stringField("flow_id")
|
||||
?: throw IOException("MCP OAuth response did not include a flow id")
|
||||
val status = root.stringField("status")
|
||||
?: throw IOException("MCP OAuth response did not include a status")
|
||||
return DashboardMcpOAuthFlow(
|
||||
flowId = flowId,
|
||||
serverName = root.stringField("server_name").orEmpty(),
|
||||
status = status,
|
||||
authorizationUrl = root.stringField("authorization_url"),
|
||||
error = root.stringField("error"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
|
||||
val current = root["current"] as? JsonObject
|
||||
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("id") ?: return@mapNotNull null
|
||||
DashboardCustomEndpoint(
|
||||
id = id,
|
||||
name = obj.stringField("name") ?: id,
|
||||
baseUrl = obj.stringField("base_url").orEmpty(),
|
||||
model = obj.stringField("model").orEmpty(),
|
||||
models = obj.stringList("models"),
|
||||
contextLength = obj.intField("context_length"),
|
||||
discoverModels = obj.booleanField("discover_models") != false,
|
||||
hasApiKey = obj.booleanField("has_api_key") == true,
|
||||
apiKeyPreview = obj.stringField("api_key_preview"),
|
||||
isCurrent = obj.booleanField("is_current") == true,
|
||||
)
|
||||
}
|
||||
return DashboardCustomEndpoints(
|
||||
endpoints = endpoints,
|
||||
currentProvider = current?.stringField("provider"),
|
||||
currentModel = current?.stringField("model"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
|
||||
buildJsonObject {
|
||||
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
@@ -947,6 +1160,20 @@ class DashboardApiClient(
|
||||
?: root["providers"]
|
||||
?: authObject?.get("providers")
|
||||
val providers = parseProviders(providersElement)
|
||||
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
|
||||
}
|
||||
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val profile = obj.stringField("profile") ?: return@mapNotNull null
|
||||
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
|
||||
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
|
||||
}.toMap()
|
||||
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
|
||||
}
|
||||
return DashboardStatus(
|
||||
authRequired = root.booleanField("auth_required")
|
||||
?: authObject.booleanField("required")
|
||||
@@ -955,6 +1182,45 @@ class DashboardApiClient(
|
||||
authProviderDetails = providers,
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
profiles = profiles,
|
||||
gatewayMode = root.stringField("gateway_mode"),
|
||||
gateways = gateways,
|
||||
componentHealth = parseComponentHealth(root),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
|
||||
val rawComponents = root["components"] as? JsonObject
|
||||
?: return DashboardComponentHealthRollup()
|
||||
val components = rawComponents.mapNotNull { (name, element) ->
|
||||
val component = element as? JsonObject ?: return@mapNotNull null
|
||||
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
DashboardComponentHealth(
|
||||
name = safeName,
|
||||
status = component.stringField("status")
|
||||
?: component.stringField("state")
|
||||
?: component.stringField("health")
|
||||
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
|
||||
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
|
||||
?: "unknown",
|
||||
message = component.stringField("message")
|
||||
?: component.stringField("summary")
|
||||
?: component.stringField("error")
|
||||
?: component.stringField("reason"),
|
||||
configured = component.intField("configured"),
|
||||
connected = component.intField("connected"),
|
||||
healthy = component.booleanField("healthy"),
|
||||
ok = component.booleanField("ok"),
|
||||
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
|
||||
)
|
||||
}.sortedBy { it.name }
|
||||
return DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = root.stringField("overall")
|
||||
?: root.stringField("status")
|
||||
?: root.stringField("health"),
|
||||
components = components,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1203,6 +1469,61 @@ class DashboardCookieJar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy only Hermes' authenticated dashboard session cookies to another host
|
||||
* that belongs to the same saved Connection. Dashboard cookies are host-only
|
||||
* by design, while a Connection may reach one server through LAN and
|
||||
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
|
||||
* explicit sign-out clears every mirrored host together.
|
||||
*
|
||||
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
|
||||
* copied. Secure cookies also remain Secure; this helper never downgrades them
|
||||
* for an HTTP route.
|
||||
*/
|
||||
fun mirrorDashboardSessionCookies(
|
||||
store: DashboardCookieStore,
|
||||
targetUrl: String,
|
||||
trustedHosts: Set<String>,
|
||||
clockMillis: () -> Long = { System.currentTimeMillis() },
|
||||
): Int {
|
||||
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
|
||||
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
if (targetHost !in allowedHosts) return 0
|
||||
|
||||
val now = clockMillis()
|
||||
val all = store.load()
|
||||
val live = all.filterNot { it.isExpired(now) }
|
||||
val existingTargetKeys = live.asSequence()
|
||||
.filter { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
|
||||
.toSet()
|
||||
val mirrored = live.asSequence()
|
||||
.filter { it.isDashboardSessionCookie() }
|
||||
.filter { it.domain.lowercase() in allowedHosts }
|
||||
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
|
||||
.groupBy { "${it.name.lowercase()}|${it.path}" }
|
||||
.values
|
||||
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
|
||||
.map { it.copy(domain = targetHost, hostOnly = true) }
|
||||
.filterNot { it.key in existingTargetKeys }
|
||||
.toList()
|
||||
|
||||
if (mirrored.isNotEmpty() || live.size != all.size) {
|
||||
store.save(live + mirrored)
|
||||
}
|
||||
return mirrored.size
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
|
||||
val bareName = name
|
||||
.removePrefix("__Host-")
|
||||
.removePrefix("__Secure-")
|
||||
return bareName == "hermes_session" ||
|
||||
bareName == "hermes_session_at" ||
|
||||
bareName == "hermes_session_rt" ||
|
||||
bareName == "hermes_session_provider"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that resolves the backing per-connection store at request time.
|
||||
*
|
||||
@@ -1353,3 +1674,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
|
||||
|
||||
private fun JsonObject?.intField(name: String): Int? =
|
||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun JsonObject?.stringList(name: String): List<String> =
|
||||
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
|
||||
* `category` field so it is robust to upstream's category-merging.
|
||||
*/
|
||||
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
|
||||
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
|
||||
fields.filter {
|
||||
it.key.startsWith("tts.") ||
|
||||
it.key.startsWith("stt.") ||
|
||||
it.key.startsWith("voice.")
|
||||
}
|
||||
|
||||
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
|
||||
data class TtsToolsetProvider(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String? = null,
|
||||
val isActive: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
|
||||
*
|
||||
* Unlike the config-schema enum, this payload adds readiness metadata and
|
||||
* reliably discovered plugin providers. Command providers remain schema-owned.
|
||||
* Older upstream builds may omit `tts_provider`;
|
||||
* those rows are ignored because their picker label is not a stable config ID.
|
||||
*/
|
||||
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
|
||||
val providers = root["providers"] as? JsonArray ?: return emptyList()
|
||||
return providers.mapNotNull { element ->
|
||||
val provider = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = provider.configString("tts_provider")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return@mapNotNull null
|
||||
TtsToolsetProvider(
|
||||
id = id,
|
||||
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
|
||||
status = provider.configString("status"),
|
||||
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
|
||||
/** Read the value at a dot-path from the nested config values tree, or null. */
|
||||
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
||||
|
||||
+469
-39
@@ -1,6 +1,8 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -24,6 +27,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -82,6 +86,8 @@ class GatewayChatClient(
|
||||
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
|
||||
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
|
||||
) {
|
||||
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
|
||||
private val sessionSource = "webui"
|
||||
companion object {
|
||||
private const val TAG = "GatewayChatClient"
|
||||
|
||||
@@ -128,6 +134,7 @@ class GatewayChatClient(
|
||||
* not enough.
|
||||
*/
|
||||
private const val ATTACH_RPC_TIMEOUT_MS = 60_000L
|
||||
private const val COMPRESS_RPC_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** Upstream image byte-upload RPC (underscore — `image.attach_bytes`, content_base64). */
|
||||
private const val ATTACH_METHOD_UPSTREAM = "image.attach_bytes"
|
||||
@@ -289,6 +296,10 @@ class GatewayChatClient(
|
||||
private val _serverContext = MutableStateFlow<Pair<Int, Int>?>(null)
|
||||
val serverContext: StateFlow<Pair<Int, Int>?> = _serverContext.asStateFlow()
|
||||
|
||||
/** Optional upstream project identity for the active session. */
|
||||
private val _serverProject = MutableStateFlow<GatewaySessionProject?>(null)
|
||||
val serverProject: StateFlow<GatewaySessionProject?> = _serverProject.asStateFlow()
|
||||
|
||||
/** Serializes connect / session-establish so concurrent sends share one socket. */
|
||||
private val connectMutex = Mutex()
|
||||
|
||||
@@ -361,6 +372,7 @@ class GatewayChatClient(
|
||||
private data class BackgroundTurn(
|
||||
val storedSessionId: String,
|
||||
val profile: String?,
|
||||
@Volatile var pendingAsk: GatewayAsk? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -399,6 +411,11 @@ class GatewayChatClient(
|
||||
private var unmatchedTurnCompleteListener:
|
||||
((GatewayBackgroundTurnCompletion) -> Unit)? = null
|
||||
|
||||
/** Input requested or resolved on a deliberately detached turn. */
|
||||
@Volatile
|
||||
private var backgroundInteractionListener:
|
||||
((GatewayBackgroundInteractionEvent) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Connection-level process listener. Unlike [GatewayTurnCallbacks], this is
|
||||
* consulted even when there is no locally initiated [activeTurn].
|
||||
@@ -528,10 +545,10 @@ class GatewayChatClient(
|
||||
// into the SSE fallback, which would resubmit the same
|
||||
// prompt as a duplicate turn. Recovery belongs to the
|
||||
// stream: the watchdog and mid-turn rejoin own it.
|
||||
if (turn.started || turn.ended) {
|
||||
if (turn.started || turn.ended || turn.transportRecoveryStarted) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"prompt.submit ack failed after turn start " +
|
||||
"prompt.submit ack failed after turn start/rejoin " +
|
||||
"(${submitted.exceptionOrNull()?.message}) — no SSE fallback",
|
||||
)
|
||||
return@launch
|
||||
@@ -580,10 +597,23 @@ class GatewayChatClient(
|
||||
val turn = activeTurn?.takeIf { !it.ended } ?: return false
|
||||
val liveId = liveSessionId ?: return false
|
||||
val storedId = storedSessionId ?: return false
|
||||
backgroundTurns[liveId] = BackgroundTurn(
|
||||
val backgroundTurn = BackgroundTurn(
|
||||
storedSessionId = storedId,
|
||||
profile = liveSessionProfile,
|
||||
pendingAsk = turn.pendingInteraction,
|
||||
)
|
||||
backgroundTurns[liveId] = backgroundTurn
|
||||
backgroundTurn.pendingAsk?.let { ask ->
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Requested(
|
||||
storedSessionId = storedId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = ask,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
turn.detach()
|
||||
return true
|
||||
}
|
||||
@@ -653,6 +683,12 @@ class GatewayChatClient(
|
||||
unmatchedTurnCompleteListener = listener
|
||||
}
|
||||
|
||||
fun setBackgroundInteractionListener(
|
||||
listener: ((GatewayBackgroundInteractionEvent) -> Unit)?,
|
||||
) {
|
||||
backgroundInteractionListener = listener
|
||||
}
|
||||
|
||||
fun setProcessEventListener(listener: ((GatewayProcessEvent) -> Unit)?) {
|
||||
processEventListener = listener
|
||||
}
|
||||
@@ -720,14 +756,15 @@ class GatewayChatClient(
|
||||
* New Hermes gateways expose `session.activate`, which attaches the new
|
||||
* WebSocket transport to the exact live id saved in the client checkpoint.
|
||||
* If that id has already been reaped (or the method is unavailable), fall
|
||||
* back to `session.resume` by durable session id. Its `running` + `inflight`
|
||||
* fields decide whether a live mapper is installed or history should settle
|
||||
* the turn instead.
|
||||
* back to `session.resume` by durable session id. Its `running`, `inflight`,
|
||||
* and optional `queued` fields decide whether a live mapper is installed or
|
||||
* history should settle the turn instead.
|
||||
*/
|
||||
suspend fun recoverTurn(
|
||||
storedId: String,
|
||||
preferredLiveId: String?,
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
queuedTurnProvider: ((GatewayQueuedTurn) -> GatewayInboundTurnRegistration?)? = null,
|
||||
): Result<GatewaySessionRecovery> = runCatching {
|
||||
require(storedId.isNotBlank()) { "stored session id required" }
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -757,6 +794,7 @@ class GatewayChatClient(
|
||||
boundTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
deferEvents = true,
|
||||
).also { turn ->
|
||||
turn.markRecoveredStarted()
|
||||
activeTurn = turn
|
||||
@@ -770,6 +808,7 @@ class GatewayChatClient(
|
||||
response = activated.getOrNull()
|
||||
if (response == null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
boundTurn.detach()
|
||||
boundTurn = null
|
||||
Log.d(
|
||||
@@ -786,6 +825,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
@@ -807,7 +847,7 @@ class GatewayChatClient(
|
||||
storedSessionId = storedId
|
||||
liveSessionProfile = requestedProfile
|
||||
updateCancelledDrainLiveSession(storedId, recoveredLiveId)
|
||||
(response["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(response)
|
||||
|
||||
val inflight = (response["inflight"] as? JsonObject)?.let { value ->
|
||||
GatewayInflightTurn(
|
||||
@@ -816,6 +856,11 @@ class GatewayChatClient(
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
)
|
||||
}
|
||||
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||
value.stringField("user")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::GatewayQueuedTurn)
|
||||
}
|
||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
||||
|
||||
if (running) {
|
||||
@@ -828,10 +873,50 @@ class GatewayChatClient(
|
||||
activeTurn = turn
|
||||
}
|
||||
}
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
}
|
||||
}
|
||||
boundTurn.releaseDeferredEvents()
|
||||
claimedBackground?.pendingAsk?.let { ask ->
|
||||
boundTurn.restoreInteraction(ask)
|
||||
}
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||
// its events flow through the completed checkpoint's mapper.
|
||||
val priorBoundTurn = boundTurn
|
||||
boundTurn = null
|
||||
val registration = queuedTurnProvider?.invoke(queued)
|
||||
if (registration != null) {
|
||||
val queuedTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(registration.callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
)
|
||||
// recoverTurn is resumed on its caller's coroutine context;
|
||||
// ChatViewModel calls it from Main, so this admission runs
|
||||
// atomically with the checkpoint handoff. Posting back
|
||||
// through bindInboundTurn would deadlock waiting on the
|
||||
// same paused Main dispatcher during cold-start recovery.
|
||||
if (registration.onHandle(queuedTurn)) {
|
||||
priorBoundTurn?.redirectDeferredEventsTo(queuedTurn)
|
||||
boundTurn = queuedTurn
|
||||
activeTurn = queuedTurn
|
||||
queuedTurn.armWatchdog()
|
||||
priorBoundTurn?.detach()
|
||||
} else {
|
||||
priorBoundTurn?.discardDeferredEvents()
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
priorBoundTurn?.discardDeferredEvents()
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
if (boundTurn != null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
boundTurn.detach()
|
||||
}
|
||||
boundTurn = null
|
||||
@@ -843,36 +928,109 @@ class GatewayChatClient(
|
||||
running = running,
|
||||
status = response.stringField("status"),
|
||||
inflight = inflight,
|
||||
handle = boundTurn?.takeUnless { it.ended },
|
||||
queued = queued,
|
||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||
?.takeUnless { it.ended },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject [text] into the in-flight turn (`session.steer`). The server
|
||||
* only accepts a steer while a tool batch is running — [SteerResult.Rejected]
|
||||
* means "no batch in flight, queue it instead". [SteerResult.Failed]
|
||||
* covers transport/RPC failure (no live session, socket down, 4010 …) —
|
||||
* callers should fall back to the local queue for both non-queued cases.
|
||||
* Inject [text] into the in-flight turn. Current upstream exposes this as
|
||||
* `session.redirect`, which can redirect an active model turn while keeping
|
||||
* valid work/context. Older gateways only expose `session.steer`; fall back
|
||||
* to that legacy RPC only when the redirect method is absent/unsupported so
|
||||
* old installs still queue the correction instead of dropping it.
|
||||
*/
|
||||
suspend fun steer(text: String): SteerResult {
|
||||
val sid = liveSessionId ?: return SteerResult.Failed
|
||||
val result = rpc(
|
||||
"session.steer",
|
||||
buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("text", text)
|
||||
},
|
||||
)
|
||||
val params = buildJsonObject {
|
||||
put("session_id", sid)
|
||||
put("text", text)
|
||||
}
|
||||
val redirect = rpc("session.redirect", params)
|
||||
val result = if (redirect.isLegacyRedirectUnsupported()) {
|
||||
Log.i(TAG, "session.redirect unsupported — falling back to session.steer (session=$storedSessionId)")
|
||||
rpc("session.steer", params)
|
||||
} else {
|
||||
redirect
|
||||
}
|
||||
val outcome = when (result.getOrNull()?.stringField("status")) {
|
||||
"queued" -> SteerResult.Queued
|
||||
"redirected", "queued" -> SteerResult.Queued
|
||||
"rejected" -> SteerResult.Rejected
|
||||
else -> SteerResult.Failed
|
||||
}
|
||||
Log.i(TAG, "Steer → $outcome (session=$storedSessionId)")
|
||||
Log.i(TAG, "Active-turn correction → $outcome (session=$storedSessionId)")
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress the live gateway session through the dedicated upstream RPC.
|
||||
* `/compress` is intentionally not sent through generic slash execution on
|
||||
* modern gateways because `session.compress` returns authoritative transcript,
|
||||
* usage, title/model/session-info, and compute-host isolation metadata.
|
||||
*/
|
||||
suspend fun compressSession(focusTopic: String? = null): Result<GatewayCompressResult> {
|
||||
val sid = liveSessionId
|
||||
?: return Result.failure(GatewayRpcException("no live session"))
|
||||
val params = buildJsonObject {
|
||||
put("session_id", sid)
|
||||
focusTopic?.trim()?.takeIf { it.isNotBlank() }?.let { put("focus_topic", it) }
|
||||
}
|
||||
val direct = rpc("session.compress", params, timeoutMs = COMPRESS_RPC_TIMEOUT_MS)
|
||||
val result = if (direct.exceptionOrNull().isMethodNotFound()) {
|
||||
val legacyCommand = "/compress" + focusTopic
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { " $it" }
|
||||
.orEmpty()
|
||||
slashExec(legacyCommand).map { slashResult ->
|
||||
buildJsonObject {
|
||||
put("status", "legacy")
|
||||
slashResult.stringField("output")?.let { put("output", it) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
direct
|
||||
}
|
||||
return result.map { payload ->
|
||||
applySessionResultInfo(payload)
|
||||
payload.toGatewayCompressResult()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Result<JsonObject>.isLegacyRedirectUnsupported(): Boolean {
|
||||
val error = exceptionOrNull() ?: return false
|
||||
val message = error.message.orEmpty()
|
||||
return error.isMethodNotFound() ||
|
||||
(error as? GatewayRpcException)?.code == 4010 ||
|
||||
message.contains("does not support active-turn redirect", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun JsonObject.toGatewayCompressResult(): GatewayCompressResult =
|
||||
GatewayCompressResult(
|
||||
status = stringField("status") ?: "completed",
|
||||
output = stringField("output"),
|
||||
removed = (this["removed"] as? JsonPrimitive)?.intOrNull,
|
||||
beforeMessages = (this["before_messages"] as? JsonPrimitive)?.intOrNull,
|
||||
afterMessages = (this["after_messages"] as? JsonPrimitive)?.intOrNull,
|
||||
beforeTokens = (this["before_tokens"] as? JsonPrimitive)?.intOrNull,
|
||||
afterTokens = (this["after_tokens"] as? JsonPrimitive)?.intOrNull,
|
||||
usage = GatewayEventMapper.parseGatewayUsage(this["usage"] as? JsonObject),
|
||||
info = this["info"] as? JsonObject,
|
||||
messages = (this["messages"] as? JsonArray)?.let { messages ->
|
||||
runCatching {
|
||||
json.decodeFromJsonElement(
|
||||
ListSerializer(MessageItem.serializer()),
|
||||
messages,
|
||||
)
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "session.compress returned unreadable messages", it)
|
||||
emptyList()
|
||||
}
|
||||
}.orEmpty(),
|
||||
)
|
||||
|
||||
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
|
||||
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> =
|
||||
rpc(
|
||||
@@ -1251,6 +1409,7 @@ class GatewayChatClient(
|
||||
unsolicitedTurnProvider = null
|
||||
coldPrewarmSessionReadyListener = null
|
||||
unmatchedTurnCompleteListener = null
|
||||
backgroundInteractionListener = null
|
||||
processEventListener = null
|
||||
closeSocket("client shutdown")
|
||||
backgroundCloseJob?.cancel()
|
||||
@@ -1350,6 +1509,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
@@ -1368,13 +1528,14 @@ class GatewayChatClient(
|
||||
// resume result's embedded `info` (same shape session.info carries),
|
||||
// so a reopened session shows its ACTUAL model immediately instead of
|
||||
// a misleading default until the first turn's async session.info.
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply connection-level session info (model / provider / reasoning effort /
|
||||
* personality / yolo / fast / context usage) into the `_server*` state flows.
|
||||
* personality / yolo / fast / context usage / project) into the `_server*`
|
||||
* state flows.
|
||||
* Shared by the `session.info` event handler and the `session.resume` RPC
|
||||
* result — the resume response embeds the same `info` object, so reopening a
|
||||
* session can paint its real model up front rather than waiting for a turn.
|
||||
@@ -1396,6 +1557,18 @@ class GatewayChatClient(
|
||||
info.stringField("credential_warning")?.takeIf { it.isNotBlank() }
|
||||
(info["yolo"] as? JsonPrimitive)?.booleanOrNull?.let { _serverYolo.value = it }
|
||||
(info["fast"] as? JsonPrimitive)?.booleanOrNull?.let { _serverFast.value = it }
|
||||
_serverProject.value = (info["project"] as? JsonObject)?.let { project ->
|
||||
project.stringField("name")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { name ->
|
||||
GatewaySessionProject(
|
||||
id = project.stringField("id")?.takeIf { it.isNotBlank() },
|
||||
slug = project.stringField("slug")?.takeIf { it.isNotBlank() },
|
||||
name = name,
|
||||
primaryPath = project.stringField("primary_path")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Context usage: require used > 0 — a COLD resume resets counters and
|
||||
// reports 0 until the first turn rebuilds the prompt; painting 0 would
|
||||
// mislead on a session that actually has history.
|
||||
@@ -1408,6 +1581,12 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a session create/resume result without leaking metadata from the prior session. */
|
||||
private fun applySessionResultInfo(result: JsonObject) {
|
||||
_serverProject.value = null
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
}
|
||||
|
||||
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
|
||||
private suspend fun ensureLiveProcessSession(): Result<String> {
|
||||
val requestedProfile = currentSessionProfile()
|
||||
@@ -1495,6 +1674,7 @@ class GatewayChatClient(
|
||||
buildJsonObject {
|
||||
put("session_id", requestedStoredId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
@@ -1505,7 +1685,7 @@ class GatewayChatClient(
|
||||
storedSessionId = requestedStoredId
|
||||
liveSessionProfile = requestedProfile
|
||||
updateCancelledDrainLiveSession(requestedStoredId, live)
|
||||
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
|
||||
applySessionResultInfo(result)
|
||||
return
|
||||
}
|
||||
Log.w(
|
||||
@@ -1519,6 +1699,7 @@ class GatewayChatClient(
|
||||
"session.create",
|
||||
buildJsonObject {
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
// Bind the in-chat overrides to the new session as its
|
||||
@@ -1640,6 +1821,30 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// read_terminal is a renderer query, not a user decision. Android has
|
||||
// no xterm pane on the Gateway chat surface, so mirror upstream
|
||||
// desktop's no-live-pane behavior and answer with empty text instead
|
||||
// of blocking the agent for the server's 30-second timeout.
|
||||
if (type == "terminal.read.request") {
|
||||
val requestId = payload?.stringField("request_id")
|
||||
val ownedSession = !eventSessionId.isNullOrBlank() &&
|
||||
(eventSessionId == liveSessionId || backgroundTurns.containsKey(eventSessionId))
|
||||
if (!requestId.isNullOrBlank() && ownedSession) {
|
||||
scope.launch {
|
||||
rpc(
|
||||
"terminal.read.respond",
|
||||
buildJsonObject {
|
||||
put("request_id", requestId)
|
||||
put("text", "")
|
||||
},
|
||||
).onFailure {
|
||||
Log.w(TAG, "terminal.read.respond failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A profile/session switch may leave an upstream turn running while a
|
||||
// different profile becomes visible. Its events must never paint the
|
||||
// new transcript, but the terminal event still needs to reconcile the
|
||||
@@ -1647,6 +1852,47 @@ class GatewayChatClient(
|
||||
// switches back.
|
||||
val backgroundTurn = eventSessionId?.let(backgroundTurns::get)
|
||||
if (backgroundTurn != null) {
|
||||
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
|
||||
if (interactionRequest != null) {
|
||||
val previous = backgroundTurn.pendingAsk
|
||||
backgroundTurn.pendingAsk = interactionRequest
|
||||
if (previous?.kind != interactionRequest.kind ||
|
||||
previous.requestId != interactionRequest.requestId
|
||||
) {
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Requested(
|
||||
storedSessionId = backgroundTurn.storedSessionId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = interactionRequest,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val expiry = GatewayEventMapper.interactionExpiry(type, payload)
|
||||
val pendingAsk = backgroundTurn.pendingAsk
|
||||
val explicitlyExpired = expiry != null && pendingAsk != null &&
|
||||
pendingAsk.kind == expiry.kind &&
|
||||
(pendingAsk.kind == GatewayAsk.Kind.APPROVAL ||
|
||||
pendingAsk.requestId == expiry.requestId)
|
||||
val turnResumed = pendingAsk != null &&
|
||||
GatewayEventMapper.isInteractionResumeEvent(type)
|
||||
if (explicitlyExpired || turnResumed) {
|
||||
backgroundTurn.pendingAsk = null
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Resolved(
|
||||
storedSessionId = backgroundTurn.storedSessionId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = pendingAsk,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type == "message.complete" || type == "error") {
|
||||
backgroundTurns.remove(eventSessionId, backgroundTurn)
|
||||
val expectedText = if (type == "message.complete") payload?.stringField("text") else null
|
||||
@@ -1877,12 +2123,14 @@ class GatewayChatClient(
|
||||
|
||||
/**
|
||||
* Recover an in-flight turn after a mid-turn socket loss by reconnecting
|
||||
* the SOCKET ONLY and keeping [preservedLiveId] as the live session id.
|
||||
* the socket and rebinding [preservedLiveId] to the new transport.
|
||||
*
|
||||
* A bare reconnect lets the tail — including the final
|
||||
* `message.complete` — keep matching without another RPC. Current upstream
|
||||
* can rebind live sessions too, but older builds cannot, so this remains the
|
||||
* lowest-common-denominator same-client recovery path.
|
||||
* Current upstream detaches a live session from a closed WebSocket; a new
|
||||
* socket receives no tail until `session.activate` attaches that exact live
|
||||
* id. Older gateways do not expose the method, so method-not-found alone
|
||||
* retains the legacy bare-socket behavior. We never call `session.resume`
|
||||
* here: resuming a durable id can create a different live runtime and
|
||||
* orphan the turn already executing server-side.
|
||||
*
|
||||
* Retries with backoff for up to [midTurnRejoinWindowMs] so a multi-second
|
||||
* radio blip doesn't abandon the turn. Events emitted while the socket was
|
||||
@@ -1900,7 +2148,39 @@ class GatewayChatClient(
|
||||
connectCooldownUntil = 0L
|
||||
ensureConnected()
|
||||
}
|
||||
true
|
||||
if (preservedLiveId == null) {
|
||||
true
|
||||
} else {
|
||||
// Bind before activation so a tail event racing the RPC ack
|
||||
// still matches the original active turn.
|
||||
liveSessionId = preservedLiveId
|
||||
val activated = rpc(
|
||||
"session.activate",
|
||||
buildJsonObject { put("session_id", preservedLiveId) },
|
||||
)
|
||||
when {
|
||||
activated.isSuccess -> {
|
||||
activated.getOrNull()?.let(::applySessionResultInfo)
|
||||
true
|
||||
}
|
||||
activated.exceptionOrNull().isMethodNotFound() -> {
|
||||
Log.i(
|
||||
TAG,
|
||||
"session.activate unsupported during mid-turn rejoin — " +
|
||||
"using legacy socket recovery",
|
||||
)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Mid-turn session.activate retry failed: " +
|
||||
activated.exceptionOrNull()?.message,
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Mid-turn reconnect retry failed: ${e.message}")
|
||||
false
|
||||
@@ -1912,7 +2192,8 @@ class GatewayChatClient(
|
||||
if (preservedLiveId != null) liveSessionId = preservedLiveId
|
||||
Log.i(
|
||||
TAG,
|
||||
"Gateway socket rejoined mid-turn (session=$storedSessionId) — kept live session, awaiting tail",
|
||||
"Gateway socket rejoined mid-turn (session=$storedSessionId) — " +
|
||||
"rebound live session, awaiting tail",
|
||||
)
|
||||
// A reconnect that followed a route RETARGET gets a short settle
|
||||
// (the fresh socket won't replay the in-flight turn); a normal
|
||||
@@ -2084,15 +2365,26 @@ class GatewayChatClient(
|
||||
private fun watchdogTimeoutFor(eventType: String): Long = when (eventType) {
|
||||
"clarify.request", "secret.request" -> ASK_CLARIFY_SECRET_TIMEOUT_MS
|
||||
"sudo.request" -> ASK_SUDO_TIMEOUT_MS
|
||||
"approval.request", "terminal.read.request" -> ASK_UNBOUNDED_TIMEOUT_MS
|
||||
"approval.request" -> ASK_UNBOUNDED_TIMEOUT_MS
|
||||
else -> turnIdleTimeoutMs
|
||||
}
|
||||
|
||||
private inner class GatewayTurn(
|
||||
val callbacks: GatewayTurnCallbacks,
|
||||
dedupeAdjacentMessageStarts: Boolean = false,
|
||||
deferEvents: Boolean = false,
|
||||
) : ActiveTurnHandle {
|
||||
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
|
||||
val pendingInteraction: GatewayAsk?
|
||||
get() = mapper.currentInteraction
|
||||
fun restoreInteraction(ask: GatewayAsk) {
|
||||
mapper.restoreInteraction(ask)
|
||||
}
|
||||
private val deferredEventLock = Any()
|
||||
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
|
||||
private var eventsDeferred = deferEvents
|
||||
private var redirectedTo: GatewayTurn? = null
|
||||
private var queuedSuccessor: Pair<GatewayInboundTurnRegistration, GatewayTurn>? = null
|
||||
|
||||
/** t0 = construction ≈ sendTurn entry (the moment the user sent). */
|
||||
val tracer = TurnLatencyTracer("gateway")
|
||||
@@ -2103,9 +2395,31 @@ class GatewayChatClient(
|
||||
|
||||
private val rejoinAttempts = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
|
||||
/** True if this socket loss should be answered with a rejoin attempt. */
|
||||
fun beginRejoin(): Boolean =
|
||||
!ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
@Volatile
|
||||
private var reconcileRequired = false
|
||||
|
||||
/**
|
||||
* True if this socket loss should be answered with a rejoin attempt.
|
||||
* Mark reconciliation before reconnecting so a terminal event arriving
|
||||
* immediately after `gateway.ready` cannot race ahead of the signal.
|
||||
*/
|
||||
fun beginRejoin(): Boolean {
|
||||
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
if (shouldRejoin) {
|
||||
reconcileRequired = true
|
||||
transportRecoveryStarted = true
|
||||
}
|
||||
return shouldRejoin
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket loss after `prompt.submit` makes server acceptance ambiguous
|
||||
* even when no turn event or RPC ack reached Android. Once recovery has
|
||||
* started, the caller must not resubmit through SSE.
|
||||
*/
|
||||
@Volatile
|
||||
var transportRecoveryStarted = false
|
||||
private set
|
||||
|
||||
private var watchdog: Job? = null
|
||||
|
||||
@@ -2123,6 +2437,21 @@ class GatewayChatClient(
|
||||
private set
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
val redirect = synchronized(deferredEventLock) {
|
||||
if (eventsDeferred) {
|
||||
deferredEvents += type to payload
|
||||
return
|
||||
}
|
||||
redirectedTo
|
||||
}
|
||||
if (redirect != null) {
|
||||
redirect.onEvent(type, payload)
|
||||
return
|
||||
}
|
||||
processEvent(type, payload)
|
||||
}
|
||||
|
||||
private fun processEvent(type: String, payload: JsonObject?) {
|
||||
if (type != "session.info") started = true
|
||||
tracer.mark("ttfe")
|
||||
if (type == "message.delta" || type == "reasoning.delta" || type == "thinking.delta") {
|
||||
@@ -2132,10 +2461,84 @@ class GatewayChatClient(
|
||||
// Ask requests block with no further events, so they arm with
|
||||
// their own (longer) duration via watchdogTimeoutFor.
|
||||
armWatchdog(watchdogTimeoutFor(type))
|
||||
// Queue this immediately before the terminal callbacks. Both are
|
||||
// marshalled through the same dispatcher, preserving callback order
|
||||
// even when the WebSocket reader and reconnect coroutine differ.
|
||||
if (type == "message.complete" && reconcileRequired) {
|
||||
callbacks.onReconcileRequired()
|
||||
}
|
||||
mapper.onEvent(type, payload)
|
||||
if (mapper.turnEnded) {
|
||||
disarmWatchdog()
|
||||
tracer.done()
|
||||
handoffQueuedSuccessor()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a queued prompt reported beside an in-flight recovery as a
|
||||
* distinct next turn. Its mapper starts deferred so events that race
|
||||
* the resume acknowledgement cannot paint the completing prior turn.
|
||||
*/
|
||||
fun installQueuedSuccessor(registration: GatewayInboundTurnRegistration) {
|
||||
synchronized(deferredEventLock) {
|
||||
if (queuedSuccessor == null) {
|
||||
queuedSuccessor = registration to GatewayTurn(
|
||||
callbacks = dispatchOn(registration.callbacks),
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
deferEvents = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handoffQueuedSuccessor() {
|
||||
val successor = synchronized(deferredEventLock) {
|
||||
queuedSuccessor?.also {
|
||||
queuedSuccessor = null
|
||||
redirectedTo = it.second
|
||||
}
|
||||
} ?: return
|
||||
val (registration, turn) = successor
|
||||
|
||||
// Claim socket ownership immediately so the next message.start is
|
||||
// buffered by this exact successor instead of being admitted as a
|
||||
// generic unsolicited turn. UI admission is ordered after the
|
||||
// prior turn's terminal callbacks on the shared dispatcher.
|
||||
activeTurn = turn
|
||||
callbackDispatcher {
|
||||
if (registration.onHandle(turn)) {
|
||||
turn.releaseDeferredEvents()
|
||||
turn.armWatchdog()
|
||||
} else {
|
||||
turn.discardDeferredEvents()
|
||||
turn.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseDeferredEvents() {
|
||||
val pending = synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
deferredEvents.toList().also { deferredEvents.clear() }
|
||||
}
|
||||
pending.forEach { (type, payload) -> onEvent(type, payload) }
|
||||
}
|
||||
|
||||
fun redirectDeferredEventsTo(target: GatewayTurn) {
|
||||
val pending = synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
redirectedTo = target
|
||||
deferredEvents.toList().also { deferredEvents.clear() }
|
||||
}
|
||||
pending.forEach { (type, payload) -> target.onEvent(type, payload) }
|
||||
}
|
||||
|
||||
fun discardDeferredEvents() {
|
||||
synchronized(deferredEventLock) {
|
||||
eventsDeferred = false
|
||||
redirectedTo = null
|
||||
deferredEvents.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2299,12 +2702,16 @@ class GatewayChatClient(
|
||||
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
|
||||
onStart = { callbackDispatcher { callbacks.onStart() } },
|
||||
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
|
||||
onComplete = { callbackDispatcher { callbacks.onComplete() } },
|
||||
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
@@ -2312,6 +2719,7 @@ class GatewayChatClient(
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
||||
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
|
||||
// onStatusUpdate a default no-op, so omitting it here silently swallows
|
||||
// EVERY gateway status line — the ❌ terminal-error lifecycle update
|
||||
@@ -2323,12 +2731,12 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
/** Outcome of [GatewayChatClient.steer] — Rejected and Failed both mean "queue locally instead". */
|
||||
/** Outcome of an active-turn correction — Rejected and Failed both mean "queue locally instead". */
|
||||
enum class SteerResult {
|
||||
/** Server accepted — text lands in the next tool batch's last result. */
|
||||
/** Server accepted the active-turn correction. */
|
||||
Queued,
|
||||
|
||||
/** Server reachable but no tool batch in flight to steer. */
|
||||
/** Server reachable but no active turn is available to correct. */
|
||||
Rejected,
|
||||
|
||||
/** Transport/RPC failure (no live session, socket down, unsupported …). */
|
||||
@@ -2360,6 +2768,28 @@ internal class GatewayRpcException(message: String, val code: Int? = null) : Exc
|
||||
|
||||
private const val JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
|
||||
data class GatewayCompressResult(
|
||||
val status: String,
|
||||
val output: String? = null,
|
||||
val removed: Int? = null,
|
||||
val beforeMessages: Int? = null,
|
||||
val afterMessages: Int? = null,
|
||||
val beforeTokens: Int? = null,
|
||||
val afterTokens: Int? = null,
|
||||
val usage: UsageInfo? = null,
|
||||
val info: JsonObject? = null,
|
||||
val messages: List<MessageItem> = emptyList(),
|
||||
) {
|
||||
val effectiveUsage: UsageInfo?
|
||||
get() = usage ?: GatewayEventMapper.parseGatewayUsage(info?.get("usage") as? JsonObject)
|
||||
|
||||
val title: String?
|
||||
get() = info?.stringField("title")?.takeIf { it.isNotBlank() }
|
||||
|
||||
val isAuthoritative: Boolean
|
||||
get() = messages.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
|
||||
|
||||
+163
-86
@@ -31,13 +31,24 @@ class GatewayEventMapper(
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
internal val currentInteraction: GatewayAsk?
|
||||
get() = pendingInteraction
|
||||
|
||||
internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
|
||||
pendingInteraction = ask
|
||||
if (!duplicate) callbacks.onInteractionRequest(ask)
|
||||
}
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var previousEventType: String? = null
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var previewedText: String? = null
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
@@ -56,6 +67,30 @@ class GatewayEventMapper(
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
|
||||
interactionRequest(type, payload)?.let { ask ->
|
||||
restoreInteraction(ask)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
interactionExpiry(type, payload)?.let { expiry ->
|
||||
val pending = pendingInteraction
|
||||
if (pending != null && pending.matches(expiry)) {
|
||||
pendingInteraction = null
|
||||
}
|
||||
callbacks.onInteractionExpired(expiry)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
if (type in INTERACTION_RESUME_EVENTS) {
|
||||
pendingInteraction?.let { ask ->
|
||||
pendingInteraction = null
|
||||
callbacks.onInteractionResolved(
|
||||
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"reasoning.delta" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -95,13 +130,30 @@ class GatewayEventMapper(
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
|
||||
clearActivityStatuses()
|
||||
sawTextDelta = true
|
||||
previewedText = null
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.interim" -> {
|
||||
val text = payload.string("text") ?: payload.string("message")
|
||||
?: payload.string("preview") ?: payload.string("rendered")
|
||||
val alreadyStreamed = payload.boolean("already_streamed") == true
|
||||
if (!text.isNullOrBlank() || alreadyStreamed) {
|
||||
clearActivityStatuses()
|
||||
if (!sawMessageStart) {
|
||||
sawMessageStart = true
|
||||
callbacks.onStart()
|
||||
}
|
||||
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
|
||||
previewedText = text
|
||||
sawTextDelta = alreadyStreamed
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// The upstream background-completion poller currently emits
|
||||
// message.start immediately before _run_prompt_submit(), which
|
||||
@@ -114,6 +166,7 @@ class GatewayEventMapper(
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
previewedText = null
|
||||
callbacks.onStart()
|
||||
}
|
||||
|
||||
@@ -164,7 +217,15 @@ class GatewayEventMapper(
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
||||
val duplicatesPreview = responsePreviewed &&
|
||||
!text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
||||
if (!text.isNullOrEmpty() &&
|
||||
!duplicatesPreview &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
@@ -209,36 +270,6 @@ class GatewayEventMapper(
|
||||
)
|
||||
}
|
||||
|
||||
"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" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
// Current Hermes omits timeout metadata. Keep the legacy
|
||||
// no-countdown behavior unless a future contract exposes
|
||||
// the effective per-request timeout explicitly.
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
),
|
||||
)
|
||||
|
||||
"tool.output_risk" -> {
|
||||
val toolId = payload.string("tool_id")
|
||||
val risk = payload.string("risk")?.lowercase() ?: return
|
||||
@@ -263,49 +294,6 @@ class GatewayEventMapper(
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"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,
|
||||
),
|
||||
)
|
||||
|
||||
"sudo.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
"secret.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for the proposed upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
),
|
||||
)
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrBlank()) {
|
||||
@@ -342,21 +330,93 @@ class GatewayEventMapper(
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in APPROVAL_CHOICES }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
"thinking.delta",
|
||||
"reasoning.available",
|
||||
"message.delta",
|
||||
"message.interim",
|
||||
"message.start",
|
||||
"tool.generating",
|
||||
"tool.start",
|
||||
"tool.complete",
|
||||
"message.complete",
|
||||
"error",
|
||||
)
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> 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" -> 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" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
)
|
||||
|
||||
"sudo.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"secret.request" -> 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,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
|
||||
"clarify.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"sudo.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"secret.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for a future upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
|
||||
|
||||
/**
|
||||
* Hermes 2026-07-15 emits these operational wait lines through the
|
||||
@@ -422,3 +482,20 @@ private fun JsonObject?.int(key: String): Int? =
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in setOf("once", "session", "always", "deny") }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
|
||||
kind == other.kind && requestId == other.requestId
|
||||
|
||||
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
|
||||
kind == expiry.kind &&
|
||||
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
|
||||
|
||||
@@ -97,6 +97,19 @@ data class GatewayInflightTurn(
|
||||
val streaming: Boolean,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
val slug: String?,
|
||||
val name: String,
|
||||
val primaryPath: String?,
|
||||
)
|
||||
|
||||
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||
data class GatewaySessionRecovery(
|
||||
val storedSessionId: String,
|
||||
@@ -104,9 +117,14 @@ data class GatewaySessionRecovery(
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
val inflight: GatewayInflightTurn?,
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
)
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
data class GatewayBackgroundTurnCompletion(
|
||||
@@ -115,6 +133,25 @@ data class GatewayBackgroundTurnCompletion(
|
||||
val expectedAssistantText: String?,
|
||||
)
|
||||
|
||||
/** Input lifecycle from a deliberately detached Gateway turn. */
|
||||
sealed interface GatewayBackgroundInteractionEvent {
|
||||
val storedSessionId: String
|
||||
val profile: String?
|
||||
val ask: GatewayAsk
|
||||
|
||||
data class Requested(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
|
||||
data class Resolved(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
@@ -296,7 +333,9 @@ data class GatewayModelOptions(
|
||||
*
|
||||
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
|
||||
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
|
||||
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
|
||||
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
|
||||
* pins priority, `false` explicitly pins normal, and `null` omits the field so
|
||||
* the profile's service tier is inherited.
|
||||
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
|
||||
* accept it as a per-session override, so it is applied post-create instead.
|
||||
*/
|
||||
@@ -328,6 +367,13 @@ class GatewayTurnCallbacks(
|
||||
/** A gateway `message.start` opened an assistant response for this turn. */
|
||||
val onStart: () -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
/**
|
||||
* Gateway `message.interim` sealed an attempted assistant message before
|
||||
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
|
||||
* has not arrived through `message.delta` and should be rendered before
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -335,6 +381,12 @@ class GatewayTurnCallbacks(
|
||||
/** Attach deterministic output-risk metadata to the matching tool card. */
|
||||
val onToolOutputRisk: (GatewayToolOutputRisk) -> Unit = { _ -> },
|
||||
val onTurnComplete: () -> Unit,
|
||||
/**
|
||||
* Fired before [onComplete] when this turn rejoined after a socket gap.
|
||||
* Events emitted while the socket was unavailable are not replayed, so
|
||||
* the caller must reconcile the durable transcript after completion.
|
||||
*/
|
||||
val onReconcileRequired: () -> Unit,
|
||||
val onComplete: () -> Unit,
|
||||
val onUsage: (UsageInfo?) -> Unit,
|
||||
val onError: (String) -> Unit,
|
||||
@@ -354,6 +406,8 @@ class GatewayTurnCallbacks(
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
/** Server declared a pending interaction expired; clear only the matching card. */
|
||||
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
|
||||
/** The turn resumed after a pending interaction was resolved elsewhere. */
|
||||
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
|
||||
/**
|
||||
* Gateway `status.update` lifecycle line — model fallback, retries, and
|
||||
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -159,6 +160,146 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ToolsetInfo(
|
||||
val name: String,
|
||||
val label: String = "",
|
||||
val description: String = "",
|
||||
val enabled: Boolean = false,
|
||||
val configured: Boolean = false,
|
||||
val tools: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
|
||||
|
||||
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
|
||||
json.decodeFromString<ToolsetListResponse>(body).data
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
|
||||
data class ApiModelOption(
|
||||
val id: String,
|
||||
val root: String? = null,
|
||||
val parent: String? = null,
|
||||
) {
|
||||
/** Secondary picker copy for a configured route alias. */
|
||||
val routeDetail: String?
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
return data.mapNotNull { row ->
|
||||
val obj = row as? JsonObject ?: return@mapNotNull null
|
||||
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
ApiModelOption(
|
||||
id = id,
|
||||
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
|
||||
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
|
||||
|
||||
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
|
||||
internal fun streamHttpFailureMessage(
|
||||
code: Int,
|
||||
reason: String,
|
||||
retryAfter: String?,
|
||||
body: String?,
|
||||
json: Json,
|
||||
): String {
|
||||
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
|
||||
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
|
||||
}
|
||||
val errorObj = error as? JsonObject
|
||||
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
|
||||
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
|
||||
?: (error as? JsonPrimitive)?.contentOrNull
|
||||
return buildString {
|
||||
append("API error ").append(code).append(": ")
|
||||
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
|
||||
append(detail?.takeIf { it.isNotBlank() } ?: reason)
|
||||
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
|
||||
append(" (Retry-After: ").append(it).append("s)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayDrainRetryDelayMillis(
|
||||
httpCode: Int?,
|
||||
retryAfter: String?,
|
||||
errorMessage: String,
|
||||
receivedEvent: Boolean,
|
||||
retryAlreadyScheduled: Boolean,
|
||||
): Long? {
|
||||
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
|
||||
!errorMessage.startsWith("API error 503: gateway_draining:")
|
||||
) return null
|
||||
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
|
||||
return seconds * 1_000L
|
||||
}
|
||||
|
||||
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
|
||||
private class RetryingEventSource(
|
||||
private val originalRequest: Request,
|
||||
private val handler: Handler,
|
||||
) : EventSource {
|
||||
private val lock = Any()
|
||||
private var active: EventSource? = null
|
||||
private var retryRunnable: Runnable? = null
|
||||
private var cancelled = false
|
||||
|
||||
override fun request(): Request = originalRequest
|
||||
|
||||
fun attach(source: EventSource) {
|
||||
synchronized(lock) {
|
||||
if (cancelled) source.cancel() else active = source
|
||||
}
|
||||
}
|
||||
|
||||
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
|
||||
val task = Runnable {
|
||||
synchronized(lock) {
|
||||
retryRunnable = null
|
||||
if (cancelled) return@Runnable
|
||||
// Keep creation under the same lock as cancel(): once Stop or
|
||||
// a session switch wins, no delayed POST can start afterward.
|
||||
active = create()
|
||||
}
|
||||
}
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
retryRunnable = task
|
||||
handler.postDelayed(task, delayMillis)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
val source: EventSource?
|
||||
val task: Runnable?
|
||||
synchronized(lock) {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
source = active
|
||||
active = null
|
||||
task = retryRunnable
|
||||
retryRunnable = null
|
||||
}
|
||||
task?.let(handler::removeCallbacks)
|
||||
source?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -199,8 +340,13 @@ class HermesApiClient(
|
||||
|
||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
response != null && !response.isSuccessful -> streamHttpFailureMessage(
|
||||
code = response.code,
|
||||
reason = response.message,
|
||||
retryAfter = response.header("Retry-After"),
|
||||
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
@@ -445,6 +591,24 @@ class HermesApiClient(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
|
||||
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/toolsets").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(IOException("HTTP ${response.code}"))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseToolsetListBody(json, body)
|
||||
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
@@ -453,17 +617,13 @@ class HermesApiClient(
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
parseModelOptionsBody(json, body).orEmpty()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
@@ -471,6 +631,9 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -623,6 +786,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
@@ -636,6 +802,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -799,9 +966,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -814,7 +992,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
@@ -876,6 +1055,9 @@ class HermesApiClient(
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
@@ -886,6 +1068,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -941,9 +1124,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -956,7 +1150,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
@@ -1070,6 +1265,9 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
@@ -1080,6 +1278,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
receivedEvent.set(true)
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
@@ -1246,9 +1445,20 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
val retryDelay = gatewayDrainRetryDelayMillis(
|
||||
response?.code,
|
||||
response?.header("Retry-After"),
|
||||
msg,
|
||||
receivedEvent.get(),
|
||||
drainRetryScheduled.get(),
|
||||
)
|
||||
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
|
||||
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
|
||||
return
|
||||
}
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -1261,7 +1471,8 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||
return turnSource
|
||||
}
|
||||
|
||||
// --- Capability detection ---
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
|
||||
* remain in memory only; OAuth codes, callback state, and tokens never enter
|
||||
* the Android client. The dashboard owns the complete PKCE/callback exchange.
|
||||
*/
|
||||
class McpOAuthFlowCoordinator(
|
||||
private val client: DashboardApiClient,
|
||||
private val pollDelayMillis: Long = 1_000,
|
||||
private val maxPolls: Int = 900,
|
||||
private val maxConsecutiveFailures: Int = 3,
|
||||
private val sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
) {
|
||||
suspend fun start(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
|
||||
if (started.status == "error") {
|
||||
throw IOException(started.error ?: "MCP OAuth failed to start")
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
var failures = 0
|
||||
repeat(maxPolls.coerceAtLeast(1)) {
|
||||
val current = client.getMcpOAuthFlow(flowId)
|
||||
if (current.isFailure) {
|
||||
failures += 1
|
||||
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
|
||||
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
|
||||
}
|
||||
} else {
|
||||
failures = 0
|
||||
val flow = current.getOrThrow()
|
||||
when (flow.status) {
|
||||
"approved" -> return@runCatching flow
|
||||
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
|
||||
}
|
||||
}
|
||||
sleep(pollDelayMillis.coerceAtLeast(0))
|
||||
}
|
||||
throw IOException("MCP OAuth authorization timed out")
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
suspend fun complete(
|
||||
serverName: String,
|
||||
profile: String? = null,
|
||||
openAuthorization: (String) -> Boolean,
|
||||
): Result<DashboardMcpOAuthFlow> = runCatching {
|
||||
val started = start(serverName, profile).getOrThrow()
|
||||
if (started.status == "approved") return@runCatching started
|
||||
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
|
||||
if (!openAuthorization(authorizationUrl)) {
|
||||
throw IOException("No browser is available to complete MCP OAuth")
|
||||
}
|
||||
resume(started.flowId).getOrThrow()
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
|
||||
val url = flow.authorizationUrl
|
||||
?: throw IOException("MCP OAuth server did not provide an authorization URL")
|
||||
if (url.toHttpUrlOrNull()?.scheme != "https") {
|
||||
throw IOException("MCP OAuth authorization URL must use HTTPS")
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
-3
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
|
||||
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
|
||||
// upstream ever adds profile-aware TTS. Until then, standard voice remains
|
||||
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
|
||||
private val profileProvider: () -> String? = { null },
|
||||
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -57,9 +67,7 @@ class StandardHermesVoiceClient(
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
standardHermesDashboardAudioClient(okHttpClient)
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
@@ -148,6 +156,42 @@ class StandardHermesVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun openSpeechStream(
|
||||
callbacks: VoiceSpeechStreamCallbacks,
|
||||
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
|
||||
baseUrl = baseUrl,
|
||||
ticket = ticket,
|
||||
path = "/api/audio/speak-stream",
|
||||
) ?: throw IOException("Could not build Hermes speech stream URL")
|
||||
val request = Request.Builder().url(websocketUrl).build()
|
||||
StandardHermesSpeechStream(
|
||||
request = request,
|
||||
callbacks = callbacks,
|
||||
json = json,
|
||||
socketFactory = webSocketFactory ?: callClient::newWebSocket,
|
||||
).also { it.connect() }
|
||||
.let { Result.success<VoiceSpeechStream?>(it) }
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -242,3 +286,209 @@ class StandardHermesVoiceClient(
|
||||
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
private class StandardHermesSpeechStream(
|
||||
private val request: Request,
|
||||
private val callbacks: VoiceSpeechStreamCallbacks,
|
||||
private val json: Json,
|
||||
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
) : VoiceSpeechStream {
|
||||
private val lock = Any()
|
||||
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
|
||||
private val pendingFrames = ArrayDeque<String>()
|
||||
private var socket: WebSocket? = null
|
||||
private var opened = false
|
||||
private var stopped = false
|
||||
private var finished = false
|
||||
private var audioStarted = false
|
||||
private var sampleRate = DEFAULT_SAMPLE_RATE
|
||||
private var oddByteCarry: Byte? = null
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
val frames = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) {
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
socket = webSocket
|
||||
opened = true
|
||||
pendingFrames.toList().also { pendingFrames.clear() }
|
||||
}
|
||||
frames.forEach(webSocket::send)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
|
||||
when (root.stringField("type")) {
|
||||
"start" -> {
|
||||
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: DEFAULT_SAMPLE_RATE
|
||||
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: 1
|
||||
if (channels != 1) {
|
||||
settle(
|
||||
status = VoiceSpeechStreamStatus.Fallback,
|
||||
error = IOException("Hermes speech stream returned $channels channels; mono required"),
|
||||
)
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
synchronized(lock) { sampleRate = nextRate }
|
||||
callbacks.onStart(nextRate, channels)
|
||||
}
|
||||
"fallback" -> {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) {
|
||||
VoiceSpeechStreamStatus.Completed
|
||||
} else {
|
||||
VoiceSpeechStreamStatus.Fallback
|
||||
},
|
||||
)
|
||||
webSocket.close(1000, "fallback")
|
||||
}
|
||||
"end" -> {
|
||||
settle(VoiceSpeechStreamStatus.Completed)
|
||||
webSocket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
val delivery = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
var incoming = bytes.toByteArray()
|
||||
oddByteCarry?.let { carry ->
|
||||
incoming = byteArrayOf(carry) + incoming
|
||||
oddByteCarry = null
|
||||
}
|
||||
val usable = incoming.size - (incoming.size % 2)
|
||||
if (usable < incoming.size) oddByteCarry = incoming.last()
|
||||
if (usable == 0) return
|
||||
audioStarted = true
|
||||
incoming.copyOf(usable) to sampleRate
|
||||
}
|
||||
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
|
||||
.onFailure { error ->
|
||||
settle(VoiceSpeechStreamStatus.Failed, error)
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
settleForDisconnect(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
val created = socketFactory(request, listener)
|
||||
synchronized(lock) {
|
||||
if (socket == null) socket = created
|
||||
if (stopped) created.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun append(text: String) {
|
||||
if (text.isEmpty()) return
|
||||
sendFrame(buildJsonObject { put("text", text) })
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
synchronized(lock) {
|
||||
if (finished || stopped || outcome.isCompleted) return
|
||||
finished = true
|
||||
}
|
||||
sendFrame(buildJsonObject { put("done", true) })
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val current = synchronized(lock) {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
socket
|
||||
}
|
||||
if (current != null) {
|
||||
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
|
||||
current.cancel()
|
||||
}
|
||||
settle(VoiceSpeechStreamStatus.Stopped)
|
||||
}
|
||||
|
||||
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
|
||||
|
||||
private fun sendFrame(frame: JsonObject) {
|
||||
val encoded = json.encodeToString(JsonObject.serializer(), frame)
|
||||
val current = synchronized(lock) {
|
||||
if (stopped || outcome.isCompleted) return
|
||||
if (!opened) {
|
||||
pendingFrames.addLast(encoded)
|
||||
return
|
||||
}
|
||||
socket
|
||||
}
|
||||
if (current?.send(encoded) != true) {
|
||||
settleForDisconnect(IOException("Hermes speech stream send failed"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleForDisconnect(error: Throwable) {
|
||||
val heardAudio = synchronized(lock) { audioStarted }
|
||||
settle(
|
||||
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
|
||||
val result = synchronized(lock) {
|
||||
if (outcome.isCompleted) return
|
||||
VoiceSpeechStreamOutcome(
|
||||
status = status,
|
||||
audioStarted = audioStarted,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
outcome.complete(result)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringField(name: String): String? =
|
||||
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_SAMPLE_RATE = 24_000
|
||||
}
|
||||
}
|
||||
|
||||
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
|
||||
|
||||
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
|
||||
requestedSeconds.coerceIn(
|
||||
minimumValue = 180L,
|
||||
maximumValue = 600L,
|
||||
)
|
||||
|
||||
internal fun standardHermesDashboardAudioClient(
|
||||
baseClient: OkHttpClient,
|
||||
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
|
||||
): OkHttpClient {
|
||||
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
|
||||
return baseClient.newBuilder()
|
||||
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ data class MessageItem(
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null,
|
||||
@SerialName("display_kind") val displayKind: String? = null,
|
||||
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
|
||||
// Reasoning persisted with the assistant message (upstream serializes
|
||||
// both names; reasoning is the canonical one). Restored into
|
||||
// ChatMessage.thinkingContent so the Thought-process block survives a
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
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.net.Uri
|
||||
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
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
|
||||
/**
|
||||
* Action-required notifications for Gateway turns blocked on user input.
|
||||
*
|
||||
* Notification copy is deliberately generic. Approval commands, clarification
|
||||
* questions, secret prompts, environment-variable names, and password requests
|
||||
* stay inside the authenticated chat surface and never appear on the lock
|
||||
* screen. A stable tag derived from the durable session and request identity
|
||||
* makes replay/reconnect delivery replace the existing notification.
|
||||
*/
|
||||
object InteractionRequestNotifier {
|
||||
|
||||
private const val TAG = "InteractionNotifier"
|
||||
internal const val CHANNEL_ID = "chat_interactions"
|
||||
private const val CHANNEL_NAME = "Hermes needs input"
|
||||
internal const val NOTIFICATION_ID = 3823
|
||||
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
||||
|
||||
internal fun shouldPost(
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
hasPermission: Boolean,
|
||||
): Boolean = alertsEnabled && !appForeground && hasPermission
|
||||
|
||||
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
|
||||
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
|
||||
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
|
||||
}
|
||||
|
||||
internal fun notificationTag(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
|
||||
|
||||
internal fun chatRoute(sessionId: String, profile: String? = null): String =
|
||||
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
|
||||
|
||||
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Hermes needs sensitive input"
|
||||
}
|
||||
|
||||
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Open Hermes to respond securely."
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
): Boolean {
|
||||
ensureChannel(context)
|
||||
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
|
||||
return false
|
||||
}
|
||||
|
||||
val tag = notificationTag(sessionId, ask, profile)
|
||||
val requestKey = requestKey(sessionId, ask, profile)
|
||||
val requestCode = requestKey.hashCode()
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.Builder()
|
||||
.scheme("hermes-relay")
|
||||
.authority("interaction")
|
||||
.appendPath(requestKey)
|
||||
.build()
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
|
||||
}
|
||||
val tapPending = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val title = safeTitle(ask)
|
||||
val body = safeBody(ask)
|
||||
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes needs your input")
|
||||
.setContentText("Open Hermes to continue.")
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
|
||||
true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "notify failed", it)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancel failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only this feature's notifications. Android keeps notifications
|
||||
* across process death, so the active-notification scan is also used when
|
||||
* MainActivity returns without an in-memory request registry.
|
||||
*/
|
||||
fun cancelAll(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
runCatching {
|
||||
manager.activeNotifications
|
||||
.filter { it.notification.channelId == CHANNEL_ID }
|
||||
.forEach { manager.cancel(it.tag, it.id) }
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancelAll failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Alerts when a Hermes turn is waiting for your response."
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setShowBadge(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,13 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -128,6 +131,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
|
||||
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
|
||||
@@ -151,12 +155,17 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.audio.VoicePlayer
|
||||
import com.hermesandroid.relay.audio.VoiceRecorder
|
||||
import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
@@ -182,6 +191,68 @@ suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Startup chrome should wait for either standard chat surface, not Relay. */
|
||||
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
|
||||
connection?.capabilities?.chatConfigured == true
|
||||
|
||||
/**
|
||||
* App-root chat health derived only from the two transports that can carry a
|
||||
* conversation. Optional Relay state is deliberately absent.
|
||||
*/
|
||||
internal fun resolveAppChatRuntimeStatus(
|
||||
connection: Connection?,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
apiHealth: ConnectionViewModel.HealthStatus,
|
||||
): ChatRuntimeStatus {
|
||||
val capabilities = connection?.capabilities
|
||||
val gateway = when {
|
||||
capabilities?.dashboardGatewayConfigured != true -> ChatTransportReadiness.NotConfigured
|
||||
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
}
|
||||
val api = when {
|
||||
capabilities?.apiServerConfigured != true -> ChatTransportReadiness.NotConfigured
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unknown ||
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
}
|
||||
return resolveChatRuntimeStatus(gateway = gateway, apiSse = api)
|
||||
}
|
||||
|
||||
/** Route represented by the app footer's currently usable chat transport. */
|
||||
internal fun resolveFooterRouteCandidate(
|
||||
runtimeStatus: ChatRuntimeStatus,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
connection: Connection?,
|
||||
effectiveDashboardUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val connected = runtimeStatus as? ChatRuntimeStatus.Connected ?: return null
|
||||
return when (connected.transport) {
|
||||
ChatTransportPath.Gateway -> {
|
||||
val dashboardUrl = effectiveDashboardUrl.trim().trimEnd('/')
|
||||
.ifBlank { connection?.resolvedDashboardUrl.orEmpty() }
|
||||
if (dashboardUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val activeDashboardUrl = activeEndpoint?.dashboard?.url
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
activeEndpoint?.takeIf { activeDashboardUrl == dashboardUrl }
|
||||
?: Connection.endpointCandidateFromDashboardUrl(
|
||||
role = Connection.inferRouteRole(dashboardUrl),
|
||||
priority = activeEndpoint?.priority ?: 0,
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatTransportPath.ApiSse -> activeEndpoint?.takeIf { it.api != null }
|
||||
?: connection?.routeCandidates?.firstOrNull { it.api != null }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
val label: String,
|
||||
@@ -203,17 +274,44 @@ sealed class Screen(
|
||||
// NavHost, and the NavigationBarItem click must navigate via [route]()
|
||||
// so no unresolved `{openAgentSheet}` leaks into the destination.
|
||||
data object Chat : Screen(
|
||||
"chat?openAgentSheet={openAgentSheet}",
|
||||
"chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
|
||||
"Chat",
|
||||
Icons.AutoMirrored.Filled.Chat,
|
||||
) {
|
||||
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
|
||||
fun route(openAgentSheet: Boolean = false): String =
|
||||
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
|
||||
const val ARG_SESSION_ID: String = "sessionId"
|
||||
const val ARG_PROFILE: String = "profile"
|
||||
fun route(
|
||||
openAgentSheet: Boolean = false,
|
||||
sessionId: String? = null,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val params = buildList {
|
||||
if (openAgentSheet) add("$ARG_OPEN_AGENT_SHEET=true")
|
||||
sessionId?.takeIf { it.isNotBlank() }?.let {
|
||||
add("$ARG_SESSION_ID=${android.net.Uri.encode(it)}")
|
||||
}
|
||||
profile?.takeIf { it.isNotBlank() }?.let {
|
||||
add("$ARG_PROFILE=${android.net.Uri.encode(it)}")
|
||||
}
|
||||
}
|
||||
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
|
||||
data object DashboardSignIn : Screen(
|
||||
"dashboard_sign_in?source={source}",
|
||||
"Dashboard sign in",
|
||||
Icons.Filled.Settings,
|
||||
) {
|
||||
const val ARG_SOURCE: String = "source"
|
||||
const val SOURCE_GENERAL: String = "general"
|
||||
const val SOURCE_PAIR: String = "pair"
|
||||
const val SOURCE_ONBOARDING: String = "onboarding"
|
||||
fun route(source: String = SOURCE_GENERAL): String = "dashboard_sign_in?source=$source"
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
|
||||
// Non-bottom-nav destinations — reached by explicit navigation, not the
|
||||
@@ -359,6 +457,12 @@ fun RelayApp() {
|
||||
// ConnectionStore's mutations are all suspend fns and we don't want to
|
||||
// block the main dispatcher from inside the composable body.
|
||||
val connectionSwitchScope = rememberCoroutineScope()
|
||||
// Add-connection preparation may outlive the initiating list frame now
|
||||
// that navigation happens immediately. Keep the job by placeholder id so
|
||||
// an instant Back can wait for creation and then discard it safely.
|
||||
val pendingAddConnectionJobs = remember {
|
||||
mutableMapOf<String, kotlinx.coroutines.Job>()
|
||||
}
|
||||
|
||||
// One-time init: the terminal channel ViewModel registers with the shared
|
||||
// multiplexer and observes the relay connection state so it can attach/
|
||||
@@ -434,11 +538,13 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ChatViewModel reactively when the chat-routed API client becomes available
|
||||
// Bind chat state independently of the optional API fallback client.
|
||||
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
|
||||
val chatTransportReady by connectionViewModel.chatReady.collectAsState()
|
||||
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val effectiveDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
@@ -633,9 +739,10 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(chatApiClient) {
|
||||
val client = chatApiClient ?: return@LaunchedEffect
|
||||
var boundCatalogConnectionId by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(chatApiClient, activeConnectionId) {
|
||||
val handler = connectionViewModel.chatHandler
|
||||
val connectionChanged = boundCatalogConnectionId != activeConnectionId
|
||||
// A route handoff / reconnect rebuilds the API client (new instance)
|
||||
// while the chat is unchanged — the bound handler is the same. Take the
|
||||
// cheap path: swap the client reference only, no re-init. This is what
|
||||
@@ -643,11 +750,24 @@ fun RelayApp() {
|
||||
// switch or a reconnect. A genuine re-bind (different handler) falls
|
||||
// through to the full one-time wiring below.
|
||||
if (chatViewModel.boundHandler === handler) {
|
||||
chatViewModel.updateApiClient(client)
|
||||
if (connectionChanged) {
|
||||
chatViewModel.resetConnectionCatalogs()
|
||||
// The active pointer changes before an API target is rebuilt.
|
||||
// Never let the outgoing API client refill the new connection's
|
||||
// catalogs during that window. Dashboard-only (null -> null)
|
||||
// refreshes immediately through the already-installed loader.
|
||||
chatViewModel.updateApiClient(null)
|
||||
} else {
|
||||
chatViewModel.updateApiClient(chatApiClient)
|
||||
}
|
||||
boundCatalogConnectionId = activeConnectionId
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
chatViewModel.initialize(client, handler)
|
||||
// The Dashboard/Gateway transport is independently sufficient for
|
||||
// chat, so handler and dashboard callbacks must bind even when no API
|
||||
// fallback client is configured.
|
||||
chatViewModel.initialize(chatApiClient, handler)
|
||||
|
||||
// Wire inbound-media dependencies. Idempotent rewire of the
|
||||
// ChatHandler callbacks.
|
||||
@@ -665,6 +785,9 @@ fun RelayApp() {
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
chatViewModel.setIsolatedProfileApiProvider {
|
||||
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
|
||||
}
|
||||
chatViewModel.setSessionProfileNameProvider {
|
||||
connectionViewModel.effectiveSessionProfileName.value
|
||||
}
|
||||
@@ -675,10 +798,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
chatViewModel.setDisplayProfileProvider {
|
||||
AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = connectionViewModel.selectedProfile.value,
|
||||
profiles = connectionViewModel.agentProfiles.value,
|
||||
)
|
||||
connectionViewModel.effectiveDisplayProfile.value
|
||||
}
|
||||
chatViewModel.setDisplayAliasProvider {
|
||||
connectionViewModel.profileDisplayAlias.value
|
||||
@@ -694,6 +814,12 @@ fun RelayApp() {
|
||||
chatViewModel.setProfileMessageLoader { sessionId ->
|
||||
connectionViewModel.loadProfileScopedMessages(sessionId)
|
||||
}
|
||||
// Personality choices live in Dashboard `/api/config` on a
|
||||
// dashboard-only connection. The setter immediately refreshes after
|
||||
// this callback is installed, covering the null-API initialization.
|
||||
chatViewModel.setDashboardConfigLoader {
|
||||
connectionViewModel.loadActiveDashboardConfig()
|
||||
}
|
||||
// …and delete from that same profile's DB so a non-default profile's
|
||||
// session can't be resurrected by the next profile-scoped list.
|
||||
chatViewModel.profileSessionDeleter = { sessionId ->
|
||||
@@ -710,6 +836,7 @@ fun RelayApp() {
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
connectionViewModel.saveLastSessionId(sessionId)
|
||||
}
|
||||
boundCatalogConnectionId = activeConnectionId
|
||||
}
|
||||
|
||||
// Reload sessions / switch profile context only on a SEMANTIC change
|
||||
@@ -718,16 +845,15 @@ fun RelayApp() {
|
||||
// means a route handoff (which churns the client) no longer triggers a
|
||||
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
|
||||
// already no-ops when the context key + session are unchanged.
|
||||
val chatClientReady = chatApiClient != null
|
||||
LaunchedEffect(
|
||||
chatClientReady,
|
||||
chatTransportReady,
|
||||
activeConnectionId,
|
||||
selectedProfile?.name,
|
||||
effectiveSessionProfileName,
|
||||
lastSessionId,
|
||||
profileSelectionSettled,
|
||||
) {
|
||||
if (!chatClientReady) return@LaunchedEffect
|
||||
if (!chatTransportReady) return@LaunchedEffect
|
||||
// Cold-start profile-isolation guard: hold the first profile-scoped load
|
||||
// until the persisted profile selection has SETTLED, so the session
|
||||
// drawer (and the restored session context) don't briefly load the
|
||||
@@ -770,7 +896,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) {
|
||||
LaunchedEffect(selectedProfile?.name, effectiveDisplayProfile?.name, agentProfiles, profileDisplayAlias) {
|
||||
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
|
||||
}
|
||||
|
||||
@@ -847,10 +973,10 @@ fun RelayApp() {
|
||||
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
|
||||
// client to follow the new dashboard route instead of stranding the turn on
|
||||
// the dead one.
|
||||
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
// Debounce a route FLIP before re-acquiring the gateway chat client. The
|
||||
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
|
||||
// stable on a transient endpoint-resolution miss, so effectiveApiUrl should
|
||||
// stable on a transient endpoint-resolution miss, so the Dashboard URL should
|
||||
// not flap — this is belt-and-suspenders against any residual sub-second
|
||||
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
|
||||
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
|
||||
@@ -859,9 +985,17 @@ fun RelayApp() {
|
||||
// unaffected; only a genuine url change waits for a settle window, and if
|
||||
// the url flips back within it the LaunchedEffect cancels + restarts so no
|
||||
// rebuild happens.
|
||||
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
|
||||
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) {
|
||||
var lastAcquiredDashboardUrl by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(
|
||||
streamingEndpoint,
|
||||
serverCapabilities,
|
||||
gatewayAvailability,
|
||||
effectiveDashboardUrl,
|
||||
) {
|
||||
if (
|
||||
lastAcquiredDashboardUrl != null &&
|
||||
lastAcquiredDashboardUrl != effectiveDashboardUrl
|
||||
) {
|
||||
delay(750L)
|
||||
}
|
||||
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
@@ -870,7 +1004,7 @@ fun RelayApp() {
|
||||
chatViewModel.updateGatewayClient(
|
||||
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
|
||||
)
|
||||
lastAcquiredApiUrl = effectiveApiUrl
|
||||
lastAcquiredDashboardUrl = effectiveDashboardUrl
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
@@ -961,7 +1095,6 @@ fun RelayApp() {
|
||||
CrashReportGate()
|
||||
|
||||
val navController = rememberNavController()
|
||||
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
@@ -1026,17 +1159,6 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
|
||||
val route = postOnboardingRoute
|
||||
if (onboardingCompleted && route != null) {
|
||||
postOnboardingRoute = null
|
||||
navController.navigate(route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startDestination uses the route TEMPLATE so it matches the
|
||||
// composable registered below; optional args default to null/false.
|
||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||
@@ -1127,11 +1249,8 @@ fun RelayApp() {
|
||||
// bottom navigation bar so the voice overlay can own the entire screen
|
||||
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
|
||||
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
@@ -1166,17 +1285,16 @@ fun RelayApp() {
|
||||
startupGateTimedOut = true
|
||||
}
|
||||
|
||||
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
|
||||
// A published activeEndpoint counts as "hermes online": the route
|
||||
// resolver only publishes a winner after a successful HEAD /health
|
||||
// probe against that route's API URL. At cold start this evidence
|
||||
// lands within ~1s — long before the client-based health probe,
|
||||
// which can't run until the API client exists (the client build
|
||||
// used to queue behind the Keystore decrypt; see
|
||||
// apiKeyForClientBuild in ConnectionViewModel).
|
||||
val startupApiUp = apiReachable ||
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
|
||||
activeEndpoint != null
|
||||
val hasStartupConnection = hasConfiguredStartupChat(activeConnection)
|
||||
val appChatRuntimeStatus = resolveAppChatRuntimeStatus(
|
||||
connection = activeConnection,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
apiHealth = apiHealth,
|
||||
)
|
||||
// A Dashboard/Gateway-only connection is a complete standard Hermes
|
||||
// connection. Startup readiness follows the same transport-neutral
|
||||
// priority as the footer instead of waiting for an optional API probe.
|
||||
val startupChatUp = appChatRuntimeStatus is ChatRuntimeStatus.Connected
|
||||
|
||||
// An Unreachable verdict only counts after it SURVIVES a settle
|
||||
// window: the first health probe often runs against the persisted
|
||||
@@ -1185,9 +1303,9 @@ fun RelayApp() {
|
||||
// first verdict was what flashed the disconnected chat UI at users
|
||||
// who were connected-just-waiting. The keyed effect restarts on
|
||||
// every health flip, cancelling a pending settle.
|
||||
LaunchedEffect(apiHealth, startupGateReleased) {
|
||||
LaunchedEffect(appChatRuntimeStatus, startupGateReleased) {
|
||||
if (startupGateReleased) return@LaunchedEffect
|
||||
if (apiHealth == ConnectionViewModel.HealthStatus.Unreachable) {
|
||||
if (hasStartupConnection && appChatRuntimeStatus is ChatRuntimeStatus.Unavailable) {
|
||||
delay(3_000L)
|
||||
startupUnreachableSettled = true
|
||||
} else {
|
||||
@@ -1211,16 +1329,16 @@ fun RelayApp() {
|
||||
StartupCheckState.Done,
|
||||
"route · ${startupEndpoint.displayLabel()}",
|
||||
)
|
||||
startupApiUp ->
|
||||
startupChatUp ->
|
||||
StartupCheck(StartupCheckState.Done, "route · direct")
|
||||
appReady ->
|
||||
StartupCheck(StartupCheckState.Active, "resolving route")
|
||||
else -> StartupCheck(StartupCheckState.Pending, "route")
|
||||
},
|
||||
when {
|
||||
startupApiUp ->
|
||||
startupChatUp ->
|
||||
StartupCheck(StartupCheckState.Done, "hermes online")
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
appChatRuntimeStatus is ChatRuntimeStatus.Unavailable ->
|
||||
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
|
||||
appReady ->
|
||||
StartupCheck(StartupCheckState.Active, "contacting hermes")
|
||||
@@ -1232,7 +1350,7 @@ fun RelayApp() {
|
||||
when {
|
||||
chatReady && initialChatSettled ->
|
||||
StartupCheck(StartupCheckState.Done, "conversation ready")
|
||||
startupApiUp ->
|
||||
startupChatUp ->
|
||||
StartupCheck(StartupCheckState.Active, "loading conversation")
|
||||
else -> StartupCheck(StartupCheckState.Pending, "conversation")
|
||||
},
|
||||
@@ -1346,7 +1464,8 @@ fun RelayApp() {
|
||||
// the previous run). The pre-warm fills cold keys and refreshes
|
||||
// stale (disk-hydrated) ones, then mirrors results back to disk.
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
|
||||
val effectiveManageProfile by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl, effectiveManageProfile) {
|
||||
val connection = activeConnection ?: return@LaunchedEffect
|
||||
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
|
||||
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
|
||||
@@ -1363,6 +1482,7 @@ fun RelayApp() {
|
||||
cookieStore = cookieStore,
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
cacheDir = hydrateContext.cacheDir,
|
||||
context = hydrateContext,
|
||||
)
|
||||
@@ -1460,7 +1580,7 @@ fun RelayApp() {
|
||||
// stays fully silent (the health "Connecting" cue used to flash here for a
|
||||
// few seconds and then clear with no "Connected" toast).
|
||||
val connectionReconnecting =
|
||||
globalConnectionStatus?.active == true && !postResumeQuiet &&
|
||||
appChatRuntimeStatus is ChatRuntimeStatus.Connecting && !postResumeQuiet &&
|
||||
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
|
||||
// === END v0.4.1 polish ===
|
||||
|
||||
@@ -1600,7 +1720,13 @@ fun RelayApp() {
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
|
||||
val routeLabel = activeEndpoint?.displayLabel()
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
runtimeStatus = appChatRuntimeStatus,
|
||||
activeEndpoint = activeEndpoint,
|
||||
connection = activeConnection,
|
||||
effectiveDashboardUrl = effectiveDashboardUrl,
|
||||
)
|
||||
val routeLabel = footerRoute?.displayLabel()
|
||||
?: activeConnection?.label
|
||||
?: stringResource(R.string.status_no_route)
|
||||
val transportStatus = resolveChatTransportStatus(
|
||||
@@ -1613,12 +1739,9 @@ fun RelayApp() {
|
||||
} else {
|
||||
routeLabel
|
||||
}
|
||||
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
|
||||
val profileLabel = AgentDisplay.profileDisplayName(effectiveDisplayProfile)
|
||||
?: stringResource(R.string.status_profile_default)
|
||||
val displayProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
val displayProfile = effectiveDisplayProfile
|
||||
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||
?: AgentDisplay.displayModelName(displayProfile?.model)
|
||||
?: AgentDisplay.displayModelName(serverModelName)
|
||||
@@ -1701,8 +1824,9 @@ fun RelayApp() {
|
||||
}
|
||||
},
|
||||
onManageSignIn = {
|
||||
postOnboardingRoute = Screen.Manage.route
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(
|
||||
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_ONBOARDING),
|
||||
)
|
||||
},
|
||||
onOpenPermissions = {
|
||||
navController.navigate(Screen.PermissionsSettings.route)
|
||||
@@ -1717,6 +1841,16 @@ fun RelayApp() {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument(Screen.Chat.ARG_SESSION_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument(Screen.Chat.ARG_PROFILE) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
// Responsive bubble width based on screen width. The "Blend"
|
||||
@@ -1741,6 +1875,48 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
LaunchedEffect(
|
||||
requestedSessionId,
|
||||
requestedProfileRoute,
|
||||
profileSelectionSettled,
|
||||
effectiveSessionProfileName,
|
||||
agentProfiles,
|
||||
) {
|
||||
val sessionId = requestedSessionId ?: return@LaunchedEffect
|
||||
if (requestedProfileRoute != null) {
|
||||
val targetProfile = requestedProfileRoute.takeUnless {
|
||||
it == com.hermesandroid.relay.notifications
|
||||
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
|
||||
}
|
||||
if (!profileSelectionSettled) return@LaunchedEffect
|
||||
if (effectiveSessionProfileName != targetProfile) {
|
||||
val selection = targetProfile?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name == name }
|
||||
}
|
||||
if (targetProfile == null || selection != null) {
|
||||
connectionViewModel.selectProfile(selection)
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
chatViewModel.switchProfileContext(
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnectionId,
|
||||
profileName = targetProfile,
|
||||
),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
} else if (chatViewModel.currentSessionId.value != sessionId) {
|
||||
chatViewModel.switchSession(sessionId)
|
||||
}
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
}
|
||||
|
||||
val screenChatLabel = stringResource(R.string.screen_chat_label)
|
||||
|
||||
@@ -1832,6 +2008,11 @@ fun RelayApp() {
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToSignIn = {
|
||||
navController.navigate(Screen.DashboardSignIn.route()) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Standard back: return to wherever Manage was opened
|
||||
// from (Settings → Hermes management, the agent sheet,
|
||||
// etc.). The prior forced navigate(Chat) with
|
||||
@@ -1865,6 +2046,39 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = Screen.DashboardSignIn.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.DashboardSignIn.ARG_SOURCE) {
|
||||
type = NavType.StringType
|
||||
defaultValue = Screen.DashboardSignIn.SOURCE_GENERAL
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val source = backStackEntry.arguments
|
||||
?.getString(Screen.DashboardSignIn.ARG_SOURCE)
|
||||
?: Screen.DashboardSignIn.SOURCE_GENERAL
|
||||
DashboardSignInScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onAuthenticated = {
|
||||
when (source) {
|
||||
Screen.DashboardSignIn.SOURCE_ONBOARDING -> {
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(Screen.Chat.route()) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
Screen.DashboardSignIn.SOURCE_PAIR -> {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack()
|
||||
}
|
||||
else -> navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Terminal.route) {
|
||||
if (coldStartAuthState is AuthState.Paired) {
|
||||
TerminalScreen(
|
||||
@@ -2082,6 +2296,7 @@ fun RelayApp() {
|
||||
voiceClient = voiceClient,
|
||||
connectionId = activeConnectionId,
|
||||
selectedProfile = selectedProfile,
|
||||
displayProfile = effectiveDisplayProfile,
|
||||
standardVoiceAvailability = standardVoiceAvailability,
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
@@ -2230,18 +2445,20 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.ConnectionDetail.route(id))
|
||||
},
|
||||
onAddConnection = {
|
||||
connectionSwitchScope.launch {
|
||||
// Create and switch to the placeholder before
|
||||
// opening the wizard. Otherwise a fast scan or
|
||||
// standard save can write into the outgoing
|
||||
// connection's auth store.
|
||||
val id = connectionViewModel.beginAddConnection(
|
||||
preAllocatedId = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
navController.navigate(
|
||||
Screen.Pair.route(connectionId = id)
|
||||
)
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
// Draw step 1 immediately. Placeholder persistence
|
||||
// and the heavy connection-context switch continue
|
||||
// underneath the discovery UI instead of blocking
|
||||
// navigation on encrypted-store/client setup.
|
||||
navController.navigate(Screen.Pair.route(connectionId = id))
|
||||
val job = connectionSwitchScope.launch {
|
||||
try {
|
||||
connectionViewModel.beginAddConnection(preAllocatedId = id)
|
||||
} finally {
|
||||
pendingAddConnectionJobs.remove(id)
|
||||
}
|
||||
}
|
||||
pendingAddConnectionJobs[id] = job
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// Pass the VM so the list cards can read live status
|
||||
@@ -2283,7 +2500,7 @@ fun RelayApp() {
|
||||
onRepair = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.switchConnection(id).join()
|
||||
navController.navigate(Screen.Pair.route(id))
|
||||
navController.navigate(Screen.Pair.route(id, autoStart = "relay"))
|
||||
}
|
||||
},
|
||||
onRevoke = { id ->
|
||||
@@ -2333,9 +2550,14 @@ fun RelayApp() {
|
||||
?.getString(Screen.Pair.ARG_CONNECTION_ID)
|
||||
val autoStartArg = backStackEntry.arguments
|
||||
?.getString(Screen.Pair.ARG_AUTO_START)
|
||||
val pairConnections by connectionViewModel.connections.collectAsState()
|
||||
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val pairSetupReady = connectionIdArg == null ||
|
||||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
|
||||
com.hermesandroid.relay.ui.screens.PairScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
autoStart = autoStartArg,
|
||||
setupReady = pairSetupReady,
|
||||
// Offer demo only on the bare "Connect" entry (the
|
||||
// "No Hermes connection" path) — not on add-connection /
|
||||
// re-pair flows, which have a placeholder connection in
|
||||
@@ -2354,8 +2576,9 @@ fun RelayApp() {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onManageSignIn = {
|
||||
navController.popBackStack()
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
navController.navigate(
|
||||
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_PAIR),
|
||||
) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
@@ -2367,6 +2590,10 @@ fun RelayApp() {
|
||||
// never got a pairedAt stamp.
|
||||
if (connectionIdArg != null) {
|
||||
connectionSwitchScope.launch {
|
||||
// If Back wins the race with background
|
||||
// preparation, wait until the placeholder
|
||||
// exists before attempting to discard it.
|
||||
pendingAddConnectionJobs.remove(connectionIdArg)?.join()
|
||||
connectionViewModel.discardPlaceholderConnection(connectionIdArg)
|
||||
}
|
||||
}
|
||||
@@ -2502,9 +2729,9 @@ fun RelayApp() {
|
||||
// doesn't happen to match the one we're inspecting
|
||||
// (shouldn't normally happen since the entry is
|
||||
// keyed off the same Profile).
|
||||
val selectedProfile by connectionViewModel
|
||||
.selectedProfile.collectAsState()
|
||||
val modelLabel = selectedProfile
|
||||
val inspectorDisplayProfile by connectionViewModel
|
||||
.effectiveDisplayProfile.collectAsState()
|
||||
val modelLabel = inspectorDisplayProfile
|
||||
?.takeIf { it.name == profileNameArg }
|
||||
?.model
|
||||
|
||||
|
||||
+748
-174
File diff suppressed because it is too large
Load Diff
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
|
||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||
if (!content.contains("![")) return content to emptyList()
|
||||
val images = mutableListOf<ChatInlineImage>()
|
||||
var inlineDataImages = 0
|
||||
var inlineDataBytes = 0L
|
||||
var inlineOverflowNoticeAdded = false
|
||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
|
||||
val alt = m.groupValues[2].trim()
|
||||
val src = normalizeImageSrc(m.groupValues[3].trim())
|
||||
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
|
||||
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
|
||||
val exceedsInlineBudget = isInlineData && (
|
||||
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
|
||||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
|
||||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
|
||||
)
|
||||
if (exceedsInlineBudget) {
|
||||
return@replace if (inlineOverflowNoticeAdded) {
|
||||
""
|
||||
} else {
|
||||
inlineOverflowNoticeAdded = true
|
||||
"\n\n_Additional inline images omitted for memory safety._\n\n"
|
||||
}
|
||||
}
|
||||
images += ChatInlineImage(
|
||||
alt = alt,
|
||||
src = normalizeImageSrc(m.groupValues[3].trim()),
|
||||
src = src,
|
||||
sensitive = spoilerWrapped || isSensitiveAltText(alt),
|
||||
)
|
||||
if (inlineDataSize != null) {
|
||||
inlineDataImages += 1
|
||||
inlineDataBytes += inlineDataSize
|
||||
}
|
||||
""
|
||||
}
|
||||
if (images.isEmpty()) return content to emptyList()
|
||||
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
|
||||
return s.startsWith("http://") || s.startsWith("https://")
|
||||
}
|
||||
|
||||
private fun ChatInlineImage.isInlineDataImage(): Boolean =
|
||||
src.startsWith("data:image/", ignoreCase = true)
|
||||
|
||||
/**
|
||||
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
|
||||
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
|
||||
@@ -232,6 +258,7 @@ fun ChatInlineImages(
|
||||
images.forEach { image ->
|
||||
when {
|
||||
image.isRemote() -> RemoteChatImage(image, maxWidth)
|
||||
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
|
||||
// A server-local file the agent referenced — fetch it through
|
||||
// /media/by-path and render inline; on failure the notice shows
|
||||
// the ACTUAL reason (for debugging) instead of a generic message.
|
||||
@@ -253,6 +280,94 @@ fun ChatInlineImages(
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface DataUrlImagePhase {
|
||||
data object Loading : DataUrlImagePhase
|
||||
data class Loaded(
|
||||
val bitmap: ImageBitmap,
|
||||
val mime: String,
|
||||
) : DataUrlImagePhase
|
||||
data object Rejected : DataUrlImagePhase
|
||||
}
|
||||
|
||||
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
|
||||
@Composable
|
||||
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
|
||||
var viewerOpen by remember(image.src) { mutableStateOf(false) }
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(image.src) { mutableStateOf(false) }
|
||||
LaunchedEffect(image.src) {
|
||||
phase = withInlineImageDecodeLock {
|
||||
val decoded = decodeInlineImageDataUrl(image.src)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
|
||||
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
|
||||
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
val bitmap = BitmapFactory.decodeByteArray(
|
||||
decoded.bytes,
|
||||
0,
|
||||
decoded.bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||
},
|
||||
)
|
||||
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
|
||||
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
|
||||
}
|
||||
}
|
||||
when (val current = phase) {
|
||||
DataUrlImagePhase.Loading -> Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
|
||||
image.copy(src = "inline image"),
|
||||
reason = "Unsupported or oversized inline image.",
|
||||
)
|
||||
is DataUrlImagePhase.Loaded -> {
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Bitmap(
|
||||
bitmap = current.bitmap,
|
||||
displayName = image.alt.ifBlank { "image" },
|
||||
mime = current.mime,
|
||||
// Decode the already-retained data URL only when the
|
||||
// user requests Save/Share; don't keep a second 5 MiB
|
||||
// byte array beside every thumbnail.
|
||||
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
sensitive = image.sensitive,
|
||||
initiallyRevealed = revealed,
|
||||
)
|
||||
}
|
||||
InlineImageColumn(image, maxWidth) {
|
||||
BlurredMedia(
|
||||
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
|
||||
onReveal = { revealed = true },
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = current.bitmap,
|
||||
contentDescription = image.alt.ifBlank { "Generated image" },
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
|
||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||
* !isStreaming -> VOICE // GraphicEq, primary
|
||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
||||
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||
* ```
|
||||
*/
|
||||
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
|
||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||
* reflows the bar, the overline doesn't.
|
||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
||||
* (steer/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* (correct/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* is STEER, muted otherwise. Null collapses the row.
|
||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||
@@ -191,7 +191,7 @@ fun ChatInputBar(
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Caption row — steer/queue hinting, single line, no buttons.
|
||||
// Caption row — correct/queue hinting, single line, no buttons.
|
||||
AnimatedVisibility(visible = caption != null) {
|
||||
Text(
|
||||
text = caption ?: lastCaption.orEmpty(),
|
||||
|
||||
@@ -84,12 +84,16 @@ import com.hermesandroid.relay.data.ProfilePresence
|
||||
import com.hermesandroid.relay.data.ProfilePresenceResolver
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -656,6 +660,7 @@ fun AgentInfoSheet(
|
||||
// Profile + personality state — same flows the old pickers consumed.
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val resolvedDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
|
||||
var showProfileManager by remember { mutableStateOf(false) }
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
@@ -667,7 +672,7 @@ fun AgentInfoSheet(
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
@@ -710,9 +715,12 @@ fun AgentInfoSheet(
|
||||
// Connection summary state.
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
@@ -735,6 +743,26 @@ fun AgentInfoSheet(
|
||||
val allConnections by connectionViewModel.connectionStore.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.connectionStore
|
||||
.activeConnectionId.collectAsState()
|
||||
val activeConnection = remember(allConnections, activeConnectionId) {
|
||||
allConnections.firstOrNull { it.id == activeConnectionId }
|
||||
}
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown -> if (
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
|
||||
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
apiServerReachable -> ChatTransportReadiness.Ready
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
|
||||
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
)
|
||||
|
||||
// Mid-stream gate — mirrors what ProfilePicker's `enabled` flag was doing:
|
||||
// a radio tap during an in-flight chat turn would race the request. Apply
|
||||
@@ -758,12 +786,8 @@ fun AgentInfoSheet(
|
||||
|
||||
val profileOverridesPersonality =
|
||||
selectedProfile?.systemMessage?.isNotBlank() == true
|
||||
val effectiveDisplayProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
val serverResolvedAgentName = AgentDisplay.agentName(
|
||||
profile = effectiveDisplayProfile,
|
||||
profile = resolvedDisplayProfile,
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
connectionLabel = null,
|
||||
@@ -816,7 +840,7 @@ fun AgentInfoSheet(
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
AgentSheetHeader(
|
||||
profile = effectiveDisplayProfile,
|
||||
profile = resolvedDisplayProfile,
|
||||
selectedPersonality = selectedPersonality,
|
||||
defaultPersonality = defaultPersonality,
|
||||
localDisplayAlias = profileDisplayAlias,
|
||||
@@ -826,8 +850,7 @@ fun AgentInfoSheet(
|
||||
// default with the session provider.
|
||||
sessionModelName = selectedModelOverride ?: gatewayCurrentModel,
|
||||
modelProviderLabel = currentProviderLabel,
|
||||
apiServerReachable = apiServerReachable,
|
||||
chatMode = chatMode,
|
||||
chatRuntimeStatus = chatRuntimeStatus,
|
||||
isCustomized = selectedProfile != null ||
|
||||
selectedPersonality != "default" ||
|
||||
profileDisplayAlias != null,
|
||||
@@ -1133,8 +1156,7 @@ fun AgentInfoSheet(
|
||||
}
|
||||
|
||||
val inspectProfileText = stringResource(R.string.conn_info_inspect_profile)
|
||||
val inspectorTarget = selectedProfile
|
||||
?: serverDefaultProfile
|
||||
val inspectorTarget = resolvedDisplayProfile
|
||||
?: visibleProfileKeys
|
||||
.asSequence()
|
||||
.mapNotNull { key -> agentProfiles.firstOrNull { it.name == key } }
|
||||
@@ -1271,11 +1293,13 @@ fun AgentInfoSheet(
|
||||
// SSE fallback model list — /v1/models plus the configured profiles'
|
||||
// models (used only when the gateway model.options groups aren't
|
||||
// available, e.g. on an SSE transport).
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
||||
.distinct()
|
||||
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
|
||||
(apiModelOptions +
|
||||
agentProfiles.mapNotNull { profile ->
|
||||
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
|
||||
} +
|
||||
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
// Always show the Model picker — choosing a model is always possible
|
||||
// (Server default at minimum). While the provider/model list is still
|
||||
@@ -1344,14 +1368,14 @@ fun AgentInfoSheet(
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
primary = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model)
|
||||
toast(modelToast.format(model))
|
||||
if (selectedModelOverride != model.id) {
|
||||
chatViewModel.selectApiModel(model.id)
|
||||
toast(modelToast.format(model.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1526,10 +1550,8 @@ fun AgentInfoSheet(
|
||||
// Pre-resolve strings for Connection section
|
||||
val connectionTitle = stringResource(R.string.conn_info_connection_title)
|
||||
val switchServersHint = stringResource(R.string.conn_info_switch_servers_hint)
|
||||
val authLabel = stringResource(R.string.conn_info_auth)
|
||||
val apiReachableLabel = stringResource(R.string.conn_info_api_reachable)
|
||||
val pairedLabel = stringResource(R.string.conn_info_paired)
|
||||
val hermesLabel = stringResource(R.string.conn_info_hermes)
|
||||
val relayAuthLabel = stringResource(R.string.conn_info_relay_auth)
|
||||
val apiFallbackLabel = stringResource(R.string.active_section_optional_api_fallback)
|
||||
|
||||
SectionLabel(
|
||||
title = connectionTitle,
|
||||
@@ -1546,10 +1568,25 @@ fun AgentInfoSheet(
|
||||
val sessionTransport = sessionPathTransport(
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint),
|
||||
)
|
||||
val routeLabel = activeEndpoint?.displayLabel()
|
||||
?: com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(apiServerUrl)
|
||||
.takeIf { it.isNotBlank() }
|
||||
val routeLabel = if (sessionTransport.isGateway) {
|
||||
activeConnection?.routeCandidates
|
||||
?.firstOrNull {
|
||||
it.dashboard?.url?.trimEnd('/') == effectiveDashboardUrl.trimEnd('/')
|
||||
}
|
||||
?.displayLabel()
|
||||
?: effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
|
||||
com.hermesandroid.relay.data.Connection.endpointCandidateFromDashboardUrl(
|
||||
role = com.hermesandroid.relay.data.Connection.inferRouteRole(url),
|
||||
priority = 0,
|
||||
dashboardUrl = url,
|
||||
)?.displayLabel()
|
||||
}
|
||||
} else {
|
||||
activeEndpoint?.displayLabel()
|
||||
?: com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(effectiveApiServerUrl)
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val relayConnected = relayConnectionState == ConnectionState.Connected
|
||||
val threadsActive = connectionViewModel.proactiveEnabled.collectAsState().value &&
|
||||
connectionViewModel.authState.collectAsState().value is
|
||||
@@ -1570,7 +1607,7 @@ fun AgentInfoSheet(
|
||||
|
||||
// Multi-connection switcher. Renders inline as a radio list
|
||||
// (mirrors the Profile + Personality sections above) when the
|
||||
// user has ≥2 paired connections. Replaces the separate top-
|
||||
// user has ≥2 connections. Replaces the separate top-
|
||||
// bar ConnectionChip that used to be the only switch surface
|
||||
// — folding it here keeps all agent/connection controls in
|
||||
// one place, matching Bailey's ask in the 2026-04-20 audit.
|
||||
@@ -1583,11 +1620,10 @@ fun AgentInfoSheet(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
allConnections.forEach { connection ->
|
||||
val isActive = connection.id == activeConnectionId
|
||||
val hostname = com.hermesandroid.relay.data.Connection
|
||||
.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val statusLine = when {
|
||||
connection.pairedAt == null -> stringResource(R.string.conn_info_hostname_hermes, hostname)
|
||||
else -> stringResource(R.string.conn_info_hostname_paired, hostname)
|
||||
else -> stringResource(R.string.conn_info_hostname_relay_paired, hostname)
|
||||
}
|
||||
ProfileRadioRow(
|
||||
primary = connection.label,
|
||||
@@ -1605,9 +1641,17 @@ fun AgentInfoSheet(
|
||||
}
|
||||
}
|
||||
|
||||
ChipRow(label = authLabel) { authStateChip(authState) }
|
||||
ChipRow(label = apiReachableLabel) {
|
||||
val (label, bg, fg) = if (apiServerReachable) {
|
||||
if (relayUrl.isNotBlank() || authState is AuthState.Paired) {
|
||||
ChipRow(label = relayAuthLabel) { authStateChip(authState) }
|
||||
}
|
||||
ChipRow(label = apiFallbackLabel) {
|
||||
val (label, bg, fg) = if (apiServerUrl.isBlank()) {
|
||||
Triple(
|
||||
stringResource(R.string.active_section_not_configured),
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (apiServerReachable) {
|
||||
Triple(
|
||||
stringResource(R.string.conn_info_yes),
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
@@ -1674,8 +1718,8 @@ fun AgentInfoSheet(
|
||||
routeLabel = routeLabel,
|
||||
relayConnectionState = relayConnectionState,
|
||||
capabilities = sessionCaps,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
apiServerUrl = effectiveApiServerUrl,
|
||||
relayUrl = effectiveRelayUrl,
|
||||
streamingEndpoint = streamingEndpoint,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
serverCapabilities = serverCapabilities,
|
||||
@@ -2307,8 +2351,7 @@ private fun AgentSheetHeader(
|
||||
serverModelName: String,
|
||||
sessionModelName: String? = null,
|
||||
modelProviderLabel: String? = null,
|
||||
apiServerReachable: Boolean,
|
||||
chatMode: ChatMode,
|
||||
chatRuntimeStatus: ChatRuntimeStatus,
|
||||
isCustomized: Boolean,
|
||||
) {
|
||||
val agentName = AgentDisplay.agentName(
|
||||
@@ -2326,17 +2369,18 @@ private fun AgentSheetHeader(
|
||||
// The global default — surfaced as a quiet caption only when THIS session
|
||||
// runs something different (the always-visible global-vs-session split).
|
||||
val serverDefaultLabel = AgentDisplay.displayModelName(serverModelName)
|
||||
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
|
||||
val isConnected = chatRuntimeStatus is ChatRuntimeStatus.Connected
|
||||
val isConnecting = chatRuntimeStatus is ChatRuntimeStatus.Connecting
|
||||
val connectedText = stringResource(R.string.conn_info_connected)
|
||||
val connectingText = stringResource(R.string.conn_info_connecting)
|
||||
val disconnectedText = stringResource(R.string.conn_info_disconnected)
|
||||
val statusText = when {
|
||||
apiServerReachable -> connectedText
|
||||
isConnected -> connectedText
|
||||
isConnecting -> connectingText
|
||||
else -> disconnectedText
|
||||
}
|
||||
val statusColor = when {
|
||||
apiServerReachable -> MaterialTheme.colorScheme.primary
|
||||
isConnected -> MaterialTheme.colorScheme.primary
|
||||
isConnecting -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurity
|
||||
|
||||
private const val LEARN_MORE_URL =
|
||||
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
|
||||
"https://hermes-relay.dev/docs/architecture/connection-security.html"
|
||||
|
||||
/**
|
||||
* Per-surface "Connection security" detail sheet — the tap target for the
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class ConnectionSetupTimelineStep(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
/** Compact completed-state timeline shared by connection and auth flows. */
|
||||
@Composable
|
||||
fun ConnectionSetupTimeline(
|
||||
steps: List<ConnectionSetupTimelineStep>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
steps.forEachIndexed { index, step ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
if (index != steps.lastIndex) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(width = 2.dp, height = 34.dp)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = if (index == steps.lastIndex) 0.dp else 10.dp),
|
||||
) {
|
||||
Text(step.title, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
step.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -44,8 +47,10 @@ 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.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
@@ -55,9 +60,12 @@ import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.data.isTlsUrl
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
@@ -72,9 +80,9 @@ import kotlinx.coroutines.launch
|
||||
* on app start, tried first on every resolve; cleared by
|
||||
* [onClearPreferred].
|
||||
*
|
||||
* 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
|
||||
* [SettingsExpandableCard] by the caller for page layout hygiene.
|
||||
* Each route stays visible, while its Dashboard/API/Relay topology uses
|
||||
* progressive disclosure: the active route opens by default and fallback
|
||||
* routes keep a compact surface-and-port summary.
|
||||
*
|
||||
* Not visible on legacy installs: when [endpoints] is empty we render a
|
||||
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
||||
@@ -106,6 +114,9 @@ fun EndpointsCard(
|
||||
* resolver's cache-key scheme.
|
||||
*/
|
||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
||||
/** Auth state applies only to the currently active Dashboard route. */
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
/**
|
||||
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||
@@ -170,11 +181,17 @@ fun EndpointsCard(
|
||||
candidate = candidate,
|
||||
isActive = activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
isProbing = isProbing,
|
||||
outcome = outcomeFor(candidate),
|
||||
dashboardAuthenticated = dashboardAuthenticated.takeIf { activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority()
|
||||
},
|
||||
dashboardSignInRequired = dashboardSignInRequired && activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
|
||||
onUseNow = { onUseNow(candidate) },
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onClearPrefer = onClearPreferred,
|
||||
@@ -219,6 +236,8 @@ private fun EndpointRow(
|
||||
isPreferred: Boolean,
|
||||
isProbing: Boolean = false,
|
||||
outcome: RouteProbeOutcome? = null,
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
onUseNow: () -> Unit,
|
||||
onPrefer: () -> Unit,
|
||||
onClearPrefer: () -> Unit,
|
||||
@@ -230,6 +249,7 @@ private fun EndpointRow(
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
|
||||
|
||||
@@ -278,17 +298,6 @@ private fun EndpointRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Full URL, scheme included: http vs https decides whether
|
||||
// the health probe TLS-handshakes, so two rows that both
|
||||
// read "host:8642" can behave completely differently. The
|
||||
// scheme must be visible to be debuggable.
|
||||
Text(
|
||||
text = candidate.api.url +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
when {
|
||||
isProbing -> Text(
|
||||
text = stringResource(R.string.endpoints_checking),
|
||||
@@ -307,6 +316,47 @@ private fun EndpointRow(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
val dashboardSurfaceUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val dashboardLabel = stringResource(R.string.active_section_dashboard)
|
||||
val apiLabel = stringResource(R.string.active_section_api_server)
|
||||
val relayLabel = stringResource(R.string.active_section_relay)
|
||||
val surfaceSummary = listOfNotNull(
|
||||
dashboardSurfaceUrl?.let { "$dashboardLabel ${displayPort(it)}" },
|
||||
candidate.api?.url?.let { "$apiLabel ${displayPort(it)}" },
|
||||
candidate.relay?.url?.let { "$relayLabel ${displayPort(it)}" },
|
||||
).joinToString(" · ")
|
||||
if (surfaceSummary.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { detailsExpanded = !detailsExpanded }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = surfaceSummary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (detailsExpanded) {
|
||||
Icons.Filled.ExpandLess
|
||||
} else {
|
||||
Icons.Filled.ExpandMore
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
@@ -392,6 +442,15 @@ private fun EndpointRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (detailsExpanded) {
|
||||
RouteSurfaceMap(
|
||||
candidate = candidate,
|
||||
dashboardAuthenticated = dashboardAuthenticated,
|
||||
dashboardSignInRequired = dashboardSignInRequired,
|
||||
modifier = Modifier.padding(start = 26.dp, end = 4.dp, top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmRemove && onRemove != null) {
|
||||
@@ -400,7 +459,7 @@ private fun EndpointRow(
|
||||
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
|
||||
text = stringResource(R.string.endpoints_remove_route_body, candidate.routeAuthority().orEmpty()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
@@ -421,7 +480,7 @@ private fun EndpointRow(
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
|
||||
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.routeAuthority().orEmpty())) },
|
||||
text = {
|
||||
Text(
|
||||
text = body,
|
||||
@@ -436,6 +495,106 @@ private fun EndpointRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteSurfaceMap(
|
||||
candidate: EndpointCandidate,
|
||||
dashboardAuthenticated: Boolean? = null,
|
||||
dashboardSignInRequired: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dashboardUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val apiUrl = candidate.api?.url
|
||||
val relayUrl = candidate.relay?.url
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_dashboard),
|
||||
url = dashboardUrl,
|
||||
status = when {
|
||||
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
|
||||
dashboardAuthenticated == true -> stringResource(R.string.active_section_signed_in)
|
||||
dashboardUrl != null -> stringResource(R.string.active_section_configured)
|
||||
else -> stringResource(R.string.active_section_not_configured)
|
||||
},
|
||||
warning = dashboardSignInRequired,
|
||||
)
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_api_server),
|
||||
url = apiUrl,
|
||||
status = stringResource(
|
||||
if (apiUrl != null) R.string.active_section_configured
|
||||
else R.string.active_section_not_configured,
|
||||
),
|
||||
)
|
||||
RouteSurfaceRow(
|
||||
label = stringResource(R.string.active_section_relay),
|
||||
url = relayUrl,
|
||||
status = stringResource(
|
||||
if (relayUrl != null) R.string.active_section_configured
|
||||
else R.string.active_section_not_configured,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteSurfaceRow(
|
||||
label: String,
|
||||
url: String?,
|
||||
status: String,
|
||||
warning: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = url ?: "—",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (warning) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayPort(url: String): String {
|
||||
val uri = runCatching { URI(url) }.getOrNull()
|
||||
val port = uri?.port?.takeIf { it > 0 } ?: when (uri?.scheme?.lowercase()) {
|
||||
"https", "wss" -> 443
|
||||
else -> 80
|
||||
}
|
||||
return ":$port"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveChip(label: String) {
|
||||
Row(
|
||||
@@ -521,7 +680,7 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
* be classified independently before it's the active route.
|
||||
*/
|
||||
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
|
||||
isTlsUrl(primaryRouteUrl().orEmpty()) -> SurfaceSecurityKind.Tls
|
||||
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
|
||||
else -> SurfaceSecurityKind.Plain
|
||||
}
|
||||
@@ -531,21 +690,24 @@ private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||
*
|
||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
||||
* The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
|
||||
* fallback uses `:8642`, and an already-enabled Relay uses `:8767`.
|
||||
* Routes that need custom per-surface hosts or ports still come from a QR.
|
||||
*
|
||||
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||
* URL, keeps the stored priority).
|
||||
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
|
||||
* @param onSave invoked with (role, dashboardUrl, resultCallback); the callback
|
||||
* receives a user-facing error string to render inline, or null on
|
||||
* success (the dialog then closes itself).
|
||||
*/
|
||||
@Composable
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
relayEnabled: Boolean = false,
|
||||
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val knownRoles = listOf("tailscale", "public")
|
||||
var selectedRole by remember {
|
||||
mutableStateOf(
|
||||
@@ -561,7 +723,7 @@ fun RouteEditorDialog(
|
||||
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
|
||||
)
|
||||
}
|
||||
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
|
||||
var url by remember(original) { mutableStateOf(original?.primaryRouteUrl().orEmpty()) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -615,13 +777,20 @@ fun RouteEditorDialog(
|
||||
// Live preview of what will actually be saved — scheme and
|
||||
// port defaults applied — so "what, which port, http or
|
||||
// https?" is answered before Save, not after a failed probe.
|
||||
val previewCandidate = remember(url, effectiveRole) {
|
||||
val previewCandidate = remember(url, effectiveRole, relayEnabled) {
|
||||
url.takeIf { it.isNotBlank() }?.let {
|
||||
Connection.endpointCandidateFromApiUrl(
|
||||
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
|
||||
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
|
||||
Connection.endpointCandidateFromDashboardUrl(
|
||||
role = effectiveRole.ifBlank { "custom" },
|
||||
priority = original?.priority ?: 1,
|
||||
apiServerUrl = Connection.normalizeApiUrlInput(it),
|
||||
relayUrl = "",
|
||||
dashboardUrl = dashboardUrl,
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = if (relayEnabled) {
|
||||
apiUrl?.let(Connection::deriveDefaultRelayUrl)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -643,7 +812,7 @@ fun RouteEditorDialog(
|
||||
url.isBlank() ->
|
||||
supportingBlank
|
||||
previewCandidate != null ->
|
||||
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
|
||||
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.primaryRouteUrl().orEmpty())
|
||||
else ->
|
||||
supportingEnter
|
||||
},
|
||||
@@ -652,6 +821,21 @@ fun RouteEditorDialog(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
previewCandidate?.let { candidate ->
|
||||
RouteSurfaceMap(candidate = candidate)
|
||||
}
|
||||
if (selectedRole == "tailscale") {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_tailscale_setup_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { uriHandler.openUri(REMOTE_ACCESS_DOCS_URL) },
|
||||
) {
|
||||
Text(stringResource(R.string.endpoints_setup_help))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -683,4 +867,7 @@ fun RouteEditorDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private const val REMOTE_ACCESS_DOCS_URL =
|
||||
"https://hermes-relay.dev/docs/guide/remote-access"
|
||||
|
||||
private const val CUSTOM_ROLE = "__custom__"
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val IMAGE_GENERATION_TOOL = "image_generate"
|
||||
private const val GRID_COLUMNS = 42
|
||||
private const val GRID_ROWS = 24
|
||||
|
||||
internal fun ToolCall.showsImageGenerationPlaceholder(): Boolean =
|
||||
!isComplete && name.trim().lowercase() == IMAGE_GENERATION_TOOL
|
||||
|
||||
/**
|
||||
* Keep the image canvas alive across the short tool-complete → media-arrival
|
||||
* handoff. Once a result surface exists it can crossfade into the same bubble;
|
||||
* a failed or fully-finished turn never leaves a stale canvas behind.
|
||||
*/
|
||||
internal fun shouldShowImageGenerationPlaceholder(
|
||||
toolCalls: List<ToolCall>,
|
||||
isStreaming: Boolean,
|
||||
hasMediaResult: Boolean,
|
||||
): Boolean {
|
||||
val imageCalls = toolCalls.filter {
|
||||
it.name.trim().lowercase() == IMAGE_GENERATION_TOOL
|
||||
}
|
||||
if (imageCalls.any { !it.isComplete }) return true
|
||||
return !hasMediaResult &&
|
||||
isStreaming &&
|
||||
imageCalls.any { it.isComplete && it.success != false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Image generation is a user-visible result lifecycle, not generic tool
|
||||
* diagnostics. Keep its active canvas visible even when upstream
|
||||
* `display.tool_progress` hides ordinary tool cards.
|
||||
*/
|
||||
internal fun ToolCall.isVisibleForToolDisplay(toolDisplay: String): Boolean =
|
||||
toolDisplay != "off" || showsImageGenerationPlaceholder()
|
||||
|
||||
/**
|
||||
* Theme-aware latent diffusion preview for an active Hermes image-generation
|
||||
* tool. It specializes the generic tool lifecycle already emitted by vanilla
|
||||
* Hermes; no Relay-only protocol or server patch is required.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageGenerationPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(R.string.image_generation_rendering)
|
||||
val transition = rememberInfiniteTransition(label = "imageGenerationDiffusion")
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 4_800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "imageGenerationDiffusionPhase",
|
||||
)
|
||||
val background = MaterialTheme.colorScheme.surfaceVariant
|
||||
val foreground = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val tertiary = MaterialTheme.colorScheme.tertiary
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(background)
|
||||
.semantics {
|
||||
contentDescription = description
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
},
|
||||
) {
|
||||
DiffusionCanvas(
|
||||
phase = phase,
|
||||
background = background,
|
||||
foreground = foreground,
|
||||
primary = primary,
|
||||
tertiary = tertiary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffusionCanvas(
|
||||
phase: Float,
|
||||
background: Color,
|
||||
foreground: Color,
|
||||
primary: Color,
|
||||
tertiary: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
drawRect(background)
|
||||
val cellWidth = size.width / GRID_COLUMNS
|
||||
val cellHeight = size.height / GRID_ROWS
|
||||
val denoise = diffusionDenoise(phase)
|
||||
val time = phase * 9f
|
||||
|
||||
repeat(GRID_ROWS) { row ->
|
||||
repeat(GRID_COLUMNS) { column ->
|
||||
val signal = diffusionSignal(column, row, time, denoise)
|
||||
if (signal < 0.2f) return@repeat
|
||||
|
||||
val x = column * cellWidth + cellWidth * 0.5f
|
||||
val y = row * cellHeight + cellHeight * 0.5f
|
||||
val radius = (cellWidth.coerceAtMost(cellHeight) * (0.14f + signal * 0.24f))
|
||||
.coerceAtLeast(0.7.dp.toPx())
|
||||
val warmMix = ((signal - 0.35f) / 0.65f).coerceIn(0f, 1f)
|
||||
val base = lerpColor(foreground, primary, warmMix)
|
||||
val color = lerpColor(base, tertiary, hash01(column + 17, row - 11) * 0.32f)
|
||||
|
||||
drawRoundRect(
|
||||
color = color.copy(alpha = (0.08f + signal * 0.76f).coerceAtMost(0.84f)),
|
||||
topLeft = Offset(x - radius, y - radius),
|
||||
size = Size(radius * 2f, radius * 2f),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius * 0.45f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffusionDenoise(phase: Float): Float {
|
||||
val normalized = phase - floor(phase)
|
||||
return if (normalized < 0.82f) {
|
||||
smoothstep(0.02f, 0.82f, normalized)
|
||||
} else {
|
||||
1f - smoothstep(0.82f, 1f, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun diffusionSignal(column: Int, row: Int, time: Float, denoise: Float): Float {
|
||||
val nx = (column + 0.5f) / GRID_COLUMNS - 0.52f
|
||||
val ny = (row + 0.5f) / GRID_ROWS - 0.5f
|
||||
val radius = kotlin.math.sqrt(nx * nx * 1.35f + ny * ny)
|
||||
val bloom = (1f - radius * 2.35f).coerceIn(0f, 1f)
|
||||
val ring = (1f - abs(radius - (0.23f + sin(time * 0.44f) * 0.025f)) * 15f)
|
||||
.coerceIn(0f, 1f)
|
||||
val latent = (bloom * 0.75f + ring * 0.5f).coerceIn(0f, 1f)
|
||||
val staticNoise = hash01(column + floor(time * 3f).toInt() * 19, row - floor(time * 3f).toInt() * 11)
|
||||
val livingNoise = hash01(column + floor(time * 7f).toInt(), row + floor(time * 5f).toInt())
|
||||
return (
|
||||
staticNoise * (1f - denoise) +
|
||||
latent * denoise +
|
||||
(livingNoise - 0.5f) * (0.42f - denoise * 0.2f)
|
||||
).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun hash01(x: Int, y: Int): Float {
|
||||
val value = sin(x * 127.1 + y * 311.7) * 43_758.5453
|
||||
return (value - floor(value)).toFloat()
|
||||
}
|
||||
|
||||
private fun smoothstep(edge0: Float, edge1: Float, value: Float): Float {
|
||||
val t = ((value - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
|
||||
return t * t * (3f - 2f * t)
|
||||
}
|
||||
|
||||
private fun lerpColor(from: Color, to: Color, amount: Float): Color = Color(
|
||||
red = from.red + (to.red - from.red) * amount,
|
||||
green = from.green + (to.green - from.green) * amount,
|
||||
blue = from.blue + (to.blue - from.blue) * amount,
|
||||
alpha = from.alpha + (to.alpha - from.alpha) * amount,
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import java.util.Base64
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
|
||||
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
|
||||
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
|
||||
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
|
||||
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
|
||||
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
private const val MAX_HEADER_CHARS = 64
|
||||
private val inlineImageDecodeMutex = Mutex()
|
||||
private val DATA_IMAGE_HEADER = Regex(
|
||||
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
internal data class DecodedInlineImageData(
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Strict decoder for upstream API-server inline images.
|
||||
*
|
||||
* Only the five raster formats upstream emits for this path are accepted. The
|
||||
* encoded input is bounded before allocation, strict base64 is required, and
|
||||
* the decoded magic bytes must agree with the declared MIME type.
|
||||
*/
|
||||
internal fun decodeInlineImageDataUrl(
|
||||
source: String,
|
||||
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
|
||||
): DecodedInlineImageData? {
|
||||
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
|
||||
val comma = source.indexOf(',')
|
||||
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
|
||||
?.groupValues?.get(1)?.lowercase() ?: return null
|
||||
val encoded = source.substring(comma + 1)
|
||||
if (encoded.isEmpty()) return null
|
||||
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
|
||||
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
|
||||
if (encoded.length.toLong() > maxEncoded) return null
|
||||
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
|
||||
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
|
||||
return DecodedInlineImageData(bytes, mime)
|
||||
}
|
||||
|
||||
/** Conservative decoded-size estimate that does not allocate a byte array. */
|
||||
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
|
||||
if (!source.startsWith("data:image/", ignoreCase = true)) return null
|
||||
val comma = source.indexOf(',')
|
||||
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||
val encodedLength = source.length.toLong() - comma - 1L
|
||||
if (encodedLength <= 0) return null
|
||||
val padding = when {
|
||||
source.endsWith("==") -> 2L
|
||||
source.endsWith('=') -> 1L
|
||||
else -> 0L
|
||||
}
|
||||
return ((encodedLength + 3L) / 4L) * 3L - padding
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
|
||||
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
|
||||
* allocations from overlapping.
|
||||
*/
|
||||
internal suspend fun <T> withInlineImageDecodeLock(
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
block: () -> T,
|
||||
): T = withContext(dispatcher) {
|
||||
inlineImageDecodeMutex.withLock { block() }
|
||||
}
|
||||
|
||||
internal suspend fun decodeInlineImageDataUrlOffMain(
|
||||
source: String,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
|
||||
decodeInlineImageDataUrl(source)
|
||||
}
|
||||
|
||||
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
|
||||
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||
)
|
||||
"image/jpeg" -> bytes.size >= 3 &&
|
||||
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
|
||||
"image/webp" -> bytes.size >= 12 &&
|
||||
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
|
||||
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
|
||||
"image/gif" -> bytes.size >= 6 &&
|
||||
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
|
||||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
|
||||
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
|
||||
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
|
||||
*/
|
||||
internal fun inlineImageSampleSize(
|
||||
width: Int,
|
||||
height: Int,
|
||||
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
|
||||
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
|
||||
): Int? {
|
||||
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
|
||||
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
|
||||
) return null
|
||||
var sample = 1
|
||||
while (true) {
|
||||
val sampledWidth = (width + sample - 1L) / sample
|
||||
val sampledHeight = (height + sample - 1L) / sample
|
||||
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
|
||||
sampledWidth <= maxPixels / sampledHeight
|
||||
) return sample
|
||||
if (sample >= 1 shl 15) return null
|
||||
sample *= 2
|
||||
}
|
||||
}
|
||||
@@ -11,34 +11,21 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.ContentCopy
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.semantics.CollectionInfo
|
||||
import androidx.compose.ui.semantics.collectionInfo
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -54,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTable
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||
@@ -69,7 +54,6 @@ import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.delay
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
|
||||
@@ -166,7 +150,7 @@ fun MarkdownContent(
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
SafeMarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
@@ -174,7 +158,7 @@ fun MarkdownContent(
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownHighlightedCodeFence(
|
||||
SafeMarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
@@ -303,285 +287,42 @@ fun StreamingMarkdownContent(
|
||||
isStreaming: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blocks = remember(content, isStreaming) {
|
||||
if (isStreaming) {
|
||||
parseStreamingMarkdownBlocks(content)
|
||||
} else {
|
||||
listOf(StreamingMarkdownBlock.Markdown(content))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
// Match the final renderer's block spacer so moving the active tail
|
||||
// into the settled Markdown prefix does not add a second layout jump.
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
|
||||
content = block.content,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Text -> Text(
|
||||
text = block.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
|
||||
// Discord-like fenced block: a contrasting inset surface with a thin header
|
||||
// (language label + copy), and a horizontally-scrollable monospace body.
|
||||
// Header is shown whenever there's a language to label or code to copy, so
|
||||
// even a bare ``` fence gets the copy affordance once it has content.
|
||||
val hasHeader = block.language.isNotBlank() || block.code.isNotBlank()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
if (hasHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = block.language.ifBlank { "code" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
CodeCopyButton(code = block.code)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = block.code.ifEmpty { " " },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small copy affordance for a code block — copies [code] to the clipboard and
|
||||
* briefly flips to a check for feedback. No-op while [code] is blank.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeCopyButton(code: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (code.isNotBlank()) {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copied = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
if (isStreaming) {
|
||||
// Keep one stable layout node for the entire live turn. Promoting each
|
||||
// blank-terminated paragraph into Markdown replaced the Text subtree
|
||||
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
|
||||
// which looked like the whole chat reloaded. Updating this Text value
|
||||
// only remeasures the growing bubble. Full Markdown is parsed once the
|
||||
// owning row releases its stable live-tail layout.
|
||||
Text(
|
||||
// CommonMark ignores blank lines before the first block. The live
|
||||
// Text renderer must do the same or a response whose transport
|
||||
// prefix contains newlines appears to start several lines down.
|
||||
// Preserve indentation on the first non-blank line so indented
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface StreamingMarkdownBlock {
|
||||
data class Markdown(val content: String) : StreamingMarkdownBlock
|
||||
data class Text(val text: String) : StreamingMarkdownBlock
|
||||
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an in-flight response into stable Markdown and one structurally
|
||||
* incomplete tail.
|
||||
*
|
||||
* Only conservative, blank-terminated top-level prose/heading blocks promote
|
||||
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
|
||||
* remain on the lightweight streaming surface until the message settles; those
|
||||
* containers can legally absorb later lines, so promoting them early causes a
|
||||
* visible re-parenting jump when the final CommonMark tree is parsed.
|
||||
*/
|
||||
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
|
||||
if (content.isBlank()) return emptyList()
|
||||
|
||||
val normalized = content
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
|
||||
|
||||
if (settledEnd > 0) {
|
||||
normalized.substring(0, settledEnd).trimEnd().let { stable ->
|
||||
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
|
||||
}
|
||||
}
|
||||
|
||||
val activeTail = normalized.substring(settledEnd)
|
||||
if (activeTail.isNotBlank()) {
|
||||
blocks += parseActiveStreamingTail(activeTail)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
|
||||
private fun findStableMarkdownBoundary(content: String): Int {
|
||||
var offset = 0
|
||||
var blockStart = 0
|
||||
var activeFence: StreamingFence? = null
|
||||
var lastStableBoundary = 0
|
||||
|
||||
while (offset < content.length) {
|
||||
val newline = content.indexOf('\n', offset)
|
||||
val lineEnd = if (newline >= 0) newline else content.length
|
||||
val line = content.substring(offset, lineEnd)
|
||||
|
||||
activeFence = when (val current = activeFence) {
|
||||
null -> streamingFence(line)
|
||||
else -> if (isClosingFence(line, current)) null else current
|
||||
}
|
||||
|
||||
// Whitespace-only lines can be meaningful indentation inside a list.
|
||||
// Require a truly empty delimiter and stop at the first ambiguous
|
||||
// container so every promoted prefix remains structurally final.
|
||||
if (activeFence == null && newline >= 0 && line.isEmpty()) {
|
||||
val candidate = content.substring(blockStart, offset).trimEnd()
|
||||
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
|
||||
lastStableBoundary = newline + 1
|
||||
blockStart = lastStableBoundary
|
||||
}
|
||||
|
||||
if (newline < 0) break
|
||||
offset = newline + 1
|
||||
}
|
||||
|
||||
return lastStableBoundary
|
||||
}
|
||||
|
||||
private fun isConservativeStableBlock(block: String): Boolean = block
|
||||
.lineSequence()
|
||||
.filter { it.isNotEmpty() }
|
||||
.none { line ->
|
||||
line.firstOrNull()?.isWhitespace() == true ||
|
||||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
|
||||
}
|
||||
|
||||
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val paragraph = StringBuilder()
|
||||
val code = StringBuilder()
|
||||
var activeFence: StreamingFence? = null
|
||||
var language = ""
|
||||
|
||||
fun flushParagraph() {
|
||||
val text = paragraph.toString().trim('\n').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.split('\n')
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
|
||||
|
||||
if (activeFence == null) {
|
||||
val fence = streamingFence(line)
|
||||
if (fence != null) {
|
||||
flushParagraph()
|
||||
activeFence = fence
|
||||
language = streamingFenceLanguage(line, fence)
|
||||
} else {
|
||||
paragraph.append(lineWithBreak)
|
||||
}
|
||||
} else if (isClosingFence(line, activeFence)) {
|
||||
flushCode()
|
||||
activeFence = null
|
||||
language = ""
|
||||
} else {
|
||||
code.append(lineWithBreak)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeFence != null) {
|
||||
flushCode()
|
||||
} else {
|
||||
flushParagraph()
|
||||
MarkdownContent(
|
||||
content = content,
|
||||
textColor = textColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private data class StreamingFence(
|
||||
val marker: Char,
|
||||
val length: Int,
|
||||
)
|
||||
internal fun String.withoutLeadingBlankLines(): String {
|
||||
var contentStart = 0
|
||||
while (contentStart < length) {
|
||||
val lineEnd = indexOf('\n', startIndex = contentStart)
|
||||
if (lineEnd < 0) break
|
||||
|
||||
private fun streamingFence(line: String): StreamingFence? {
|
||||
val trimmed = line.trimStart()
|
||||
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
|
||||
val length = trimmed.takeWhile { it == marker }.length
|
||||
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
|
||||
val line = substring(contentStart, lineEnd).removeSuffix("\r")
|
||||
if (line.isNotBlank()) break
|
||||
contentStart = lineEnd + 1
|
||||
}
|
||||
return substring(contentStart)
|
||||
}
|
||||
|
||||
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
|
||||
val trimmed = line.trimStart()
|
||||
if (trimmed.firstOrNull() != activeFence.marker) return false
|
||||
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
|
||||
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
|
||||
}
|
||||
|
||||
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
|
||||
val tail = line.trimStart().drop(fence.length).trim()
|
||||
return tail
|
||||
.takeWhile { !it.isWhitespace() && it != fence.marker }
|
||||
.take(32)
|
||||
}
|
||||
|
||||
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
|
||||
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
@@ -83,6 +86,12 @@ fun MessageBubble(
|
||||
showThinking: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
/**
|
||||
* Keeps a just-completed live tail on its stable Text layout. The owning
|
||||
* list releases this once another row becomes the tail, allowing full
|
||||
* Markdown to render without disturbing the visible bottom anchor.
|
||||
*/
|
||||
retainStreamingLayout: Boolean = false,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Quote this message into the input field. Null hides the Quote entry in
|
||||
@@ -197,6 +206,11 @@ fun MessageBubble(
|
||||
extractChatInlineImages(message.content)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -301,6 +315,7 @@ fun MessageBubble(
|
||||
val showBubble = isUser || isSystem ||
|
||||
message.content.isNotBlank() ||
|
||||
message.isStreaming ||
|
||||
showImageGeneration ||
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
@@ -361,6 +376,26 @@ fun MessageBubble(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isUser && !isSystem &&
|
||||
(message.isStreaming || retainStreamingLayout)
|
||||
) {
|
||||
// The frame-paced text node is already measured at its
|
||||
// new size. Animate and clip the owning surface so a
|
||||
// newly wrapped line is revealed inside the expanding
|
||||
// bubble instead of drawing below the previous bounds
|
||||
// for one frame. TopStart keeps existing prose fixed.
|
||||
Modifier.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 72,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
alignment = Alignment.TopStart,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
Modifier.leftEdgeGlow(
|
||||
@@ -396,15 +431,16 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Settled blocks keep the real Markdown renderer while
|
||||
// only the structurally incomplete tail stays raw. The
|
||||
// same composable settles the final tail so retained
|
||||
// parser state survives the streaming -> final handoff.
|
||||
// Keep one plain Text node stable while content grows
|
||||
// and while this completed response remains the visible
|
||||
// tail. Full Markdown renders once another row becomes
|
||||
// the tail (or the session is revisited), where its
|
||||
// remeasure cannot reset the active viewport.
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor,
|
||||
isStreaming = message.isStreaming,
|
||||
isStreaming = message.isStreaming || retainStreamingLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -449,32 +485,41 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments — two or more loaded images collapse into one
|
||||
// grid + swipe-across gallery. Every other item stays on the
|
||||
// unified InboundAttachmentCard path, and layout items retain
|
||||
// their original ChatMessage.attachments indices so retry /
|
||||
// manual-fetch callbacks cannot drift after grouping.
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = remember(message.attachments) {
|
||||
attachmentLayoutItems(message.attachments)
|
||||
}
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
// Image generation owns the bubble's progress slot. It replaces
|
||||
// the generic first-token dots, remains mounted through the
|
||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
||||
// attachment renderer in this same Surface.
|
||||
Crossfade(
|
||||
targetState = showImageGeneration,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "imageGenerationToResult",
|
||||
) { generating ->
|
||||
if (generating) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationPlaceholder()
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,7 +530,11 @@ fun MessageBubble(
|
||||
// signal, so the pulsing dots stop (Messenger/Telegram drop the
|
||||
// typing bubble the moment content appears) instead of throbbing
|
||||
// under the text for the whole turn.
|
||||
if (message.isStreaming && message.content.isBlank()) {
|
||||
if (
|
||||
message.isStreaming &&
|
||||
message.content.isBlank() &&
|
||||
!showImageGeneration
|
||||
) {
|
||||
// After a few seconds with no content yet, escalate the bare
|
||||
// dots to a labeled "Still working…" so a slow first token
|
||||
// never reads as a hang on the SSE / sessions paths.
|
||||
@@ -538,7 +587,11 @@ fun MessageBubble(
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
if (isLastInGroup) {
|
||||
// Keep the live bubble's footer structurally quiet. Showing a
|
||||
// timestamp while text is still growing makes it chase every
|
||||
// token and exaggerates any single-frame layout lag. Reveal it
|
||||
// once the message settles into its final layout.
|
||||
if (isLastInGroup && !message.isStreaming) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
|
||||
@@ -219,6 +219,16 @@ data class RelayPairing(
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/** True only when a scoped Relay scan can immediately begin authentication. */
|
||||
internal fun HermesPairingPayload.hasUsableRelayPairing(): Boolean {
|
||||
val relay = relay ?: return false
|
||||
if (relay.code.isBlank()) return false
|
||||
val uri = runCatching { URI(relay.url.trim()) }.getOrNull() ?: return false
|
||||
val supportedScheme = uri.scheme.equals("ws", ignoreCase = true) ||
|
||||
uri.scheme.equals("wss", ignoreCase = true)
|
||||
return supportedScheme && !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -258,8 +268,8 @@ 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
|
||||
// should still parse on this phone so Bailey doesn't have to ship a
|
||||
// whole release to keep up with wire-format growth.
|
||||
// should still parse so wire-format growth does not require an app
|
||||
// release for every compatible payload version.
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
|
||||
if (version < 1) return null
|
||||
@@ -532,7 +542,8 @@ private fun mapBoxToViewport(
|
||||
@Composable
|
||||
fun QrPairingScanner(
|
||||
onPairingDetected: (HermesPairingPayload) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
relayOnly: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -628,7 +639,10 @@ fun QrPairingScanner(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.qr_scanner_title),
|
||||
text = stringResource(
|
||||
if (relayOnly) R.string.qr_scanner_relay_title
|
||||
else R.string.qr_scanner_title,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
@@ -740,7 +754,11 @@ fun QrPairingScanner(
|
||||
) {
|
||||
val rawValue = barcode.rawValue ?: continue
|
||||
val payload = parseHermesPairingQr(rawValue)
|
||||
if (payload != null && hasDetected.compareAndSet(false, true)) {
|
||||
if (
|
||||
payload != null &&
|
||||
(!relayOnly || payload.hasUsableRelayPairing()) &&
|
||||
hasDetected.compareAndSet(false, true)
|
||||
) {
|
||||
lockedPayload = payload
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
@@ -798,13 +816,19 @@ fun QrPairingScanner(
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.qr_scanner_instruction),
|
||||
text = stringResource(
|
||||
if (relayOnly) R.string.qr_scanner_relay_instruction
|
||||
else R.string.qr_scanner_instruction,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.qr_scanner_subtext),
|
||||
text = stringResource(
|
||||
if (relayOnly) R.string.qr_scanner_relay_subtext
|
||||
else R.string.qr_scanner_subtext,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBackground
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
|
||||
@Composable
|
||||
internal fun SafeMarkdownHighlightedCodeFence(
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
style: TextStyle = LocalMarkdownTypography.current.code,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean = false,
|
||||
) {
|
||||
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
|
||||
SafeMarkdownHighlightedCode(
|
||||
code = code,
|
||||
language = language,
|
||||
style = codeStyle,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SafeMarkdownHighlightedCodeBlock(
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
style: TextStyle = LocalMarkdownTypography.current.code,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean = false,
|
||||
) {
|
||||
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
|
||||
SafeMarkdownHighlightedCode(
|
||||
code = code,
|
||||
language = language,
|
||||
style = codeStyle,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = showHeader,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafeMarkdownHighlightedCode(
|
||||
code: String,
|
||||
language: String?,
|
||||
style: TextStyle,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
showHeader: Boolean,
|
||||
) {
|
||||
val codeHighlights by produceState(
|
||||
initialValue = AnnotatedString(code),
|
||||
key1 = code,
|
||||
key2 = language,
|
||||
key3 = highlightsBuilder,
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
buildSafeHighlightedAnnotatedString(code, language, highlightsBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
val codeBackgroundCornerSize = LocalMarkdownDimens.current.codeBackgroundCornerSize
|
||||
MarkdownCodeBackground(
|
||||
color = LocalMarkdownColors.current.codeBackground,
|
||||
shape = RoundedCornerShape(codeBackgroundCornerSize),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
showHeader = showHeader,
|
||||
language = language,
|
||||
code = code,
|
||||
) {
|
||||
MarkdownBasicText(
|
||||
text = codeHighlights,
|
||||
style = style,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(LocalMarkdownPadding.current.codeBlock),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Highlights ranges into Compose spans without trusting dependency
|
||||
* offsets. Highlights 1.1.0 can return a reversed multiline-comment range
|
||||
* when a multiline-comment closing delimiter precedes its opening delimiter.
|
||||
* Streaming can expose that shape before a code block is complete.
|
||||
*/
|
||||
internal fun buildSafeHighlightedAnnotatedString(
|
||||
code: String,
|
||||
language: String?,
|
||||
highlightsBuilder: Highlights.Builder,
|
||||
): AnnotatedString {
|
||||
val syntaxLanguage = language?.let(SyntaxLanguage::getByName)
|
||||
val highlights = highlightsBuilder
|
||||
.code(code)
|
||||
.let { if (syntaxLanguage != null) it.language(syntaxLanguage) else it }
|
||||
.build()
|
||||
.getHighlights()
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
highlights.forEach { highlight ->
|
||||
val start = highlight.location.start.coerceIn(0, code.length)
|
||||
val end = highlight.location.end.coerceIn(0, code.length)
|
||||
if (start >= end) return@forEach
|
||||
|
||||
val style = when (highlight) {
|
||||
is ColorHighlight -> SpanStyle(color = Color(highlight.rgb).copy(alpha = 1f))
|
||||
is BoldHighlight -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
}
|
||||
addStyle(style = style, start = start, end = end)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,15 @@ data class SourceBadge(val label: String, val color: Color)
|
||||
* The app's own chats — no badge (they're "your" conversations from this app /
|
||||
* desktop / API, not a distinct gateway lane).
|
||||
*/
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "")
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "webui", "")
|
||||
|
||||
/**
|
||||
* Map a session `source` to a drawer badge, or null for the app's own chats and
|
||||
* the phone **Thread** (which renders its own thread-spool chip). External
|
||||
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
|
||||
* small colored chip so the drawer reads like the desktop's per-channel tags.
|
||||
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron,
|
||||
* webhook, phone.
|
||||
* Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
|
||||
* cron, webhook, phone.
|
||||
*/
|
||||
fun sourceBadge(source: String?): SourceBadge? {
|
||||
val s = source?.trim()?.lowercase() ?: return null
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
@@ -140,6 +141,8 @@ fun VoiceWaveform(
|
||||
amplitude: Float,
|
||||
state: VoiceState,
|
||||
outputAudioActive: Boolean = false,
|
||||
height: Dp = 56.dp,
|
||||
compactBars: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// No downstream smoothing. The VoiceViewModel already runs an
|
||||
@@ -154,6 +157,7 @@ fun VoiceWaveform(
|
||||
// doesn't look abrupt.
|
||||
val dim = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val compactColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val targetPrimary = when (state) {
|
||||
VoiceState.Idle -> dim.copy(alpha = 0.3f)
|
||||
@@ -198,16 +202,38 @@ fun VoiceWaveform(
|
||||
)
|
||||
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
val canvasModifier = if (compactBars) {
|
||||
modifier.height(height)
|
||||
} else {
|
||||
modifier.fillMaxWidth().height(height)
|
||||
}
|
||||
Canvas(modifier = canvasModifier) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
if (width <= 0f || height <= 0f) return@Canvas
|
||||
|
||||
val centerY = height / 2f
|
||||
|
||||
if (compactBars) {
|
||||
val barCount = 10
|
||||
val gap = 3.dp.toPx()
|
||||
val barWidth = 2.5.dp.toPx()
|
||||
val totalWidth = barWidth * barCount + gap * (barCount - 1)
|
||||
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
|
||||
repeat(barCount) { index ->
|
||||
val wave = ((sin(phases[0] + index * 0.82f) + 1f) * 0.5f)
|
||||
val activeHeight = height * (0.22f + wave * (0.28f + displayAmplitude * 0.5f))
|
||||
val x = startX + index * (barWidth + gap)
|
||||
drawRoundRect(
|
||||
color = compactColor.copy(alpha = if (index < 5) 0.96f else 0.22f),
|
||||
topLeft = Offset(x, centerY - activeHeight / 2f),
|
||||
size = Size(barWidth, activeHeight),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
|
||||
)
|
||||
}
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
val peakPixels = height * PEAK_FRACTION
|
||||
val strokePx = STROKE_WIDTH_DP.dp.toPx()
|
||||
val centerX = width / 2f
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -20,23 +19,21 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
|
||||
@Composable
|
||||
fun OnboardingPage(
|
||||
@@ -44,7 +41,6 @@ fun OnboardingPage(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
transparentHero: Boolean = false,
|
||||
heroContent: @Composable BoxScope.() -> Unit = {
|
||||
FeatureHero(
|
||||
icon = icon,
|
||||
@@ -53,101 +49,44 @@ fun OnboardingPage(
|
||||
},
|
||||
content: @Composable ColumnScope.() -> Unit = {}
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val heroShape = RoundedCornerShape(30.dp)
|
||||
val bodyShape = RoundedCornerShape(26.dp)
|
||||
val heroBrush = Brush.radialGradient(
|
||||
colors = if (isDarkTheme) {
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f),
|
||||
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f),
|
||||
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.70f),
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
|
||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.60f),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Short viewports (small phones, large font scale, split screen) shrink or
|
||||
// drop the hero so the body text fits; the vertical scroll below is the
|
||||
// safety net when even that isn't enough. The enclosing pager Box centers
|
||||
// short content, so no Arrangement.Center here — it conflicts with
|
||||
// verticalScroll when content overflows.
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val heroHeight = when {
|
||||
maxHeight < 480.dp -> 0.dp
|
||||
maxHeight < 620.dp -> 160.dp
|
||||
else -> 232.dp
|
||||
}
|
||||
Column(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 42.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.height(210.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (heroHeight > 0.dp) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
|
||||
shape = bodyShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
heroContent()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 40.sp,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 25.sp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,26 +95,45 @@ private fun FeatureHero(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(136.dp)
|
||||
.size(176.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f)
|
||||
Color(0xFF7B55F6).copy(alpha = 0.07f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(74.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF7B55F6).copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(86.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f),
|
||||
),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(46.dp),
|
||||
tint = Color(0xFF7B55F6),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -18,19 +17,33 @@ 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.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -45,28 +58,64 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
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 }
|
||||
/** Page identifiers for the standard-first onboarding flow. */
|
||||
internal enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect, Permissions }
|
||||
|
||||
internal val standardOnboardingPages = listOf(
|
||||
OnboardingPage.Welcome,
|
||||
OnboardingPage.Chat,
|
||||
OnboardingPage.Manage,
|
||||
OnboardingPage.Power,
|
||||
OnboardingPage.Connect,
|
||||
OnboardingPage.Permissions,
|
||||
)
|
||||
|
||||
internal enum class OnboardingNotificationAction {
|
||||
RequestPermission,
|
||||
Finish,
|
||||
}
|
||||
|
||||
internal fun onboardingNotificationAction(
|
||||
sdkInt: Int,
|
||||
notificationsPermitted: Boolean,
|
||||
): OnboardingNotificationAction {
|
||||
return if (
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
!notificationsPermitted
|
||||
) {
|
||||
OnboardingNotificationAction.RequestPermission
|
||||
} else {
|
||||
OnboardingNotificationAction.Finish
|
||||
}
|
||||
}
|
||||
|
||||
private val OnboardingAccent = Color(0xFF7B55F6)
|
||||
|
||||
/**
|
||||
* Standard-first onboarding:
|
||||
@@ -112,21 +161,23 @@ fun OnboardingScreen(
|
||||
*/
|
||||
onTryDemo: () -> Unit = {},
|
||||
) {
|
||||
val pages = remember {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
add(OnboardingPage.Manage)
|
||||
add(OnboardingPage.Power)
|
||||
add(OnboardingPage.Connect)
|
||||
}
|
||||
}
|
||||
val pages = standardOnboardingPages
|
||||
val pageCount = pages.size
|
||||
val lastPage = pageCount - 1
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
var notificationsPermitted by remember {
|
||||
mutableStateOf(AppPermissionStatusProbe.snapshot(context).notificationsPermitted)
|
||||
}
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
notificationsPermitted =
|
||||
granted || AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -156,22 +207,35 @@ fun OnboardingScreen(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Top bar with Skip — only on informational pages, not the wizard
|
||||
// (which has its own "Skip for now" affordance).
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
val currentPage = pagerState.currentPage
|
||||
val currentPageType = pages[currentPage]
|
||||
|
||||
// The selected welcome frame has no toolbar. Later information
|
||||
// pages keep quiet navigation without reserving space above it.
|
||||
if (
|
||||
currentPageType == OnboardingPage.Chat ||
|
||||
currentPageType == OnboardingPage.Manage ||
|
||||
currentPageType == OnboardingPage.Power
|
||||
) {
|
||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(currentPage - 1) }
|
||||
},
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_back))
|
||||
}
|
||||
TextButton(onClick = { showSkipConfirm = true }) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_skip),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(stringResource(R.string.onboarding_skip))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +243,13 @@ fun OnboardingScreen(
|
||||
// Pager content
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
// Connect and permission setup advance only through their
|
||||
// explicit actions. This keeps the post-connect setup page
|
||||
// unreachable until the wizard reports a successful save.
|
||||
userScrollEnabled =
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions,
|
||||
) { pageIndex ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -194,243 +264,332 @@ fun OnboardingScreen(
|
||||
)
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onComplete = {
|
||||
notificationsPermitted =
|
||||
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
pages.indexOf(OnboardingPage.Permissions),
|
||||
)
|
||||
}
|
||||
},
|
||||
onManageSignIn = onManageSignIn,
|
||||
onSkip = { showSkipConfirm = true },
|
||||
onTryDemo = onTryDemo,
|
||||
)
|
||||
OnboardingPage.Permissions -> PermissionSetupPage(
|
||||
notificationAction = onboardingNotificationAction(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
notificationsPermitted = notificationsPermitted,
|
||||
),
|
||||
onEnableNotifications = {
|
||||
notificationPermissionLauncher.launch(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
},
|
||||
onReviewPermissions = onOpenPermissions,
|
||||
onFinish = onComplete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom navigation only on informational pages — the wizard
|
||||
// owns its own back/pair affordances.
|
||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
||||
// Short viewports get a tighter footer so more of the pager
|
||||
// content stays above the fold; indicator + Back/Next remain
|
||||
// pinned outside the (scrollable) pager pages either way.
|
||||
if (
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions
|
||||
) {
|
||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.padding(horizontal = 42.dp)
|
||||
.padding(bottom = if (compactHeight) 10.dp else 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(if (compactHeight) 8.dp else 12.dp),
|
||||
) {
|
||||
PageIndicator(
|
||||
pageCount = pageCount,
|
||||
currentPage = pagerState.currentPage
|
||||
GradientOnboardingButton(
|
||||
label = when {
|
||||
currentPage == 0 -> stringResource(R.string.onboarding_get_started)
|
||||
currentPage == lastPage - 1 -> stringResource(R.string.onboarding_connect)
|
||||
else -> stringResource(R.string.onboarding_next)
|
||||
},
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage((currentPage + 1).coerceAtMost(lastPage))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = pagerState.currentPage > 0,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage - 1)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.onboarding_back))
|
||||
}
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
(pagerState.currentPage + 1).coerceAtMost(lastPage)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (currentPage == 0) {
|
||||
TextButton(onClick = onTryDemo) {
|
||||
Text(
|
||||
text = if (pagerState.currentPage == lastPage - 1) {
|
||||
stringResource(R.string.onboarding_connect)
|
||||
} else {
|
||||
stringResource(R.string.onboarding_next)
|
||||
}
|
||||
text = stringResource(R.string.chat_try_demo),
|
||||
color = OnboardingAccent,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SegmentedOnboardingProgress(
|
||||
pageCount = pageCount,
|
||||
currentPage = currentPage,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_step_count, currentPage + 1, pageCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GradientOnboardingButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Spacer(Modifier.width(24.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SegmentedOnboardingProgress(
|
||||
pageCount: Int,
|
||||
currentPage: Int,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 36.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repeat(pageCount) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(
|
||||
if (index == currentPage) {
|
||||
OnboardingAccent
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomePage() {
|
||||
val context = LocalContext.current
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
title = stringResource(R.string.onboarding_welcome_title),
|
||||
description = stringResource(R.string.onboarding_welcome_description),
|
||||
transparentHero = true,
|
||||
heroContent = {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = SphereState.Idle,
|
||||
intensity = 0.12f,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.80f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_welcome_badge),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
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),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.onboarding_hermes_logo),
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Hermes-Relay",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(380.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SetupPathSummary(
|
||||
label = stringResource(R.string.onboarding_chat_manage_label),
|
||||
description = stringResource(R.string.onboarding_chat_manage_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
label = stringResource(R.string.onboarding_power_tools_label),
|
||||
description = stringResource(R.string.onboarding_power_tools_description),
|
||||
Image(
|
||||
painter = painterResource(R.drawable.onboarding_hero_option1),
|
||||
contentDescription = stringResource(R.string.onboarding_hermes_logo),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_setup_guide_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = buildAnnotatedString {
|
||||
append(titleParts.first())
|
||||
if (titleParts.size > 1) append("\n")
|
||||
withStyle(SpanStyle(color = OnboardingAccent)) {
|
||||
if (titleParts.size > 1) append(titleParts[1])
|
||||
}
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_setup_guide))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_hermes_docs))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_api_server_docs),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
text = stringResource(R.string.onboarding_welcome_description),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
lineHeight = 27.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
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")))
|
||||
}
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
WelcomeCapabilityRow(
|
||||
icon = Icons.Filled.Dashboard,
|
||||
label = stringResource(R.string.onboarding_chat_manage_label),
|
||||
description = stringResource(R.string.onboarding_chat_manage_description),
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
WelcomeCapabilityRow(
|
||||
icon = Icons.Filled.Bolt,
|
||||
label = stringResource(R.string.onboarding_power_tools_label),
|
||||
description = stringResource(R.string.onboarding_power_tools_description),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeCapabilityRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 47.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(30.dp),
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
modifier = Modifier.size(58.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f),
|
||||
),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(30.dp),
|
||||
tint = OnboardingAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF57E389)),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPathSummary(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
description: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f),
|
||||
),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = OnboardingAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(96.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -448,14 +607,17 @@ private fun ChatPage() {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Bolt,
|
||||
label = stringResource(R.string.onboarding_streaming_label),
|
||||
description = stringResource(R.string.onboarding_streaming_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Person,
|
||||
label = stringResource(R.string.onboarding_profiles_label),
|
||||
description = stringResource(R.string.onboarding_profiles_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Mic,
|
||||
label = stringResource(R.string.onboarding_voice_label),
|
||||
description = stringResource(R.string.onboarding_voice_description),
|
||||
)
|
||||
@@ -475,14 +637,17 @@ private fun ManagePage() {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Tune,
|
||||
label = stringResource(R.string.onboarding_control_label),
|
||||
description = stringResource(R.string.onboarding_control_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Extension,
|
||||
label = stringResource(R.string.onboarding_skills_hub_label),
|
||||
description = stringResource(R.string.onboarding_skills_hub_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Lock,
|
||||
label = stringResource(R.string.onboarding_one_sign_in_label),
|
||||
description = stringResource(R.string.onboarding_one_sign_in_description),
|
||||
)
|
||||
@@ -504,14 +669,17 @@ private fun PowerToolsPage(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Outlined.Terminal,
|
||||
label = stringResource(R.string.onboarding_terminal_label),
|
||||
description = stringResource(R.string.onboarding_terminal_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
label = stringResource(R.string.onboarding_bridge_label),
|
||||
description = stringResource(R.string.onboarding_bridge_description),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
label = stringResource(R.string.onboarding_realtime_label),
|
||||
description = stringResource(R.string.onboarding_realtime_description),
|
||||
)
|
||||
@@ -531,6 +699,80 @@ private fun PowerToolsPage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PermissionSetupPage(
|
||||
notificationAction: OnboardingNotificationAction,
|
||||
onEnableNotifications: () -> Unit,
|
||||
onReviewPermissions: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.onboarding_finish_setup_title),
|
||||
description = stringResource(R.string.onboarding_finish_setup_description),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
label = stringResource(R.string.perms_chat_and_manage),
|
||||
description = stringResource(R.string.onboarding_chat_manage_ready),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Notifications,
|
||||
label = stringResource(R.string.onboarding_chat_alerts),
|
||||
description = if (
|
||||
notificationAction == OnboardingNotificationAction.Finish
|
||||
) {
|
||||
stringResource(R.string.onboarding_chat_alerts_ready)
|
||||
} else {
|
||||
stringResource(R.string.onboarding_chat_alerts_description)
|
||||
},
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Tune,
|
||||
label = stringResource(R.string.onboarding_optional_features),
|
||||
description = stringResource(R.string.onboarding_optional_features_description),
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onReviewPermissions,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_review_optional_permissions))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
if (notificationAction == OnboardingNotificationAction.RequestPermission) {
|
||||
GradientOnboardingButton(
|
||||
label = stringResource(R.string.onboarding_enable_chat_alerts),
|
||||
onClick = onEnableNotifications,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_not_now))
|
||||
}
|
||||
} else {
|
||||
GradientOnboardingButton(
|
||||
label = stringResource(R.string.onboarding_finish),
|
||||
onClick = onFinish,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
|
||||
@@ -394,7 +394,7 @@ fun AboutScreen(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -432,7 +432,7 @@ fun AboutScreen(
|
||||
// Privacy policy link
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay/blob/main/docs/privacy.md"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/privacy.html"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -244,6 +244,9 @@ fun AppearanceSettingsScreen(
|
||||
val languageLabels = mapOf(
|
||||
AppLanguage.SYSTEM_DEFAULT to stringResource(R.string.appearance_language_system),
|
||||
AppLanguage.ENGLISH to stringResource(R.string.appearance_language_english),
|
||||
AppLanguage.GERMAN to stringResource(R.string.appearance_language_german),
|
||||
AppLanguage.BRAZILIAN_PORTUGUESE to stringResource(R.string.appearance_language_brazilian_portuguese),
|
||||
AppLanguage.JAPANESE to stringResource(R.string.appearance_language_japanese),
|
||||
AppLanguage.SIMPLIFIED_CHINESE to stringResource(R.string.appearance_language_simplified_chinese),
|
||||
AppLanguage.SPANISH to stringResource(R.string.appearance_language_spanish),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Turn-complete notification toggle. First enable on
|
||||
// Background chat-alert 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.
|
||||
|
||||
@@ -8,12 +8,16 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.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.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Button
|
||||
@@ -42,12 +46,14 @@ 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.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||
@@ -60,7 +66,6 @@ import com.hermesandroid.relay.ui.components.SessionInfoSheet
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
|
||||
/**
|
||||
* Tabbed detail for a single Hermes connection — the level-2 screen the
|
||||
@@ -105,8 +110,6 @@ fun ConnectionDetailScreen(
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRow by connectionViewModel.relayRowState.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
@@ -200,6 +203,12 @@ fun ConnectionDetailScreen(
|
||||
menuExpanded = false
|
||||
onRepair(connectionId)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
if (connection.pairedAt != null) {
|
||||
DropdownMenuItem(
|
||||
@@ -265,8 +274,6 @@ fun ConnectionDetailScreen(
|
||||
ActiveOverview(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
relayConfigured = relayConfigured,
|
||||
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
|
||||
relayUiState = relayUiState,
|
||||
relayEnabled = relayEnabled,
|
||||
onReconnect = onReconnect,
|
||||
@@ -289,18 +296,23 @@ fun ConnectionDetailScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
liveState = relayUiState,
|
||||
onEditDashboard = {
|
||||
selectedTab = tabs.indexOf(DetailTab.Advanced)
|
||||
},
|
||||
)
|
||||
|
||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onPairRelay = { onRepair(connectionId) },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
)
|
||||
|
||||
DetailTab.Security -> ActiveCardSecurityPosture(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToPairedDevices = onNavigateToPairedDevices,
|
||||
onRevokeRelay = { showRevokeConfirm = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -410,8 +422,6 @@ private enum class DetailTab {
|
||||
private fun ActiveOverview(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
relayConfigured: Boolean,
|
||||
relayStatusText: String,
|
||||
relayUiState: RelayUiState,
|
||||
relayEnabled: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
@@ -421,37 +431,67 @@ private fun ActiveOverview(
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val dashboardReady = connection.dashboardLastStatus?.reachable == true
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = headerLine,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = hostname,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Dashboard,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_dashboard_primary),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = connection.resolvedDashboardUrl.ifBlank { hostname },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (dashboardReady) {
|
||||
stringResource(R.string.detail_core_ready)
|
||||
} else {
|
||||
stringResource(R.string.detail_core_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.detail_routes_help),
|
||||
text = stringResource(R.string.detail_overview_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -463,6 +503,7 @@ private fun ActiveOverview(
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
onPairRelay = onRepair,
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -472,9 +513,6 @@ private fun ActiveOverview(
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
|
||||
}
|
||||
TextButton(onClick = onRepair) {
|
||||
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,10 +527,10 @@ private fun InactiveOverview(
|
||||
onSwitch: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val statusLine = when {
|
||||
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
|
||||
connection.apiServerUrl.isNotBlank() -> stringResource(R.string.detail_standard_not_paired)
|
||||
connection.capabilities.chatConfigured -> stringResource(R.string.detail_standard_not_paired)
|
||||
else -> stringResource(R.string.detail_not_configured)
|
||||
}
|
||||
|
||||
|
||||
+385
-137
@@ -1,6 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -21,12 +24,24 @@ 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.Add
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.CellTower
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -39,21 +54,33 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Level-1 list of Hermes connections. Reachable via Settings → Connections.
|
||||
@@ -91,6 +118,15 @@ fun ConnectionsSettingsScreen(
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val startupConnectionId: String? = if (connectionViewModel != null) {
|
||||
val startupId by connectionViewModel.startupConnectionId.collectAsState()
|
||||
startupId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
var switchingConnectionId by remember { mutableStateOf<String?>(null) }
|
||||
var justSwitchedConnectionId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Kick a WSS reconnect on entry in case the user landed here from a Stale
|
||||
// chip (same intent as the old inline screen).
|
||||
@@ -101,7 +137,22 @@ fun ConnectionsSettingsScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.conn_title)) },
|
||||
title = {
|
||||
Column {
|
||||
Text(stringResource(R.string.conn_title))
|
||||
if (connections.isNotEmpty()) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.conn_server_count,
|
||||
connections.size,
|
||||
connections.size,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
@@ -115,13 +166,6 @@ fun ConnectionsSettingsScreen(
|
||||
),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAddConnection,
|
||||
icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.conn_add_connection)) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (connections.isEmpty()) {
|
||||
// Shown in practice only during tests / after a wipe; cold start
|
||||
@@ -149,6 +193,15 @@ fun ConnectionsSettingsScreen(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (connections.size > 1 && connectionViewModel != null) {
|
||||
item {
|
||||
StartupConnectionSelector(
|
||||
connections = connections,
|
||||
startupConnectionId = startupConnectionId,
|
||||
onSelect = connectionViewModel::setStartupConnection,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(connections, key = { it.id }) { connection ->
|
||||
val isActive = connection.id == activeConnectionId
|
||||
ConnectionListCard(
|
||||
@@ -162,10 +215,118 @@ fun ConnectionsSettingsScreen(
|
||||
connection.hasConfiguredRelay()
|
||||
},
|
||||
onClick = { onOpenConnection(connection.id) },
|
||||
onSwitch = if (!isActive && connectionViewModel != null) {
|
||||
{
|
||||
if (switchingConnectionId == null) {
|
||||
scope.launch {
|
||||
switchingConnectionId = connection.id
|
||||
connectionViewModel.switchConnection(connection.id).join()
|
||||
switchingConnectionId = null
|
||||
if (connectionViewModel.activeConnectionId.value == connection.id) {
|
||||
justSwitchedConnectionId = connection.id
|
||||
delay(800)
|
||||
if (justSwitchedConnectionId == connection.id) {
|
||||
justSwitchedConnectionId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
isSwitching = switchingConnectionId == connection.id,
|
||||
justSwitched = justSwitchedConnectionId == connection.id,
|
||||
)
|
||||
}
|
||||
// Footer spacer so the last card isn't hidden by the FAB.
|
||||
item { Spacer(modifier = Modifier.height(72.dp)) }
|
||||
item {
|
||||
Button(
|
||||
onClick = onAddConnection,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.conn_add_connection),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartupConnectionSelector(
|
||||
connections: List<Connection>,
|
||||
startupConnectionId: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedLabel = startupConnectionId
|
||||
?.let { id -> connections.firstOrNull { it.id == id }?.label }
|
||||
?: stringResource(R.string.conn_startup_last_used)
|
||||
|
||||
Box {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true },
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccessTime,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.conn_startup_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(text = selectedLabel, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = stringResource(R.string.conn_startup_choose),
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.conn_startup_last_used))
|
||||
Text(
|
||||
text = stringResource(R.string.conn_startup_recommended),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(null)
|
||||
},
|
||||
)
|
||||
connections.forEach { connection ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(connection.label) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(connection.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,16 +346,28 @@ private fun ConnectionListCard(
|
||||
activeConnectionViewModel: ConnectionViewModel?,
|
||||
relayConfigured: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSwitch: (() -> Unit)?,
|
||||
isSwitching: Boolean,
|
||||
justSwitched: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Active card: muted indigo wash instead of full-strength primaryContainer —
|
||||
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
|
||||
// feedback); small accents keep the vivid blue.
|
||||
val containerColor = if (isActive) {
|
||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
|
||||
val targetContainerColor = if (isActive || isSwitching || justSwitched) {
|
||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.25f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val containerColor by animateColorAsState(targetValue = targetContainerColor, label = "connectionCardColor")
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isSwitching || justSwitched) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0f)
|
||||
},
|
||||
label = "connectionCardBorder",
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -202,6 +375,7 @@ private fun ConnectionListCard(
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
border = BorderStroke(if (isSwitching || justSwitched) 1.5.dp else 0.dp, borderColor),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -209,52 +383,87 @@ private fun ConnectionListCard(
|
||||
) {
|
||||
// ── Title row ────────────────────────────────────────────────
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
if (isActive) com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = connection.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp),
|
||||
)
|
||||
if (isActive) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Text(text = stringResource(R.string.conn_active), modifier = Modifier.padding(horizontal = 6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.conn_active).uppercase(),
|
||||
modifier = Modifier.padding(horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onSwitch != null || isSwitching || justSwitched) {
|
||||
OutlinedButton(
|
||||
onClick = { onSwitch?.invoke() },
|
||||
enabled = !isSwitching && !justSwitched,
|
||||
) {
|
||||
when {
|
||||
isSwitching -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
justSwitched -> {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
else -> Text(stringResource(R.string.conn_switch))
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Subtitle: hostname + status ──────────────────────────────
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val hasStandardApi = connection.apiServerUrl.isNotBlank()
|
||||
val pairedStatus = when {
|
||||
liveState != null &&
|
||||
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
|
||||
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected))
|
||||
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt)
|
||||
hasStandardApi -> stringResource(R.string.conn_standard_not_paired)
|
||||
else -> stringResource(R.string.conn_not_paired)
|
||||
}
|
||||
val statusColor = if (liveState == RelayUiState.Stale) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||
val dashboardStatus = connection.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val connectionStatus = when {
|
||||
dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
|
||||
dashboardStatus?.reachable == true -> stringResource(R.string.conn_dashboard_available)
|
||||
dashboardStatus != null && !dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
|
||||
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_unchecked)
|
||||
else -> stringResource(R.string.conn_dashboard_missing)
|
||||
}
|
||||
Text(
|
||||
text = "$hostname • $pairedStatus",
|
||||
text = if (isSwitching) {
|
||||
stringResource(R.string.conn_connecting_to, connection.label)
|
||||
} else {
|
||||
"Dashboard · $hostname"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = statusColor,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = if (isActive) {
|
||||
stringResource(R.string.conn_last_used_now)
|
||||
} else {
|
||||
connection.lastUsedAt?.let { formatUsedRelative(context, it) } ?: connectionStatus
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// ── Steps/timeline summary (every card) ──────────────────────
|
||||
ConnectionSurfaceSummary(
|
||||
@@ -267,6 +476,42 @@ private fun ConnectionListCard(
|
||||
// detail — the same destination as the card tap.
|
||||
onOpenDashboard = onClick,
|
||||
)
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lan,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.padding(end = 2.dp),
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
connection.routeCandidates.isNotEmpty() -> connection.routeCandidates
|
||||
.sortedBy { it.priority }
|
||||
.joinToString(" · ") { route ->
|
||||
route.role.replaceFirstChar { it.titlecase() }
|
||||
}
|
||||
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_only_route)
|
||||
else -> stringResource(R.string.conn_no_routes)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +549,12 @@ private fun ConnectionSurfaceSummary(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val gatewayAvailability: GatewayAvailability? = if (activeConnectionViewModel != null) {
|
||||
val availability by activeConnectionViewModel.gatewayAvailability.collectAsState()
|
||||
availability
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val standardVoiceAvailability: StandardVoiceAvailability? =
|
||||
if (activeConnectionViewModel != null) {
|
||||
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
@@ -315,17 +566,41 @@ private fun ConnectionSurfaceSummary(
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
|
||||
val apiText = when {
|
||||
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing)
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking)
|
||||
activeApiReachable == true -> stringResource(R.string.conn_api_ready)
|
||||
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline)
|
||||
else -> stringResource(R.string.conn_api_configured)
|
||||
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
|
||||
resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown, null -> ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
connection.apiServerUrl.isBlank() -> ChatTransportReadiness.NotConfigured
|
||||
activeApiReachable == true -> ChatTransportReadiness.Ready
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
activeApiHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
|
||||
else -> ChatTransportReadiness.Connecting
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val apiTone = when {
|
||||
activeApiReachable == true -> SummaryTone.Good
|
||||
connection.apiServerUrl.isBlank() -> SummaryTone.Warning
|
||||
isActive && activeApiReachable == false -> SummaryTone.Warning
|
||||
|
||||
val chatText = when {
|
||||
chatRuntimeStatus is ChatRuntimeStatus.Connected -> stringResource(R.string.conn_api_ready)
|
||||
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> stringResource(R.string.conn_api_checking)
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
|
||||
dashboardStatus?.reachable == true && !dashboardSignInRequired -> stringResource(R.string.conn_dashboard_available)
|
||||
connection.capabilities.chatConfigured -> stringResource(R.string.conn_api_configured)
|
||||
else -> stringResource(R.string.conn_api_missing)
|
||||
}
|
||||
val chatTone = when {
|
||||
chatRuntimeStatus is ChatRuntimeStatus.Connected -> SummaryTone.Good
|
||||
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> SummaryTone.Info
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> SummaryTone.Info
|
||||
dashboardStatus?.reachable == true && !dashboardSignInRequired -> SummaryTone.Good
|
||||
connection.capabilities.chatConfigured -> SummaryTone.Neutral
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
@@ -374,99 +649,78 @@ private fun ConnectionSurfaceSummary(
|
||||
else -> SummaryTone.Neutral
|
||||
}
|
||||
|
||||
// A single grouped surface with dot + label + value rows — the
|
||||
// steps/timeline vocabulary shared with the detail's Features list.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
|
||||
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone)
|
||||
SurfaceRowDivider()
|
||||
ConnectionSurfaceRow(
|
||||
label = stringResource(R.string.conn_dashboard_label),
|
||||
value = dashboardText,
|
||||
tone = dashboardTone,
|
||||
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
SurfaceRowDivider()
|
||||
ConnectionSurfaceRow(
|
||||
label = stringResource(R.string.conn_voice_label),
|
||||
value = voiceText,
|
||||
tone = voiceTone,
|
||||
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
SurfaceRowDivider()
|
||||
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone)
|
||||
}
|
||||
ConnectionStatusChip(Icons.Filled.Chat, stringResource(R.string.conn_chat_label), chatText, chatTone, Modifier.weight(1f))
|
||||
ConnectionStatusChip(
|
||||
Icons.Filled.Dashboard,
|
||||
stringResource(R.string.conn_manage_label),
|
||||
dashboardText,
|
||||
dashboardTone,
|
||||
Modifier.weight(1f),
|
||||
if (dashboardSignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
ConnectionStatusChip(
|
||||
Icons.Filled.GraphicEq,
|
||||
stringResource(R.string.conn_voice_label),
|
||||
voiceText,
|
||||
voiceTone,
|
||||
Modifier.weight(1f),
|
||||
if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
|
||||
)
|
||||
ConnectionStatusChip(Icons.Filled.CellTower, stringResource(R.string.conn_relay_label), relayText, relayTone, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SummaryTone { Neutral, Good, Info, Warning }
|
||||
|
||||
/** Hairline divider between summary rows — inset so it reads as a list. */
|
||||
@Composable
|
||||
private fun SurfaceRowDivider() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One health line: status dot + label + value. The dot carries the tone so
|
||||
* the value text stays short and the row stays light.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConnectionSurfaceRow(
|
||||
private fun ConnectionStatusChip(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
tone: SummaryTone,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val dotColor = when (tone) {
|
||||
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.primary
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.error
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
val valueColor = when (tone) {
|
||||
val accent = when (tone) {
|
||||
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||
SummaryTone.Info -> MaterialTheme.colorScheme.primary
|
||||
SummaryTone.Warning -> MaterialTheme.colorScheme.error
|
||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
Surface(
|
||||
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = valueColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = accent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,21 +731,15 @@ private fun Connection.hasConfiguredRelay(): Boolean {
|
||||
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils`
|
||||
* returns awkward copy ("in 0 minutes") for small deltas.
|
||||
*/
|
||||
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String {
|
||||
val deltaMs = System.currentTimeMillis() - pairedAtMillis
|
||||
if (deltaMs < 0) return context.getString(R.string.conn_just_paired)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
|
||||
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
|
||||
return when {
|
||||
minutes < 1L -> context.getString(R.string.conn_just_paired)
|
||||
minutes < 60L -> context.resources.getQuantityString(R.plurals.conn_paired_minutes_ago, minutes.toInt(), minutes)
|
||||
hours < 24L -> context.resources.getQuantityString(R.plurals.conn_paired_hours_ago, hours.toInt(), hours)
|
||||
days < 30L -> context.resources.getQuantityString(R.plurals.conn_paired_days_ago, days.toInt(), days)
|
||||
else -> context.getString(R.string.conn_paired)
|
||||
private fun formatUsedRelative(context: Context, usedAtMillis: Long): String {
|
||||
val relative = if (System.currentTimeMillis() - usedAtMillis < DateUtils.MINUTE_IN_MILLIS) {
|
||||
context.getString(R.string.conn_just_now)
|
||||
} else {
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
usedAtMillis,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
}
|
||||
return context.getString(R.string.conn_last_used_format, relative)
|
||||
}
|
||||
|
||||
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
|
||||
EnableMcp,
|
||||
DisableMcp,
|
||||
TestMcp,
|
||||
AuthenticateMcp,
|
||||
RemoveMcp,
|
||||
InstallMcpCatalog,
|
||||
ViewProfileSoul,
|
||||
ActivateProfile,
|
||||
DeleteProfile,
|
||||
EditCustomEndpoint,
|
||||
ValidateCustomEndpoint,
|
||||
ActivateCustomEndpoint,
|
||||
DeleteCustomEndpoint,
|
||||
|
||||
// Input-backed kinds — intercepted before runAction and routed to a
|
||||
// text-input or model-picker dialog instead of firing immediately.
|
||||
|
||||
+590
-115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
* Manage so onboarding, connection setup, Voice, Chat, and Manage can all use
|
||||
* the same cookie/session flow without inheriting Manage's navigation stack.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardSignInScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val routeHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
val connectionId = activeConnection?.id ?: "default"
|
||||
var providers by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf<List<DashboardAuthProvider>>(emptyList())
|
||||
}
|
||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||
var actionInFlight by remember { mutableStateOf(false) }
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
|
||||
val cookieStoreFactory = remember(context, connectionId) {
|
||||
{
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||
val status = client.getStatus().getOrNull()
|
||||
val session = client.currentSession().getOrNull()
|
||||
val ticketAvailable = if (session?.authenticated == true) {
|
||||
client.requestWsTicket().isSuccess
|
||||
} else {
|
||||
null
|
||||
}
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
session = session,
|
||||
reachable = status != null,
|
||||
gatewayTicketAvailable = ticketAvailable,
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
fun finishAuthentication() {
|
||||
scope.launch {
|
||||
invalidateDashboardManageCache(context.cacheDir)
|
||||
connectionViewModel.refreshStandardVoice()
|
||||
connectionViewModel.refreshDashboardProfiles()
|
||||
authenticationComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dashboardUrl, connectionId) {
|
||||
if (dashboardUrl.isBlank()) {
|
||||
loading = false
|
||||
actionMessage = context.getString(R.string.dashboard_no_url_configured)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providers = status.authProviderDetails.ifEmpty {
|
||||
client.getAuthProviders().getOrNull().orEmpty()
|
||||
}
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
session = session,
|
||||
reachable = true,
|
||||
gatewayTicketAvailable = null,
|
||||
)
|
||||
if (!status.authRequired || session?.authenticated == true) {
|
||||
finishAuthentication()
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
fun submitPassword(provider: String, username: String, password: String) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val result = client.loginPassword(provider, username, password)
|
||||
val session = if (result.isSuccess) verifyAndRecord(client) else null
|
||||
if (result.isSuccess && session?.authenticated == true) {
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oauthProvider?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
onDismiss = { oauthProvider = null },
|
||||
onAuthenticated = { session ->
|
||||
oauthProvider = null
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
verifyAndRecord(client)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
onError = { actionMessage = it },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.dashboard_sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (authenticationComplete) {
|
||||
DashboardAuthenticationComplete(onContinue = onAuthenticated)
|
||||
} else if (loading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
DashboardSignInForm(
|
||||
dashboardUrl = dashboardUrl,
|
||||
routeHint = routeHint,
|
||||
providers = providers,
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = { oauthProvider = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cw_step_3_3),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.cw_dashboard_connected_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.cw_dashboard_connected_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
ConnectionSetupTimeline(
|
||||
steps = listOf(
|
||||
ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.cw_timeline_discovered),
|
||||
stringResource(R.string.cw_timeline_discovered_detail),
|
||||
),
|
||||
ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.cw_timeline_access),
|
||||
stringResource(R.string.cw_timeline_authenticated),
|
||||
),
|
||||
ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.cw_timeline_ready),
|
||||
stringResource(R.string.cw_timeline_ready_detail),
|
||||
),
|
||||
),
|
||||
)
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.cw_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardSignInForm(
|
||||
dashboardUrl: String,
|
||||
routeHint: String?,
|
||||
providers: List<DashboardAuthProvider>,
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val passwordProvider = providers.firstOrNull { it.supportsPassword }
|
||||
val redirectProviders = providers.filter { it.isRedirectProvider }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_required_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_required_body, dashboardUrl),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
routeHint?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_signin_route_hint, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
redirectProviders.forEach { provider ->
|
||||
Button(
|
||||
onClick = { onOAuthSignIn(provider) },
|
||||
enabled = !actionInFlight,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(R.string.dashboard_username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.dashboard_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
Button(
|
||||
onClick = { onSignIn(passwordProvider?.name ?: "basic", username, password) },
|
||||
enabled = !actionInFlight && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (actionInFlight) stringResource(R.string.dashboard_signing_in) else stringResource(R.string.dashboard_sign_in))
|
||||
}
|
||||
}
|
||||
actionMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardOAuthDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val initialStatus = stringResource(R.string.dashboard_oauth_initial_status)
|
||||
val verifyingStatus = stringResource(R.string.dashboard_oauth_verifying)
|
||||
val notAcceptedStatus = stringResource(R.string.dashboard_oauth_not_accepted)
|
||||
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
|
||||
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||
DashboardApiClient.authLoginUrl(
|
||||
baseUrl = dashboardUrl,
|
||||
provider = provider.name,
|
||||
next = DashboardApiClient.authLandingPath(dashboardUrl),
|
||||
)
|
||||
}
|
||||
|
||||
fun maybeVerify(url: String?) {
|
||||
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||
val root = dashboardUrl.trim().trimEnd('/')
|
||||
val relative = loadedUrl.trim().removePrefix(root)
|
||||
val stillAuthenticating = relative.startsWith("/login", true) ||
|
||||
relative.startsWith("/auth/login", true) ||
|
||||
relative.startsWith("/auth/callback", true)
|
||||
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
|
||||
val manager = CookieManager.getInstance()
|
||||
manager.flush()
|
||||
val imported = importDashboardCookieHeader(
|
||||
cookieStoreFactory(),
|
||||
loadedUrl,
|
||||
manager.getCookie(loadedUrl),
|
||||
)
|
||||
if (checking || imported == 0) return
|
||||
checking = true
|
||||
statusText = verifyingStatus
|
||||
scope.launch {
|
||||
val client = DashboardApiClient(
|
||||
dashboardUrl,
|
||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
try {
|
||||
val session = client.currentSession().getOrNull()
|
||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||
checking = false
|
||||
statusText = notAcceptedStatus
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
checking = false
|
||||
val message = e.message ?: verifyFailedStatus
|
||||
statusText = message
|
||||
onError(message)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
|
||||
}
|
||||
Text(statusText, style = MaterialTheme.typography.bodySmall)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
factory = { viewContext ->
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean = false
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
maybeVerify(url)
|
||||
}
|
||||
}
|
||||
loadUrl(loginUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,16 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.StatusCheck
|
||||
import com.hermesandroid.relay.network.relay.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.shared.ConnectivityObserver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
|
||||
/**
|
||||
* Dedicated Diagnostics screen — replaces the old modal bottom sheet. Hosts a
|
||||
@@ -62,17 +67,21 @@ fun DiagnosticsScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val network by connectionViewModel.networkStatus.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val capabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val chatReady by connectionViewModel.chatReady.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayHealth by connectionViewModel.relayServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val relayUpdateInfo by connectionViewModel.relayUpdateInfo.collectAsState()
|
||||
val relayInfo by connectionViewModel.relayInfo.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveSessionProfileName by
|
||||
connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||
val toolsets by connectionViewModel.toolsetInventory.collectAsState()
|
||||
val checkedAt by connectionViewModel.diagnosticsCheckedAt.collectAsState()
|
||||
val refreshing by connectionViewModel.diagnosticsRefreshing.collectAsState()
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
@@ -82,17 +91,20 @@ fun DiagnosticsScreen(
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
val checks = remember(
|
||||
network, apiHealth, apiUrl, capabilities, authState, chatReady,
|
||||
network, activeConnection, dashboardUrl, gatewayAvailability,
|
||||
apiHealth, apiUrl, capabilities, authState,
|
||||
relayConfigured, relayHealth, relayReady, relayUpdateInfo,
|
||||
voiceReady, relayVoiceReady, entries,
|
||||
) {
|
||||
buildStatusChecks(
|
||||
network = network,
|
||||
dashboardUrl = dashboardUrl,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
apiConfigured = activeConnection?.apiServerUrl?.isNotBlank() == true,
|
||||
apiHealth = apiHealth,
|
||||
apiUrl = apiUrl,
|
||||
capabilities = capabilities,
|
||||
authState = authState,
|
||||
chatReady = chatReady,
|
||||
relayConfigured = relayConfigured,
|
||||
relayHealth = relayHealth,
|
||||
relayReady = relayReady,
|
||||
@@ -171,7 +183,19 @@ fun DiagnosticsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val profileKey = selectedProfile?.name ?: "(default)"
|
||||
info.gatewayHeartbeat?.let { heartbeat ->
|
||||
val detail = heartbeat.ageSeconds?.let { " · ${it}s" }.orEmpty()
|
||||
Text(
|
||||
text = stringResource(R.string.diag_gateway_heartbeat, heartbeat.status, detail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (heartbeat.status in setOf("stale", "malformed", "pid_mismatch", "start_mismatch")) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
val profileKey = effectiveSessionProfileName ?: "(default)"
|
||||
val profileState = info.profiles.firstOrNull { it.name == profileKey }?.relayState
|
||||
?: stringResource(R.string.diag_check_not_checked)
|
||||
Text(
|
||||
@@ -180,6 +204,22 @@ fun DiagnosticsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
toolsets?.let { inventory ->
|
||||
val enabled = inventory.count { it.enabled }
|
||||
val relayVisible = inventory.any { item ->
|
||||
item.tools.any { it.startsWith("relay_") || it.startsWith("android_") }
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.diag_toolsets_inventory,
|
||||
enabled,
|
||||
inventory.size,
|
||||
if (relayVisible) stringResource(R.string.diag_yes) else stringResource(R.string.diag_no),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
StatusCheckTimeline(
|
||||
checks = checks,
|
||||
@@ -233,11 +273,13 @@ fun DiagnosticsScreen(
|
||||
*/
|
||||
internal fun buildStatusChecks(
|
||||
network: ConnectivityObserver.Status,
|
||||
dashboardUrl: String,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
apiConfigured: Boolean,
|
||||
apiHealth: ConnectionViewModel.HealthStatus,
|
||||
apiUrl: String,
|
||||
capabilities: ServerCapabilities,
|
||||
authState: AuthState,
|
||||
chatReady: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
relayHealth: ConnectionViewModel.HealthStatus,
|
||||
relayReady: Boolean,
|
||||
@@ -279,37 +321,94 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 2) API server reachability.
|
||||
val apiLabel = context.getString(R.string.diag_check_api_server)
|
||||
// 2) Dashboard/Gateway — the standard upstream Hermes connection.
|
||||
val dashboardLabel = context.getString(R.string.cw_dashboard) + " / " +
|
||||
context.getString(R.string.chat_settings_gateway)
|
||||
val reachableAt = context.getString(R.string.diag_check_reachable_at)
|
||||
val reachable = context.getString(R.string.diag_check_reachable)
|
||||
val notReachableAt = context.getString(R.string.diag_check_not_reachable_at)
|
||||
val notReachable = context.getString(R.string.diag_check_not_reachable)
|
||||
val probing = context.getString(R.string.diag_check_probing)
|
||||
val notChecked = context.getString(R.string.diag_check_not_checked)
|
||||
val dashboardHost = DiagnosticsLog.sanitizeUrl(dashboardUrl)
|
||||
val dashboardErr = recentError(DiagnosticCategory.Endpoint)
|
||||
checks += when {
|
||||
dashboardUrl.isBlank() ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_not_configured),
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Ready ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Pass,
|
||||
reason = dashboardHost?.let {
|
||||
context.getString(R.string.diag_check_reachable_at, it)
|
||||
} ?: reachable,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Warn,
|
||||
reason = context.getString(R.string.cw_dashboard_sign_in_required),
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Unreachable ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Fail,
|
||||
reason = dashboardErr?.message()
|
||||
?: dashboardHost?.let {
|
||||
context.getString(R.string.diag_check_not_reachable_at, it)
|
||||
}
|
||||
?: notReachable,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
timestampMs = dashboardErr?.timestampMs,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Unsupported ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Fail,
|
||||
reason = context.getString(R.string.diag_check_no_chat_endpoint),
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
else ->
|
||||
StatusCheck(
|
||||
dashboardLabel, CheckStatus.Unknown,
|
||||
reason = notChecked,
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Optional API-server fallback reachability.
|
||||
val apiLabel = context.getString(R.string.active_section_optional_api_fallback)
|
||||
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
|
||||
val apiErr = recentError(DiagnosticCategory.Api)
|
||||
checks += when (apiHealth) {
|
||||
ConnectionViewModel.HealthStatus.Reachable ->
|
||||
checks += when {
|
||||
!apiConfigured ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_not_configured),
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Pass,
|
||||
reason = host?.let { context.getString(R.string.diag_check_reachable_at, it) } ?: reachable,
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Fail,
|
||||
reason = apiErr?.message() ?: (host?.let { context.getString(R.string.diag_check_not_reachable_at, it) } ?: notReachable),
|
||||
category = DiagnosticCategory.Api,
|
||||
timestampMs = apiErr?.timestampMs,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Probing ->
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Unknown,
|
||||
reason = probing,
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
ConnectionViewModel.HealthStatus.Unknown ->
|
||||
else ->
|
||||
StatusCheck(
|
||||
apiLabel, CheckStatus.Unknown,
|
||||
reason = notChecked,
|
||||
@@ -317,13 +416,19 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Server capabilities (which chat surfaces the server advertises).
|
||||
// 4) Optional API-server capabilities.
|
||||
val capsLabel = context.getString(R.string.diag_check_server_capabilities)
|
||||
val capsNoHealthy = context.getString(R.string.diag_check_no_server_yet)
|
||||
val capsNativeSessions = context.getString(R.string.diag_check_native_sessions)
|
||||
val capsSseFallback = context.getString(R.string.diag_check_sse_fallback)
|
||||
val capsNoEndpoint = context.getString(R.string.diag_check_no_chat_endpoint)
|
||||
checks += when {
|
||||
!apiConfigured ->
|
||||
StatusCheck(
|
||||
capsLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_not_configured),
|
||||
category = DiagnosticCategory.Api,
|
||||
)
|
||||
!capabilities.healthy ->
|
||||
StatusCheck(
|
||||
capsLabel, CheckStatus.Unknown,
|
||||
@@ -350,55 +455,96 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 4) Chat transport readiness.
|
||||
// 5) Chat transport readiness.
|
||||
val chatLabel = context.getString(R.string.diag_check_chat_transport)
|
||||
val chatReadyFmt = context.getString(R.string.diag_check_ready_with)
|
||||
val chatNotReady = context.getString(R.string.diag_check_not_ready)
|
||||
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
|
||||
checks += if (chatReady) {
|
||||
StatusCheck(
|
||||
chatLabel, CheckStatus.Pass,
|
||||
reason = context.getString(R.string.diag_check_ready_with, capabilities.preferredChatEndpoint()),
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
} else {
|
||||
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
|
||||
StatusCheck(
|
||||
chatLabel,
|
||||
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
|
||||
reason = chatErr?.message() ?: chatNotReady,
|
||||
category = DiagnosticCategory.Session,
|
||||
timestampMs = chatErr?.timestampMs,
|
||||
)
|
||||
val chatErr = recentError(DiagnosticCategory.Session)
|
||||
?: recentError(DiagnosticCategory.Endpoint)
|
||||
?: recentError(DiagnosticCategory.Api)
|
||||
val chatRuntime = resolveChatRuntimeStatus(
|
||||
gateway = when {
|
||||
dashboardUrl.isBlank() -> ChatTransportReadiness.NotConfigured
|
||||
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
!apiConfigured -> ChatTransportReadiness.NotConfigured
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
else -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
)
|
||||
checks += when (chatRuntime) {
|
||||
is ChatRuntimeStatus.Connected -> {
|
||||
val route = when (chatRuntime.transport) {
|
||||
ChatTransportPath.Gateway -> context.getString(R.string.chat_settings_gateway)
|
||||
ChatTransportPath.ApiSse -> capabilities.preferredChatEndpoint()
|
||||
}
|
||||
StatusCheck(
|
||||
chatLabel,
|
||||
if (chatRuntime.fallback) CheckStatus.Warn else CheckStatus.Pass,
|
||||
reason = context.getString(R.string.diag_check_ready_with, route),
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
}
|
||||
ChatRuntimeStatus.Connecting ->
|
||||
StatusCheck(
|
||||
chatLabel, CheckStatus.Unknown,
|
||||
reason = probing,
|
||||
category = DiagnosticCategory.Session,
|
||||
)
|
||||
ChatRuntimeStatus.Unavailable ->
|
||||
StatusCheck(
|
||||
chatLabel,
|
||||
if (gatewayAvailability == GatewayAvailability.SignInRequired) {
|
||||
CheckStatus.Warn
|
||||
} else {
|
||||
CheckStatus.Fail
|
||||
},
|
||||
reason = if (gatewayAvailability == GatewayAvailability.SignInRequired) {
|
||||
context.getString(R.string.cw_dashboard_sign_in_required)
|
||||
} else {
|
||||
chatErr?.message() ?: chatNotReady
|
||||
},
|
||||
category = DiagnosticCategory.Session,
|
||||
timestampMs = chatErr?.timestampMs,
|
||||
)
|
||||
}
|
||||
|
||||
// 5) Relay / pairing auth.
|
||||
// 6) Optional Relay / pairing auth.
|
||||
val authLabel = context.getString(R.string.diag_check_pairing_auth)
|
||||
val authRelayActive = context.getString(R.string.diag_check_relay_active)
|
||||
val authPairingProg = context.getString(R.string.diag_check_pairing_progress)
|
||||
val authNotPaired = context.getString(R.string.diag_check_not_paired)
|
||||
val authErr = recentError(DiagnosticCategory.Auth)
|
||||
checks += when (authState) {
|
||||
is AuthState.Paired ->
|
||||
checks += when {
|
||||
!relayConfigured ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Unknown,
|
||||
reason = context.getString(R.string.active_section_optional),
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
authState is AuthState.Paired ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Pass,
|
||||
reason = authRelayActive,
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
is AuthState.Pairing ->
|
||||
authState is AuthState.Pairing ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Warn,
|
||||
reason = authPairingProg,
|
||||
category = DiagnosticCategory.Auth,
|
||||
)
|
||||
is AuthState.Failed ->
|
||||
authState is AuthState.Failed ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Fail,
|
||||
reason = authState.reason,
|
||||
category = DiagnosticCategory.Auth,
|
||||
timestampMs = authErr?.timestampMs,
|
||||
)
|
||||
is AuthState.Unpaired ->
|
||||
else ->
|
||||
StatusCheck(
|
||||
authLabel, CheckStatus.Unknown,
|
||||
reason = authNotPaired,
|
||||
@@ -406,8 +552,8 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 6) Relay server (optional — Unknown when not paired/configured).
|
||||
val relayLabel = context.getString(R.string.diag_check_relay_server)
|
||||
// 7) Relay server (optional — Unknown when not paired/configured).
|
||||
val relayLabel = context.getString(R.string.active_section_optional_relay)
|
||||
val relayNotConfigured = context.getString(R.string.diag_check_relay_not_configured)
|
||||
val relayConnected = context.getString(R.string.diag_check_connected)
|
||||
val relayReachableNotReady = context.getString(R.string.diag_check_reachable_not_ready)
|
||||
@@ -442,7 +588,7 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 7) Relay plugin version + release availability. The update route is
|
||||
// 8) Relay plugin version + release availability. The update route is
|
||||
// optional on older plugin versions, so a connected relay with no result is
|
||||
// explicitly Unknown rather than incorrectly reported as current.
|
||||
val pluginLabel = context.getString(R.string.diag_check_relay_plugin)
|
||||
@@ -494,7 +640,7 @@ internal fun buildStatusChecks(
|
||||
)
|
||||
}
|
||||
|
||||
// 8) Voice readiness.
|
||||
// 9) Voice readiness.
|
||||
val voiceLabel = context.getString(R.string.diag_check_voice)
|
||||
val voiceRelayReady = context.getString(R.string.diag_check_voice_relay)
|
||||
val voiceStandardReady = context.getString(R.string.diag_check_voice_standard)
|
||||
|
||||
@@ -32,10 +32,9 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
* button), and pops back to wherever it came from on complete or cancel.
|
||||
*
|
||||
* [autoStart] lets the caller deep-link into a specific pair method. When
|
||||
* set to `"scan"`, the wizard jumps straight to camera-permission-request
|
||||
* → scanner on first composition. Null (default) shows the full Method
|
||||
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
|
||||
* method.
|
||||
* set to `"scan"`, the wizard jumps straight to the scanner. `"relay"`
|
||||
* opens the connection-scoped Relay method chooser without exposing the
|
||||
* new-server flow. Null shows the full connection chooser.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -45,6 +44,7 @@ fun PairScreen(
|
||||
onCancel: () -> Unit,
|
||||
onManageSignIn: (() -> Unit)? = null,
|
||||
autoStart: String? = null,
|
||||
setupReady: Boolean = true,
|
||||
/**
|
||||
* Optional offline "Try the demo" entry, forwarded to [ConnectionWizard].
|
||||
* Wired by [RelayApp] only for the bare Connect entry (no placeholder
|
||||
@@ -65,7 +65,14 @@ fun PairScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.pair_connect_to_hermes)) },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (autoStart == "relay") R.string.detail_pair_relay
|
||||
else R.string.pair_connect_to_hermes,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
@@ -93,6 +100,7 @@ fun PairScreen(
|
||||
onManageSignIn = onManageSignIn,
|
||||
showSkip = false,
|
||||
autoStart = autoStart,
|
||||
setupReady = setupReady,
|
||||
onTryDemo = onTryDemo,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.ui.components.SessionTtlPickerDialog
|
||||
import com.hermesandroid.relay.ui.components.TransportSecurityBadge
|
||||
import com.hermesandroid.relay.ui.components.TransportSecuritySize
|
||||
@@ -872,8 +873,7 @@ private fun EndpointsSubList(
|
||||
for (candidate in endpoints) {
|
||||
val isActive = activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port
|
||||
activeEndpoint.routeAuthority() == candidate.routeAuthority()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -899,7 +899,7 @@ private fun EndpointsSubList(
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}",
|
||||
text = candidate.routeAuthority().orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -91,15 +96,19 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.ui.components.AgentAvatarFace
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
|
||||
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||
|
||||
/**
|
||||
* Root Settings destination. After the 2026-04-11 split, Settings is a
|
||||
@@ -176,6 +185,7 @@ fun SettingsScreen(
|
||||
// ring-accent, and subtitle.
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
@@ -183,27 +193,45 @@ fun SettingsScreen(
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val relayPaired = authState is AuthState.Paired
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||
gateway = when (gatewayAvailability) {
|
||||
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||
GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
|
||||
},
|
||||
apiSse = when {
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
|
||||
apiServerReachable -> ChatTransportReadiness.Ready
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
|
||||
else -> ChatTransportReadiness.Connecting
|
||||
},
|
||||
)
|
||||
// Status pills are exception-only: a pill appears only when the surface
|
||||
// needs attention (missing / checking / offline / sign-in). When it's
|
||||
// healthy the pill is null so the card + agent summary stay clean.
|
||||
val apiPill: SettingsStatusPillModel? = when {
|
||||
activeConnection?.apiServerUrl.isNullOrBlank() -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_api_missing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_api_checking),
|
||||
// Chat is transport-level: Gateway is primary and API is only fallback.
|
||||
// A healthy transport suppresses warnings from the other optional surface.
|
||||
val chatPill: SettingsStatusPillModel? = when {
|
||||
chatRuntimeStatus is ChatRuntimeStatus.Connected -> null
|
||||
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> null
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_sign_in),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
apiServerReachable -> null
|
||||
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_api_offline),
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_missing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
gatewayAvailability == GatewayAvailability.Unreachable ||
|
||||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_offline),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
else -> null
|
||||
@@ -213,38 +241,18 @@ fun SettingsScreen(
|
||||
label = stringResource(R.string.settings_dashboard_missing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
dashboardStatus == null -> null
|
||||
!dashboardStatus.reachable -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_offline),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
dashboardSignInRequired -> SettingsStatusPillModel(
|
||||
gatewayAvailability == GatewayAvailability.Ready -> null
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_sign_in),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
dashboardStatus.authenticated == true -> null
|
||||
gatewayAvailability == GatewayAvailability.Unreachable ||
|
||||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_dashboard_offline),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
val relayPill: SettingsStatusPillModel? = when (relayUiState) {
|
||||
RelayUiState.Connected -> null
|
||||
RelayUiState.Connecting -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_relay_reconnecting),
|
||||
tone = SettingsStatusTone.Info,
|
||||
)
|
||||
RelayUiState.Stale -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_relay_stale),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.Expired -> SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_pairing_expired),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.Disconnected -> SettingsStatusPillModel(
|
||||
label = if (relayPaired) stringResource(R.string.settings_relay_offline) else stringResource(R.string.settings_requires_pairing),
|
||||
tone = SettingsStatusTone.Warning,
|
||||
)
|
||||
RelayUiState.NotConfigured -> null
|
||||
}
|
||||
// The Power tools below all ride the relay plugin. Rather than stamp an
|
||||
// identical badge on every card (noise, not signal), the dependency is
|
||||
// surfaced ONCE on the section header as a single plugin-state badge.
|
||||
@@ -323,10 +331,6 @@ fun SettingsScreen(
|
||||
// + one-line `connection · model · personality` subtitle).
|
||||
// Tapping opens AgentInfoSheet inline so users can change
|
||||
// Connection / Profile / Personality without leaving Settings.
|
||||
val effectiveProfile = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = selectedProfile,
|
||||
profiles = agentProfiles,
|
||||
)
|
||||
ActiveAgentCard(
|
||||
agentName = AgentDisplay.agentName(
|
||||
profile = effectiveProfile,
|
||||
@@ -342,7 +346,7 @@ fun SettingsScreen(
|
||||
defaultPersonality = defaultPersonality,
|
||||
),
|
||||
isCustomized = selectedProfile != null || selectedPersonality != "default",
|
||||
statusPills = listOfNotNull(apiPill, dashboardPill, relayPill),
|
||||
statusPills = listOfNotNull(chatPill),
|
||||
onClick = { showAgentSheet = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
@@ -354,8 +358,7 @@ fun SettingsScreen(
|
||||
// `default` profile — the relay always advertises one, and
|
||||
// it IS the effective agent. Still falls back to disabled
|
||||
// when no profiles have loaded yet (unpaired / pre-auth).
|
||||
val inspectorTarget = selectedProfile
|
||||
?: agentProfiles.firstOrNull { it.name == "default" }
|
||||
val inspectorTarget = effectiveProfile
|
||||
?: agentProfiles.firstOrNull()
|
||||
ProfileInspectorCard(
|
||||
activeProfile = inspectorTarget,
|
||||
@@ -438,7 +441,7 @@ fun SettingsScreen(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
title = stringResource(R.string.settings_chat),
|
||||
subtitle = stringResource(R.string.settings_chat_desc),
|
||||
badge = apiPill,
|
||||
badge = chatPill,
|
||||
onClick = onNavigateToChatSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
@@ -814,6 +817,20 @@ private fun QuickControlsCard(
|
||||
) {
|
||||
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Posting re-checks the grant. */ }
|
||||
val requestNotificationPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -846,7 +863,10 @@ private fun QuickControlsCard(
|
||||
stringResource(R.string.settings_connect_on_demand)
|
||||
},
|
||||
checked = gatewayKeepAlive,
|
||||
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) },
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setGatewayKeepAlive(enabled)
|
||||
if (enabled && notifyTurnComplete) requestNotificationPermission()
|
||||
},
|
||||
)
|
||||
// Doze: even with the keep-alive service running, a specialUse FGS
|
||||
// still gets its network deferred in deep sleep unless the app is
|
||||
@@ -865,7 +885,10 @@ private fun QuickControlsCard(
|
||||
stringResource(R.string.settings_turn_complete_alerts_off)
|
||||
},
|
||||
checked = notifyTurnComplete,
|
||||
onCheckedChange = { connectionViewModel.setNotifyTurnComplete(it) },
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setNotifyTurnComplete(enabled)
|
||||
if (enabled) requestNotificationPermission()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -227,6 +227,18 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
// Upstream rejects new API work with this stable code while an intentional
|
||||
// shutdown/external drain is in progress. Keep it distinct from provider
|
||||
// 503s: the server is healthy and will accept work after the drain clears.
|
||||
if (msg.startsWith("api error 503: gateway_draining:")) {
|
||||
return HumanError(
|
||||
title = "Hermes is restarting",
|
||||
body = "Hermes is draining active work. Wait a moment, then retry this message.",
|
||||
retryable = true,
|
||||
actionLabel = ctx?.getString(R.string.error_classify_retry) ?: "Retry",
|
||||
)
|
||||
}
|
||||
|
||||
// Typed exceptions are checked first because an IOException message scan
|
||||
// would otherwise swallow SSL/timeout/connect errors whose messages
|
||||
// happen to contain HTTP-ish substrings. Only fall through to the
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
/** Runtime readiness for one independently optional chat transport. */
|
||||
enum class ChatTransportReadiness {
|
||||
NotConfigured,
|
||||
Connecting,
|
||||
Ready,
|
||||
Unavailable,
|
||||
}
|
||||
enum class ChatTransportPath {
|
||||
Gateway,
|
||||
ApiSse,
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral UI state for chat connectivity. Relay is intentionally
|
||||
* absent: it is an optional bridge surface and cannot make chat unhealthy.
|
||||
*/
|
||||
sealed interface ChatRuntimeStatus {
|
||||
data class Connected(
|
||||
val transport: ChatTransportPath,
|
||||
val fallback: Boolean,
|
||||
) : ChatRuntimeStatus
|
||||
|
||||
data object Connecting : ChatRuntimeStatus
|
||||
|
||||
data object Unavailable : ChatRuntimeStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve chat health in product priority order:
|
||||
* Gateway primary, API/SSE fallback, pending connection, then unavailable.
|
||||
*/
|
||||
fun resolveChatRuntimeStatus(
|
||||
gateway: ChatTransportReadiness,
|
||||
apiSse: ChatTransportReadiness,
|
||||
): ChatRuntimeStatus = when {
|
||||
gateway == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
|
||||
transport = ChatTransportPath.Gateway,
|
||||
fallback = false,
|
||||
)
|
||||
|
||||
apiSse == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
|
||||
transport = ChatTransportPath.ApiSse,
|
||||
fallback = true,
|
||||
)
|
||||
|
||||
gateway == ChatTransportReadiness.Connecting ||
|
||||
apiSse == ChatTransportReadiness.Connecting -> ChatRuntimeStatus.Connecting
|
||||
|
||||
else -> ChatRuntimeStatus.Unavailable
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -59,18 +59,16 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* 8. Update URL flows via [setApiServerUrl] and [setRelayUrl] so
|
||||
* [HermesApiClient] and [RelayHttpClient] pick up the new endpoints.
|
||||
* [persistUrls] writes both values to the app DataStore in one pass.
|
||||
* 9. Rebuild the API client via [rebuildApiClient] so subsequent chat
|
||||
* calls hit the new API server with the new API key.
|
||||
* 10. Await the new AuthManager's first non-[AuthState.Unpaired] /
|
||||
* 9. Persist the new active-connection id so Dashboard/Gateway probes resolve
|
||||
* against the target connection rather than the outgoing one.
|
||||
* 10. Rebuild the API client (or clear it for Dashboard-only) and probe the
|
||||
* target Dashboard/Gateway surface.
|
||||
* 11. Await the new AuthManager's first non-[AuthState.Unpaired] /
|
||||
* non-[AuthState.Loading] emission (or a short timeout) before deciding
|
||||
* whether to kick the WSS handshake. Connecting too early fires an
|
||||
* auth envelope with no pair context, which tick the relay's rate
|
||||
* limiter — exactly the trap [AuthManager.hasPairContext] was
|
||||
* introduced to avoid.
|
||||
* 11. Persist the new active-connection id via
|
||||
* [ConnectionStore.setActiveConnection] last so an early failure leaves
|
||||
* the previous active connection in place.
|
||||
*
|
||||
* The whole sequence runs under [switchMutex] so rapid-fire switches from
|
||||
* the UI queue cleanly instead of interleaving partial teardowns.
|
||||
*/
|
||||
@@ -269,11 +267,22 @@ class ConnectionSwitchCoordinator(
|
||||
runCatching { persistUrls(target.apiServerUrl, targetRelayUrl) }
|
||||
.onFailure { Log.w(TAG, "persistUrls failed: ${it.message}") }
|
||||
|
||||
// 9 — rebuild the API client against the new URL + key.
|
||||
// 9 — publish the target context BEFORE rebuilding/probing. The
|
||||
// Dashboard URL and cookie store are resolved from activeConnection;
|
||||
// probing first would accidentally re-probe the outgoing connection
|
||||
// and leave a Dashboard-only target stuck at Gateway.Unknown.
|
||||
runCatching { connectionStore.setActiveConnection(connectionId) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}")
|
||||
return@withLock
|
||||
}
|
||||
|
||||
// 10 — rebuild the API client against the new URL + key (or clear
|
||||
// it and probe Dashboard/Gateway when this is Dashboard-only).
|
||||
runCatching { rebuildApiClient() }
|
||||
.onFailure { Log.w(TAG, "rebuildApiClient failed: ${it.message}") }
|
||||
|
||||
// 10 — wait briefly for the new AuthManager to hydrate its
|
||||
// 11 — wait briefly for the new AuthManager to hydrate its
|
||||
// stored token (if any) so hasPairContext returns the right
|
||||
// answer. The authState flow seeds to Unpaired and flips to
|
||||
// Paired asynchronously; without this await we'd almost
|
||||
@@ -309,24 +318,16 @@ class ConnectionSwitchCoordinator(
|
||||
)
|
||||
}
|
||||
|
||||
if (newAuth.hasPairContext) {
|
||||
if (newAuth.hasPairContext && targetRelayUrl.isNotBlank()) {
|
||||
runCatching { connectionManager.connect(targetRelayUrl) }
|
||||
.onFailure { Log.w(TAG, "connectionManager.connect failed: ${it.message}") }
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"switchConnection: new connection has no pair context — skipping WSS connect " +
|
||||
"(user will pair from the Connection screen)",
|
||||
"switchConnection: Relay is not ready for this connection — " +
|
||||
"skipping WSS connect",
|
||||
)
|
||||
}
|
||||
|
||||
// 11 — persist last so an earlier failure leaves the previous
|
||||
// active pointer in place. setActiveConnection updates the
|
||||
// connectionStore.activeConnectionId StateFlow synchronously
|
||||
// after the DataStore write lands, so the UI reacts without
|
||||
// waiting for a recomposition pass.
|
||||
runCatching { connectionStore.setActiveConnection(connectionId) }
|
||||
.onFailure { Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/** Non-secret handle required to resume a dashboard-owned MCP OAuth flow. */
|
||||
data class PendingMcpOAuth(
|
||||
val flowId: String,
|
||||
val serverName: String,
|
||||
val profile: String?,
|
||||
/** Non-secret connection + normalized dashboard identity that owns this flow. */
|
||||
val routeIdentity: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Process-recreatable Manage OAuth state. Authorization URLs, OAuth codes,
|
||||
* callback state, and tokens are deliberately never written here.
|
||||
*/
|
||||
class DashboardManageOAuthViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
private val flowId = savedStateHandle.getStateFlow<String?>(KEY_FLOW_ID, null)
|
||||
private val serverName = savedStateHandle.getStateFlow<String?>(KEY_SERVER_NAME, null)
|
||||
private val profile = savedStateHandle.getStateFlow<String?>(KEY_PROFILE, null)
|
||||
private val routeIdentity = savedStateHandle.getStateFlow<String?>(KEY_ROUTE_IDENTITY, null)
|
||||
|
||||
val pending: StateFlow<PendingMcpOAuth?> = combine(
|
||||
flowId,
|
||||
serverName,
|
||||
profile,
|
||||
routeIdentity,
|
||||
) { id, server, scope, route ->
|
||||
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) null
|
||||
else PendingMcpOAuth(id, server, scope, route)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, currentPending())
|
||||
|
||||
val unsupportedRoutes: StateFlow<ArrayList<String>> =
|
||||
savedStateHandle.getStateFlow(KEY_UNSUPPORTED_ROUTES, arrayListOf())
|
||||
val supportedRoutes: StateFlow<ArrayList<String>> =
|
||||
savedStateHandle.getStateFlow(KEY_SUPPORTED_ROUTES, arrayListOf())
|
||||
|
||||
fun remember(flowId: String, serverName: String, profile: String?, routeIdentity: String) {
|
||||
if (routeIdentity.isBlank()) return
|
||||
savedStateHandle[KEY_FLOW_ID] = flowId
|
||||
savedStateHandle[KEY_SERVER_NAME] = serverName
|
||||
savedStateHandle[KEY_PROFILE] = profile
|
||||
savedStateHandle[KEY_ROUTE_IDENTITY] = routeIdentity
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
savedStateHandle[KEY_FLOW_ID] = null
|
||||
savedStateHandle[KEY_SERVER_NAME] = null
|
||||
savedStateHandle[KEY_PROFILE] = null
|
||||
savedStateHandle[KEY_ROUTE_IDENTITY] = null
|
||||
}
|
||||
|
||||
fun markUnsupported(routeKey: String) {
|
||||
if (routeKey.isBlank()) return
|
||||
val next = ArrayList(unsupportedRoutes.value)
|
||||
if (routeKey !in next) {
|
||||
next += routeKey
|
||||
savedStateHandle[KEY_UNSUPPORTED_ROUTES] = next
|
||||
}
|
||||
removeRoute(KEY_SUPPORTED_ROUTES, supportedRoutes.value, routeKey)
|
||||
}
|
||||
|
||||
fun markSupported(routeKey: String) {
|
||||
if (routeKey.isBlank()) return
|
||||
val next = ArrayList(supportedRoutes.value)
|
||||
if (routeKey !in next) {
|
||||
next += routeKey
|
||||
savedStateHandle[KEY_SUPPORTED_ROUTES] = next
|
||||
}
|
||||
removeRoute(KEY_UNSUPPORTED_ROUTES, unsupportedRoutes.value, routeKey)
|
||||
}
|
||||
|
||||
private fun removeRoute(key: String, routes: ArrayList<String>, routeKey: String) {
|
||||
if (routeKey !in routes) return
|
||||
val next = ArrayList(routes)
|
||||
next.remove(routeKey)
|
||||
savedStateHandle[key] = next
|
||||
}
|
||||
|
||||
private fun currentPending(): PendingMcpOAuth? {
|
||||
val id = savedStateHandle.get<String>(KEY_FLOW_ID)
|
||||
val server = savedStateHandle.get<String>(KEY_SERVER_NAME)
|
||||
val route = savedStateHandle.get<String>(KEY_ROUTE_IDENTITY)
|
||||
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) return null
|
||||
return PendingMcpOAuth(id, server, savedStateHandle.get(KEY_PROFILE), route)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_FLOW_ID = "manage_mcp_oauth_flow_id"
|
||||
const val KEY_SERVER_NAME = "manage_mcp_oauth_server"
|
||||
const val KEY_PROFILE = "manage_mcp_oauth_profile"
|
||||
const val KEY_ROUTE_IDENTITY = "manage_mcp_oauth_route_identity"
|
||||
const val KEY_UNSUPPORTED_ROUTES = "manage_mcp_oauth_unsupported_routes"
|
||||
const val KEY_SUPPORTED_ROUTES = "manage_mcp_oauth_supported_routes"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user